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"
license_category: binary
module: web-console

View File

@ -42,7 +42,6 @@
"react-dom": "^18.3.1",
"react-router": "^5.3.4",
"react-router-dom": "^5.3.4",
"react-splitter-layout": "^4.0.0",
"react-table": "~6.11.5",
"regenerator-runtime": "^0.13.7",
"tslib": "^2.8.0",
@ -75,7 +74,6 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@types/react-router-dom": "^5.3.3",
"@types/react-splitter-layout": "^3.0.5",
"@types/react-table": "6.8.5",
"@types/uuid": "^7.0.2",
"autoprefixer": "^10.4.20",
@ -4064,15 +4062,6 @@
"@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": {
"version": "6.8.5",
"resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-6.8.5.tgz",
@ -14964,15 +14953,6 @@
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
"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": {
"version": "6.11.5",
"resolved": "https://registry.npmjs.org/react-table/-/react-table-6.11.5.tgz",
@ -21157,15 +21137,6 @@
"@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": {
"version": "6.8.5",
"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": {
"version": "6.11.5",
"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-router": "^5.3.4",
"react-router-dom": "^5.3.4",
"react-splitter-layout": "^4.0.0",
"react-table": "~6.11.5",
"regenerator-runtime": "^0.13.7",
"tslib": "^2.8.0",
@ -116,7 +115,6 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@types/react-router-dom": "^5.3.3",
"@types/react-splitter-layout": "^3.0.5",
"@types/react-table": "6.8.5",
"@types/uuid": "^7.0.2",
"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-log/show-log';
export * from './show-value/show-value';
export * from './splitter-layout/splitter-layout';
export * from './suggestion-menu/suggestion-menu';
export * from './supervisor-history-panel/supervisor-history-panel';
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 {
// Must have status set for a valid report
const id = deepGet(taskReport, 'multiStageQuery.taskId');
@ -327,6 +331,7 @@ export class Execution {
new Column({ name: sig.name, nativeType: sig.type, sqlType: sqlTypeNames?.[i] }),
),
rows: results,
queryDuration: durationMs,
}).inflateDatesFromSqlTypes();
}

View File

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

View File

@ -51,6 +51,8 @@ export const LocalStorageKeys = {
WORKBENCH_QUERIES: 'workbench-queries' as const,
WORKBENCH_LAST_TAB: 'workbench-last-tab' 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_TASK_PANEL: 'workbench-task-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;
}
}
@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)>
<SplitterLayout
percentage={true}
primaryIndex={1}
primaryMinSize={20}
secondaryInitialSize={35}
secondaryMinSize={10}
vertical={true}
>
<ReactTable
AggregatedComponent={[Function]}
ExpanderComponent={[Function]}
@ -438,5 +446,6 @@ exports[`DatasourcesView matches snapshot 1`] = `
style={{}}
subRowsKey="_subRows"
/>
</SplitterLayout>
</div>
`;

View File

