Web console: Improve workbench view with resizable side panels (#17387)

This commit is contained in:
Vadim Ogievetsky 2024-10-30 11:50:52 -07:00 committed by GitHub
parent d5bb7de5cf
commit 4b7902e74a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1160 additions and 631 deletions

View File

@ -6329,16 +6329,6 @@ license_file_path: licenses/bin/react-router.MIT
--- ---
name: "react-splitter-layout"
license_category: binary
module: web-console
license_name: MIT License
copyright: Yang Liu
version: 4.0.0
license_file_path: licenses/bin/react-splitter-layout.MIT
---
name: "react-table" name: "react-table"
license_category: binary license_category: binary
module: web-console module: web-console

View File

@ -42,7 +42,6 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router": "^5.3.4", "react-router": "^5.3.4",
"react-router-dom": "^5.3.4", "react-router-dom": "^5.3.4",
"react-splitter-layout": "^4.0.0",
"react-table": "~6.11.5", "react-table": "~6.11.5",
"regenerator-runtime": "^0.13.7", "regenerator-runtime": "^0.13.7",
"tslib": "^2.8.0", "tslib": "^2.8.0",
@ -75,7 +74,6 @@
"@types/react": "^18.3.11", "@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/react-splitter-layout": "^3.0.5",
"@types/react-table": "6.8.5", "@types/react-table": "6.8.5",
"@types/uuid": "^7.0.2", "@types/uuid": "^7.0.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
@ -4064,15 +4062,6 @@
"@types/react-router": "*" "@types/react-router": "*"
} }
}, },
"node_modules/@types/react-splitter-layout": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/react-splitter-layout/-/react-splitter-layout-3.0.5.tgz",
"integrity": "sha512-J3NKVdPguGcSSN41/EDV3BYdYfndxUil2SX4wDBJiVfcnyopCegdhIrC2kcaa4CxjpaPChe1bja2k8LLDxP0ZQ==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-table": { "node_modules/@types/react-table": {
"version": "6.8.5", "version": "6.8.5",
"resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-6.8.5.tgz", "resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-6.8.5.tgz",
@ -14964,15 +14953,6 @@
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
"dev": true "dev": true
}, },
"node_modules/react-splitter-layout": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/react-splitter-layout/-/react-splitter-layout-4.0.0.tgz",
"integrity": "sha512-SLqOjBOxRuizWUa83w6q5/u9cDWa9/yj9Iko9V9JFN8x+cqIXiDlUFWSx+icz3IIgvsN/oRIw3za5/32RjIwrA==",
"peerDependencies": {
"prop-types": "^15.5.0",
"react": "^15.5.0 || ^16.0.0"
}
},
"node_modules/react-table": { "node_modules/react-table": {
"version": "6.11.5", "version": "6.11.5",
"resolved": "https://registry.npmjs.org/react-table/-/react-table-6.11.5.tgz", "resolved": "https://registry.npmjs.org/react-table/-/react-table-6.11.5.tgz",
@ -21157,15 +21137,6 @@
"@types/react-router": "*" "@types/react-router": "*"
} }
}, },
"@types/react-splitter-layout": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/react-splitter-layout/-/react-splitter-layout-3.0.5.tgz",
"integrity": "sha512-J3NKVdPguGcSSN41/EDV3BYdYfndxUil2SX4wDBJiVfcnyopCegdhIrC2kcaa4CxjpaPChe1bja2k8LLDxP0ZQ==",
"dev": true,
"requires": {
"@types/react": "^18.3.11"
}
},
"@types/react-table": { "@types/react-table": {
"version": "6.8.5", "version": "6.8.5",
"resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-6.8.5.tgz", "resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-6.8.5.tgz",
@ -28826,11 +28797,6 @@
} }
} }
}, },
"react-splitter-layout": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/react-splitter-layout/-/react-splitter-layout-4.0.0.tgz",
"integrity": "sha512-SLqOjBOxRuizWUa83w6q5/u9cDWa9/yj9Iko9V9JFN8x+cqIXiDlUFWSx+icz3IIgvsN/oRIw3za5/32RjIwrA=="
},
"react-table": { "react-table": {
"version": "6.11.5", "version": "6.11.5",
"resolved": "https://registry.npmjs.org/react-table/-/react-table-6.11.5.tgz", "resolved": "https://registry.npmjs.org/react-table/-/react-table-6.11.5.tgz",

View File

@ -83,7 +83,6 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router": "^5.3.4", "react-router": "^5.3.4",
"react-router-dom": "^5.3.4", "react-router-dom": "^5.3.4",
"react-splitter-layout": "^4.0.0",
"react-table": "~6.11.5", "react-table": "~6.11.5",
"regenerator-runtime": "^0.13.7", "regenerator-runtime": "^0.13.7",
"tslib": "^2.8.0", "tslib": "^2.8.0",
@ -116,7 +115,6 @@
"@types/react": "^18.3.11", "@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/react-splitter-layout": "^3.0.5",
"@types/react-table": "6.8.5", "@types/react-table": "6.8.5",
"@types/uuid": "^7.0.2", "@types/uuid": "^7.0.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",

View File

@ -51,6 +51,7 @@ export * from './segment-timeline/segment-timeline';
export * from './show-json/show-json'; export * from './show-json/show-json';
export * from './show-log/show-log'; export * from './show-log/show-log';
export * from './show-value/show-value'; export * from './show-value/show-value';
export * from './splitter-layout/splitter-layout';
export * from './suggestion-menu/suggestion-menu'; export * from './suggestion-menu/suggestion-menu';
export * from './supervisor-history-panel/supervisor-history-panel'; export * from './supervisor-history-panel/supervisor-history-panel';
export * from './table-cell/table-cell'; export * from './table-cell/table-cell';

View File

@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SplitterLayout matches snapshot one child 1`] = `
<div
class="splitter-layout splitter-layout-horizontal"
>
<div
class="layout-pane layout-pane-primary"
>
<div
class="child1"
/>
</div>
</div>
`;
exports[`SplitterLayout matches snapshot two children 1`] = `
<div
class="splitter-layout splitter-layout-vertical"
>
<div
class="layout-pane layout-pane-primary"
>
<div
class="child1"
/>
</div>
<div
class="layout-splitter"
role="separator"
/>
<div
class="layout-pane"
style="height: 50%;"
>
<div
class="child2"
/>
</div>
</div>
`;

View File

@ -0,0 +1,48 @@
/*
* 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 classNames from 'classnames';
import type { ReactNode } from 'react';
export interface LayoutPaneProps {
vertical?: boolean;
size: number | undefined;
percentage?: boolean;
children: ReactNode;
}
export function LayoutPane(props: LayoutPaneProps) {
return (
<div
className={classNames('layout-pane', {
'layout-pane-primary': typeof props.size === 'undefined',
})}
style={
typeof props.size === 'number'
? {
[props.vertical ? 'height' : 'width']: `${props.size}${
props.percentage ? '%' : 'px'
}`,
}
: undefined
}
>
{props.children}
</div>
);
}

View File

@ -0,0 +1,68 @@
/*
* 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.
*/
// Originally copied from https://github.com/zesik/react-splitter-layout/blob/master/src/stylesheets/index.css and heavily refactored
$default-splitter-size: 8px;
.splitter-layout {
display: flex;
overflow: hidden;
&.splitter-layout-horizontal {
flex-direction: row;
&.layout-changing {
cursor: col-resize;
}
& > .layout-splitter {
width: $default-splitter-size;
height: 100%;
cursor: col-resize;
}
}
&.splitter-layout-vertical {
flex-direction: column;
&.layout-changing {
cursor: row-resize;
}
& > .layout-splitter {
width: 100%;
height: $default-splitter-size;
cursor: row-resize;
}
}
& > .layout-pane {
position: relative;
flex: 0 0 auto;
overflow: auto;
&.layout-pane-primary {
flex: 1 1 auto;
}
}
& > .layout-splitter {
flex: 0 0 auto;
}
}

View File

@ -0,0 +1,52 @@
/*
* 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 { render } from '@testing-library/react';
import { SplitterLayout } from './splitter-layout';
describe('SplitterLayout', () => {
it('matches snapshot one child', () => {
const splitterLayout = (
<SplitterLayout primaryMinSize={100} secondaryInitialSize={50} secondaryMinSize={10}>
<div className="child1" />
</SplitterLayout>
);
const { container } = render(splitterLayout);
expect(container.firstChild).toMatchSnapshot();
});
it('matches snapshot two children', () => {
const splitterLayout = (
<SplitterLayout
vertical
percentage
primaryMinSize={100}
secondaryInitialSize={50}
secondaryMinSize={10}
>
<div className="child1" />
<div className="child2" />
</SplitterLayout>
);
const { container } = render(splitterLayout);
expect(container.firstChild).toMatchSnapshot();
});
});

View File

@ -0,0 +1,249 @@
/*
* 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.
*/
// Originally copied from https://github.com/zesik/react-splitter-layout/blob/master/src/components/SplitterLayout.jsx and heavily refactored
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { Children, Component } from 'react';
import { clamp } from '../../utils';
import { LayoutPane } from './layout-pane';
import './splitter-layout.scss';
function clearSelection() {
if (window.getSelection) {
const selection = window.getSelection();
if (selection) {
if (selection.empty) {
selection.empty();
} else if (selection.removeAllRanges) {
selection.removeAllRanges();
}
}
}
}
export interface SplitterLayoutProps {
className?: string;
vertical?: boolean;
percentage?: boolean;
primaryIndex?: 0 | 1;
primaryMinSize?: number;
secondaryInitialSize: number;
secondaryMinSize?: number;
secondaryMaxSize?: number;
splitterSize?: number;
onSecondaryPaneSizeChange?: (size: number) => void;
children: ReactNode | ReactNode[];
}
interface SplitterLayoutState {
secondaryPaneSize: number;
resizing: boolean;
}
export class SplitterLayout extends Component<SplitterLayoutProps, SplitterLayoutState> {
private container: HTMLDivElement | null = null;
private splitter: HTMLDivElement | null = null;
constructor(props: SplitterLayoutProps) {
super(props);
this.handleResize = this.handleResize.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.handleMouseUp = this.handleMouseUp.bind(this);
this.handleTouchMove = this.handleTouchMove.bind(this);
this.handleSplitterMouseDown = this.handleSplitterMouseDown.bind(this);
this.state = {
secondaryPaneSize: props.secondaryInitialSize,
resizing: false,
};
}
componentDidMount() {
window.addEventListener('resize', this.handleResize);
document.addEventListener('mouseup', this.handleMouseUp);
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('touchend', this.handleMouseUp);
document.addEventListener('touchmove', this.handleTouchMove);
}
componentDidUpdate(_prevProps: SplitterLayoutProps, prevState: SplitterLayoutState) {
if (prevState.secondaryPaneSize !== this.state.secondaryPaneSize) {
this.props.onSecondaryPaneSizeChange?.(this.state.secondaryPaneSize);
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
document.removeEventListener('mouseup', this.handleMouseUp);
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('touchend', this.handleMouseUp);
document.removeEventListener('touchmove', this.handleTouchMove);
}
getSecondaryPaneSize(
containerRect: DOMRect,
splitterRect: DOMRect,
clientPosition: { top: number; left: number },
offsetMouse: boolean,
) {
const {
vertical,
percentage,
primaryIndex,
primaryMinSize = 0,
secondaryMinSize = 0,
secondaryMaxSize = Infinity,
} = this.props;
let totalSize;
let splitterSize;
let offset;
if (vertical) {
totalSize = containerRect.height;
splitterSize = splitterRect.height;
offset = clientPosition.top - containerRect.top;
} else {
totalSize = containerRect.width;
splitterSize = splitterRect.width;
offset = clientPosition.left - containerRect.left;
}
if (offsetMouse) {
offset -= splitterSize / 2;
}
offset = clamp(offset, 0, totalSize - splitterSize);
let secondaryPaneSize = primaryIndex === 1 ? offset : totalSize - splitterSize - offset;
if (percentage) {
secondaryPaneSize = (secondaryPaneSize * 100) / totalSize;
splitterSize = (splitterSize * 100) / totalSize;
totalSize = 100;
}
return clamp(
secondaryPaneSize,
secondaryMinSize,
Math.min(secondaryMaxSize, totalSize - splitterSize - primaryMinSize),
);
}
handleResize() {
if (this.container && this.splitter && !this.props.percentage) {
const containerRect = this.container.getBoundingClientRect();
const splitterRect = this.splitter.getBoundingClientRect();
const secondaryPaneSize = this.getSecondaryPaneSize(
containerRect,
splitterRect,
{
left: splitterRect.left,
top: splitterRect.top,
},
false,
);
this.setState({ secondaryPaneSize });
}
}
handleMouseMove(e: MouseEvent | Touch) {
if (this.container && this.splitter && this.state.resizing) {
const containerRect = this.container.getBoundingClientRect();
const splitterRect = this.splitter.getBoundingClientRect();
const secondaryPaneSize = this.getSecondaryPaneSize(
containerRect,
splitterRect,
{
left: e.clientX,
top: e.clientY,
},
true,
);
clearSelection();
this.setState({ secondaryPaneSize });
}
}
handleTouchMove(e: TouchEvent) {
this.handleMouseMove(e.changedTouches[0]);
}
handleSplitterMouseDown() {
clearSelection();
this.setState({ resizing: true });
}
handleMouseUp() {
this.setState(prevState => (prevState.resizing ? { resizing: false } : null));
}
render() {
const { className, vertical, percentage, primaryIndex, splitterSize, children } = this.props;
const { resizing } = this.state;
const childrenArray = Children.toArray(children).slice(0, 2);
if (childrenArray.length === 0) return null;
const effectivePrimaryIndex = primaryIndex === 1 ? 1 : 0;
const wrappedChildren = childrenArray.map((child, i) => {
const isSecondary = childrenArray.length > 1 && i !== effectivePrimaryIndex;
return (
<LayoutPane
key={isSecondary ? 'secondary' : 'primary'}
vertical={vertical}
percentage={percentage}
size={isSecondary ? this.state.secondaryPaneSize : undefined}
>
{child}
</LayoutPane>
);
});
return (
<div
className={classNames(
'splitter-layout',
className,
vertical ? 'splitter-layout-vertical' : 'splitter-layout-horizontal',
{ 'layout-changing': resizing },
)}
ref={c => {
this.container = c;
}}
>
{wrappedChildren[0]}
{wrappedChildren.length > 1 && (
<div
role="separator"
className="layout-splitter"
ref={c => {
this.splitter = c;
}}
onMouseDown={this.handleSplitterMouseDown}
onTouchStart={this.handleSplitterMouseDown}
style={splitterSize ? { [vertical ? 'height' : 'width']: splitterSize } : undefined}
/>
)}
{wrappedChildren[1]}
</div>
);
}
}

View File

@ -285,6 +285,10 @@ export class Execution {
}); });
} }
static fromDartReport(dartReport: MsqTaskReportResponse): Execution {
return Execution.fromTaskReport(dartReport).changeEngine('sql-msq-dart');
}
static fromTaskReport(taskReport: MsqTaskReportResponse): Execution { static fromTaskReport(taskReport: MsqTaskReportResponse): Execution {
// Must have status set for a valid report // Must have status set for a valid report
const id = deepGet(taskReport, 'multiStageQuery.taskId'); const id = deepGet(taskReport, 'multiStageQuery.taskId');
@ -327,6 +331,7 @@ export class Execution {
new Column({ name: sig.name, nativeType: sig.type, sqlType: sqlTypeNames?.[i] }), new Column({ name: sig.name, nativeType: sig.type, sqlType: sqlTypeNames?.[i] }),
), ),
rows: results, rows: results,
queryDuration: durationMs,
}).inflateDatesFromSqlTypes(); }).inflateDatesFromSqlTypes();
} }

View File

@ -24,7 +24,6 @@
@import '@blueprintjs/datetime/src/blueprint-datetime'; @import '@blueprintjs/datetime/src/blueprint-datetime';
@import '@blueprintjs/datetime2/src/blueprint-datetime2'; @import '@blueprintjs/datetime2/src/blueprint-datetime2';
@import '@blueprintjs/select/src/blueprint-select'; @import '@blueprintjs/select/src/blueprint-select';
@import 'react-splitter-layout/lib/index';
@import './react-table/react-table-base-styles'; @import './react-table/react-table-base-styles';
@import './react-table/react-table-extra'; @import './react-table/react-table-extra';

View File

@ -51,6 +51,8 @@ export const LocalStorageKeys = {
WORKBENCH_QUERIES: 'workbench-queries' as const, WORKBENCH_QUERIES: 'workbench-queries' as const,
WORKBENCH_LAST_TAB: 'workbench-last-tab' as const, WORKBENCH_LAST_TAB: 'workbench-last-tab' as const,
WORKBENCH_PANE_SIZE: 'workbench-pane-size' as const, WORKBENCH_PANE_SIZE: 'workbench-pane-size' as const,
WORKBENCH_LEFT_SIZE: 'workbench-left-size' as const,
WORKBENCH_RIGHT_SIZE: 'workbench-right-size' as const,
WORKBENCH_HISTORY: 'workbench-history' as const, WORKBENCH_HISTORY: 'workbench-history' as const,
WORKBENCH_TASK_PANEL: 'workbench-task-panel' as const, WORKBENCH_TASK_PANEL: 'workbench-task-panel' as const,
WORKBENCH_DART_PANEL: 'workbench-dart-panel' as const, WORKBENCH_DART_PANEL: 'workbench-dart-panel' as const,

View File

@ -55,3 +55,11 @@ $table-cell-h-padding: 5px;
box-shadow: $pt-dark-elevation-shadow-1; box-shadow: $pt-dark-elevation-shadow-1;
} }
} }
@mixin pin-full {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}

View File

@ -108,6 +108,14 @@ exports[`DatasourcesView matches snapshot 1`] = `
} }
/> />
</Memo(ViewControlBar)> </Memo(ViewControlBar)>
<SplitterLayout
percentage={true}
primaryIndex={1}
primaryMinSize={20}
secondaryInitialSize={35}
secondaryMinSize={10}
vertical={true}
>
<ReactTable <ReactTable
AggregatedComponent={[Function]} AggregatedComponent={[Function]}
ExpanderComponent={[Function]} ExpanderComponent={[Function]}
@ -438,5 +446,6 @@ exports[`DatasourcesView matches snapshot 1`] = `
style={{}} style={{}}
subRowsKey="_subRows" subRowsKey="_subRows"
/> />
</SplitterLayout>
</div> </div>
`; `;

View File

@ -23,25 +23,30 @@
width: 100%; width: 100%;
overflow: auto; overflow: auto;
.clickable-cell { .splitter-layout {
cursor: pointer;
}
.ReactTable {
position: absolute; position: absolute;
top: $view-control-bar-height + $standard-padding; top: $view-control-bar-height + $standard-padding;
bottom: 0; bottom: 0;
width: 100%; width: 100%;
& > .layout-splitter:hover {
background: black;
opacity: 0.1;
border-radius: 2px;
}
} }
&.show-segment-timeline {
.segment-timeline { .segment-timeline {
height: calc(50% - 55px); position: absolute;
margin-top: 10px; width: 100%;
height: 100%;
} }
.ReactTable { .ReactTable {
top: 50%; @include pin-full;
.clickable-cell {
cursor: pointer;
} }
} }
} }

View File

@ -19,7 +19,6 @@
import { FormGroup, InputGroup, Intent, MenuItem, Switch, Tag } from '@blueprintjs/core'; import { FormGroup, InputGroup, Intent, MenuItem, Switch, Tag } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import { SqlQuery, T } from '@druid-toolkit/query'; import { SqlQuery, T } from '@druid-toolkit/query';
import classNames from 'classnames';
import { sum } from 'd3-array'; import { sum } from 'd3-array';
import React from 'react'; import React from 'react';
import type { Filter } from 'react-table'; import type { Filter } from 'react-table';
@ -34,6 +33,7 @@ import {
MoreButton, MoreButton,
RefreshButton, RefreshButton,
SegmentTimeline, SegmentTimeline,
SplitterLayout,
TableClickableCell, TableClickableCell,
TableColumnSelector, TableColumnSelector,
type TableColumnSelectorColumn, type TableColumnSelectorColumn,
@ -1678,11 +1678,7 @@ GROUP BY 1, 2`;
} = this.state; } = this.state;
return ( return (
<div <div className="datasources-view app-view">
className={classNames('datasources-view app-view', {
'show-segment-timeline': showSegmentTimeline,
})}
>
<ViewControlBar label="Datasources"> <ViewControlBar label="Datasources">
<RefreshButton <RefreshButton
onRefresh={auto => { onRefresh={auto => {
@ -1717,8 +1713,17 @@ GROUP BY 1, 2`;
tableColumnsHidden={visibleColumns.getHiddenColumns()} tableColumnsHidden={visibleColumns.getHiddenColumns()}
/> />
</ViewControlBar> </ViewControlBar>
<SplitterLayout
vertical
percentage
secondaryInitialSize={35}
primaryIndex={1}
primaryMinSize={20}
secondaryMinSize={10}
>
{showSegmentTimeline && <SegmentTimeline capabilities={capabilities} />} {showSegmentTimeline && <SegmentTimeline capabilities={capabilities} />}
{this.renderDatasourcesTable()} {this.renderDatasourcesTable()}
</SplitterLayout>
{datasourceTableActionDialogId && ( {datasourceTableActionDialogId && (
<DatasourceTableActionDialog <DatasourceTableActionDialog
datasource={datasourceTableActionDialogId} datasource={datasourceTableActionDialogId}

View File

@ -27,7 +27,7 @@
width: 100%; width: 100%;
} }
.init-div { .init-pane {
text-align: center; text-align: center;
margin-top: 35vh; margin-top: 35vh;
} }

View File

@ -354,11 +354,13 @@ export class LookupsView extends React.PureComponent<LookupsViewProps, LookupsVi
if (isLookupsUninitialized(lookupEntriesAndTiersState.error)) { if (isLookupsUninitialized(lookupEntriesAndTiersState.error)) {
return ( return (
<div className="init-div"> <div className="init-pane">
<Button <Button
icon={IconNames.BUILD} icon={IconNames.BUILD}
text="Initialize lookups" text="Initialize lookups"
onClick={() => void this.initializeLookup()} onClick={() => void this.initializeLookup()}
large
intent={Intent.PRIMARY}
/> />
</div> </div>
); );

View File

@ -1,10 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SegmentsView matches snapshot 1`] = ` exports[`SegmentsView matches snapshot 1`] = `
<React.Fragment> <div
<div
className="segments-view app-view" className="segments-view app-view"
> >
<Memo(ViewControlBar) <Memo(ViewControlBar)
label="Segments" label="Segments"
> >
@ -85,6 +84,14 @@ exports[`SegmentsView matches snapshot 1`] = `
} }
/> />
</Memo(ViewControlBar)> </Memo(ViewControlBar)>
<SplitterLayout
percentage={true}
primaryIndex={1}
primaryMinSize={20}
secondaryInitialSize={35}
secondaryMinSize={10}
vertical={true}
>
<ReactTable <ReactTable
AggregatedComponent={[Function]} AggregatedComponent={[Function]}
ExpanderComponent={[Function]} ExpanderComponent={[Function]}
@ -453,6 +460,6 @@ exports[`SegmentsView matches snapshot 1`] = `
style={{}} style={{}}
subRowsKey="_subRows" subRowsKey="_subRows"
/> />
</div> </SplitterLayout>
</React.Fragment> </div>
`; `;

View File

@ -22,12 +22,28 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
.ReactTable { .splitter-layout {
position: absolute; position: absolute;
top: $view-control-bar-height + $standard-padding; top: $view-control-bar-height + $standard-padding;
bottom: 0; bottom: 0;
width: 100%; width: 100%;
& > .layout-splitter:hover {
background: black;
opacity: 0.1;
border-radius: 2px;
}
}
.segment-timeline {
position: absolute;
width: 100%;
height: 100%;
}
.ReactTable {
@include pin-full;
.-totalPages { .-totalPages {
display: none; display: none;
} }
@ -60,15 +76,4 @@
} }
} }
} }
&.show-segment-timeline {
.segment-timeline {
height: calc(50% - 55px);
margin-top: 10px;
}
.ReactTable {
top: 50%;
}
}
} }

