You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@druid.apache.org by fj...@apache.org on 2019/06/17 20:14:51 UTC
[incubator-druid] branch master updated: Web console: added Query
View metadata pane, more query details (#7905)
This is an automated email from the ASF dual-hosted git repository.
fjy pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-druid.git
The following commit(s) were added to refs/heads/master by this push:
new df9cdcf Web console: added Query View metadata pane, more query details (#7905)
df9cdcf is described below
commit df9cdcf13b089e445c764e86a8b277ecec24d0ba
Author: Vadim Ogievetsky <va...@gmail.com>
AuthorDate: Mon Jun 17 13:14:42 2019 -0700
Web console: added Query View metadata pane, more query details (#7905)
* refactor
* adding metadata hookup
* add scroll
* standerdize icons
* better loading state
* update snapshots
* fix lint
* do not mix up queryId and sqlQueryId
---
web-console/package-lock.json | 10 +
web-console/package.json | 2 +
web-console/src/console-application.tsx | 45 ++-
.../src/dialogs/snitch-dialog/snitch-dialog.tsx | 2 +-
.../{views/index.ts => utils/column-metadata.ts} | 15 +-
web-console/src/utils/druid-query.ts | 2 +-
web-console/src/utils/general.tsx | 43 ++-
web-console/src/utils/query-context.tsx | 79 ++++
web-console/src/utils/query-manager.tsx | 2 +-
.../views/datasource-view/datasource-view.spec.tsx | 2 +-
.../src/views/datasource-view/datasource-view.tsx | 10 +-
web-console/src/views/index.ts | 2 +-
.../src/views/load-data-view/load-data-view.tsx | 13 +-
.../__snapshots__/query-view.spec.tsx.snap | 52 +++
.../__snapshots__/column-tree.spec.tsx.snap | 115 ++++++
.../column-tree/column-tree.scss} | 38 +-
.../column-tree/column-tree.spec.tsx} | 22 +-
.../views/query-view/column-tree/column-tree.tsx | 203 +++++++++++
.../__snapshots__/query-extra-info.spec.tsx.snap | 58 +++
.../query-extra-info/query-extra-info.scss} | 20 +-
.../query-extra-info/query-extra-info.spec.tsx} | 22 +-
.../query-extra-info/query-extra-info.tsx | 105 ++++++
.../__snapshots__/query-input.spec.tsx.snap | 100 ++++++
.../query-input/query-input.scss} | 27 +-
.../query-input/query-input.spec.tsx} | 15 +-
.../views/query-view/query-input/query-input.tsx | 194 ++++++++++
.../__snapshots__/query-output.spec.tsx.snap | 320 +++++++++++++++++
.../query-output/query-output.scss} | 17 +-
.../query-output/query-output.spec.tsx} | 16 +-
.../views/query-view/query-output/query-output.tsx | 56 +++
.../sql-view.scss => query-view/query-view.scss} | 71 ++--
.../query-view.spec.tsx} | 12 +-
web-console/src/views/query-view/query-view.tsx | 397 +++++++++++++++++++++
.../__snapshots__/run-button.spec.tsx.snap | 78 ++++
.../run-button/run-button.scss} | 12 +-
.../run-button/run-button.spec.tsx} | 18 +-
.../src/views/query-view/run-button/run-button.tsx | 154 ++++++++
.../src/views/segments-view/segments-view.spec.tsx | 2 +-
.../src/views/segments-view/segments-view.tsx | 6 +-
.../src/views/servers-view/servers-view.spec.tsx | 2 +-
.../src/views/servers-view/servers-view.tsx | 6 +-
.../sql-view/__snapshots__/sql-view.spec.tsx.snap | 170 ---------
.../__snapshots__/sql-control.spec.tsx.snap | 206 -----------
.../src/views/sql-view/sql-control/sql-control.tsx | 385 --------------------
web-console/src/views/sql-view/sql-view.tsx | 275 --------------
.../__snapshots__/tasks-view.spec.tsx.snap | 57 ++-
.../src/views/task-view/tasks-view.spec.tsx | 2 +-
web-console/src/views/task-view/tasks-view.tsx | 36 +-
48 files changed, 2228 insertions(+), 1268 deletions(-)
diff --git a/web-console/package-lock.json b/web-console/package-lock.json
index ce1e783..d422113 100644
--- a/web-console/package-lock.json
+++ b/web-console/package-lock.json
@@ -634,6 +634,11 @@
"@types/lodash": "*"
}
},
+ "@types/memoize-one": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@types/memoize-one/-/memoize-one-4.1.1.tgz",
+ "integrity": "sha512-+9djKUUn8hOyktLCfCy4hLaIPgDNovaU36fsnZe9trFHr6ddlbIn2q0SEsnkCkNR+pBWEU440Molz/+Mpyf+gQ=="
+ },
"@types/minimatch": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
@@ -6995,6 +7000,11 @@
"p-is-promise": "^2.0.0"
}
},
+ "memoize-one": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.0.4.tgz",
+ "integrity": "sha512-P0z5IeAH6qHHGkJIXWw0xC2HNEgkx/9uWWBQw64FJj3/ol14VYdfVGWWr0fXfjhhv3TKVIqUq65os6O4GUNksA=="
+ },
"memory-fs": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
diff --git a/web-console/package.json b/web-console/package.json
index e6e6b8c..f55ebe9 100644
--- a/web-console/package.json
+++ b/web-console/package.json
@@ -43,6 +43,7 @@
},
"dependencies": {
"@blueprintjs/core": "^3.15.1",
+ "@types/memoize-one": "^4.1.1",
"axios": "^0.19.0",
"brace": "^0.11.1",
"classnames": "^2.2.6",
@@ -54,6 +55,7 @@
"file-saver": "^2.0.2",
"hjson": "^3.1.2",
"lodash.debounce": "^4.0.8",
+ "memoize-one": "^5.0.4",
"numeral": "^2.0.6",
"react": "^16.8.6",
"react-ace": "^7.0.1",
diff --git a/web-console/src/console-application.tsx b/web-console/src/console-application.tsx
index 084fd44..6e7f204 100644
--- a/web-console/src/console-application.tsx
+++ b/web-console/src/console-application.tsx
@@ -23,21 +23,21 @@ import classNames from 'classnames';
import React from 'react';
import { HashRouter, Route, Switch } from 'react-router-dom';
-import { ExternalLink } from './components/external-link/external-link';
-import { HeaderActiveTab, HeaderBar } from './components/header-bar/header-bar';
-import { Loader } from './components/loader/loader';
+import { ExternalLink, HeaderActiveTab, HeaderBar, Loader } from './components';
import { AppToaster } from './singletons/toaster';
import { UrlBaser } from './singletons/url-baser';
import { QueryManager } from './utils';
import { DRUID_DOCS_API, DRUID_DOCS_SQL } from './variables';
-import { DatasourcesView } from './views/datasource-view/datasource-view';
-import { HomeView } from './views/home-view/home-view';
-import { LoadDataView } from './views/load-data-view/load-data-view';
-import { LookupsView } from './views/lookups-view/lookups-view';
-import { SegmentsView } from './views/segments-view/segments-view';
-import { ServersView } from './views/servers-view/servers-view';
-import { SqlView } from './views/sql-view/sql-view';
-import { TasksView } from './views/task-view/tasks-view';
+import {
+ DatasourcesView,
+ HomeView,
+ LoadDataView,
+ LookupsView,
+ QueryView,
+ SegmentsView,
+ ServersView,
+ TasksView
+} from './views';
import './console-application.scss';
@@ -105,7 +105,7 @@ export class ConsoleApplication extends React.PureComponent<ConsoleApplicationPr
private openDialog: string | null;
private datasource: string | null;
private onlyUnavailable: boolean | null;
- private initSql: string | null;
+ private initQuery: string | null;
private middleManager: string | null;
constructor(props: ConsoleApplicationProps, context: any) {
@@ -156,7 +156,7 @@ export class ConsoleApplication extends React.PureComponent<ConsoleApplicationPr
this.openDialog = null;
this.datasource = null;
this.onlyUnavailable = null;
- this.initSql = null;
+ this.initQuery = null;
this.middleManager = null;
}, 50);
}
@@ -188,8 +188,8 @@ export class ConsoleApplication extends React.PureComponent<ConsoleApplicationPr
this.resetInitialsWithDelay();
}
- private goToSql = (initSql: string) => {
- this.initSql = initSql;
+ private goToQuery = (initQuery: string) => {
+ this.initQuery = initQuery;
window.location.hash = 'query';
this.resetInitialsWithDelay();
}
@@ -213,28 +213,28 @@ export class ConsoleApplication extends React.PureComponent<ConsoleApplicationPr
return this.wrapInViewContainer('load-data', <LoadDataView initSupervisorId={this.supervisorId} initTaskId={this.taskId} goToTask={this.goToTask}/>, 'narrow-pad');
}
- private wrappedSqlView = () => {
- return this.wrapInViewContainer('query', <SqlView initSql={this.initSql}/>);
+ private wrappedQueryView = () => {
+ return this.wrapInViewContainer('query', <QueryView initQuery={this.initQuery}/>);
}
private wrappedDatasourcesView = () => {
const { noSqlMode } = this.state;
- return this.wrapInViewContainer('datasources', <DatasourcesView goToSql={this.goToSql} goToSegments={this.goToSegments} noSqlMode={noSqlMode}/>);
+ return this.wrapInViewContainer('datasources', <DatasourcesView goToQuery={this.goToQuery} 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}/>);
+ return this.wrapInViewContainer('segments', <SegmentsView datasource={this.datasource} onlyUnavailable={this.onlyUnavailable} goToQuery={this.goToQuery} noSqlMode={noSqlMode}/>);
}
private wrappedTasksView = () => {
const { noSqlMode } = this.state;
- return this.wrapInViewContainer('tasks', <TasksView taskId={this.taskId} openDialog={this.openDialog} goToSql={this.goToSql} goToMiddleManager={this.goToMiddleManager} goToLoadDataView={this.goToLoadDataView} noSqlMode={noSqlMode}/>);
+ return this.wrapInViewContainer('tasks', <TasksView taskId={this.taskId} openDialog={this.openDialog} goToQuery={this.goToQuery} goToMiddleManager={this.goToMiddleManager} goToLoadDataView={this.goToLoadDataView} noSqlMode={noSqlMode}/>);
}
private wrappedServersView = () => {
const { noSqlMode } = this.state;
- return this.wrapInViewContainer('servers', <ServersView middleManager={this.middleManager} goToSql={this.goToSql} goToTask={this.goToTask} noSqlMode={noSqlMode}/>);
+ return this.wrapInViewContainer('servers', <ServersView middleManager={this.middleManager} goToQuery={this.goToQuery} goToTask={this.goToTask} noSqlMode={noSqlMode}/>);
}
private wrappedLookupsView = () => {
@@ -257,8 +257,7 @@ export class ConsoleApplication extends React.PureComponent<ConsoleApplicationPr
<div className="console-application">
<Switch>
<Route path="/load-data" component={this.wrappedLoadDataView}/>
- <Route path="/query" component={this.wrappedSqlView}/>
- <Route path="/sql" component={this.wrappedSqlView}/>
+ <Route path="/query" component={this.wrappedQueryView}/>
<Route path="/datasources" component={this.wrappedDatasourcesView}/>
<Route path="/segments" component={this.wrappedSegmentsView}/>
diff --git a/web-console/src/dialogs/snitch-dialog/snitch-dialog.tsx b/web-console/src/dialogs/snitch-dialog/snitch-dialog.tsx
index cf6240a..5451ec0 100644
--- a/web-console/src/dialogs/snitch-dialog/snitch-dialog.tsx
+++ b/web-console/src/dialogs/snitch-dialog/snitch-dialog.tsx
@@ -26,7 +26,7 @@ import {
Intent
} from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
-import classNames = require('classnames');
+import classNames from 'classnames';
import React from 'react';
import { HistoryDialog } from '../history-dialog/history-dialog';
diff --git a/web-console/src/views/index.ts b/web-console/src/utils/column-metadata.ts
similarity index 69%
copy from web-console/src/views/index.ts
copy to web-console/src/utils/column-metadata.ts
index 7a753bb..2902796 100644
--- a/web-console/src/views/index.ts
+++ b/web-console/src/utils/column-metadata.ts
@@ -15,11 +15,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-export * from './datasource-view/datasource-view';
-export * from './home-view/home-view';
-export * from './load-data-view/load-data-view';
-export * from './lookups-view/lookups-view';
-export * from './segments-view/segments-view';
-export * from './servers-view/servers-view';
-export * from './sql-view/sql-view';
-export * from './task-view/tasks-view';
+
+export interface ColumnMetadata {
+ TABLE_SCHEMA: string;
+ TABLE_NAME: string;
+ COLUMN_NAME: string;
+ DATA_TYPE: string;
+}
diff --git a/web-console/src/utils/druid-query.ts b/web-console/src/utils/druid-query.ts
index 9230d1f..2a780e7 100644
--- a/web-console/src/utils/druid-query.ts
+++ b/web-console/src/utils/druid-query.ts
@@ -61,7 +61,7 @@ export async function queryDruidRune(runeQuery: Record<string, any>): Promise<an
return runeResultResp.data;
}
-export async function queryDruidSql(sqlQuery: Record<string, any>): Promise<any[]> {
+export async function queryDruidSql<T = any>(sqlQuery: Record<string, any>): Promise<T[]> {
let sqlResultResp: AxiosResponse<any>;
try {
sqlResultResp = await axios.post('/druid/v2/sql', sqlQuery);
diff --git a/web-console/src/utils/general.tsx b/web-console/src/utils/general.tsx
index 94c3427..3a80686 100644
--- a/web-console/src/utils/general.tsx
+++ b/web-console/src/utils/general.tsx
@@ -135,13 +135,39 @@ function identity(x: any): any {
export function lookupBy<T, Q>(array: T[], keyFn: (x: T, index: number) => string = String, valueFn: (x: T, index: number) => Q = identity): Record<string, Q> {
const lookup: Record<string, Q> = {};
- for (let i = 0; i < array.length; i++) {
+ const n = array.length;
+ for (let i = 0; i < n; i++) {
const a = array[i];
lookup[keyFn(a, i)] = valueFn(a, i);
}
return lookup;
}
+export function mapRecord<T, Q>(record: Record<string, T>, fn: (value: T, key: string) => Q): Record<string, Q> {
+ const newRecord: Record<string, Q> = {};
+ const keys = Object.keys(record);
+ for (const key of keys) {
+ newRecord[key] = fn(record[key], key);
+ }
+ return newRecord;
+}
+
+export function groupBy<T, Q>(array: T[], keyFn: (x: T, index: number) => string, aggregateFn: (xs: T[], key: string) => Q): Q[] {
+ const buckets: Record<string, T[]> = {};
+ const n = array.length;
+ for (let i = 0; i < n; i++) {
+ const value = array[i];
+ const key = keyFn(value, i);
+ buckets[key] = buckets[key] || [];
+ buckets[key].push(value);
+ }
+ return Object.keys(buckets).map(key => aggregateFn(buckets[key], key));
+}
+
+export function uniq(array: string[]): string[] {
+ return Object.keys(lookupBy(array));
+}
+
export function parseList(list: string): string[] {
if (!list) return [];
return list.split(',');
@@ -184,17 +210,6 @@ export function getHeadProp(results: Record<string, any>[], prop: string): any {
// ----------------------------
-export function memoize<T, U>(fn: (x: T) => U): (x: T) => U {
- let lastInput: T;
- let lastOutput: U;
- return (x: T) => {
- if (x === lastInput) return lastOutput;
- lastInput = x;
- lastOutput = fn(lastInput);
- return lastOutput;
- };
-}
-
export function parseJson(json: string): any {
try {
return JSON.parse(json);
@@ -247,7 +262,7 @@ export function sortWithPrefixSuffix(things: string[], prefix: string[], suffix:
// ----------------------------
-export function downloadFile(text: string, type: string, fileName: string): void {
+export function downloadFile(text: string, type: string, filename: string): void {
let blobType: string = '';
switch (type) {
case 'json':
@@ -262,5 +277,5 @@ export function downloadFile(text: string, type: string, fileName: string): void
const blob = new Blob([text], {
type: blobType
});
- FileSaver.saveAs(blob, fileName);
+ FileSaver.saveAs(blob, filename);
}
diff --git a/web-console/src/utils/query-context.tsx b/web-console/src/utils/query-context.tsx
new file mode 100644
index 0000000..4d864b4
--- /dev/null
+++ b/web-console/src/utils/query-context.tsx
@@ -0,0 +1,79 @@
+/*
+ * 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, deepSet } from './object-change';
+
+export interface QueryContext {
+ useCache?: boolean | undefined;
+ populateCache?: boolean | undefined;
+ useApproximateCountDistinct?: boolean | undefined;
+ useApproximateTopN?: boolean | undefined;
+}
+
+export function isEmptyContext(context: QueryContext): boolean {
+ return Object.keys(context).length === 0;
+}
+
+// -----------------------------
+
+export function getUseCache(context: QueryContext): boolean {
+ const { useCache } = context;
+ return typeof useCache === 'boolean' ? useCache : true;
+}
+
+export function setUseCache(context: QueryContext, useCache: boolean): QueryContext {
+ let newContext = context;
+ if (useCache) {
+ newContext = deepDelete(newContext, 'useCache');
+ newContext = deepDelete(newContext, 'populateCache');
+ } else {
+ newContext = deepSet(newContext, 'useCache', false);
+ newContext = deepSet(newContext, 'populateCache', false);
+ }
+ return newContext;
+}
+
+// -----------------------------
+
+export function getUseApproximateCountDistinct(context: QueryContext): boolean {
+ const { useApproximateCountDistinct } = context;
+ return typeof useApproximateCountDistinct === 'boolean' ? useApproximateCountDistinct : true;
+}
+
+export function setUseApproximateCountDistinct(context: QueryContext, useApproximateCountDistinct: boolean): QueryContext {
+ if (useApproximateCountDistinct) {
+ return deepDelete(context, 'useApproximateCountDistinct');
+ } else {
+ return deepSet(context, 'useApproximateCountDistinct', false);
+ }
+}
+
+// -----------------------------
+
+export function getUseApproximateTopN(context: QueryContext): boolean {
+ const { useApproximateTopN } = context;
+ return typeof useApproximateTopN === 'boolean' ? useApproximateTopN : true;
+}
+
+export function setUseApproximateTopN(context: QueryContext, useApproximateTopN: boolean): QueryContext {
+ if (useApproximateTopN) {
+ return deepDelete(context, 'useApproximateTopN');
+ } else {
+ return deepSet(context, 'useApproximateTopN', false);
+ }
+}
diff --git a/web-console/src/utils/query-manager.tsx b/web-console/src/utils/query-manager.tsx
index 7dd347d..d545bd5 100644
--- a/web-console/src/utils/query-manager.tsx
+++ b/web-console/src/utils/query-manager.tsx
@@ -16,7 +16,7 @@
* limitations under the License.
*/
-import debounce = require('lodash.debounce');
+import debounce from 'lodash.debounce';
export interface QueryStateInt<R> {
result: R | null;
diff --git a/web-console/src/views/datasource-view/datasource-view.spec.tsx b/web-console/src/views/datasource-view/datasource-view.spec.tsx
index 0b87da3..ed645b0 100644
--- a/web-console/src/views/datasource-view/datasource-view.spec.tsx
+++ b/web-console/src/views/datasource-view/datasource-view.spec.tsx
@@ -25,7 +25,7 @@ describe('data source view', () => {
it('matches snapshot', () => {
const dataSourceView = shallow(
<DatasourcesView
- goToSql={(initSql: string) => {}}
+ goToQuery={(initSql: string) => {}}
goToSegments={(datasource: string, onlyUnavailable?: boolean) => {}}
noSqlMode={false}
/>);
diff --git a/web-console/src/views/datasource-view/datasource-view.tsx b/web-console/src/views/datasource-view/datasource-view.tsx
index dc96eeb..21b7803 100644
--- a/web-console/src/views/datasource-view/datasource-view.tsx
+++ b/web-console/src/views/datasource-view/datasource-view.tsx
@@ -45,7 +45,7 @@ const tableColumns: string[] = ['Datasource', 'Availability', 'Retention', 'Comp
const tableColumnsNoSql: string[] = ['Datasource', 'Availability', 'Retention', 'Compaction', 'Size', ActionCell.COLUMN_LABEL];
export interface DatasourcesViewProps extends React.Props<any> {
- goToSql: (initSql: string) => void;
+ goToQuery: (initSql: string) => void;
goToSegments: (datasource: string, onlyUnavailable?: boolean) => void;
noSqlMode: boolean;
}
@@ -407,7 +407,7 @@ GROUP BY 1`);
}
getDatasourceActions(datasource: string, disabled: boolean): BasicAction[] {
- const { goToSql } = this.props;
+ const { goToQuery } = this.props;
if (disabled) {
return [
@@ -428,7 +428,7 @@ GROUP BY 1`);
{
icon: IconNames.APPLICATION,
title: 'Query with SQL',
- onAction: () => goToSql(`SELECT * FROM "${datasource}"`)
+ onAction: () => goToQuery(`SELECT * FROM "${datasource}"`)
},
{
icon: IconNames.EXPORT,
@@ -646,7 +646,7 @@ GROUP BY 1`);
}
render() {
- const { goToSql, noSqlMode } = this.props;
+ const { goToQuery, noSqlMode } = this.props;
const { showDisabled } = this.state;
const { tableColumnSelectionHandler } = this;
@@ -662,7 +662,7 @@ GROUP BY 1`);
<Button
icon={IconNames.APPLICATION}
text="Go to SQL"
- onClick={() => goToSql(this.datasourceQueryManager.getLastQuery())}
+ onClick={() => goToQuery(this.datasourceQueryManager.getLastQuery())}
/>
}
<Switch
diff --git a/web-console/src/views/index.ts b/web-console/src/views/index.ts
index 7a753bb..0a4a0bb 100644
--- a/web-console/src/views/index.ts
+++ b/web-console/src/views/index.ts
@@ -21,5 +21,5 @@ export * from './load-data-view/load-data-view';
export * from './lookups-view/lookups-view';
export * from './segments-view/segments-view';
export * from './servers-view/servers-view';
-export * from './sql-view/sql-view';
+export * from './query-view/query-view';
export * from './task-view/tasks-view';
diff --git a/web-console/src/views/load-data-view/load-data-view.tsx b/web-console/src/views/load-data-view/load-data-view.tsx
index b9aee95..68fdf89 100644
--- a/web-console/src/views/load-data-view/load-data-view.tsx
+++ b/web-console/src/views/load-data-view/load-data-view.tsx
@@ -28,9 +28,10 @@ import {
import { IconNames } from '@blueprintjs/icons';
import axios from 'axios';
import classNames from 'classnames';
+import memoize from 'memoize-one';
import React from 'react';
-import { AutoForm, CenterMessage, ClearableInput, ExternalLink, JSONInput, Loader, TableCell } from '../../components';
+import { AutoForm, CenterMessage, ClearableInput, ExternalLink, JSONInput, Loader } from '../../components';
import { AsyncActionDialog } from '../../dialogs';
import { AppToaster } from '../../singletons/toaster';
import {
@@ -38,7 +39,7 @@ import {
getDruidErrorMessage,
localStorageGet,
LocalStorageKeys,
- localStorageSet, memoize, parseJson,
+ localStorageSet, parseJson,
QueryState
} from '../../utils';
import { possibleDruidFormatForValues } from '../../utils/druid-time';
@@ -365,6 +366,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
{this.renderResetConfirm()}
</div>;
}
+
renderStepNav() {
const { stage } = this.state;
@@ -381,7 +383,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
key={s}
active={s === stage}
onClick={() => this.updateStage(s)}
- icon={s === 'json-spec' && IconNames.EYE_OPEN}
+ icon={s === 'json-spec' && IconNames.MANUALLY_ENTERED_DATA}
text={VIEW_TITLE[s]}
/>
))}
@@ -401,13 +403,14 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
onPrevStage &&
<Button
className="prev"
- icon={IconNames.ARROW_LEFT}
+ icon={IconNames.UNDO}
text={prevLabel}
onClick={onPrevStage}
/>
}
<Button
text={`Next: ${VIEW_TITLE[nextStage]}`}
+ rightIcon={IconNames.ARROW_RIGHT}
intent={Intent.PRIMARY}
disabled={disabled}
onClick={() => {
@@ -1294,7 +1297,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
});
}
- private getMemoizedDimensionFiltersFromSpec = memoize<IngestionSpec, DruidFilter[]>((spec) => {
+ private getMemoizedDimensionFiltersFromSpec = memoize((spec) => {
const { dimensionFilters } = splitFilter(deepGet(spec, 'dataSchema.transformSpec.filter'));
return dimensionFilters;
});
diff --git a/web-console/src/views/query-view/__snapshots__/query-view.spec.tsx.snap b/web-console/src/views/query-view/__snapshots__/query-view.spec.tsx.snap
new file mode 100644
index 0000000..3e4cfff
--- /dev/null
+++ b/web-console/src/views/query-view/__snapshots__/query-view.spec.tsx.snap
@@ -0,0 +1,52 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`sql view matches snapshot 1`] = `
+<div
+ className="query-view app-view"
+>
+ <ColumnTree
+ columnMetadata={null}
+ columnMetadataLoading={true}
+ onQueryStringChange={[Function]}
+ />
+ <t
+ customClassName=""
+ onDragEnd={null}
+ onDragStart={null}
+ onSecondaryPaneSizeChange={[Function]}
+ percentage={true}
+ primaryIndex={0}
+ primaryMinSize={30}
+ secondaryInitialSize={60}
+ secondaryMinSize={30}
+ vertical={true}
+ >
+ <div
+ className="control-pane"
+ >
+ <QueryInput
+ columnMetadata={null}
+ onQueryStringChange={[Function]}
+ queryString="test"
+ runeMode={false}
+ />
+ <div
+ className="control-bar"
+ >
+ <HotkeysTarget(RunButton)
+ onExplain={[Function]}
+ onQueryContextChange={[Function]}
+ onRun={[Function]}
+ queryContext={Object {}}
+ runeMode={false}
+ />
+ </div>
+ </div>
+ <QueryOutput
+ error={null}
+ loading={false}
+ result={null}
+ />
+ </t>
+</div>
+`;
diff --git a/web-console/src/views/query-view/column-tree/__snapshots__/column-tree.spec.tsx.snap b/web-console/src/views/query-view/column-tree/__snapshots__/column-tree.spec.tsx.snap
new file mode 100644
index 0000000..35754d9
--- /dev/null
+++ b/web-console/src/views/query-view/column-tree/__snapshots__/column-tree.spec.tsx.snap
@@ -0,0 +1,115 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`column tree matches snapshot 1`] = `
+<div
+ class="column-tree"
+>
+ <div
+ class="bp3-html-select bp3-fill bp3-large bp3-minimal schema-selector"
+ >
+ <select>
+ <option
+ value="0"
+ >
+ druid
+ </option>
+ <option
+ value="1"
+ >
+ sys
+ </option>
+ </select>
+ <span
+ class="bp3-icon bp3-icon-double-caret-vertical"
+ icon="double-caret-vertical"
+ >
+ <svg
+ data-icon="double-caret-vertical"
+ height="16"
+ viewBox="0 0 16 16"
+ width="16"
+ >
+ <desc>
+ double-caret-vertical
+ </desc>
+ <path
+ d="M5 7h6a1.003 1.003 0 0 0 .71-1.71l-3-3C8.53 2.11 8.28 2 8 2s-.53.11-.71.29l-3 3A1.003 1.003 0 0 0 5 7zm6 2H5a1.003 1.003 0 0 0-.71 1.71l3 3c.18.18.43.29.71.29s.53-.11.71-.29l3-3A1.003 1.003 0 0 0 11 9z"
+ fill-rule="evenodd"
+ />
+ </svg>
+ </span>
+ </div>
+ <div
+ class="tree-container"
+ >
+ <div
+ class="bp3-tree"
+ >
+ <ul
+ class="bp3-tree-node-list bp3-tree-root"
+ >
+ <li
+ class="bp3-tree-node"
+ >
+ <div
+ class="bp3-tree-node-content bp3-tree-node-content-0"
+ >
+ <span
+ class="bp3-icon bp3-icon-chevron-right bp3-tree-node-caret bp3-tree-node-caret-closed"
+ icon="chevron-right"
+ >
+ <svg
+ data-icon="chevron-right"
+ height="16"
+ viewBox="0 0 16 16"
+ width="16"
+ >
+ <desc>
+ chevron-right
+ </desc>
+ <path
+ d="M10.71 7.29l-4-4a1.003 1.003 0 0 0-1.42 1.42L8.59 8 5.3 11.29c-.19.18-.3.43-.3.71a1.003 1.003 0 0 0 1.71.71l4-4c.18-.18.29-.43.29-.71 0-.28-.11-.53-.29-.71z"
+ fill-rule="evenodd"
+ />
+ </svg>
+ </span>
+ <span
+ class="bp3-icon bp3-icon-th bp3-tree-node-icon"
+ icon="th"
+ >
+ <svg
+ data-icon="th"
+ height="16"
+ viewBox="0 0 16 16"
+ width="16"
+ >
+ <desc>
+ th
+ </desc>
+ <path
+ d="M15 1H1c-.6 0-1 .5-1 1v12c0 .6.4 1 1 1h14c.6 0 1-.4 1-1V2c0-.5-.4-1-1-1zM6 13H2v-2h4v2zm0-3H2V8h4v2zm0-3H2V5h4v2zm8 6H7v-2h7v2zm0-3H7V8h7v2zm0-3H7V5h7v2z"
+ fill-rule="evenodd"
+ />
+ </svg>
+ </span>
+ <span
+ class="bp3-tree-node-label"
+ >
+ deletion-tutorial
+ </span>
+ </div>
+ <div
+ class="bp3-collapse"
+ >
+ <div
+ aria-hidden="false"
+ class="bp3-collapse-body"
+ style="transform: translateY(-0px);"
+ />
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+</div>
+`;
diff --git a/web-console/src/views/sql-view/sql-view.scss b/web-console/src/views/query-view/column-tree/column-tree.scss
similarity index 67%
copy from web-console/src/views/sql-view/sql-view.scss
copy to web-console/src/views/query-view/column-tree/column-tree.scss
index f1ef90b..0b121a6 100644
--- a/web-console/src/views/sql-view/sql-view.scss
+++ b/web-console/src/views/query-view/column-tree/column-tree.scss
@@ -16,42 +16,28 @@
* limitations under the License.
*/
-@import "../../variables";
+.column-tree {
+ background: #27313b;
-.sql-view {
- height: 100%;
- width: 100%;
-
- .top-pane {
+ .schema-selector {
position: absolute;
- width: 100%;
top: 0;
- bottom: 10px;
- .sql-control {
- position: absolute;
- width: 100%;
- height: 100%;
+ select {
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
}
}
- &.splitter-layout.splitter-layout-vertical > .layout-splitter {
- height: 3px;
- background-color: #6d8ea9;
- }
-
- .bottom-pane {
+ .tree-container {
position: absolute;
- width: 100%;
- top: 10px;
+ top: 40px;
bottom: 0;
+ width: 100%;
+ overflow: auto;
- .ReactTable {
- position: absolute;
- top: 0;
- bottom: 0;
- width: 100%;
+ .bp3-tree-node-content-1 {
+ padding-left: 0;
}
}
}
-
diff --git a/web-console/src/views/sql-view/sql-control/sql-control.spec.tsx b/web-console/src/views/query-view/column-tree/column-tree.spec.tsx
similarity index 58%
copy from web-console/src/views/sql-view/sql-control/sql-control.spec.tsx
copy to web-console/src/views/query-view/column-tree/column-tree.spec.tsx
index a044aa4..53505bd 100644
--- a/web-console/src/views/sql-view/sql-control/sql-control.spec.tsx
+++ b/web-console/src/views/query-view/column-tree/column-tree.spec.tsx
@@ -19,19 +19,23 @@
import React from 'react';
import { render } from 'react-testing-library';
-import { SqlControl } from './sql-control';
+import { ColumnMetadata } from '../../../utils/column-metadata';
-describe('sql control', () => {
+import { ColumnTree } from './column-tree';
+
+describe('column tree', () => {
it('matches snapshot', () => {
- const sqlControl = <SqlControl
- initSql={'test'}
- onRun={(query, context, wrapQuery) => {}}
- onExplain={(sqlQuery, context) => {}}
- queryElapsed={2}
- onDownload={() => {}}
+ const columnTree = <ColumnTree
+ columnMetadataLoading={false}
+ columnMetadata={[
+ {'TABLE_SCHEMA': 'druid', 'TABLE_NAME': 'deletion-tutorial', 'COLUMN_NAME': '__time', 'DATA_TYPE': 'TIMESTAMP'},
+ {'TABLE_SCHEMA': 'druid', 'TABLE_NAME': 'deletion-tutorial', 'COLUMN_NAME': 'added', 'DATA_TYPE': 'BIGINT'},
+ {'TABLE_SCHEMA': 'sys', 'TABLE_NAME': 'tasks', 'COLUMN_NAME': 'error_msg', 'DATA_TYPE': 'VARCHAR'}
+ ] as ColumnMetadata[]}
+ onQueryStringChange={() => null}
/>;
- const { container } = render(sqlControl);
+ const { container } = render(columnTree);
expect(container.firstChild).toMatchSnapshot();
});
});
diff --git a/web-console/src/views/query-view/column-tree/column-tree.tsx b/web-console/src/views/query-view/column-tree/column-tree.tsx
new file mode 100644
index 0000000..b3b2213
--- /dev/null
+++ b/web-console/src/views/query-view/column-tree/column-tree.tsx
@@ -0,0 +1,203 @@
+/*
+ * 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 { HTMLSelect, IconName, ITreeNode, Tree } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import React, { ChangeEvent } from 'react';
+
+import { Loader } from '../../../components';
+import { groupBy } from '../../../utils';
+import { ColumnMetadata } from '../../../utils/column-metadata';
+
+import './column-tree.scss';
+
+export interface ColumnTreeProps extends React.Props<any> {
+ columnMetadataLoading: boolean;
+ columnMetadata: ColumnMetadata[] | null;
+ onQueryStringChange: (queryString: string) => void;
+}
+
+export interface ColumnTreeState {
+ prevColumnMetadata: ColumnMetadata[] | null;
+ columnTree: ITreeNode[] | null;
+ selectedTreeIndex: number;
+}
+
+export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeState> {
+
+ static getDerivedStateFromProps(props: ColumnTreeProps, state: ColumnTreeState) {
+ const { columnMetadata } = props;
+
+ if (columnMetadata && columnMetadata !== state.prevColumnMetadata) {
+ return {
+ prevColumnMetadata: columnMetadata,
+ columnTree: groupBy(
+ columnMetadata,
+ (r) => r.TABLE_SCHEMA,
+ (metadata, schema): ITreeNode => ({
+ id: schema,
+ label: schema,
+ childNodes: groupBy(
+ metadata,
+ (r) => r.TABLE_NAME,
+ (metadata, table) => ({
+ id: table,
+ icon: IconNames.TH,
+ label: table,
+ childNodes: metadata.map(columnData => ({
+ id: columnData.COLUMN_NAME,
+ icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE),
+ label: columnData.COLUMN_NAME
+ }))
+ })
+ )
+ })
+ )
+ };
+ }
+ return null;
+ }
+
+ static dataTypeToIcon(dataType: string): IconName {
+ switch (dataType) {
+ case 'TIMESTAMP': return IconNames.TIME;
+ case 'VARCHAR': return IconNames.FONT;
+ case 'BIGINT': return IconNames.NUMERICAL;
+ default: return IconNames.HELP;
+ }
+ }
+
+ constructor(props: ColumnTreeProps, context: any) {
+ super(props, context);
+ this.state = {
+ prevColumnMetadata: null,
+ columnTree: null,
+ selectedTreeIndex: 0
+ };
+ }
+
+ renderSchemaSelector() {
+ const { columnTree, selectedTreeIndex } = this.state;
+ if (!columnTree) return null;
+
+ return <HTMLSelect
+ className="schema-selector"
+ value={selectedTreeIndex}
+ onChange={this.handleSchemaSelectorChange}
+ fill
+ minimal
+ large
+ >
+ {columnTree.map((treeNode, i) => (
+ <option key={i} value={i}>
+ {treeNode.label}
+ </option>
+ ))}
+ </HTMLSelect>;
+ }
+
+ private handleSchemaSelectorChange = (e: ChangeEvent<HTMLSelectElement>) => {
+ this.setState({ selectedTreeIndex: Number(e.target.value) });
+ }
+
+ render() {
+ const { columnMetadataLoading } = this.props;
+ if (columnMetadataLoading) {
+ return <div className="column-tree">
+ <Loader loading/>
+ </div>;
+ }
+
+ const { columnTree, selectedTreeIndex } = this.state;
+ if (!columnTree) return null;
+ const currentSchemaSubtree = columnTree[selectedTreeIndex].childNodes;
+ if (!currentSchemaSubtree) return null;
+
+ return <div className="column-tree">
+ {this.renderSchemaSelector()}
+ <div className="tree-container">
+ <Tree
+ contents={currentSchemaSubtree}
+ onNodeClick={this.handleNodeClick}
+ onNodeCollapse={this.handleNodeCollapse}
+ onNodeExpand={this.handleNodeExpand}
+ />
+ </div>
+ </div>;
+ }
+
+ private handleNodeClick = (nodeData: ITreeNode, nodePath: number[], e: React.MouseEvent<HTMLElement>) => {
+ const { onQueryStringChange } = this.props;
+ const { columnTree, selectedTreeIndex } = this.state;
+ if (!columnTree) return;
+
+ switch (nodePath.length) {
+ case 1: // Datasource
+ const tableSchema = columnTree[selectedTreeIndex].label;
+ if (tableSchema === 'druid') {
+ onQueryStringChange(`SELECT * FROM "${nodeData.label}"
+WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY`);
+ } else {
+ onQueryStringChange(`SELECT * FROM ${tableSchema}.${nodeData.label}`);
+ }
+ break;
+
+ case 2: // Column
+ const schemaNode = columnTree[selectedTreeIndex];
+ const columnSchema = schemaNode.label;
+ const columnTable = schemaNode.childNodes ? schemaNode.childNodes[nodePath[0]].label : '?';
+ if (columnSchema === 'druid') {
+ if (nodeData.icon === IconNames.TIME) {
+ onQueryStringChange(`SELECT TIME_FLOOR("${nodeData.label}", 'PT1H') AS "Time", COUNT(*) AS "Count"
+FROM "${columnTable}"
+WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY
+GROUP BY 1
+ORDER BY "Time" ASC`);
+ } else {
+ onQueryStringChange(`SELECT "${nodeData.label}", COUNT(*) AS "Count"
+FROM "${columnTable}"
+WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY
+GROUP BY 1
+ORDER BY "Count" DESC`);
+ }
+ } else {
+ onQueryStringChange(`SELECT "${nodeData.label}", COUNT(*) AS "Count"
+FROM ${columnSchema}.${columnTable}
+GROUP BY 1
+ORDER BY "Count" DESC`);
+ }
+ break;
+ }
+ }
+
+ private handleNodeCollapse = (nodeData: ITreeNode) => {
+ nodeData.isExpanded = false;
+ this.bounceState();
+ }
+
+ private handleNodeExpand = (nodeData: ITreeNode) => {
+ nodeData.isExpanded = true;
+ this.bounceState();
+ }
+
+ bounceState() {
+ const { columnTree } = this.state;
+ if (!columnTree) return;
+ this.setState({ columnTree: columnTree.slice() });
+ }
+}
diff --git a/web-console/src/views/query-view/query-extra-info/__snapshots__/query-extra-info.spec.tsx.snap b/web-console/src/views/query-view/query-extra-info/__snapshots__/query-extra-info.spec.tsx.snap
new file mode 100644
index 0000000..9aacc34
--- /dev/null
+++ b/web-console/src/views/query-view/query-extra-info/__snapshots__/query-extra-info.spec.tsx.snap
@@ -0,0 +1,58 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`query extra info matches snapshot 1`] = `
+<div
+ class="query-extra-info"
+>
+ <div
+ class="query-info"
+ >
+ <span
+ class="bp3-popover-wrapper"
+ >
+ <span
+ class="bp3-popover-target"
+ >
+ <span
+ class=""
+ tabindex="0"
+ >
+ 1999+ results in 8.00s
+ </span>
+ </span>
+ </span>
+ </div>
+ <span
+ class="bp3-popover-wrapper download-button"
+ >
+ <span
+ class="bp3-popover-target"
+ >
+ <button
+ class="bp3-button bp3-minimal"
+ type="button"
+ >
+ <span
+ class="bp3-icon bp3-icon-download"
+ icon="download"
+ >
+ <svg
+ data-icon="download"
+ height="16"
+ viewBox="0 0 16 16"
+ width="16"
+ >
+ <desc>
+ download
+ </desc>
+ <path
+ d="M7.99-.01c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8zM11.7 9.7l-3 3c-.18.18-.43.29-.71.29s-.53-.11-.71-.29l-3-3A1.003 1.003 0 0 1 5.7 8.28l1.29 1.29V3.99c0-.55.45-1 1-1s1 .45 1 1v5.59l1.29-1.29a1.003 1.003 0 0 1 1.71.71c0 .27-.11.52-.29.7z"
+ fill-rule="evenodd"
+ />
+ </svg>
+ </span>
+ </button>
+ </span>
+ </span>
+</div>
+`;
diff --git a/web-console/src/views/index.ts b/web-console/src/views/query-view/query-extra-info/query-extra-info.scss
similarity index 69%
copy from web-console/src/views/index.ts
copy to web-console/src/views/query-view/query-extra-info/query-extra-info.scss
index 7a753bb..3b16a5d 100644
--- a/web-console/src/views/index.ts
+++ b/web-console/src/views/query-view/query-extra-info/query-extra-info.scss
@@ -15,11 +15,15 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-export * from './datasource-view/datasource-view';
-export * from './home-view/home-view';
-export * from './load-data-view/load-data-view';
-export * from './lookups-view/lookups-view';
-export * from './segments-view/segments-view';
-export * from './servers-view/servers-view';
-export * from './sql-view/sql-view';
-export * from './task-view/tasks-view';
+
+.query-extra-info {
+ & > * {
+ display: inline-block;
+ }
+
+ .query-info {
+ line-height: 30px;
+ white-space: nowrap;
+ cursor: pointer;
+ }
+}
diff --git a/web-console/src/views/sql-view/sql-control/sql-control.spec.tsx b/web-console/src/views/query-view/query-extra-info/query-extra-info.spec.tsx
similarity index 66%
copy from web-console/src/views/sql-view/sql-control/sql-control.spec.tsx
copy to web-console/src/views/query-view/query-extra-info/query-extra-info.spec.tsx
index a044aa4..8fa03a9 100644
--- a/web-console/src/views/sql-view/sql-control/sql-control.spec.tsx
+++ b/web-console/src/views/query-view/query-extra-info/query-extra-info.spec.tsx
@@ -19,19 +19,23 @@
import React from 'react';
import { render } from 'react-testing-library';
-import { SqlControl } from './sql-control';
+import { QueryExtraInfo } from './query-extra-info';
-describe('sql control', () => {
+describe('query extra info', () => {
it('matches snapshot', () => {
- const sqlControl = <SqlControl
- initSql={'test'}
- onRun={(query, context, wrapQuery) => {}}
- onExplain={(sqlQuery, context) => {}}
- queryElapsed={2}
- onDownload={() => {}}
+ const queryExtraInfo = <QueryExtraInfo
+ queryExtraInfo={{
+ queryId: 'e3ee781b-c0b6-4385-9d99-a8a1994bebac',
+ sqlQueryId: null,
+ startTime: new Date('1986-04-26T01:23:40+03:00'),
+ endTime: new Date('1986-04-26T01:23:48+03:00'),
+ numResults: 2000,
+ wrappedLimit: 2000
+ }}
+ onDownload={() => null}
/>;
- const { container } = render(sqlControl);
+ const { container } = render(queryExtraInfo);
expect(container.firstChild).toMatchSnapshot();
});
});
diff --git a/web-console/src/views/query-view/query-extra-info/query-extra-info.tsx b/web-console/src/views/query-view/query-extra-info/query-extra-info.tsx
new file mode 100644
index 0000000..0246b04
--- /dev/null
+++ b/web-console/src/views/query-view/query-extra-info/query-extra-info.tsx
@@ -0,0 +1,105 @@
+/*
+ * 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, Intent, Menu, MenuDivider, MenuItem, Popover, Position, Tooltip } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import copy from 'copy-to-clipboard';
+import React from 'react';
+
+import { AppToaster } from '../../../singletons/toaster';
+import { pluralIfNeeded } from '../../../utils';
+
+import './query-extra-info.scss';
+
+export interface QueryExtraInfoData {
+ queryId: string | null;
+ sqlQueryId: string | null;
+ startTime: Date;
+ endTime: Date;
+ numResults: number;
+ wrappedLimit?: number;
+}
+
+export interface QueryExtraInfoProps extends React.Props<any> {
+ queryExtraInfo: QueryExtraInfoData;
+ onDownload: (filename: string, format: string) => void;
+}
+
+export class QueryExtraInfo extends React.PureComponent<QueryExtraInfoProps> {
+
+ render() {
+ const { queryExtraInfo } = this.props;
+
+ const downloadMenu = <Menu className="download-format-menu">
+ <MenuDivider title="Download as:"/>
+ <MenuItem text="CSV" onClick={() => this.handleDownload('csv')} />
+ <MenuItem text="TSV" onClick={() => this.handleDownload('tsv')} />
+ <MenuItem text="JSON (new line delimited)" onClick={() => this.handleDownload('json')}/>
+ </Menu>;
+
+ let resultCount: string;
+ if (queryExtraInfo.wrappedLimit && queryExtraInfo.numResults === queryExtraInfo.wrappedLimit) {
+ resultCount = `${queryExtraInfo.numResults - 1}+ results`;
+ } else {
+ resultCount = pluralIfNeeded(queryExtraInfo.numResults, 'result');
+ }
+
+ const elapsed = queryExtraInfo.endTime.valueOf() - queryExtraInfo.startTime.valueOf();
+
+ let tooltipContent: JSX.Element | undefined;
+ if (queryExtraInfo.queryId) {
+ tooltipContent = <>Query ID: <strong>{queryExtraInfo.queryId}</strong> (click to copy)</>;
+ } else if (queryExtraInfo.sqlQueryId) {
+ tooltipContent = <>SQL query ID: <strong>{queryExtraInfo.sqlQueryId}</strong> (click to copy)</>;
+ }
+
+ return <div className="query-extra-info">
+ <div className="query-info" onClick={this.handleQueryInfoClick}>
+ <Tooltip content={tooltipContent} hoverOpenDelay={500}>
+ {`${resultCount} in ${(elapsed / 1000).toFixed(2)}s`}
+ </Tooltip>
+ </div>
+ <Popover className="download-button" content={downloadMenu} position={Position.BOTTOM_RIGHT}>
+ <Button
+ icon={IconNames.DOWNLOAD}
+ minimal
+ />
+ </Popover>
+ </div>;
+ }
+
+ private handleQueryInfoClick = () => {
+ const { queryExtraInfo } = this.props;
+ const id = queryExtraInfo.queryId || queryExtraInfo.sqlQueryId;
+ if (!id) return;
+
+ copy(id, { format: 'text/plain' });
+ AppToaster.show({
+ message: 'Query ID copied to clipboard',
+ intent: Intent.SUCCESS
+ });
+ }
+
+ private handleDownload = (format: string) => {
+ const { queryExtraInfo, onDownload } = this.props;
+ const id = queryExtraInfo.queryId || queryExtraInfo.sqlQueryId;
+ if (!id) return;
+
+ onDownload(`query-${id}.${format}`, format);
+ }
+}
diff --git a/web-console/src/views/query-view/query-input/__snapshots__/query-input.spec.tsx.snap b/web-console/src/views/query-view/query-input/__snapshots__/query-input.spec.tsx.snap
new file mode 100644
index 0000000..e2d965d
--- /dev/null
+++ b/web-console/src/views/query-view/query-input/__snapshots__/query-input.spec.tsx.snap
@@ -0,0 +1,100 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`query input matches snapshot 1`] = `
+<div
+ class="query-input"
+>
+ <div
+ class="ace-container"
+ >
+ <div
+ class=" ace_editor ace-tm ace_focus"
+ id="ace-editor"
+ style="width: 100%; height: 200px; font-size: 14px;"
+ >
+ <textarea
+ autocapitalize="off"
+ autocorrect="off"
+ class="ace_text-input"
+ spellcheck="false"
+ style="opacity: 0; position: fixed; top: 0px;"
+ wrap="off"
+ />
+ <div
+ aria-hidden="true"
+ class="ace_gutter"
+ >
+ <div
+ class="ace_layer ace_gutter-layer ace_folding-enabled"
+ />
+ <div
+ class="ace_gutter-active-line"
+ />
+ </div>
+ <div
+ class="ace_scroller"
+ >
+ <div
+ class="ace_content"
+ >
+ <div
+ class="ace_layer ace_print-margin-layer"
+ >
+ <div
+ class="ace_print-margin"
+ style="left: 4px; visibility: hidden;"
+ />
+ </div>
+ <div
+ class="ace_layer ace_marker-layer"
+ />
+ <div
+ class="ace_layer ace_text-layer"
+ style="padding: 0px 4px;"
+ />
+ <div
+ class="ace_layer ace_marker-layer"
+ />
+ <div
+ class="ace_layer ace_cursor-layer"
+ >
+ <div
+ class="ace_cursor"
+ />
+ </div>
+ </div>
+ </div>
+ <div
+ class="ace_scrollbar ace_scrollbar-v"
+ style="display: none; width: 20px;"
+ >
+ <div
+ class="ace_scrollbar-inner"
+ style="width: 20px;"
+ />
+ </div>
+ <div
+ class="ace_scrollbar ace_scrollbar-h"
+ style="display: none; height: 20px;"
+ >
+ <div
+ class="ace_scrollbar-inner"
+ style="height: 20px;"
+ />
+ </div>
+ <div
+ style="height: auto; width: auto; top: 0px; left: 0px; visibility: hidden; position: absolute; white-space: pre; overflow: hidden;"
+ >
+ <div
+ style="height: auto; width: auto; top: 0px; left: 0px; visibility: hidden; position: absolute; white-space: pre; overflow: visible;"
+ />
+ <div
+ style="height: auto; width: auto; top: 0px; left: 0px; visibility: hidden; position: absolute; white-space: pre; overflow: visible;"
+ >
+ XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+`;
diff --git a/web-console/src/views/sql-view/sql-control/sql-control.scss b/web-console/src/views/query-view/query-input/query-input.scss
similarity index 76%
rename from web-console/src/views/sql-view/sql-control/sql-control.scss
rename to web-console/src/views/query-view/query-input/query-input.scss
index 7b54424..ec7c3f3 100644
--- a/web-console/src/views/sql-view/sql-control/sql-control.scss
+++ b/web-console/src/views/query-view/query-input/query-input.scss
@@ -18,12 +18,11 @@
@import "../../../variables";
-.sql-control {
+.query-input {
.ace-container {
position: absolute;
width: 100%;
- top: 0;
- bottom: 30px + $standard-padding;
+ height: 100%;
.ace_scroller {
background-color: #232C35;
@@ -33,24 +32,6 @@
background-color: #27313c;
}
}
-
- .buttons {
- position: absolute;
- width: 100%;
- bottom: 0;
- height: 30px;
-
- .query-elapsed {
- padding: 5px;
- position: absolute;
- right: 25px;
- }
-
- .download-button {
- position: absolute;
- right: 0px;
- }
- }
}
.ace_tooltip {
@@ -70,7 +51,3 @@
font-size: 18px;
}
}
-
-.bp3-menu.download-format-menu {
- min-width: 80px;
-}
diff --git a/web-console/src/views/sql-view/sql-control/sql-control.spec.tsx b/web-console/src/views/query-view/query-input/query-input.spec.tsx
similarity index 78%
copy from web-console/src/views/sql-view/sql-control/sql-control.spec.tsx
copy to web-console/src/views/query-view/query-input/query-input.spec.tsx
index a044aa4..55606d3 100644
--- a/web-console/src/views/sql-view/sql-control/sql-control.spec.tsx
+++ b/web-console/src/views/query-view/query-input/query-input.spec.tsx
@@ -19,16 +19,15 @@
import React from 'react';
import { render } from 'react-testing-library';
-import { SqlControl } from './sql-control';
+import { QueryInput } from './query-input';
-describe('sql control', () => {
+describe('query input', () => {
it('matches snapshot', () => {
- const sqlControl = <SqlControl
- initSql={'test'}
- onRun={(query, context, wrapQuery) => {}}
- onExplain={(sqlQuery, context) => {}}
- queryElapsed={2}
- onDownload={() => {}}
+ const sqlControl = <QueryInput
+ queryString="hello world"
+ onQueryStringChange={() => null}
+ runeMode={false}
+ columnMetadata={null}
/>;
const { container } = render(sqlControl);
diff --git a/web-console/src/views/query-view/query-input/query-input.tsx b/web-console/src/views/query-view/query-input/query-input.tsx
new file mode 100644
index 0000000..ac7ad65
--- /dev/null
+++ b/web-console/src/views/query-view/query-input/query-input.tsx
@@ -0,0 +1,194 @@
+/*
+ * 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 { IResizeEntry, ITreeNode, ResizeSensor } from '@blueprintjs/core';
+import ace from 'brace';
+import React from 'react';
+import AceEditor from 'react-ace';
+import ReactDOMServer from 'react-dom/server';
+
+import { SQLFunctionDoc } from '../../../../lib/sql-function-doc';
+import { uniq } from '../../../utils';
+import { ColumnMetadata } from '../../../utils/column-metadata';
+import { ColumnTreeProps, ColumnTreeState } from '../column-tree/column-tree';
+
+import './query-input.scss';
+
+const langTools = ace.acequire('ace/ext/language_tools');
+
+export interface QueryInputProps extends React.Props<any> {
+ queryString: string;
+ onQueryStringChange: (newQueryString: string) => void;
+ runeMode: boolean;
+ columnMetadata: ColumnMetadata[] | null;
+}
+
+export interface QueryInputState {
+ // For reasons (https://github.com/securingsincity/react-ace/issues/415) react ace editor needs an explicit height
+ // Since this component will grown and shrink dynamically we will measure its height and then set it.
+ editorHeight: number;
+ prevColumnMetadata: ColumnMetadata[] | null;
+}
+
+export class QueryInput extends React.PureComponent<QueryInputProps, QueryInputState> {
+
+ static getDerivedStateFromProps(props: ColumnTreeProps, state: ColumnTreeState) {
+ const { columnMetadata } = props;
+
+ if (columnMetadata && columnMetadata !== state.prevColumnMetadata) {
+ const completions = ([] as any[]).concat(
+ uniq(columnMetadata.map(d => d.TABLE_SCHEMA)).map(v => ({ value: v, score: 10, meta: 'schema' })),
+ uniq(columnMetadata.map(d => d.TABLE_NAME)).map(v => ({ value: v, score: 49, meta: 'datasource' })),
+ uniq(columnMetadata.map(d => d.COLUMN_NAME)).map(v => ({ value: v, score: 50, meta: 'column' }))
+ );
+
+ langTools.addCompleter({
+ getCompletions: (editor: any, session: any, pos: any, prefix: any, callback: any) => {
+ callback(null, completions);
+ }
+ });
+
+ return {
+ prevColumnMetadata: columnMetadata
+ };
+ }
+ return null;
+ }
+
+ constructor(props: QueryInputProps, context: any) {
+ super(props, context);
+ this.state = {
+ editorHeight: 200,
+ prevColumnMetadata: null
+ };
+ }
+
+ private replaceDefaultAutoCompleter = () => {
+ if (!langTools) return;
+ /*
+ Please refer to the source code @
+ https://github.com/ajaxorg/ace/blob/9b5b63d1dc7c1b81b58d30c87d14b5905d030ca5/lib/ace/ext/language_tools.js#L41
+ for the implementation of keyword completer
+ */
+ const keywordCompleter = {
+ getCompletions: (editor: any, session: any, pos: any, prefix: any, callback: any) => {
+ if (session.$mode.completer) {
+ return session.$mode.completer.getCompletions(editor, session, pos, prefix, callback);
+ }
+ const state = editor.session.getState(pos.row);
+ let keywordCompletions = session.$mode.getCompletions(state, session, pos, prefix);
+ keywordCompletions = keywordCompletions.map((d: any) => {
+ return Object.assign(d, {name: d.name.toUpperCase(), value: d.value.toUpperCase()});
+ });
+ return callback(null, keywordCompletions);
+ }
+ };
+ langTools.setCompleters([langTools.snippetCompleter, langTools.textCompleter, keywordCompleter]);
+ }
+
+ private addFunctionAutoCompleter = (): void => {
+ if (!langTools) return;
+
+ const functionList: any[] = SQLFunctionDoc.map((entry: any) => {
+ let funcName: string = entry.syntax.replace(/\(.*\)/, '()');
+ if (!funcName.includes('(')) funcName = funcName.substr(0, 10);
+ return {
+ value: funcName,
+ score: 80,
+ meta: 'function',
+ syntax: entry.syntax,
+ description: entry.description,
+ completer: {
+ insertMatch: (editor: any, data: any) => {
+ editor.completer.insertMatch({value: data.caption});
+ const pos = editor.getCursorPosition();
+ editor.gotoLine(pos.row + 1, pos.column - 1);
+ }
+ }
+ };
+ });
+
+ langTools.addCompleter({
+ getCompletions: (editor: any, session: any, pos: any, prefix: any, callback: any) => {
+ callback(null, functionList);
+ },
+ getDocTooltip: (item: any) => {
+ if (item.meta === 'function') {
+ const functionName = item.caption.slice(0, -2);
+ item.docHTML = ReactDOMServer.renderToStaticMarkup((
+ <div className="function-doc">
+ <div className="function-doc-name"><b>{functionName}</b></div>
+ <hr/>
+ <div><b>Syntax:</b></div>
+ <div>{item.syntax}</div>
+ <br/>
+ <div><b>Description:</b></div>
+ <div>{item.description}</div>
+ </div>
+ ));
+ }
+ }
+ });
+ }
+
+ componentDidMount(): void {
+ this.replaceDefaultAutoCompleter();
+ this.addFunctionAutoCompleter();
+ }
+
+ private handleAceContainerResize = (entries: IResizeEntry[]) => {
+ if (entries.length !== 1) return;
+ this.setState({ editorHeight: entries[0].contentRect.height });
+ }
+
+ render() {
+ const { queryString, runeMode, onQueryStringChange } = this.props;
+ const { editorHeight } = this.state;
+
+ // Set the key in the AceEditor to force a rebind and prevent an error that happens otherwise
+ return <div className="query-input">
+ <ResizeSensor onResize={this.handleAceContainerResize}>
+ <div className="ace-container">
+ <AceEditor
+ key={runeMode ? 'hjson' : 'sql'}
+ mode={runeMode ? 'hjson' : 'sql'}
+ theme="solarized_dark"
+ name="ace-editor"
+ onChange={onQueryStringChange}
+ focus
+ fontSize={14}
+ width="100%"
+ height={`${editorHeight}px`}
+ showPrintMargin={false}
+ value={queryString}
+ editorProps={{
+ $blockScrolling: Infinity
+ }}
+ setOptions={{
+ enableBasicAutocompletion: !runeMode,
+ enableLiveAutocompletion: !runeMode,
+ showLineNumbers: true,
+ tabSize: 2
+ }}
+ style={{}}
+ />
+ </div>
+ </ResizeSensor>
+ </div>;
+ }
+}
diff --git a/web-console/src/views/query-view/query-output/__snapshots__/query-output.spec.tsx.snap b/web-console/src/views/query-view/query-output/__snapshots__/query-output.spec.tsx.snap
new file mode 100644
index 0000000..65e36a8
--- /dev/null
+++ b/web-console/src/views/query-view/query-output/__snapshots__/query-output.spec.tsx.snap
@@ -0,0 +1,320 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`query output matches snapshot 1`] = `
+<div
+ class="query-output"
+>
+ <div
+ class="ReactTable"
+ >
+ <div
+ class="rt-table"
+ role="grid"
+ >
+ <div
+ class="rt-thead -header"
+ style="min-width: 0px;"
+ >
+ <div
+ class="rt-tr"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tbody"
+ style="min-width: 0px;"
+ >
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -odd"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -even"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -odd"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -even"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -odd"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -even"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -odd"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -even"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -odd"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -even"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -odd"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -even"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -odd"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -even"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -odd"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -even"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -odd"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -even"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -odd"
+ role="row"
+ />
+ </div>
+ <div
+ class="rt-tr-group"
+ role="rowgroup"
+ >
+ <div
+ class="rt-tr -padRow -even"
+ role="row"
+ />
+ </div>
+ </div>
+ </div>
+ <div
+ class="pagination-bottom"
+ >
+ <div
+ class="-pagination"
+ >
+ <div
+ class="-previous"
+ >
+ <button
+ class="-btn"
+ disabled=""
+ type="button"
+ >
+ Previous
+ </button>
+ </div>
+ <div
+ class="-center"
+ >
+ <span
+ class="-pageInfo"
+ >
+ Page
+
+ <div
+ class="-pageJump"
+ >
+ <input
+ aria-label="jump to page"
+ type="number"
+ value="1"
+ />
+ </div>
+
+ of
+
+ <span
+ class="-totalPages"
+ >
+ 1
+ </span>
+ </span>
+ <span
+ class="select-wrap -pageSizeOptions"
+ >
+ <select
+ aria-label="rows per page"
+ >
+ <option
+ value="5"
+ >
+ 5 rows
+ </option>
+ <option
+ value="10"
+ >
+ 10 rows
+ </option>
+ <option
+ value="20"
+ >
+ 20 rows
+ </option>
+ <option
+ value="25"
+ >
+ 25 rows
+ </option>
+ <option
+ value="50"
+ >
+ 50 rows
+ </option>
+ <option
+ value="100"
+ >
+ 100 rows
+ </option>
+ </select>
+ </span>
+ </div>
+ <div
+ class="-next"
+ >
+ <button
+ class="-btn"
+ disabled=""
+ type="button"
+ >
+ Next
+ </button>
+ </div>
+ </div>
+ </div>
+ <div
+ class="rt-noData"
+ >
+ lol
+ </div>
+ <div
+ class="-loading"
+ >
+ <div
+ class="-loading-inner"
+ >
+ Loading...
+ </div>
+ </div>
+ </div>
+</div>
+`;
diff --git a/web-console/src/views/index.ts b/web-console/src/views/query-view/query-output/query-output.scss
similarity index 69%
copy from web-console/src/views/index.ts
copy to web-console/src/views/query-view/query-output/query-output.scss
index 7a753bb..f5dd473 100644
--- a/web-console/src/views/index.ts
+++ b/web-console/src/views/query-view/query-output/query-output.scss
@@ -15,11 +15,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-export * from './datasource-view/datasource-view';
-export * from './home-view/home-view';
-export * from './load-data-view/load-data-view';
-export * from './lookups-view/lookups-view';
-export * from './segments-view/segments-view';
-export * from './servers-view/servers-view';
-export * from './sql-view/sql-view';
-export * from './task-view/tasks-view';
+
+.query-output {
+ .ReactTable {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 100%;
+ }
+}
diff --git a/web-console/src/views/sql-view/sql-control/sql-control.spec.tsx b/web-console/src/views/query-view/query-output/query-output.spec.tsx
similarity index 75%
copy from web-console/src/views/sql-view/sql-control/sql-control.spec.tsx
copy to web-console/src/views/query-view/query-output/query-output.spec.tsx
index a044aa4..1a9f04e 100644
--- a/web-console/src/views/sql-view/sql-control/sql-control.spec.tsx
+++ b/web-console/src/views/query-view/query-output/query-output.spec.tsx
@@ -19,19 +19,17 @@
import React from 'react';
import { render } from 'react-testing-library';
-import { SqlControl } from './sql-control';
+import { QueryOutput } from './query-output';
-describe('sql control', () => {
+describe('query output', () => {
it('matches snapshot', () => {
- const sqlControl = <SqlControl
- initSql={'test'}
- onRun={(query, context, wrapQuery) => {}}
- onExplain={(sqlQuery, context) => {}}
- queryElapsed={2}
- onDownload={() => {}}
+ const queryOutput = <QueryOutput
+ loading={false}
+ result={null}
+ error="lol"
/>;
- const { container } = render(sqlControl);
+ const { container } = render(queryOutput);
expect(container.firstChild).toMatchSnapshot();
});
});
diff --git a/web-console/src/views/query-view/query-output/query-output.tsx b/web-console/src/views/query-view/query-output/query-output.tsx
new file mode 100644
index 0000000..96e4779
--- /dev/null
+++ b/web-console/src/views/query-view/query-output/query-output.tsx
@@ -0,0 +1,56 @@
+/*
+ * 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 React from 'react';
+import ReactTable from 'react-table';
+
+import { TableCell } from '../../../components';
+import { HeaderRows } from '../../../utils';
+
+import './query-output.scss';
+
+export interface QueryOutputProps extends React.Props<any> {
+ loading: boolean;
+ result: HeaderRows | null;
+ error: string | null;
+}
+
+export class QueryOutput extends React.PureComponent<QueryOutputProps> {
+
+ render() {
+ const { result, loading, error } = this.props;
+
+ return <div className="query-output">
+ <ReactTable
+ data={result ? result.rows : []}
+ loading={loading}
+ noDataText={!loading && result && !result.rows.length ? 'No results' : (error || '')}
+ sortable={false}
+ columns={
+ (result ? result.header : []).map((h: any, i) => {
+ return {
+ Header: h,
+ accessor: String(i),
+ Cell: row => <TableCell value={row.value}/>
+ };
+ })
+ }
+ />
+ </div>;
+ }
+}
diff --git a/web-console/src/views/sql-view/sql-view.scss b/web-console/src/views/query-view/query-view.scss
similarity index 56%
rename from web-console/src/views/sql-view/sql-view.scss
rename to web-console/src/views/query-view/query-view.scss
index f1ef90b..ec179eb 100644
--- a/web-console/src/views/sql-view/sql-view.scss
+++ b/web-console/src/views/query-view/query-view.scss
@@ -18,40 +18,71 @@
@import "../../variables";
-.sql-view {
+$nav-width: 250px;
+
+.query-view {
height: 100%;
width: 100%;
- .top-pane {
+ .column-tree {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: $nav-width;
+ }
+
+ .splitter-layout {
position: absolute;
- width: 100%;
top: 0;
- bottom: 10px;
+ height: 100%;
+ left: $nav-width + $standard-padding;
+ right: 0;
+ width: auto;
- .sql-control {
+ .control-pane {
position: absolute;
width: 100%;
- height: 100%;
- }
- }
+ top: 0;
+ bottom: 10px;
- &.splitter-layout.splitter-layout-vertical > .layout-splitter {
- height: 3px;
- background-color: #6d8ea9;
- }
+ .query-input {
+ position: absolute;
+ top: 0;
+ width: 100%;
+ bottom: 30px + $standard-padding;
+ }
- .bottom-pane {
- position: absolute;
- width: 100%;
- top: 10px;
- bottom: 0;
+ .control-bar {
+ position: absolute;
+ bottom: 0;
+ width: 100%;
- .ReactTable {
+ .query-extra-info {
+ position: absolute;
+ right: 0;
+ top: 0;
+ }
+ }
+ }
+
+ &.splitter-layout-vertical > .layout-splitter {
+ height: 3px;
+ background-color: #6d8ea9;
+ }
+
+ .query-output {
position: absolute;
- top: 0;
- bottom: 0;
width: 100%;
+ top: 10px;
+ bottom: 0;
+
+
}
}
+
+ &.hide-column-tree .splitter-layout {
+ left: 0;
+ }
}
diff --git a/web-console/src/views/sql-view/sql-view.spec.tsx b/web-console/src/views/query-view/query-view.spec.tsx
similarity index 73%
rename from web-console/src/views/sql-view/sql-view.spec.tsx
rename to web-console/src/views/query-view/query-view.spec.tsx
index e5809a8..a34cf0a 100644
--- a/web-console/src/views/sql-view/sql-view.spec.tsx
+++ b/web-console/src/views/query-view/query-view.spec.tsx
@@ -19,20 +19,20 @@
import { shallow } from 'enzyme';
import React from 'react';
-import { SqlView } from './sql-view';
+import { QueryView } from './query-view';
describe('sql view', () => {
it('matches snapshot', () => {
const sqlView = shallow(
- <SqlView
- initSql={'test'}
+ <QueryView
+ initQuery={'test'}
/>);
expect(sqlView).toMatchSnapshot();
});
it('trimSemicolon', () => {
- expect(SqlView.trimSemicolon('SELECT * FROM tbl;')).toEqual('SELECT * FROM tbl');
- expect(SqlView.trimSemicolon('SELECT * FROM tbl; ')).toEqual('SELECT * FROM tbl ');
- expect(SqlView.trimSemicolon('SELECT * FROM tbl; --hello ')).toEqual('SELECT * FROM tbl --hello ');
+ expect(QueryView.trimSemicolon('SELECT * FROM tbl;')).toEqual('SELECT * FROM tbl');
+ expect(QueryView.trimSemicolon('SELECT * FROM tbl; ')).toEqual('SELECT * FROM tbl ');
+ expect(QueryView.trimSemicolon('SELECT * FROM tbl; --hello ')).toEqual('SELECT * FROM tbl --hello ');
});
});
diff --git a/web-console/src/views/query-view/query-view.tsx b/web-console/src/views/query-view/query-view.tsx
new file mode 100644
index 0000000..cffc9e9
--- /dev/null
+++ b/web-console/src/views/query-view/query-view.tsx
@@ -0,0 +1,397 @@
+/*
+ * 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 { Intent } from '@blueprintjs/core';
+import axios from 'axios';
+import classNames from 'classnames';
+import Hjson from 'hjson';
+import React from 'react';
+import SplitterLayout from 'react-splitter-layout';
+
+import { QueryPlanDialog } from '../../dialogs';
+import { AppToaster } from '../../singletons/toaster';
+import {
+ BasicQueryExplanation,
+ decodeRune,
+ downloadFile, getDruidErrorMessage,
+ HeaderRows,
+ localStorageGet, LocalStorageKeys,
+ localStorageSet, parseQueryPlan,
+ queryDruidSql, QueryManager,
+ SemiJoinQueryExplanation
+} from '../../utils';
+import { ColumnMetadata } from '../../utils/column-metadata';
+import { isEmptyContext, QueryContext } from '../../utils/query-context';
+
+import { ColumnTree } from './column-tree/column-tree';
+import { QueryExtraInfo, QueryExtraInfoData } from './query-extra-info/query-extra-info';
+import { QueryInput } from './query-input/query-input';
+import { QueryOutput } from './query-output/query-output';
+import { RunButton } from './run-button/run-button';
+
+import './query-view.scss';
+
+interface QueryWithContext {
+ queryString: string;
+ queryContext: QueryContext;
+ wrapQuery?: boolean;
+}
+
+export interface QueryViewProps extends React.Props<any> {
+ initQuery: string | null;
+}
+
+export interface QueryViewState {
+ queryString: string;
+ queryContext: QueryContext;
+
+ columnMetadataLoading: boolean;
+ columnMetadata: ColumnMetadata[] | null;
+ columnMetadataError: string | null;
+
+ loading: boolean;
+ result: HeaderRows | null;
+ queryExtraInfo: QueryExtraInfoData | null;
+ error: string | null;
+
+ explainDialogOpen: boolean;
+ explainResult: BasicQueryExplanation | SemiJoinQueryExplanation | string | null;
+ loadingExplain: boolean;
+ explainError: Error | null;
+}
+
+interface QueryResult {
+ queryResult: HeaderRows;
+ queryExtraInfo: QueryExtraInfoData;
+}
+
+export class QueryView extends React.PureComponent<QueryViewProps, QueryViewState> {
+ static trimSemicolon(query: string): string {
+ // Trims out a trailing semicolon while preserving space (https://bit.ly/1n1yfkJ)
+ return query.replace(/;+((?:\s*--[^\n]*)?\s*)$/, '$1');
+ }
+
+ static isRune(queryString: string): boolean {
+ return queryString.trim().startsWith('{');
+ }
+
+ static validRune(queryString: string): boolean {
+ try {
+ Hjson.parse(queryString);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ static formatStr(s: string | number, format: 'csv' | 'tsv') {
+ if (format === 'csv') {
+ // remove line break, single quote => double quote, handle ','
+ return `"${String(s).replace(/(?:\r\n|\r|\n)/g, ' ').replace(/"/g, '""')}"`;
+ } else { // tsv
+ // remove line break, single quote => double quote, \t => ''
+ return String(s).replace(/(?:\r\n|\r|\n)/g, ' ').replace(/\t/g, '').replace(/"/g, '""');
+ }
+ }
+
+ private metadataQueryManager: QueryManager<string, ColumnMetadata[]>;
+ private sqlQueryManager: QueryManager<QueryWithContext, QueryResult>;
+ private explainQueryManager: QueryManager<QueryWithContext, BasicQueryExplanation | SemiJoinQueryExplanation | string>;
+
+ constructor(props: QueryViewProps, context: any) {
+ super(props, context);
+ this.state = {
+ queryString: props.initQuery || localStorageGet(LocalStorageKeys.QUERY_KEY) || '',
+ queryContext: {},
+
+ columnMetadataLoading: false,
+ columnMetadata: null,
+ columnMetadataError: null,
+
+ loading: false,
+ result: null,
+ queryExtraInfo: null,
+ error: null,
+
+ explainDialogOpen: false,
+ loadingExplain: false,
+ explainResult: null,
+ explainError: null
+ };
+ }
+
+ componentDidMount(): void {
+ this.metadataQueryManager = new QueryManager({
+ processQuery: async (query: string) => {
+ return await queryDruidSql<ColumnMetadata>({
+ query: `SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS`
+ });
+ },
+ onStateChange: ({ result, loading, error }) => {
+ if (error) {
+ AppToaster.show({
+ message: 'Could not load SQL metadata',
+ intent: Intent.DANGER
+ });
+ }
+ this.setState({
+ columnMetadataLoading: loading,
+ columnMetadata: result,
+ columnMetadataError: error
+ });
+ }
+ });
+
+ this.metadataQueryManager.runQuery('dummy');
+
+ this.sqlQueryManager = new QueryManager({
+ processQuery: async (queryWithContext: QueryWithContext) => {
+ const { queryString, queryContext, wrapQuery } = queryWithContext;
+ let queryId: string | null = null;
+ let sqlQueryId: string | null = null;
+ let wrappedLimit: number | undefined;
+
+ let queryResult: HeaderRows;
+ const startTime = new Date();
+ let endTime: Date;
+
+ if (QueryView.isRune(queryString)) {
+ // Secret way to issue a native JSON "rune" query
+ const runeQuery = Hjson.parse(queryString);
+
+ if (!isEmptyContext(queryContext)) runeQuery.context = queryContext;
+ let runeResult: any[];
+ try {
+ const runeResultResp = await axios.post('/druid/v2', runeQuery);
+ endTime = new Date();
+ runeResult = runeResultResp.data;
+ queryId = runeResultResp.headers['x-druid-query-id'];
+ } catch (e) {
+ throw new Error(getDruidErrorMessage(e));
+ }
+
+ queryResult = decodeRune(runeQuery, runeResult);
+
+ } else {
+ const actualQuery = wrapQuery ?
+ `SELECT * FROM (${QueryView.trimSemicolon(queryString)}\n) LIMIT 1000` :
+ queryString;
+
+ if (wrapQuery) wrappedLimit = 1000;
+
+ const queryPayload: Record<string, any> = {
+ query: actualQuery,
+ resultFormat: 'array',
+ header: true
+ };
+
+ if (!isEmptyContext(queryContext)) queryPayload.context = queryContext;
+ let sqlResult: any[];
+ try {
+ const sqlResultResp = await axios.post('/druid/v2/sql', queryPayload);
+ endTime = new Date();
+ sqlResult = sqlResultResp.data;
+ sqlQueryId = sqlResultResp.headers['x-druid-sql-query-id'];
+ } catch (e) {
+ throw new Error(getDruidErrorMessage(e));
+ }
+
+ queryResult = {
+ header: (sqlResult && sqlResult.length) ? sqlResult[0] : [],
+ rows: (sqlResult && sqlResult.length) ? sqlResult.slice(1) : []
+ };
+ }
+
+ return {
+ queryResult,
+ queryExtraInfo: {
+ queryId,
+ sqlQueryId,
+ startTime,
+ endTime,
+ numResults: queryResult.rows.length,
+ wrappedLimit
+ }
+ };
+ },
+ onStateChange: ({ result, loading, error }) => {
+ this.setState({
+ result: result ? result.queryResult : null,
+ queryExtraInfo: result ? result.queryExtraInfo : null,
+ loading,
+ error
+ });
+ }
+ });
+
+ this.explainQueryManager = new QueryManager({
+ processQuery: async (queryWithContext: QueryWithContext) => {
+ const { queryString, queryContext } = queryWithContext;
+ const explainPayload: Record<string, any> = {
+ query: `EXPLAIN PLAN FOR (${QueryView.trimSemicolon(queryString)}\n)`,
+ resultFormat: 'object'
+ };
+
+ if (!isEmptyContext(queryContext)) explainPayload.context = queryContext;
+ const result = await queryDruidSql(explainPayload);
+
+ return parseQueryPlan(result[0]['PLAN']);
+ },
+ onStateChange: ({ result, loading, error }) => {
+ this.setState({
+ explainResult: result,
+ loadingExplain: loading,
+ explainError: error !== null ? new Error(error) : null
+ });
+ }
+ });
+ }
+
+ componentWillUnmount(): void {
+ this.metadataQueryManager.terminate();
+ this.sqlQueryManager.terminate();
+ this.explainQueryManager.terminate();
+ }
+
+ handleDownload = (filename: string, format: string) => {
+ const { result } = this.state;
+ if (!result) return;
+ let lines: string[] = [];
+ let separator: string = '';
+
+ if (format === 'csv' || format === 'tsv') {
+ separator = format === 'csv' ? ',' : '\t';
+ lines.push(result.header.map(str => QueryView.formatStr(str, format)).join(separator));
+ lines = lines.concat(result.rows.map(r => r.map(cell => QueryView.formatStr(cell, format)).join(separator)));
+ } else { // json
+ lines = result.rows.map(r => {
+ const outputObject: Record<string, any> = {};
+ for (let k = 0; k < r.length; k++) {
+ const newName = result.header[k];
+ if (newName) {
+ outputObject[newName] = r[k];
+ }
+ }
+ return JSON.stringify(outputObject);
+ });
+ }
+
+ const lineBreak = '\n';
+ downloadFile(lines.join(lineBreak), format, filename);
+ }
+
+ renderExplainDialog() {
+ const {explainDialogOpen, explainResult, loadingExplain, explainError} = this.state;
+ if (!loadingExplain && explainDialogOpen) {
+ return <QueryPlanDialog
+ explainResult={explainResult}
+ explainError={explainError}
+ onClose={() => this.setState({explainDialogOpen: false})}
+ />;
+ }
+ return null;
+ }
+
+ renderMainArea() {
+ const { queryString, queryContext, loading, result, queryExtraInfo, error, columnMetadata } = this.state;
+ const runeMode = QueryView.isRune(queryString);
+
+ return <SplitterLayout
+ vertical
+ percentage
+ secondaryInitialSize={Number(localStorageGet(LocalStorageKeys.QUERY_VIEW_PANE_SIZE) as string) || 60}
+ primaryMinSize={30}
+ secondaryMinSize={30}
+ onSecondaryPaneSizeChange={this.handleSecondaryPaneSizeChange}
+ >
+ <div className="control-pane">
+ <QueryInput
+ queryString={queryString}
+ onQueryStringChange={this.handleQueryStringChange}
+ runeMode={runeMode}
+ columnMetadata={columnMetadata}
+ />
+ <div className="control-bar">
+ <RunButton
+ runeMode={runeMode}
+ queryContext={queryContext}
+ onQueryContextChange={this.handleQueryContextChange}
+ onRun={this.handleRun}
+ onExplain={this.handleExplain}
+ />
+ {
+ queryExtraInfo &&
+ <QueryExtraInfo
+ queryExtraInfo={queryExtraInfo}
+ onDownload={this.handleDownload}
+ />
+ }
+ </div>
+ </div>
+ <QueryOutput
+ loading={loading}
+ result={result}
+ error={error}
+ />
+ </SplitterLayout>;
+ }
+
+ private handleQueryStringChange = (queryString: string): void => {
+ this.setState({ queryString });
+ }
+
+ private handleQueryContextChange = (queryContext: QueryContext) => {
+ this.setState({ queryContext });
+ }
+
+ private handleRun = (wrapQuery: boolean) => {
+ const { queryString, queryContext } = this.state;
+
+ if (QueryView.isRune(queryString) && !QueryView.validRune(queryString)) return;
+
+ localStorageSet(LocalStorageKeys.QUERY_KEY, queryString);
+ this.sqlQueryManager.runQuery({ queryString, queryContext, wrapQuery });
+ }
+
+ private handleExplain = () => {
+ const { queryString, queryContext } = this.state;
+ this.setState({ explainDialogOpen: true });
+ this.explainQueryManager.runQuery({ queryString, queryContext });
+ }
+
+ private handleSecondaryPaneSizeChange = (secondaryPaneSize: number) => {
+ localStorageSet(LocalStorageKeys.QUERY_VIEW_PANE_SIZE, String(secondaryPaneSize));
+ }
+
+ render() {
+ const { columnMetadata, columnMetadataLoading, columnMetadataError } = this.state;
+
+ return <div className={classNames('query-view app-view', { 'hide-column-tree': columnMetadataError })}>
+ {
+ !columnMetadataError &&
+ <ColumnTree
+ columnMetadataLoading={columnMetadataLoading}
+ columnMetadata={columnMetadata}
+ onQueryStringChange={this.handleQueryStringChange}
+ />
+ }
+ {this.renderMainArea()}
+ {this.renderExplainDialog()}
+ </div>;
+ }
+}
diff --git a/web-console/src/views/query-view/run-button/__snapshots__/run-button.spec.tsx.snap b/web-console/src/views/query-view/run-button/__snapshots__/run-button.spec.tsx.snap
new file mode 100644
index 0000000..034d79b
--- /dev/null
+++ b/web-console/src/views/query-view/run-button/__snapshots__/run-button.spec.tsx.snap
@@ -0,0 +1,78 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`run button matches snapshot 1`] = `
+<div
+ class="bp3-button-group run-button"
+>
+ <span
+ class="bp3-popover-wrapper"
+ >
+ <span
+ class="bp3-popover-target"
+ >
+ <button
+ class="bp3-button"
+ tabindex="0"
+ type="button"
+ >
+ <span
+ class="bp3-icon bp3-icon-caret-right"
+ icon="caret-right"
+ >
+ <svg
+ data-icon="caret-right"
+ height="16"
+ viewBox="0 0 16 16"
+ width="16"
+ >
+ <desc>
+ caret-right
+ </desc>
+ <path
+ d="M11 8c0-.15-.07-.28-.17-.37l-4-3.5A.495.495 0 0 0 6 4.5v7a.495.495 0 0 0 .83.37l4-3.5c.1-.09.17-.22.17-.37z"
+ fill-rule="evenodd"
+ />
+ </svg>
+ </span>
+ <span
+ class="bp3-button-text"
+ >
+ Run with limit
+ </span>
+ </button>
+ </span>
+ </span>
+ <span
+ class="bp3-popover-wrapper"
+ >
+ <span
+ class="bp3-popover-target"
+ >
+ <button
+ class="bp3-button"
+ type="button"
+ >
+ <span
+ class="bp3-icon bp3-icon-more"
+ icon="more"
+ >
+ <svg
+ data-icon="more"
+ height="16"
+ viewBox="0 0 16 16"
+ width="16"
+ >
+ <desc>
+ more
+ </desc>
+ <path
+ d="M2 6.03a2 2 0 1 0 0 4 2 2 0 1 0 0-4zM14 6.03a2 2 0 1 0 0 4 2 2 0 1 0 0-4zM8 6.03a2 2 0 1 0 0 4 2 2 0 1 0 0-4z"
+ fill-rule="evenodd"
+ />
+ </svg>
+ </span>
+ </button>
+ </span>
+ </span>
+</div>
+`;
diff --git a/web-console/src/views/index.ts b/web-console/src/views/query-view/run-button/run-button.scss
similarity index 69%
copy from web-console/src/views/index.ts
copy to web-console/src/views/query-view/run-button/run-button.scss
index 7a753bb..4bd20b2 100644
--- a/web-console/src/views/index.ts
+++ b/web-console/src/views/query-view/run-button/run-button.scss
@@ -15,11 +15,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-export * from './datasource-view/datasource-view';
-export * from './home-view/home-view';
-export * from './load-data-view/load-data-view';
-export * from './lookups-view/lookups-view';
-export * from './segments-view/segments-view';
-export * from './servers-view/servers-view';
-export * from './sql-view/sql-view';
-export * from './task-view/tasks-view';
+
+.run-button {
+
+}
diff --git a/web-console/src/views/sql-view/sql-control/sql-control.spec.tsx b/web-console/src/views/query-view/run-button/run-button.spec.tsx
similarity index 75%
rename from web-console/src/views/sql-view/sql-control/sql-control.spec.tsx
rename to web-console/src/views/query-view/run-button/run-button.spec.tsx
index a044aa4..18b6f1c 100644
--- a/web-console/src/views/sql-view/sql-control/sql-control.spec.tsx
+++ b/web-console/src/views/query-view/run-button/run-button.spec.tsx
@@ -19,19 +19,19 @@
import React from 'react';
import { render } from 'react-testing-library';
-import { SqlControl } from './sql-control';
+import { RunButton } from './run-button';
-describe('sql control', () => {
+describe('run button', () => {
it('matches snapshot', () => {
- const sqlControl = <SqlControl
- initSql={'test'}
- onRun={(query, context, wrapQuery) => {}}
- onExplain={(sqlQuery, context) => {}}
- queryElapsed={2}
- onDownload={() => {}}
+ const runButton = <RunButton
+ runeMode={false}
+ queryContext={{}}
+ onQueryContextChange={() => null}
+ onRun={() => null}
+ onExplain={() => null}
/>;
- const { container } = render(sqlControl);
+ const { container } = render(runButton);
expect(container.firstChild).toMatchSnapshot();
});
});
diff --git a/web-console/src/views/query-view/run-button/run-button.tsx b/web-console/src/views/query-view/run-button/run-button.tsx
new file mode 100644
index 0000000..8b8e3de
--- /dev/null
+++ b/web-console/src/views/query-view/run-button/run-button.tsx
@@ -0,0 +1,154 @@
+/*
+ * 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, ButtonGroup,
+ Hotkey,
+ Hotkeys,
+ HotkeysTarget,
+ Menu,
+ MenuItem,
+ Popover,
+ Position, Tooltip
+} from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import React from 'react';
+
+import { MenuCheckbox } from '../../../components';
+import {
+ getUseApproximateCountDistinct,
+ getUseApproximateTopN, getUseCache,
+ QueryContext,
+ setUseApproximateCountDistinct, setUseApproximateTopN, setUseCache
+} from '../../../utils/query-context';
+import { DRUID_DOCS_RUNE, DRUID_DOCS_SQL } from '../../../variables';
+
+import './run-button.scss';
+
+export interface RunButtonProps extends React.Props<any> {
+ runeMode: boolean;
+ queryContext: QueryContext;
+ onQueryContextChange: (newQueryContext: QueryContext) => void;
+ onRun: ((wrapQuery: boolean) => void) | null;
+ onExplain: () => void;
+}
+
+interface RunButtonState {
+ wrapQuery: boolean;
+}
+
+@HotkeysTarget
+export class RunButton extends React.PureComponent<RunButtonProps, RunButtonState> {
+ constructor(props: RunButtonProps, context: any) {
+ super(props, context);
+ this.state = {
+ wrapQuery: true
+ };
+ }
+
+ public renderHotkeys() {
+ return <Hotkeys>
+ <Hotkey
+ allowInInput
+ global
+ combo="ctrl + enter"
+ label="run on click"
+ onKeyDown={this.handleRun}
+ />
+ </Hotkeys>;
+ }
+
+ private handleRun = () => {
+ const { onRun } = this.props;
+ if (!onRun) return;
+ onRun(this.state.wrapQuery);
+ }
+
+ renderExtraMenu() {
+ const { runeMode, onExplain, queryContext, onQueryContextChange } = this.props;
+ const { wrapQuery } = this.state;
+
+ const useCache = getUseCache(queryContext);
+ const useApproximateCountDistinct = getUseApproximateCountDistinct(queryContext);
+ const useApproximateTopN = getUseApproximateTopN(queryContext);
+
+ return <Menu>
+ <MenuItem
+ icon={IconNames.HELP}
+ text="Docs"
+ href={runeMode ? DRUID_DOCS_RUNE : DRUID_DOCS_SQL}
+ target="_blank"
+ />
+ {
+ !runeMode &&
+ <>
+ <MenuItem
+ icon={IconNames.CLEAN}
+ text="Explain"
+ onClick={onExplain}
+ />
+ <MenuCheckbox
+ checked={wrapQuery}
+ label="Wrap query with limit"
+ onChange={() => this.setState({wrapQuery: !wrapQuery})}
+ />
+ <MenuCheckbox
+ checked={useApproximateCountDistinct}
+ label="Use approximate COUNT(DISTINCT)"
+ onChange={() => {
+ onQueryContextChange(setUseApproximateCountDistinct(queryContext, !useApproximateCountDistinct));
+ }}
+ />
+ <MenuCheckbox
+ checked={useApproximateTopN}
+ label="Use approximate TopN"
+ onChange={() => {
+ onQueryContextChange(setUseApproximateTopN(queryContext, !useApproximateTopN));
+ }}
+ />
+ </>
+ }
+ <MenuCheckbox
+ checked={useCache}
+ label="Use cache"
+ onChange={() => {
+ onQueryContextChange(setUseCache(queryContext, !useCache));
+ }}
+ />
+ </Menu>;
+ }
+
+ render() {
+ const { runeMode, onRun } = this.props;
+ const { wrapQuery } = this.state;
+
+ return <ButtonGroup className="run-button">
+ <Tooltip content="Control + Enter" hoverOpenDelay={900}>
+ <Button
+ icon={IconNames.CARET_RIGHT}
+ onClick={this.handleRun}
+ text={runeMode ? 'Rune' : (wrapQuery ? 'Run with limit' : 'Run as is')}
+ disabled={!onRun}
+ />
+ </Tooltip>
+ <Popover position={Position.BOTTOM_LEFT} content={this.renderExtraMenu()}>
+ <Button icon={IconNames.MORE}/>
+ </Popover>
+ </ButtonGroup>;
+ }
+}
diff --git a/web-console/src/views/segments-view/segments-view.spec.tsx b/web-console/src/views/segments-view/segments-view.spec.tsx
index b835cc6..789e376 100644
--- a/web-console/src/views/segments-view/segments-view.spec.tsx
+++ b/web-console/src/views/segments-view/segments-view.spec.tsx
@@ -27,7 +27,7 @@ describe('segments-view', () => {
<SegmentsView
datasource={'test'}
onlyUnavailable={false}
- goToSql={(initSql: string) => {}}
+ goToQuery={(initSql: string) => {}}
noSqlMode={false}
/>);
expect(segmentsView).toMatchSnapshot();
diff --git a/web-console/src/views/segments-view/segments-view.tsx b/web-console/src/views/segments-view/segments-view.tsx
index 9e3f895..eb4eb3e 100644
--- a/web-console/src/views/segments-view/segments-view.tsx
+++ b/web-console/src/views/segments-view/segments-view.tsx
@@ -44,7 +44,7 @@ const tableColumns: string[] = ['Segment ID', 'Datasource', 'Start', 'End', 'Ver
const tableColumnsNoSql: string[] = ['Segment ID', 'Datasource', 'Start', 'End', 'Version', 'Partition', 'Size'];
export interface SegmentsViewProps extends React.Props<any> {
- goToSql: (initSql: string) => void;
+ goToQuery: (initSql: string) => void;
datasource: string | null;
onlyUnavailable: boolean | null;
noSqlMode: boolean;
@@ -381,7 +381,7 @@ export class SegmentsView extends React.PureComponent<SegmentsViewProps, Segment
}
render() {
- const { goToSql, noSqlMode } = this.props;
+ const { goToQuery, noSqlMode } = this.props;
const { tableColumnSelectionHandler } = this;
return <div className="segments-view app-view">
@@ -397,7 +397,7 @@ export class SegmentsView extends React.PureComponent<SegmentsViewProps, Segment
icon={IconNames.APPLICATION}
text="Go to SQL"
hidden={noSqlMode}
- onClick={() => goToSql(this.segmentsSqlQueryManager.getLastQuery().query)}
+ onClick={() => goToQuery(this.segmentsSqlQueryManager.getLastQuery().query)}
/>
}
<TableColumnSelector
diff --git a/web-console/src/views/servers-view/servers-view.spec.tsx b/web-console/src/views/servers-view/servers-view.spec.tsx
index a8f01a7..8fdcf06 100644
--- a/web-console/src/views/servers-view/servers-view.spec.tsx
+++ b/web-console/src/views/servers-view/servers-view.spec.tsx
@@ -26,7 +26,7 @@ describe('servers view', () => {
const serversView = shallow(
<ServersView
middleManager={'test'}
- goToSql={(initSql: string) => {}}
+ goToQuery={(initSql: string) => {}}
goToTask={(taskId: string) => {}}
noSqlMode={false}
/>);
diff --git a/web-console/src/views/servers-view/servers-view.tsx b/web-console/src/views/servers-view/servers-view.tsx
index aa0789a..a3185ef 100644
--- a/web-console/src/views/servers-view/servers-view.tsx
+++ b/web-console/src/views/servers-view/servers-view.tsx
@@ -57,7 +57,7 @@ function formatQueues(segmentsToLoad: number, segmentsToLoadSize: number, segmen
export interface ServersViewProps extends React.Props<any> {
middleManager: string | null;
- goToSql: (initSql: string) => void;
+ goToQuery: (initSql: string) => void;
goToTask: (taskId: string) => void;
noSqlMode: boolean;
}
@@ -537,7 +537,7 @@ ORDER BY "rank" DESC, "server" DESC`);
}
render() {
- const { goToSql, noSqlMode } = this.props;
+ const { goToQuery, noSqlMode } = this.props;
const { groupServersBy } = this.state;
const { serverTableColumnSelectionHandler } = this;
@@ -559,7 +559,7 @@ ORDER BY "rank" DESC, "server" DESC`);
<Button
icon={IconNames.APPLICATION}
text="Go to SQL"
- onClick={() => goToSql(this.serverQueryManager.getLastQuery())}
+ onClick={() => goToQuery(this.serverQueryManager.getLastQuery())}
/>
}
<TableColumnSelector
diff --git a/web-console/src/views/sql-view/__snapshots__/sql-view.spec.tsx.snap b/web-console/src/views/sql-view/__snapshots__/sql-view.spec.tsx.snap
deleted file mode 100644
index 737e993..0000000
--- a/web-console/src/views/sql-view/__snapshots__/sql-view.spec.tsx.snap
+++ /dev/null
@@ -1,170 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`sql view matches snapshot 1`] = `
-<t
- customClassName="sql-view app-view"
- onDragEnd={null}
- onDragStart={null}
- onSecondaryPaneSizeChange={[Function]}
- percentage={true}
- primaryIndex={0}
- primaryMinSize={30}
- secondaryInitialSize={60}
- secondaryMinSize={30}
- vertical={true}
->
- <div
- className="top-pane"
- >
- <HotkeysTarget(SqlControl)
- initSql="test"
- onDownload={[Function]}
- onExplain={[Function]}
- onRun={[Function]}
- queryElapsed={null}
- />
- </div>
- <div
- className="bottom-pane"
- >
- <ReactTable
- AggregatedComponent={[Function]}
- ExpanderComponent={[Function]}
- FilterComponent={[Function]}
- LoadingComponent={[Function]}
- NoDataComponent={[Function]}
- PadRowComponent={[Function]}
- PaginationComponent={[Function]}
- PivotValueComponent={[Function]}
- ResizerComponent={[Function]}
- TableComponent={[Function]}
- TbodyComponent={[Function]}
- TdComponent={[Function]}
- TfootComponent={[Function]}
- ThComponent={[Function]}
- TheadComponent={[Function]}
- TrComponent={[Function]}
- TrGroupComponent={[Function]}
- aggregatedKey="_aggregated"
- className=""
- collapseOnDataChange={true}
- collapseOnPageChange={true}
- collapseOnSortingChange={true}
- column={
- Object {
- "Aggregated": undefined,
- "Cell": undefined,
- "Expander": undefined,
- "Filter": undefined,
- "Footer": undefined,
- "Header": undefined,
- "Pivot": undefined,
- "PivotValue": undefined,
- "Placeholder": undefined,
- "aggregate": undefined,
- "className": "",
- "filterAll": false,
- "filterMethod": undefined,
- "filterable": undefined,
- "footerClassName": "",
- "footerStyle": Object {},
- "getFooterProps": [Function],
- "getHeaderProps": [Function],
- "getProps": [Function],
- "headerClassName": "",
- "headerStyle": Object {},
- "minResizeWidth": 11,
- "minWidth": 100,
- "resizable": undefined,
- "show": true,
- "sortMethod": undefined,
- "sortable": undefined,
- "style": Object {},
- }
- }
- columns={Array []}
- data={Array []}
- defaultExpanded={Object {}}
- defaultFilterMethod={[Function]}
- defaultFiltered={Array []}
- defaultPage={0}
- defaultPageSize={20}
- defaultResized={Array []}
- defaultSortDesc={false}
- defaultSortMethod={[Function]}
- defaultSorted={Array []}
- expanderDefaults={
- Object {
- "filterable": false,
- "resizable": false,
- "sortable": false,
- "width": 35,
- }
- }
- filterable={false}
- freezeWhenExpanded={false}
- getLoadingProps={[Function]}
- getNoDataProps={[Function]}
- getPaginationProps={[Function]}
- getProps={[Function]}
- getResizerProps={[Function]}
- getTableProps={[Function]}
- getTbodyProps={[Function]}
- getTdProps={[Function]}
- getTfootProps={[Function]}
- getTfootTdProps={[Function]}
- getTfootTrProps={[Function]}
- getTheadFilterProps={[Function]}
- getTheadFilterThProps={[Function]}
- getTheadFilterTrProps={[Function]}
- getTheadGroupProps={[Function]}
- getTheadGroupThProps={[Function]}
- getTheadGroupTrProps={[Function]}
- getTheadProps={[Function]}
- getTheadThProps={[Function]}
- getTheadTrProps={[Function]}
- getTrGroupProps={[Function]}
- getTrProps={[Function]}
- groupedByPivotKey="_groupedByPivot"
- indexKey="_index"
- loading={false}
- loadingText="Loading..."
- multiSort={true}
- nestingLevelKey="_nestingLevel"
- nextText="Next"
- noDataText=""
- ofText="of"
- onFetchData={[Function]}
- originalKey="_original"
- pageJumpText="jump to page"
- pageSizeOptions={
- Array [
- 5,
- 10,
- 20,
- 25,
- 50,
- 100,
- ]
- }
- pageText="Page"
- pivotDefaults={Object {}}
- pivotIDKey="_pivotID"
- pivotValKey="_pivotVal"
- previousText="Previous"
- resizable={true}
- resolveData={[Function]}
- rowsSelectorText="rows per page"
- rowsText="rows"
- showPageJump={true}
- showPageSizeOptions={true}
- showPagination={true}
- showPaginationBottom={true}
- showPaginationTop={false}
- sortable={false}
- style={Object {}}
- subRowsKey="_subRows"
- />
- </div>
-</t>
-`;
diff --git a/web-console/src/views/sql-view/sql-control/__snapshots__/sql-control.spec.tsx.snap b/web-console/src/views/sql-view/sql-control/__snapshots__/sql-control.spec.tsx.snap
deleted file mode 100644
index 98e7d7c..0000000
--- a/web-console/src/views/sql-view/sql-control/__snapshots__/sql-control.spec.tsx.snap
+++ /dev/null
@@ -1,206 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`sql control matches snapshot 1`] = `
-<div
- class="sql-control"
->
- <div
- class="ace-container"
- >
- <div
- class=" ace_editor ace-tm ace_focus"
- id="ace-editor"
- style="width: 100%; height: 200px; font-size: 14px;"
- >
- <textarea
- autocapitalize="off"
- autocorrect="off"
- class="ace_text-input"
- spellcheck="false"
- style="opacity: 0; position: fixed; top: 0px;"
- wrap="off"
- />
- <div
- aria-hidden="true"
- class="ace_gutter"
- >
- <div
- class="ace_layer ace_gutter-layer ace_folding-enabled"
- />
- <div
- class="ace_gutter-active-line"
- />
- </div>
- <div
- class="ace_scroller"
- >
- <div
- class="ace_content"
- >
- <div
- class="ace_layer ace_print-margin-layer"
- >
- <div
- class="ace_print-margin"
- style="left: 4px; visibility: hidden;"
- />
- </div>
- <div
- class="ace_layer ace_marker-layer"
- />
- <div
- class="ace_layer ace_text-layer"
- style="padding: 0px 4px;"
- />
- <div
- class="ace_layer ace_marker-layer"
- />
- <div
- class="ace_layer ace_cursor-layer"
- >
- <div
- class="ace_cursor"
- />
- </div>
- </div>
- </div>
- <div
- class="ace_scrollbar ace_scrollbar-v"
- style="display: none; width: 20px;"
- >
- <div
- class="ace_scrollbar-inner"
- style="width: 20px;"
- />
- </div>
- <div
- class="ace_scrollbar ace_scrollbar-h"
- style="display: none; height: 20px;"
- >
- <div
- class="ace_scrollbar-inner"
- style="height: 20px;"
- />
- </div>
- <div
- style="height: auto; width: auto; top: 0px; left: 0px; visibility: hidden; position: absolute; white-space: pre; overflow: hidden;"
- >
- <div
- style="height: auto; width: auto; top: 0px; left: 0px; visibility: hidden; position: absolute; white-space: pre; overflow: visible;"
- />
- <div
- style="height: auto; width: auto; top: 0px; left: 0px; visibility: hidden; position: absolute; white-space: pre; overflow: visible;"
- >
- XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
- </div>
- </div>
- </div>
- </div>
- <div
- class="buttons"
- >
- <div
- class="bp3-button-group"
- >
- <button
- class="bp3-button"
- type="button"
- >
- <span
- class="bp3-icon bp3-icon-caret-right"
- icon="caret-right"
- >
- <svg
- data-icon="caret-right"
- height="16"
- viewBox="0 0 16 16"
- width="16"
- >
- <desc>
- caret-right
- </desc>
- <path
- d="M11 8c0-.15-.07-.28-.17-.37l-4-3.5A.495.495 0 0 0 6 4.5v7a.495.495 0 0 0 .83.37l4-3.5c.1-.09.17-.22.17-.37z"
- fill-rule="evenodd"
- />
- </svg>
- </span>
- <span
- class="bp3-button-text"
- >
- Run with limit
- </span>
- </button>
- <span
- class="bp3-popover-wrapper"
- >
- <span
- class="bp3-popover-target"
- >
- <button
- class="bp3-button"
- type="button"
- >
- <span
- class="bp3-icon bp3-icon-more"
- icon="more"
- >
- <svg
- data-icon="more"
- height="16"
- viewBox="0 0 16 16"
- width="16"
- >
- <desc>
- more
- </desc>
- <path
- d="M2 6.03a2 2 0 1 0 0 4 2 2 0 1 0 0-4zM14 6.03a2 2 0 1 0 0 4 2 2 0 1 0 0-4zM8 6.03a2 2 0 1 0 0 4 2 2 0 1 0 0-4z"
- fill-rule="evenodd"
- />
- </svg>
- </span>
- </button>
- </span>
- </span>
- </div>
- <span
- class="query-elapsed"
- >
- Last query took 0.00 seconds
- </span>
- <span
- class="bp3-popover-wrapper download-button"
- >
- <span
- class="bp3-popover-target"
- >
- <button
- class="bp3-button bp3-minimal"
- type="button"
- >
- <span
- class="bp3-icon bp3-icon-download"
- icon="download"
- >
- <svg
- data-icon="download"
- height="16"
- viewBox="0 0 16 16"
- width="16"
- >
- <desc>
- download
- </desc>
- <path
- d="M7.99-.01c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8zM11.7 9.7l-3 3c-.18.18-.43.29-.71.29s-.53-.11-.71-.29l-3-3A1.003 1.003 0 0 1 5.7 8.28l1.29 1.29V3.99c0-.55.45-1 1-1s1 .45 1 1v5.59l1.29-1.29a1.003 1.003 0 0 1 1.71.71c0 .27-.11.52-.29.7z"
- fill-rule="evenodd"
- />
- </svg>
- </span>
- </button>
- </span>
- </span>
- </div>
-</div>
-`;
diff --git a/web-console/src/views/sql-view/sql-control/sql-control.tsx b/web-console/src/views/sql-view/sql-control/sql-control.tsx
deleted file mode 100644
index 58486a3..0000000
--- a/web-console/src/views/sql-view/sql-control/sql-control.tsx
+++ /dev/null
@@ -1,385 +0,0 @@
-/*
- * 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,
- ButtonGroup,
- Intent, IResizeEntry,
- Menu,
- MenuItem, NavbarGroup,
- Popover,
- Position, ResizeSensor
-} from '@blueprintjs/core';
-import { Hotkey, Hotkeys, HotkeysTarget } from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
-import axios from 'axios';
-import ace from 'brace';
-import Hjson from 'hjson';
-import React from 'react';
-import AceEditor from 'react-ace';
-import ReactDOMServer from 'react-dom/server';
-
-import { SQLFunctionDoc } from '../../../../lib/sql-function-doc';
-import { MenuCheckbox } from '../../../components';
-import { AppToaster } from '../../../singletons/toaster';
-import { DRUID_DOCS_RUNE, DRUID_DOCS_SQL } from '../../../variables';
-
-import './sql-control.scss';
-
-function validHjson(query: string) {
- try {
- Hjson.parse(query);
- return true;
- } catch {
- return false;
- }
-}
-
-const langTools = ace.acequire('ace/ext/language_tools');
-
-export interface SqlControlProps extends React.Props<any> {
- initSql: string | null;
- onRun: (query: string, context: Record<string, any>, wrapQuery: boolean) => void;
- onExplain: (sqlQuery: string, context: Record<string, any>) => void;
- queryElapsed: number | null;
- onDownload: (format: string) => void;
-}
-
-export interface SqlControlState {
- query: string;
- autoComplete: boolean;
- autoCompleteLoading: boolean;
- wrapQuery: boolean;
- useApproximateCountDistinct: boolean;
- useApproximateTopN: boolean;
- useCache: boolean;
-
- // For reasons (https://github.com/securingsincity/react-ace/issues/415) react ace editor needs an explicit height
- // Since this component will grown and shrink dynamically we will measure its height and then set it.
- editorHeight: number;
-}
-
-@HotkeysTarget
-export class SqlControl extends React.PureComponent<SqlControlProps, SqlControlState> {
- constructor(props: SqlControlProps, context: any) {
- super(props, context);
- this.state = {
- query: props.initSql || '',
- autoComplete: true,
- autoCompleteLoading: false,
- wrapQuery: true,
- useApproximateCountDistinct: true,
- useApproximateTopN: true,
- useCache: true,
-
- editorHeight: 200
- };
- }
-
- private replaceDefaultAutoCompleter = () => {
- /*
- Please refer to the source code @
- https://github.com/ajaxorg/ace/blob/9b5b63d1dc7c1b81b58d30c87d14b5905d030ca5/lib/ace/ext/language_tools.js#L41
- for the implementation of keyword completer
- */
- const keywordCompleter = {
- getCompletions: (editor: any, session: any, pos: any, prefix: any, callback: any) => {
- if (session.$mode.completer) {
- return session.$mode.completer.getCompletions(editor, session, pos, prefix, callback);
- }
- const state = editor.session.getState(pos.row);
- let keywordCompletions = session.$mode.getCompletions(state, session, pos, prefix);
- keywordCompletions = keywordCompletions.map((d: any) => {
- return Object.assign(d, {name: d.name.toUpperCase(), value: d.value.toUpperCase()});
- });
- return callback(null, keywordCompletions);
- }
- };
- langTools.setCompleters([langTools.snippetCompleter, langTools.textCompleter, keywordCompleter]);
- }
-
- private addDatasourceAutoCompleter = async (): Promise<any> => {
- const datasourceResp = await axios.post('/druid/v2/sql', { query: `SELECT datasource FROM sys.segments GROUP BY 1`});
- const datasourceList: any[] = datasourceResp.data.map((d: any) => {
- const datasourceName: string = d.datasource;
- return {
- value: datasourceName,
- score: 50,
- meta: 'datasource'
- };
- });
-
- const completer = {
- getCompletions: (editor: any, session: any, pos: any, prefix: any, callback: any) => {
- callback(null, datasourceList);
- }
- };
-
- langTools.addCompleter(completer);
- }
-
- private addColumnNameAutoCompleter = async (): Promise<any> => {
- const columnNameResp = await axios.post('/druid/v2/sql', {query: `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = 'druid'`});
- const columnNameList: any[] = columnNameResp.data.map((d: any) => {
- const columnName: string = d.COLUMN_NAME;
- return {
- value: columnName,
- score: 50,
- meta: 'column'
- };
- });
-
- const completer = {
- getCompletions: (editor: any, session: any, pos: any, prefix: any, callback: any) => {
- callback(null, columnNameList);
- }
- };
-
- langTools.addCompleter(completer);
- }
-
- private addFunctionAutoCompleter = (): void => {
- const functionList: any[] = SQLFunctionDoc.map((entry: any) => {
- let funcName: string = entry.syntax.replace(/\(.*\)/, '()');
- if (!funcName.includes('(')) funcName = funcName.substr(0, 10);
- return {
- value: funcName,
- score: 80,
- meta: 'function',
- syntax: entry.syntax,
- description: entry.description,
- completer: {
- insertMatch: (editor: any, data: any) => {
- editor.completer.insertMatch({value: data.caption});
- const pos = editor.getCursorPosition();
- editor.gotoLine(pos.row + 1, pos.column - 1);
- }
- }
- };
- });
-
- const completer = {
- getCompletions: (editor: any, session: any, pos: any, prefix: any, callback: any) => {
- callback(null, functionList);
- },
- getDocTooltip: (item: any) => {
- if (item.meta === 'function') {
- const functionName = item.caption.slice(0, -2);
- item.docHTML = ReactDOMServer.renderToStaticMarkup((
- <div className="function-doc">
- <div className="function-doc-name"><b>{functionName}</b></div>
- <hr/>
- <div><b>Syntax:</b></div>
- <div>{item.syntax}</div>
- <br/>
- <div><b>Description:</b></div>
- <div>{item.description}</div>
- </div>
- ));
- }
- }
- };
- langTools.addCompleter(completer);
- }
-
- private addCompleters = async () => {
- try {
- this.replaceDefaultAutoCompleter();
- this.addFunctionAutoCompleter();
- await this.addDatasourceAutoCompleter();
- await this.addColumnNameAutoCompleter();
- } catch (e) {
- AppToaster.show({
- message: 'Failed to load SQL auto completer',
- intent: Intent.DANGER
- });
- }
- }
-
- componentDidMount(): void {
- this.addCompleters();
- }
-
- getContext(): Record<string, any> {
- const { useCache, useApproximateCountDistinct, useApproximateTopN } = this.state;
- const context: Record<string, any> = {};
-
- if (useCache === false) {
- context.useCache = false;
- context.populateCache = false;
- }
-
- if (useApproximateCountDistinct === false) {
- context.useApproximateCountDistinct = false;
- }
-
- if (useApproximateTopN === false) {
- context.useApproximateTopN = false;
- }
-
- return context;
- }
-
- private handleChange = (newValue: string): void => {
- this.setState({
- query: newValue
- });
- }
-
- private onRunClick = () => {
- const { onRun } = this.props;
- const { query, wrapQuery } = this.state;
- onRun(query, this.getContext(), wrapQuery);
- }
-
- private handleAceContainerResize = (entries: IResizeEntry[]) => {
- if (entries.length !== 1) return;
- this.setState({ editorHeight: entries[0].contentRect.height });
- }
-
- renderExtraMenu(isRune: boolean) {
- const { onExplain } = this.props;
- const { query, autoComplete, useCache, wrapQuery, useApproximateCountDistinct, useApproximateTopN } = this.state;
-
- return <Menu>
- <MenuItem
- icon={IconNames.HELP}
- text="Docs"
- href={isRune ? DRUID_DOCS_RUNE : DRUID_DOCS_SQL}
- target="_blank"
- />
- {
- !isRune &&
- <>
- <MenuItem
- icon={IconNames.CLEAN}
- text="Explain"
- onClick={() => onExplain(query, this.getContext())}
- />
- <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
- checked={useApproximateCountDistinct}
- label="Use approximate COUNT(DISTINCT)"
- onChange={() => this.setState({useApproximateCountDistinct: !useApproximateCountDistinct})}
- />
- <MenuCheckbox
- checked={useApproximateTopN}
- label="Use approximate TopN"
- onChange={() => this.setState({useApproximateTopN: !useApproximateTopN})}
- />
- </>
- }
- <MenuCheckbox
- checked={useCache}
- label="Use cache"
- onChange={() => this.setState({useCache: !useCache})}
- />
- </Menu>;
- }
-
- public renderHotkeys() {
- return <Hotkeys>
- <Hotkey
- allowInInput
- global
- combo="ctrl + enter"
- label="run on click"
- onKeyDown={this.onRunClick}
- />
- </Hotkeys>;
- }
-
- render() {
- const { queryElapsed, onDownload } = this.props;
- const { query, autoComplete, wrapQuery, editorHeight } = this.state;
- const isRune = query.trim().startsWith('{');
- const downloadMenu = <Menu className="download-format-menu">
- <MenuItem text="csv" onClick={() => onDownload('csv')} />
- <MenuItem text="tsv" onClick={() => onDownload('tsv')} />
- <MenuItem text="JSON" onClick={() => onDownload('json')}/>
- </Menu>;
-
- // Set the key in the AceEditor to force a rebind and prevent an error that happens otherwise
- return <div className="sql-control">
- <ResizeSensor onResize={this.handleAceContainerResize}>
- <div className="ace-container">
- <AceEditor
- key={isRune ? 'hjson' : 'sql'}
- mode={isRune ? 'hjson' : 'sql'}
- theme="solarized_dark"
- name="ace-editor"
- onChange={this.handleChange}
- focus
- fontSize={14}
- width="100%"
- height={`${editorHeight}px`}
- showPrintMargin={false}
- value={query}
- editorProps={{
- $blockScrolling: Infinity
- }}
- setOptions={{
- enableBasicAutocompletion: isRune ? false : autoComplete,
- enableLiveAutocompletion: isRune ? false : autoComplete,
- showLineNumbers: true,
- tabSize: 2
- }}
- style={{}}
- />
- </div>
- </ResizeSensor>
- <div className="buttons">
- <ButtonGroup>
- <Button
- icon={IconNames.CARET_RIGHT}
- onClick={this.onRunClick}
- text={isRune ? 'Rune' : (wrapQuery ? 'Run with limit' : 'Run as is')}
- disabled={isRune && !validHjson(query)}
- />
- <Popover position={Position.BOTTOM_LEFT} content={this.renderExtraMenu(isRune)}>
- <Button icon={IconNames.MORE}/>
- </Popover>
- </ButtonGroup>
- {
- queryElapsed &&
- <span className="query-elapsed">
- {`Last query took ${(queryElapsed / 1000).toFixed(2)} seconds`}
- </span>
- }
- {
- queryElapsed &&
- <Popover className="download-button" content={downloadMenu} position={Position.BOTTOM_RIGHT}>
- <Button
- icon={IconNames.DOWNLOAD}
- minimal
- />
- </Popover>
- }
- </div>
- </div>;
- }
-}
diff --git a/web-console/src/views/sql-view/sql-view.tsx b/web-console/src/views/sql-view/sql-view.tsx
deleted file mode 100644
index d132080..0000000
--- a/web-console/src/views/sql-view/sql-view.tsx
+++ /dev/null
@@ -1,275 +0,0 @@
-/*
- * 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 Hjson from 'hjson';
-import React from 'react';
-import SplitterLayout from 'react-splitter-layout';
-import ReactTable from 'react-table';
-
-import { TableCell } from '../../components';
-import { QueryPlanDialog } from '../../dialogs';
-import {
- BasicQueryExplanation,
- decodeRune,
- downloadFile,
- HeaderRows,
- localStorageGet, LocalStorageKeys,
- localStorageSet, parseQueryPlan,
- queryDruidRune,
- queryDruidSql, QueryManager,
- SemiJoinQueryExplanation
-} from '../../utils';
-
-import { SqlControl } from './sql-control/sql-control';
-
-import './sql-view.scss';
-
-interface QueryWithContext {
- queryString: string;
- context?: Record<string, any>;
- wrapQuery?: boolean;
-}
-
-export interface SqlViewProps extends React.Props<any> {
- initSql: string | null;
-}
-
-export interface SqlViewState {
- loading: boolean;
- result: HeaderRows | null;
- error: string | null;
- explainDialogOpen: boolean;
- explainResult: BasicQueryExplanation | SemiJoinQueryExplanation | string | null;
- loadingExplain: boolean;
- explainError: Error | null;
- queryElapsed: number | null;
-}
-
-interface SqlQueryResult {
- queryResult: HeaderRows;
- queryElapsed: number;
-}
-
-export class SqlView extends React.PureComponent<SqlViewProps, SqlViewState> {
- static trimSemicolon(query: string): string {
- // Trims out a trailing semicolon while preserving space (https://bit.ly/1n1yfkJ)
- return query.replace(/;+((?:\s*--[^\n]*)?\s*)$/, '$1');
- }
-
- private sqlQueryManager: QueryManager<QueryWithContext, SqlQueryResult>;
- private explainQueryManager: QueryManager<QueryWithContext, BasicQueryExplanation | SemiJoinQueryExplanation | string>;
-
- constructor(props: SqlViewProps, context: any) {
- super(props, context);
- this.state = {
- loading: false,
- result: null,
- error: null,
- explainDialogOpen: false,
- loadingExplain: false,
- explainResult: null,
- explainError: null,
- queryElapsed: null
- };
- }
-
- componentDidMount(): void {
- this.sqlQueryManager = new QueryManager({
- processQuery: async (queryWithContext: QueryWithContext) => {
- const { queryString, context, wrapQuery } = queryWithContext;
- const startTime = new Date();
-
- if (queryString.trim().startsWith('{')) {
- // Secret way to issue a native JSON "rune" query
- const runeQuery = Hjson.parse(queryString);
-
- if (context) runeQuery.context = context;
- const result = await queryDruidRune(runeQuery);
- return {
- queryResult: decodeRune(runeQuery, result),
- queryElapsed: new Date().valueOf() - startTime.valueOf()
- };
-
- } else {
- const actualQuery = wrapQuery ?
- `SELECT * FROM (${SqlView.trimSemicolon(queryString)}\n) LIMIT 2000` :
- queryString;
-
- const queryPayload: Record<string, any> = {
- query: actualQuery,
- resultFormat: 'array',
- header: true
- };
-
- if (context) queryPayload.context = context;
- const result = await queryDruidSql(queryPayload);
-
- return {
- queryResult: {
- header: (result && result.length) ? result[0] : [],
- rows: (result && result.length) ? result.slice(1) : []
- },
- queryElapsed: new Date().valueOf() - startTime.valueOf()
- };
- }
- },
- onStateChange: ({ result, loading, error }) => {
- this.setState({
- result: result ? result.queryResult : null,
- queryElapsed: result ? result.queryElapsed : null,
- loading,
- error
- });
- }
- });
-
- this.explainQueryManager = new QueryManager({
- processQuery: async (queryWithContext: QueryWithContext) => {
- const { queryString, context } = queryWithContext;
- const explainPayload: Record<string, any> = {
- query: `EXPLAIN PLAN FOR (${SqlView.trimSemicolon(queryString)}\n)`,
- resultFormat: 'object'
- };
-
- if (context) explainPayload.context = context;
- const result = await queryDruidSql(explainPayload);
-
- return parseQueryPlan(result[0]['PLAN']);
- },
- onStateChange: ({ result, loading, error }) => {
- this.setState({
- explainResult: result,
- loadingExplain: loading,
- explainError: error !== null ? new Error(error) : null
- });
- }
- });
- }
-
- componentWillUnmount(): void {
- this.sqlQueryManager.terminate();
- this.explainQueryManager.terminate();
- }
-
- onSecondaryPaneSizeChange(secondaryPaneSize: number) {
- localStorageSet(LocalStorageKeys.QUERY_VIEW_PANE_SIZE, String(secondaryPaneSize));
- }
-
- formatStr(s: string | number, format: 'csv' | 'tsv') {
- if (format === 'csv') {
- // remove line break, single quote => double quote, handle ','
- return `"${s.toString().replace(/(?:\r\n|\r|\n)/g, ' ').replace(/"/g, '""')}"`;
- } else { // tsv
- // remove line break, single quote => double quote, \t => ''
- return `${s.toString().replace(/(?:\r\n|\r|\n)/g, ' ').replace(/\t/g, '').replace(/"/g, '""')}`;
- }
- }
-
- onDownload = (format: string) => {
- const { result } = this.state;
- if (!result) return;
- let data: string = '';
- let seperator: string = '';
- const lineBreak = '\n';
-
- if (format === 'csv' || format === 'tsv') {
- seperator = format === 'csv' ? ',' : '\t';
- data = result.header.map(str => this.formatStr(str, format)).join(seperator) + lineBreak;
- data += result.rows.map(r => r.map(cell => this.formatStr(cell, format)).join(seperator)).join(lineBreak);
- } else { // json
- data = result.rows.map(r => {
- const outputObject: Record<string, any> = {};
- for (let k = 0; k < r.length; k++) {
- const newName = result.header[k];
- if (newName) {
- outputObject[newName] = r[k];
- }
- }
- return JSON.stringify(outputObject);
- }).join(lineBreak);
- }
- downloadFile(data, format, 'query_result.' + format);
- }
-
- renderExplainDialog() {
- const {explainDialogOpen, explainResult, loadingExplain, explainError} = this.state;
- if (!loadingExplain && explainDialogOpen) {
- return <QueryPlanDialog
- explainResult={explainResult}
- explainError={explainError}
- onClose={() => this.setState({explainDialogOpen: false})}
- />;
- }
- return null;
- }
-
- renderResultTable() {
- const { result, loading, error } = this.state;
-
- return <ReactTable
- data={result ? result.rows : []}
- loading={loading}
- noDataText={!loading && result && !result.rows.length ? 'No results' : (error || '')}
- sortable={false}
- columns={
- (result ? result.header : []).map((h: any, i) => {
- return {
- Header: h,
- accessor: String(i),
- Cell: row => <TableCell value={row.value}/>
- };
- })
- }
- />;
- }
-
- render() {
- const { initSql } = this.props;
- const { queryElapsed } = this.state;
-
- return <SplitterLayout
- customClassName="sql-view app-view"
- vertical
- percentage
- secondaryInitialSize={Number(localStorageGet(LocalStorageKeys.QUERY_VIEW_PANE_SIZE) as string) || 60}
- primaryMinSize={30}
- secondaryMinSize={30}
- onSecondaryPaneSizeChange={this.onSecondaryPaneSizeChange}
- >
- <div className="top-pane">
- <SqlControl
- initSql={initSql || localStorageGet(LocalStorageKeys.QUERY_KEY)}
- onRun={(queryString, context, wrapQuery) => {
- localStorageSet(LocalStorageKeys.QUERY_KEY, queryString);
- this.sqlQueryManager.runQuery({ queryString, context, wrapQuery });
- }}
- onExplain={(queryString, context) => {
- this.setState({ explainDialogOpen: true });
- this.explainQueryManager.runQuery({ queryString, context });
- }}
- queryElapsed={queryElapsed}
- onDownload={this.onDownload}
- />
- </div>
- <div className="bottom-pane">
- {this.renderResultTable()}
- {this.renderExplainDialog()}
- </div>
- </SplitterLayout>;
- }
-}
diff --git a/web-console/src/views/task-view/__snapshots__/tasks-view.spec.tsx.snap b/web-console/src/views/task-view/__snapshots__/tasks-view.spec.tsx.snap
index a01963f..8ba938c 100644
--- a/web-console/src/views/task-view/__snapshots__/tasks-view.spec.tsx.snap
+++ b/web-console/src/views/task-view/__snapshots__/tasks-view.spec.tsx.snap
@@ -25,11 +25,52 @@ exports[`tasks view matches snapshot 1`] = `
onClick={[Function]}
text="Refresh"
/>
- <Blueprint3.Button
- icon="plus"
- onClick={[Function]}
- text="Submit supervisor"
- />
+ <Blueprint3.Popover
+ boundary="scrollParent"
+ captureDismiss={false}
+ content={
+ <Blueprint3.Menu>
+ <Blueprint3.MenuItem
+ disabled={false}
+ icon="cloud-upload"
+ multiline={false}
+ onClick={[Function]}
+ popoverProps={Object {}}
+ shouldDismissPopover={true}
+ text="Go to data loader"
+ />
+ <Blueprint3.MenuItem
+ disabled={false}
+ icon="manually-entered-data"
+ multiline={false}
+ onClick={[Function]}
+ popoverProps={Object {}}
+ shouldDismissPopover={true}
+ text="Submit JSON supervisor"
+ />
+ </Blueprint3.Menu>
+ }
+ defaultIsOpen={false}
+ disabled={false}
+ hasBackdrop={false}
+ hoverCloseDelay={300}
+ hoverOpenDelay={150}
+ inheritDarkTheme={true}
+ interactionKind="click"
+ minimal={false}
+ modifiers={Object {}}
+ openOnTargetFocus={true}
+ position="bottom-left"
+ targetTagName="span"
+ transitionDuration={300}
+ usePortal={true}
+ wrapperTagName="span"
+ >
+ <Blueprint3.Button
+ icon="plus"
+ text="Submit supervisor"
+ />
+ </Blueprint3.Popover>
<TableColumnSelector
columns={
Array [
@@ -325,19 +366,21 @@ exports[`tasks view matches snapshot 1`] = `
<Blueprint3.Menu>
<Blueprint3.MenuItem
disabled={false}
+ icon="cloud-upload"
multiline={false}
onClick={[Function]}
popoverProps={Object {}}
shouldDismissPopover={true}
- text="Raw JSON task"
+ text="Go to data loader"
/>
<Blueprint3.MenuItem
disabled={false}
+ icon="manually-entered-data"
multiline={false}
onClick={[Function]}
popoverProps={Object {}}
shouldDismissPopover={true}
- text="Go to data loader"
+ text="Submit JSON task"
/>
</Blueprint3.Menu>
}
diff --git a/web-console/src/views/task-view/tasks-view.spec.tsx b/web-console/src/views/task-view/tasks-view.spec.tsx
index 2e962f4..4736241 100644
--- a/web-console/src/views/task-view/tasks-view.spec.tsx
+++ b/web-console/src/views/task-view/tasks-view.spec.tsx
@@ -27,7 +27,7 @@ describe('tasks view', () => {
<TasksView
openDialog={'test'}
taskId={'test'}
- goToSql={(initSql: string) => null}
+ goToQuery={(initSql: string) => null}
goToMiddleManager={(middleManager: string) => null}
goToLoadDataView={() => null}
noSqlMode={false}
diff --git a/web-console/src/views/task-view/tasks-view.tsx b/web-console/src/views/task-view/tasks-view.tsx
index b2441ca..7b1f532 100644
--- a/web-console/src/views/task-view/tasks-view.tsx
+++ b/web-console/src/views/task-view/tasks-view.tsx
@@ -46,7 +46,7 @@ const taskTableColumns: string[] = ['Task ID', 'Type', 'Datasource', 'Location',
export interface TasksViewProps extends React.Props<any> {
taskId: string | null;
openDialog: string | null;
- goToSql: (initSql: string) => void;
+ goToQuery: (initSql: string) => void;
goToMiddleManager: (middleManager: string) => void;
goToLoadDataView: (supervisorId?: string, taskId?: string) => void;
noSqlMode: boolean;
@@ -709,18 +709,34 @@ ORDER BY "rank" DESC, "created_time" DESC`);
render() {
- const { goToSql, goToLoadDataView, noSqlMode } = this.props;
+ const { goToQuery, goToLoadDataView, noSqlMode } = this.props;
const { groupTasksBy, supervisorSpecDialogOpen, taskSpecDialogOpen, alertErrorMsg, taskTableActionDialogId, taskTableActionDialogActions, supervisorTableActionDialogId, supervisorTableActionDialogActions, taskTableActionDialogStatus } = this.state;
const { supervisorTableColumnSelectionHandler, taskTableColumnSelectionHandler } = this;
- const submitTaskMenu = <Menu>
+
+ const submitSupervisorMenu = <Menu>
<MenuItem
- text="Raw JSON task"
- onClick={() => this.setState({ taskSpecDialogOpen: true })}
+ icon={IconNames.CLOUD_UPLOAD}
+ text="Go to data loader"
+ onClick={() => goToLoadDataView()}
+ />
+ <MenuItem
+ icon={IconNames.MANUALLY_ENTERED_DATA}
+ text="Submit JSON supervisor"
+ onClick={() => this.setState({ supervisorSpecDialogOpen: true })}
/>
+ </Menu>;
+
+ const submitTaskMenu = <Menu>
<MenuItem
+ icon={IconNames.CLOUD_UPLOAD}
text="Go to data loader"
onClick={() => goToLoadDataView()}
/>
+ <MenuItem
+ icon={IconNames.MANUALLY_ENTERED_DATA}
+ text="Submit JSON task"
+ onClick={() => this.setState({ taskSpecDialogOpen: true })}
+ />
</Menu>;
return <>
@@ -740,11 +756,9 @@ ORDER BY "rank" DESC, "created_time" DESC`);
text="Refresh"
onClick={() => this.supervisorQueryManager.rerunLastQuery()}
/>
- <Button
- icon={IconNames.PLUS}
- text="Submit supervisor"
- onClick={() => this.setState({ supervisorSpecDialogOpen: true })}
- />
+ <Popover content={submitSupervisorMenu} position={Position.BOTTOM_LEFT}>
+ <Button icon={IconNames.PLUS} text="Submit supervisor"/>
+ </Popover>
<TableColumnSelector
columns={supervisorTableColumns}
onChange={(column) => supervisorTableColumnSelectionHandler.changeTableColumnSelector(column)}
@@ -772,7 +786,7 @@ ORDER BY "rank" DESC, "created_time" DESC`);
<Button
icon={IconNames.APPLICATION}
text="Go to SQL"
- onClick={() => goToSql(this.taskQueryManager.getLastQuery())}
+ onClick={() => goToQuery(this.taskQueryManager.getLastQuery())}
/>
}
<Popover content={submitTaskMenu} position={Position.BOTTOM_LEFT}>
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@druid.apache.org
For additional commands, e-mail: commits-help@druid.apache.org