@ -23,25 +23,30 @@
width: 100%;
overflow: auto;
.clickable-cell {
cursor: pointer;
}
.ReactTable {
.splitter-layout {
position: absolute;
top: $view-control-bar-height + $standard-padding;
bottom: 0;
width: 100%;
& > .layout-splitter:hover {
background: black;
opacity: 0.1;
border-radius: 2px;
}
}
&.show-segment-timeline {
.segment-timeline {
height: calc(50% - 55px);
margin-top: 10px;
position: absolute;
width: 100%;
height: 100%;
}
.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 { IconNames } from '@blueprintjs/icons';
import { SqlQuery, T } from '@druid-toolkit/query';
import classNames from 'classnames';
import { sum } from 'd3-array';
import React from 'react';
import type { Filter } from 'react-table';
@ -34,6 +33,7 @@ import {
MoreButton,
RefreshButton,
SegmentTimeline,
SplitterLayout,
TableClickableCell,
TableColumnSelector,
type TableColumnSelectorColumn,
@ -1678,11 +1678,7 @@ GROUP BY 1, 2`;
} = this.state;
return (
<div
className={classNames('datasources-view app-view', {
'show-segment-timeline': showSegmentTimeline,
})}
>
<div className="datasources-view app-view">
<ViewControlBar label="Datasources">
<RefreshButton
onRefresh={auto => {
@ -1717,8 +1713,17 @@ GROUP BY 1, 2`;
tableColumnsHidden={visibleColumns.getHiddenColumns()}
/>
</ViewControlBar>
<SplitterLayout
vertical
percentage
secondaryInitialSize={35}
primaryIndex={1}
primaryMinSize={20}
secondaryMinSize={10}
>
{showSegmentTimeline && <SegmentTimeline capabilities={capabilities} />}
{this.renderDatasourcesTable()}
</SplitterLayout>
{datasourceTableActionDialogId && (
<DatasourceTableActionDialog
datasource={datasourceTableActionDialogId}

View File

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

View File

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

View File

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

View File

@ -22,12 +22,28 @@
height: 100%;
width: 100%;
.ReactTable {
.splitter-layout {
position: absolute;
top: $view-control-bar-height + $standard-padding;
bottom: 0;
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 {
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 { IconNames } from '@blueprintjs/icons';
import { C, L, SqlComparison, SqlExpression } from '@druid-toolkit/query';
import classNames from 'classnames';
import * as JSONBig from 'json-bigint-native';
import React from 'react';
import type { Filter } from 'react-table';
@ -34,6 +33,7 @@ import {
MoreButton,
RefreshButton,
SegmentTimeline,
SplitterLayout,
TableClickableCell,
TableColumnSelector,
type TableColumnSelectorColumn,
@ -969,12 +969,7 @@ END AS "time_span"`,
const { groupByInterval } = this.state;
return (
<>
<div
className={classNames('segments-view app-view', {
'show-segment-timeline': showSegmentTimeline,
})}
>
<div className="segments-view app-view">
<ViewControlBar label="Segments">
<RefreshButton
onRefresh={auto => {
@ -1025,9 +1020,17 @@ END AS "time_span"`,
tableColumnsHidden={visibleColumns.getHiddenColumns()}
/>
</ViewControlBar>
<SplitterLayout
vertical
percentage
secondaryInitialSize={35}
primaryIndex={1}
primaryMinSize={20}
secondaryMinSize={10}
>
{showSegmentTimeline && <SegmentTimeline capabilities={capabilities} />}
{this.renderSegmentsTable()}
</div>
</SplitterLayout>
{this.renderTerminateSegmentAction()}
{segmentTableActionDialogId && datasourceTableActionDialogId && (
<SegmentTableActionDialog
@ -1044,7 +1047,7 @@ END AS "time_span"`,
onClose={() => this.setState({ showFullShardSpec: undefined })}
/>
)}
</>
</div>
);
}
}

View File

@ -135,10 +135,11 @@ export const NumberMenuItems = React.memo(function NumberMenuItems(props: Number
return (
<MenuItem icon={IconNames.FUNCTION} text="Aggregate">
{aggregateMenuItem(F('SUM', column), `sum_${columnName}`)}
{aggregateMenuItem(F('MIN', column), `min_${columnName}`)}
{aggregateMenuItem(F('MAX', column), `max_${columnName}`)}
{aggregateMenuItem(F('AVG', column), `avg_${columnName}`)}
{aggregateMenuItem(F.sum(column), `sum_${columnName}`)}
{aggregateMenuItem(F.min(column), `min_${columnName}`)}
{aggregateMenuItem(F.max(column), `max_${columnName}`)}
{aggregateMenuItem(F.avg(column), `avg_${columnName}`)}
{aggregateMenuItem(F.countDistinct(column), `dist_${columnName}`)}
{aggregateMenuItem(F('APPROX_QUANTILE', column, 0.98), `p98_${columnName}`)}
{aggregateMenuItem(F('LATEST', column), `latest_${columnName}`)}
</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_MDOE_TITLE: Record<SearchMode, string> = {
const SEARCH_MODE_TITLE: Record<SearchMode, string> = {
'tables-and-columns': 'Tables and columns',
'tables-only': 'Tables only',
'columns-only': 'Columns only',
@ -641,7 +641,7 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
<MenuItem
key={mode}
icon={tickIcon(mode === searchMode)}
text={SEARCH_MDOE_TITLE[mode]}
text={SEARCH_MODE_TITLE[mode]}
onClick={() => this.setState({ searchMode: mode })}
/>
))}

View File

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

View File

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

View File

@ -22,10 +22,9 @@ import { QueryResult, QueryRunner, SqlQuery } from '@druid-toolkit/query';
import axios from 'axios';
import type { JSX } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import SplitterLayout from 'react-splitter-layout';
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 { DEFAULT_SERVER_QUERY_CONTEXT, Execution, WorkbenchQuery } from '../../../druid-models';
import {
@ -44,9 +43,9 @@ import {
deepGet,
DruidError,
findAllSqlQueriesInText,
localStorageGet,
localStorageGetJson,
LocalStorageKeys,
localStorageSet,
localStorageSetJson,
QueryManager,
} from '../../../utils';
import { CapacityAlert } from '../capacity-alert/capacity-alert';
@ -70,6 +69,10 @@ const queryRunner = new QueryRunner({
inflateDateStrategy: 'none',
});
function handleSecondaryPaneSizeChange(secondaryPaneSize: number) {
localStorageSetJson(LocalStorageKeys.WORKBENCH_PANE_SIZE, secondaryPaneSize);
}
export interface QueryTabProps
extends Pick<
RunPanelProps,
@ -180,15 +183,12 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) {
);
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;
return Boolean(queryDuration && queryDuration < 10000);
}
const handleSecondaryPaneSizeChange = useCallback((secondaryPaneSize: number) => {
localStorageSet(LocalStorageKeys.WORKBENCH_PANE_SIZE, String(secondaryPaneSize));
}, []);
const queryInputRef = useRef<FlexibleQueryInput | null>(null);
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') {
const dartReport = dartResponse[dartResponse.length - 1][0];
return Execution.fromTaskReport(dartReport)
.changeEngine('sql-msq-dart')
.changeSqlQuery(query.query, query.context);
return Execution.fromDartReport(dartReport).changeSqlQuery(
query.query,
query.context,
);
} else {
return Execution.fromResult(
engine,
@ -308,7 +309,7 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) {
query.header,
query.typesHeader,
query.sqlTypesHeader,
),
).changeQueryDuration(Date.now() - startTime.valueOf()),
).changeSqlQuery(query.query, query.context);
}
},
@ -372,6 +373,16 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) {
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(
metadataStateStore,
useCallback(state => state.increment, []),
@ -456,7 +467,9 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) {
<SplitterLayout
vertical
percentage
secondaryInitialSize={Number(localStorageGet(LocalStorageKeys.WORKBENCH_PANE_SIZE)!) || 40}
secondaryInitialSize={
Number(localStorageGetJson(LocalStorageKeys.WORKBENCH_PANE_SIZE)) || 40
}
primaryMinSize={20}
secondaryMinSize={20}
onSecondaryPaneSizeChange={handleSecondaryPaneSizeChange}
@ -525,40 +538,40 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) {
)}
</div>
)}
{execution &&
(execution.error ? (
{executionToShow &&
(executionToShow.error ? (
<div className="error-container">
<ExecutionErrorPane execution={execution} />
{execution.stages && (
<ExecutionErrorPane execution={executionToShow} />
{executionToShow.stages && (
<ExecutionStagesPane
execution={execution}
onErrorClick={() => onDetails(execution, 'error')}
onWarningClick={() => onDetails(execution, 'warnings')}
execution={executionToShow}
onErrorClick={() => onDetails(executionToShow, 'error')}
onWarningClick={() => onDetails(executionToShow, 'warnings')}
goToTask={goToTask}
/>
)}
</div>
) : execution.result ? (
) : executionToShow.result ? (
<ResultTablePane
runeMode={execution.engine === 'native'}
queryResult={execution.result}
runeMode={executionToShow.engine === 'native'}
queryResult={executionToShow.result}
onQueryAction={handleQueryAction}
/>
) : execution.isSuccessfulIngest() ? (
) : executionToShow.isSuccessfulIngest() ? (
<IngestSuccessPane
execution={execution}
execution={executionToShow}
onDetails={onDetails}
onQueryTab={onQueryTab}
/>
) : (
<div className="generic-status-container">
<div className="generic-status-container-info">
{`Execution completed with status: ${execution.status}`}
{`Execution completed with status: ${executionToShow.status}`}
</div>
<ExecutionStagesPane
execution={execution}
onErrorClick={() => onDetails(execution, 'error')}
onWarningClick={() => onDetails(execution, 'warnings')}
execution={executionToShow}
onErrorClick={() => onDetails(executionToShow, 'error')}
onWarningClick={() => onDetails(executionToShow, 'warnings')}
goToTask={goToTask}
/>
</div>

View File

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

View File

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

View File

@ -32,7 +32,7 @@ import classNames from 'classnames';
import copy from 'copy-to-clipboard';
import React from 'react';
import { MenuCheckbox } from '../../components';
import { MenuCheckbox, SplitterLayout } from '../../components';
import { SpecDialog, StringInputDialog } from '../../dialogs';
import type {
CapacityInfo,
@ -87,6 +87,14 @@ import './workbench-view.scss';
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 {
const discardedId = tabEntry.id;
WorkbenchRunningPromises.deletePromise(discardedId);
@ -912,11 +920,28 @@ export class WorkbenchView extends React.PureComponent<WorkbenchViewProps, Workb
const showRightPanel = showRecentQueryTaskPanel || showCurrentDartPanel;
return (
<div
className={classNames('workbench-view app-view', {
'hide-column-tree': columnMetadataState.isError(),
'hide-right-panel': !showRightPanel,
})}
<div className="workbench-view app-view">
<SplitterLayout
className="workbench-splitter"
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() && (
<ColumnTree
@ -931,6 +956,7 @@ export class WorkbenchView extends React.PureComponent<WorkbenchViewProps, Workb
/>
)}
{this.renderCenterPanel()}
</SplitterLayout>
{showRightPanel && (
<div className="recent-panel">
{showRecentQueryTaskPanel && (
@ -946,6 +972,8 @@ export class WorkbenchView extends React.PureComponent<WorkbenchViewProps, Workb
)}
</div>
)}
</SplitterLayout>
{this.renderExecutionDetailsDialog()}
{this.renderExplainDialog()}
{this.renderHistoryDialog()}