View File

@ -19,7 +19,6 @@
import { Button, ButtonGroup, Intent, Label, MenuItem, Switch, Tag } from '@blueprintjs/core'; import { Button, ButtonGroup, Intent, Label, MenuItem, Switch, Tag } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import { C, L, SqlComparison, SqlExpression } from '@druid-toolkit/query'; import { C, L, SqlComparison, SqlExpression } from '@druid-toolkit/query';
import classNames from 'classnames';
import * as JSONBig from 'json-bigint-native'; import * as JSONBig from 'json-bigint-native';
import React from 'react'; import React from 'react';
import type { Filter } from 'react-table'; import type { Filter } from 'react-table';
@ -34,6 +33,7 @@ import {
MoreButton, MoreButton,
RefreshButton, RefreshButton,
SegmentTimeline, SegmentTimeline,
SplitterLayout,
TableClickableCell, TableClickableCell,
TableColumnSelector, TableColumnSelector,
type TableColumnSelectorColumn, type TableColumnSelectorColumn,
@ -969,12 +969,7 @@ END AS "time_span"`,
const { groupByInterval } = this.state; const { groupByInterval } = this.state;
return ( return (
<> <div className="segments-view app-view">
<div
className={classNames('segments-view app-view', {
'show-segment-timeline': showSegmentTimeline,
})}
>
<ViewControlBar label="Segments"> <ViewControlBar label="Segments">
<RefreshButton <RefreshButton
onRefresh={auto => { onRefresh={auto => {
@ -1025,9 +1020,17 @@ END AS "time_span"`,
tableColumnsHidden={visibleColumns.getHiddenColumns()} tableColumnsHidden={visibleColumns.getHiddenColumns()}
/> />
</ViewControlBar> </ViewControlBar>
<SplitterLayout
vertical
percentage
secondaryInitialSize={35}
primaryIndex={1}
primaryMinSize={20}
secondaryMinSize={10}
>
{showSegmentTimeline && <SegmentTimeline capabilities={capabilities} />} {showSegmentTimeline && <SegmentTimeline capabilities={capabilities} />}
{this.renderSegmentsTable()} {this.renderSegmentsTable()}
</div> </SplitterLayout>
{this.renderTerminateSegmentAction()} {this.renderTerminateSegmentAction()}
{segmentTableActionDialogId && datasourceTableActionDialogId && ( {segmentTableActionDialogId && datasourceTableActionDialogId && (
<SegmentTableActionDialog <SegmentTableActionDialog
@ -1044,7 +1047,7 @@ END AS "time_span"`,
onClose={() => this.setState({ showFullShardSpec: undefined })} onClose={() => this.setState({ showFullShardSpec: undefined })}
/> />
)} )}
</> </div>
); );
} }
} }

View File

@ -135,10 +135,11 @@ export const NumberMenuItems = React.memo(function NumberMenuItems(props: Number
return ( return (
<MenuItem icon={IconNames.FUNCTION} text="Aggregate"> <MenuItem icon={IconNames.FUNCTION} text="Aggregate">
{aggregateMenuItem(F('SUM', column), `sum_${columnName}`)} {aggregateMenuItem(F.sum(column), `sum_${columnName}`)}
{aggregateMenuItem(F('MIN', column), `min_${columnName}`)} {aggregateMenuItem(F.min(column), `min_${columnName}`)}
{aggregateMenuItem(F('MAX', column), `max_${columnName}`)} {aggregateMenuItem(F.max(column), `max_${columnName}`)}
{aggregateMenuItem(F('AVG', column), `avg_${columnName}`)} {aggregateMenuItem(F.avg(column), `avg_${columnName}`)}
{aggregateMenuItem(F.countDistinct(column), `dist_${columnName}`)}
{aggregateMenuItem(F('APPROX_QUANTILE', column, 0.98), `p98_${columnName}`)} {aggregateMenuItem(F('APPROX_QUANTILE', column, 0.98), `p98_${columnName}`)}
{aggregateMenuItem(F('LATEST', column), `latest_${columnName}`)} {aggregateMenuItem(F('LATEST', column), `latest_${columnName}`)}
</MenuItem> </MenuItem>

View File

@ -104,7 +104,7 @@ type SearchMode = 'tables-and-columns' | 'tables-only' | 'columns-only';
const SEARCH_MODES: SearchMode[] = ['tables-and-columns', 'tables-only', 'columns-only']; const SEARCH_MODES: SearchMode[] = ['tables-and-columns', 'tables-only', 'columns-only'];
const SEARCH_MDOE_TITLE: Record<SearchMode, string> = { const SEARCH_MODE_TITLE: Record<SearchMode, string> = {
'tables-and-columns': 'Tables and columns', 'tables-and-columns': 'Tables and columns',
'tables-only': 'Tables only', 'tables-only': 'Tables only',
'columns-only': 'Columns only', 'columns-only': 'Columns only',
@ -641,7 +641,7 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
<MenuItem <MenuItem
key={mode} key={mode}
icon={tickIcon(mode === searchMode)} icon={tickIcon(mode === searchMode)}
text={SEARCH_MDOE_TITLE[mode]} text={SEARCH_MODE_TITLE[mode]}
onClick={() => this.setState({ searchMode: mode })} onClick={() => this.setState({ searchMode: mode })}
/> />
))} ))}

View File

@ -142,7 +142,7 @@ export const ExecutionSummaryPanel = React.memo(function ExecutionSummaryPanel(
<Button <Button
key="reset" key="reset"
icon={IconNames.CROSS} icon={IconNames.CROSS}
data-tooltip="Clear output pane" data-tooltip="Clear output"
minimal minimal
onClick={onReset} onClick={onReset}
/>, />,

View File

@ -19,6 +19,7 @@
@import '../../../variables'; @import '../../../variables';
$vertical-gap: 6px; $vertical-gap: 6px;
$visible-splitter-size: 3px;
.query-tab { .query-tab {
position: relative; position: relative;
@ -27,14 +28,26 @@ $vertical-gap: 6px;
background: $dark-gray2; background: $dark-gray2;
} }
.splitter-layout { .splitter-layout.splitter-layout-vertical {
position: absolute;
width: 100%;
height: 100%; height: 100%;
&.splitter-layout-vertical > .layout-splitter { & > .layout-splitter {
height: 3px; height: 3px + $vertical-gap * 2;
position: relative;
&::after {
content: '';
background-color: $gray1; background-color: $gray1;
position: absolute;
top: $vertical-gap;
width: 100%;
height: $visible-splitter-size;
border-radius: 2px;
}
&:hover::after {
background-color: $gray2;
}
} }
} }
@ -42,7 +55,7 @@ $vertical-gap: 6px;
position: absolute; position: absolute;
width: 100%; width: 100%;
top: 0; top: 0;
bottom: $vertical-gap; bottom: 0;
.query-section { .query-section {
position: absolute; position: absolute;
@ -84,7 +97,7 @@ $vertical-gap: 6px;
.output-section { .output-section {
position: absolute; position: absolute;
width: 100%; width: 100%;
top: $vertical-gap; top: 0;
bottom: 0; bottom: 0;
@include card-like; @include card-like;

View File

@ -22,10 +22,9 @@ import { QueryResult, QueryRunner, SqlQuery } from '@druid-toolkit/query';
import axios from 'axios'; import axios from 'axios';
import type { JSX } from 'react'; import type { JSX } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import SplitterLayout from 'react-splitter-layout';
import { useStore } from 'zustand'; import { useStore } from 'zustand';
import { Loader, QueryErrorPane } from '../../../components'; import { Loader, QueryErrorPane, SplitterLayout } from '../../../components';
import type { CapacityInfo, DruidEngine, LastExecution, QueryContext } from '../../../druid-models'; import type { CapacityInfo, DruidEngine, LastExecution, QueryContext } from '../../../druid-models';
import { DEFAULT_SERVER_QUERY_CONTEXT, Execution, WorkbenchQuery } from '../../../druid-models'; import { DEFAULT_SERVER_QUERY_CONTEXT, Execution, WorkbenchQuery } from '../../../druid-models';
import { import {
@ -44,9 +43,9 @@ import {
deepGet, deepGet,
DruidError, DruidError,
findAllSqlQueriesInText, findAllSqlQueriesInText,
localStorageGet, localStorageGetJson,
LocalStorageKeys, LocalStorageKeys,
localStorageSet, localStorageSetJson,
QueryManager, QueryManager,
} from '../../../utils'; } from '../../../utils';
import { CapacityAlert } from '../capacity-alert/capacity-alert'; import { CapacityAlert } from '../capacity-alert/capacity-alert';
@ -70,6 +69,10 @@ const queryRunner = new QueryRunner({
inflateDateStrategy: 'none', inflateDateStrategy: 'none',
}); });
function handleSecondaryPaneSizeChange(secondaryPaneSize: number) {
localStorageSetJson(LocalStorageKeys.WORKBENCH_PANE_SIZE, secondaryPaneSize);
}
export interface QueryTabProps export interface QueryTabProps
extends Pick< extends Pick<
RunPanelProps, RunPanelProps,
@ -180,15 +183,12 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) {
); );
function shouldAutoRun(): boolean { function shouldAutoRun(): boolean {
if (query.getEffectiveEngine() !== 'sql-native') return false; const effectiveEngine = query.getEffectiveEngine();
if (effectiveEngine !== 'sql-native' && effectiveEngine !== 'sql-msq-dart') return false;
const queryDuration = executionState.data?.result?.queryDuration; const queryDuration = executionState.data?.result?.queryDuration;
return Boolean(queryDuration && queryDuration < 10000); return Boolean(queryDuration && queryDuration < 10000);
} }
const handleSecondaryPaneSizeChange = useCallback((secondaryPaneSize: number) => {
localStorageSet(LocalStorageKeys.WORKBENCH_PANE_SIZE, String(secondaryPaneSize));
}, []);
const queryInputRef = useRef<FlexibleQueryInput | null>(null); const queryInputRef = useRef<FlexibleQueryInput | null>(null);
const cachedExecutionState = ExecutionStateCache.getState(id); const cachedExecutionState = ExecutionStateCache.getState(id);
@ -296,9 +296,10 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) {
if (deepGet(query, 'context.fullReport') && dartResponse[0][0] === 'fullReport') { if (deepGet(query, 'context.fullReport') && dartResponse[0][0] === 'fullReport') {
const dartReport = dartResponse[dartResponse.length - 1][0]; const dartReport = dartResponse[dartResponse.length - 1][0];
return Execution.fromTaskReport(dartReport) return Execution.fromDartReport(dartReport).changeSqlQuery(
.changeEngine('sql-msq-dart') query.query,
.changeSqlQuery(query.query, query.context); query.context,
);
} else { } else {
return Execution.fromResult( return Execution.fromResult(
engine, engine,
@ -308,7 +309,7 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) {
query.header, query.header,
query.typesHeader, query.typesHeader,
query.sqlTypesHeader, query.sqlTypesHeader,
), ).changeQueryDuration(Date.now() - startTime.valueOf()),
).changeSqlQuery(query.query, query.context); ).changeSqlQuery(query.query, query.context);
} }
}, },
@ -372,6 +373,16 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) {
const execution = executionState.data; const execution = executionState.data;
// This is the execution that would be shown in the output pane, it is either the actual execution or a result
// execution that will be shown under the loader
const executionToShow =
execution ||
(() => {
if (executionState.intermediate) return;
const e = executionState.getSomeData();
return e?.result ? e : undefined;
})();
const incrementMetadataVersion = useStore( const incrementMetadataVersion = useStore(
metadataStateStore, metadataStateStore,
useCallback(state => state.increment, []), useCallback(state => state.increment, []),
@ -456,7 +467,9 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) {
<SplitterLayout <SplitterLayout
vertical vertical
percentage percentage
secondaryInitialSize={Number(localStorageGet(LocalStorageKeys.WORKBENCH_PANE_SIZE)!) || 40} secondaryInitialSize={
Number(localStorageGetJson(LocalStorageKeys.WORKBENCH_PANE_SIZE)) || 40
}
primaryMinSize={20} primaryMinSize={20}
secondaryMinSize={20} secondaryMinSize={20}
onSecondaryPaneSizeChange={handleSecondaryPaneSizeChange} onSecondaryPaneSizeChange={handleSecondaryPaneSizeChange}
@ -525,40 +538,40 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) {
)} )}
</div> </div>
)} )}
{execution && {executionToShow &&
(execution.error ? ( (executionToShow.error ? (
<div className="error-container"> <div className="error-container">
<ExecutionErrorPane execution={execution} /> <ExecutionErrorPane execution={executionToShow} />
{execution.stages && ( {executionToShow.stages && (
<ExecutionStagesPane <ExecutionStagesPane
execution={execution} execution={executionToShow}
onErrorClick={() => onDetails(execution, 'error')} onErrorClick={() => onDetails(executionToShow, 'error')}
onWarningClick={() => onDetails(execution, 'warnings')} onWarningClick={() => onDetails(executionToShow, 'warnings')}
goToTask={goToTask} goToTask={goToTask}
/> />
)} )}
</div> </div>
) : execution.result ? ( ) : executionToShow.result ? (
<ResultTablePane <ResultTablePane
runeMode={execution.engine === 'native'} runeMode={executionToShow.engine === 'native'}
queryResult={execution.result} queryResult={executionToShow.result}
onQueryAction={handleQueryAction} onQueryAction={handleQueryAction}
/> />
) : execution.isSuccessfulIngest() ? ( ) : executionToShow.isSuccessfulIngest() ? (
<IngestSuccessPane <IngestSuccessPane
execution={execution} execution={executionToShow}
onDetails={onDetails} onDetails={onDetails}
onQueryTab={onQueryTab} onQueryTab={onQueryTab}
/> />
) : ( ) : (
<div className="generic-status-container"> <div className="generic-status-container">
<div className="generic-status-container-info"> <div className="generic-status-container-info">
{`Execution completed with status: ${execution.status}`} {`Execution completed with status: ${executionToShow.status}`}
</div> </div>
<ExecutionStagesPane <ExecutionStagesPane
execution={execution} execution={executionToShow}
onErrorClick={() => onDetails(execution, 'error')} onErrorClick={() => onDetails(executionToShow, 'error')}
onWarningClick={() => onDetails(execution, 'warnings')} onWarningClick={() => onDetails(executionToShow, 'warnings')}
goToTask={goToTask} goToTask={goToTask}
/> />
</div> </div>

View File

@ -37,6 +37,9 @@
border-bottom: 1px solid rgba(255, 255, 255, 0.3); border-bottom: 1px solid rgba(255, 255, 255, 0.3);
padding: 8px 10px; padding: 8px 10px;
user-select: none; user-select: none;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
.close-button { .close-button {
position: absolute; position: absolute;

View File

@ -19,18 +19,27 @@
@import '../../variables'; @import '../../variables';
$column-tree-width: 250px; $column-tree-width: 250px;
$recent-query-task-panel-width: 250px;
.workbench-view { .workbench-view {
height: 100%; height: 100%;
width: 100%; width: 100%;
.workbench-splitter {
height: 100%;
& > .layout-splitter:hover {
background: black;
opacity: 0.1;
border-radius: 2px;
}
}
.column-tree { .column-tree {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
height: 100%; height: 100%;
width: $column-tree-width; width: 100%;
@include card-like; @include card-like;
} }
@ -39,7 +48,7 @@ $recent-query-task-panel-width: 250px;
top: 0; top: 0;
right: 0; right: 0;
height: 100%; height: 100%;
width: $recent-query-task-panel-width; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
@ -54,8 +63,7 @@ $recent-query-task-panel-width: 250px;
position: absolute; position: absolute;
top: 0; top: 0;
height: 100%; height: 100%;
left: $column-tree-width + $thin-padding; width: 100%;
right: $recent-query-task-panel-width + $thin-padding;
.tab-and-tool-bar { .tab-and-tool-bar {
position: absolute; position: absolute;

View File

@ -32,7 +32,7 @@ import classNames from 'classnames';
import copy from 'copy-to-clipboard'; import copy from 'copy-to-clipboard';
import React from 'react'; import React from 'react';
import { MenuCheckbox } from '../../components'; import { MenuCheckbox, SplitterLayout } from '../../components';
import { SpecDialog, StringInputDialog } from '../../dialogs'; import { SpecDialog, StringInputDialog } from '../../dialogs';
import type { import type {
CapacityInfo, CapacityInfo,
@ -87,6 +87,14 @@ import './workbench-view.scss';
const LAST_DAY = SqlExpression.parse(`__time >= CURRENT_TIMESTAMP - INTERVAL '1' DAY`); const LAST_DAY = SqlExpression.parse(`__time >= CURRENT_TIMESTAMP - INTERVAL '1' DAY`);
function handleLeftPaneSizeChange(paneSize: number) {
localStorageSetJson(LocalStorageKeys.WORKBENCH_LEFT_SIZE, paneSize);
}
function handleRightPaneSizeChange(paneSize: number) {
localStorageSetJson(LocalStorageKeys.WORKBENCH_RIGHT_SIZE, paneSize);
}
function cleanupTabEntry(tabEntry: TabEntry): void { function cleanupTabEntry(tabEntry: TabEntry): void {
const discardedId = tabEntry.id; const discardedId = tabEntry.id;
WorkbenchRunningPromises.deletePromise(discardedId); WorkbenchRunningPromises.deletePromise(discardedId);
@ -912,11 +920,28 @@ export class WorkbenchView extends React.PureComponent<WorkbenchViewProps, Workb
const showRightPanel = showRecentQueryTaskPanel || showCurrentDartPanel; const showRightPanel = showRecentQueryTaskPanel || showCurrentDartPanel;
return ( return (
<div <div className="workbench-view app-view">
className={classNames('workbench-view app-view', { <SplitterLayout
'hide-column-tree': columnMetadataState.isError(), className="workbench-splitter"
'hide-right-panel': !showRightPanel, primaryIndex={0}
})} secondaryMinSize={250}
secondaryMaxSize={500}
secondaryInitialSize={
Number(localStorageGetJson(LocalStorageKeys.WORKBENCH_RIGHT_SIZE)) || 250
}
onSecondaryPaneSizeChange={handleRightPaneSizeChange}
primaryMinSize={600}
>
<SplitterLayout
className="workbench-splitter"
primaryIndex={1}
secondaryMinSize={250}
secondaryMaxSize={500}
secondaryInitialSize={
Number(localStorageGetJson(LocalStorageKeys.WORKBENCH_LEFT_SIZE)) || 250
}
onSecondaryPaneSizeChange={handleLeftPaneSizeChange}
primaryMinSize={400}
> >
{!columnMetadataState.isError() && ( {!columnMetadataState.isError() && (
<ColumnTree <ColumnTree
@ -931,6 +956,7 @@ export class WorkbenchView extends React.PureComponent<WorkbenchViewProps, Workb
/> />
)} )}
{this.renderCenterPanel()} {this.renderCenterPanel()}
</SplitterLayout>
{showRightPanel && ( {showRightPanel && (
<div className="recent-panel"> <div className="recent-panel">
{showRecentQueryTaskPanel && ( {showRecentQueryTaskPanel && (
@ -946,6 +972,8 @@ export class WorkbenchView extends React.PureComponent<WorkbenchViewProps, Workb
)} )}
</div> </div>
)} )}
</SplitterLayout>
{this.renderExecutionDetailsDialog()} {this.renderExecutionDetailsDialog()}
{this.renderExplainDialog()} {this.renderExplainDialog()}
{this.renderHistoryDialog()} {this.renderHistoryDialog()}