diff --git a/package-lock.json b/package-lock.json index 5148b6f2a1..3dc345fcfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,7 +67,7 @@ "@gravity-ui/prettier-config": "^1.1.0", "@gravity-ui/stylelint-config": "^4.0.1", "@gravity-ui/tsconfig": "^1.0.0", - "@playwright/test": "^1.49.1", + "@playwright/test": "^1.50.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", @@ -4654,12 +4654,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", - "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.1.tgz", + "integrity": "sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "playwright": "1.49.1" + "playwright": "1.50.1" }, "bin": { "playwright": "cli.js" @@ -19740,12 +19741,13 @@ } }, "node_modules/playwright": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", - "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.1.tgz", + "integrity": "sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.49.1" + "playwright-core": "1.50.1" }, "bin": { "playwright": "cli.js" @@ -19758,10 +19760,11 @@ } }, "node_modules/playwright-core": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", - "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.1.tgz", + "integrity": "sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==", "dev": true, + "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, @@ -19775,6 +19778,7 @@ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" diff --git a/package.json b/package.json index 630eee5c62..8dc2e7ca0f 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "@gravity-ui/prettier-config": "^1.1.0", "@gravity-ui/stylelint-config": "^4.0.1", "@gravity-ui/tsconfig": "^1.0.0", - "@playwright/test": "^1.49.1", + "@playwright/test": "^1.50.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", diff --git a/playwright.config.ts b/playwright.config.ts index 4db02a1c33..597bb5724d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,8 +18,11 @@ const config: PlaywrightTestConfig = { ? undefined : { command: 'npm run dev', + env: { + REACT_APP_DISABLE_CHECKS: 'true', + }, port: 3000, - reuseExistingServer: true, + reuseExistingServer: !process.env.CI, }, use: { baseURL: baseUrl || 'http://localhost:3000/', diff --git a/src/components/ElapsedTime/ElapsedTime.scss b/src/components/ElapsedTime/ElapsedTime.scss deleted file mode 100644 index 01da8c07b2..0000000000 --- a/src/components/ElapsedTime/ElapsedTime.scss +++ /dev/null @@ -1,3 +0,0 @@ -.ydb-query-elapsed-time { - visibility: visible; -} diff --git a/src/components/ElapsedTime/ElapsedTime.tsx b/src/components/ElapsedTime/ElapsedTime.tsx deleted file mode 100644 index b0301231ba..0000000000 --- a/src/components/ElapsedTime/ElapsedTime.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; - -import {duration} from '@gravity-ui/date-utils'; -import {Label} from '@gravity-ui/uikit'; - -import {cn} from '../../utils/cn'; -import {HOUR_IN_SECONDS, SECOND_IN_MS} from '../../utils/constants'; - -const b = cn('ydb-query-elapsed-time'); - -interface ElapsedTimeProps { - className?: string; -} - -export default function ElapsedTime({className}: ElapsedTimeProps) { - const [, reRender] = React.useState({}); - const [startTime] = React.useState(Date.now()); - const elapsedTime = Date.now() - startTime; - - React.useEffect(() => { - const timerId = setInterval(() => { - reRender({}); - }, SECOND_IN_MS); - return () => { - clearInterval(timerId); - }; - }, []); - - const elapsedTimeFormatted = - elapsedTime > HOUR_IN_SECONDS * SECOND_IN_MS - ? duration(elapsedTime).format('hh:mm:ss') - : duration(elapsedTime).format('mm:ss'); - - return ; -} diff --git a/src/components/QueryExecutionStatus/QueryExecutionStatus.scss b/src/components/QueryExecutionStatus/QueryExecutionStatus.scss index 6acf9cb1f7..91f0321495 100644 --- a/src/components/QueryExecutionStatus/QueryExecutionStatus.scss +++ b/src/components/QueryExecutionStatus/QueryExecutionStatus.scss @@ -2,16 +2,4 @@ display: flex; align-items: center; gap: 4px; - - color: var(--g-color-text-complementary); - &__result-status-icon { - color: var(--g-color-text-positive); - &_error { - color: var(--g-color-text-danger); - } - } - - &__query-settings-icon { - color: var(--g-color-text-hint); - } } diff --git a/src/components/QueryExecutionStatus/QueryExecutionStatus.tsx b/src/components/QueryExecutionStatus/QueryExecutionStatus.tsx index 593ac63c12..fa06b7516f 100644 --- a/src/components/QueryExecutionStatus/QueryExecutionStatus.tsx +++ b/src/components/QueryExecutionStatus/QueryExecutionStatus.tsx @@ -1,20 +1,16 @@ import React from 'react'; -import { - CircleCheck, - CircleInfo, - CircleQuestionFill, - CircleStop, - CircleXmark, -} from '@gravity-ui/icons'; -import {Icon, Spin, Tooltip} from '@gravity-ui/uikit'; +import {duration} from '@gravity-ui/date-utils'; +import {CircleCheckFill, CircleQuestionFill, CircleStop, CircleXmark} from '@gravity-ui/icons'; +import type {LabelProps, TextProps} from '@gravity-ui/uikit'; +import {Icon, Label, Spin, Text} from '@gravity-ui/uikit'; -import i18n from '../../containers/Tenant/Query/i18n'; import {isQueryCancelledError} from '../../containers/Tenant/Query/utils/isQueryCancelledError'; +import {selectQueryDuration} from '../../store/reducers/query/query'; import {cn} from '../../utils/cn'; -import {useChangedQuerySettings} from '../../utils/hooks/useChangedQuerySettings'; +import {HOUR_IN_SECONDS, SECOND_IN_MS} from '../../utils/constants'; +import {useTypedSelector} from '../../utils/hooks'; import {isAxiosError} from '../../utils/response'; -import QuerySettingsDescription from '../QuerySettingsDescription/QuerySettingsDescription'; import './QueryExecutionStatus.scss'; @@ -26,46 +22,68 @@ interface QueryExecutionStatusProps { loading?: boolean; } -const QuerySettingsIndicator = () => { - const {isIndicatorShown, changedLastExecutionSettingsDescriptions} = useChangedQuerySettings(); - - if (!isIndicatorShown) { - return null; - } - - return ( - - } - > - - - ); -}; - export const QueryExecutionStatus = ({className, error, loading}: QueryExecutionStatusProps) => { let icon: React.ReactNode; let label: string; + let theme: LabelProps['theme']; + let textColor: TextProps['color']; + const {startTime, endTime} = useTypedSelector(selectQueryDuration); + + const [queryDuration, setQueryDuration] = React.useState( + startTime ? (endTime || Date.now()) - startTime : 0, + ); + + const isCancelled = isQueryCancelledError(error); + + const setDuration = React.useCallback(() => { + if (startTime) { + const actualEndTime = endTime || Date.now(); + setQueryDuration(actualEndTime - startTime); + } + }, [endTime, startTime]); + + React.useEffect(() => { + let timerId: ReturnType | undefined; + setDuration(); + + if (loading) { + timerId = setInterval(setDuration, SECOND_IN_MS); + } else { + clearInterval(timerId); + } + return () => { + clearInterval(timerId); + }; + }, [loading, setDuration]); + + const formattedQueryDuration = React.useMemo(() => { + return queryDuration > HOUR_IN_SECONDS * SECOND_IN_MS + ? duration(queryDuration).format('hh:mm:ss') + : duration(queryDuration).format('mm:ss'); + }, [queryDuration]); if (loading) { + theme = 'info'; + textColor = 'info-heavy'; icon = ; label = 'Running'; } else if (isAxiosError(error) && error.code === 'ECONNABORTED') { + theme = 'danger'; + textColor = 'danger-heavy'; icon = ; label = 'Connection aborted'; - } else if (isQueryCancelledError(error)) { - icon = ; + } else if (isCancelled) { + theme = 'warning'; + textColor = 'warning-heavy'; + icon = ; label = 'Stopped'; } else { const hasError = Boolean(error); + theme = hasError ? 'danger' : 'success'; + textColor = hasError ? 'danger-heavy' : 'positive-heavy'; icon = ( ); @@ -73,10 +91,14 @@ export const QueryExecutionStatus = ({className, error, loading}: QueryExecution } return ( -
- {icon} - {label} - {isQueryCancelledError(error) || loading ? null : } -
+ ); }; diff --git a/src/containers/Tenant/Query/CancelQueryButton/CancelQueryButton.tsx b/src/containers/Tenant/Query/CancelQueryButton/CancelQueryButton.tsx deleted file mode 100644 index 9a1b35f017..0000000000 --- a/src/containers/Tenant/Query/CancelQueryButton/CancelQueryButton.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import {StopFill} from '@gravity-ui/icons'; -import {Button, Icon} from '@gravity-ui/uikit'; - -import {cn} from '../../../../utils/cn'; -import i18n from '../i18n'; - -import './CancelQueryButton.scss'; - -const b = cn('cancel-query-button'); - -interface CancelQueryButtonProps { - isLoading: boolean; - isError: boolean; - onClick?: VoidFunction; -} - -export function CancelQueryButton({isLoading, isError, onClick}: CancelQueryButtonProps) { - return ( - - ); -} diff --git a/src/containers/Tenant/Query/QueryDuration/QueryDuration.scss b/src/containers/Tenant/Query/QueryDuration/QueryDuration.scss deleted file mode 100644 index cad246f07c..0000000000 --- a/src/containers/Tenant/Query/QueryDuration/QueryDuration.scss +++ /dev/null @@ -1,27 +0,0 @@ -.ydb-query-duration { - display: flex; - align-items: center; - - margin-left: 10px; - - color: var(--g-color-text-complementary); - - &__item-with-popover { - display: flex; - - white-space: nowrap; - } - - &__popover { - display: flex; - align-items: center; - } - - &__popover-content { - max-width: 300px; - } - - &__popover-button { - display: flex; - } -} diff --git a/src/containers/Tenant/Query/QueryDuration/QueryDuration.tsx b/src/containers/Tenant/Query/QueryDuration/QueryDuration.tsx deleted file mode 100644 index 40cca78174..0000000000 --- a/src/containers/Tenant/Query/QueryDuration/QueryDuration.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import {LabelWithPopover} from '../../../../components/LabelWithPopover'; -import {cn} from '../../../../utils/cn'; -import {formatDurationToShortTimeFormat, parseUsToMs} from '../../../../utils/timeParsers'; -import i18n from '../i18n'; - -import './QueryDuration.scss'; - -interface QueryDurationProps { - duration?: string | number; -} - -const b = cn('ydb-query-duration'); - -export const QueryDuration = ({duration}: QueryDurationProps) => { - if (!duration) { - return null; - } - - const parsedDuration = formatDurationToShortTimeFormat(parseUsToMs(duration), 1); - - return ( - - - - ); -}; diff --git a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx index 49233ebe35..61b67347b3 100644 --- a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx +++ b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx @@ -5,7 +5,6 @@ import {isEqual} from 'lodash'; import {v4 as uuidv4} from 'uuid'; import SplitPane from '../../../../components/SplitPane'; -import {cancelQueryApi} from '../../../../store/reducers/cancelQuery'; import { useStreamingAvailable, useTracingLevelOptionAvailable, @@ -52,6 +51,7 @@ import {QueryResultViewer} from '../QueryResult/QueryResultViewer'; import {QuerySettingsDialog} from '../QuerySettingsDialog/QuerySettingsDialog'; import {YqlEditor} from './YqlEditor'; +import {queryManagerInstance} from './helpers'; import './QueryEditor.scss'; @@ -92,14 +92,11 @@ export default function QueryEditor(props: QueryEditorProps) { LAST_USED_QUERY_ACTION_KEY, ); const [lastExecutedQueryText, setLastExecutedQueryText] = React.useState(''); - const [isQueryStreamingEnabled] = useSetting(ENABLE_QUERY_STREAMING); + const [isQueryStreamingEnabled] = useSetting(ENABLE_QUERY_STREAMING); const isStreamingEnabled = useStreamingAvailable() && isQueryStreamingEnabled; const [sendQuery] = queryApi.useUseSendQueryMutation(); const [streamQuery] = queryApi.useUseStreamQueryMutation(); - const [sendCancelQuery, cancelQueryResponse] = cancelQueryApi.useCancelQueryMutation(); - - const runningQueryRef = React.useRef<{abort: VoidFunction} | null>(null); const tableSettings = React.useMemo(() => { return isStreamingEnabled @@ -144,16 +141,17 @@ export default function QueryEditor(props: QueryEditorProps) { const queryId = uuidv4(); if (isStreamingEnabled) { - runningQueryRef.current = streamQuery({ + const query = streamQuery({ actionType: 'execute', query: text, database: tenantName, querySettings, enableTracingLevel, - queryId, }); + + queryManagerInstance.registerQuery(query); } else { - runningQueryRef.current = sendQuery({ + const query = sendQuery({ actionType: 'execute', query: text, database: tenantName, @@ -161,6 +159,8 @@ export default function QueryEditor(props: QueryEditorProps) { enableTracingLevel, queryId, }); + + queryManagerInstance.registerQuery(query); } dispatch(setShowPreview(false)); @@ -188,7 +188,7 @@ export default function QueryEditor(props: QueryEditorProps) { const queryId = uuidv4(); - runningQueryRef.current = sendQuery({ + const query = sendQuery({ actionType: 'explain', query: text, database: tenantName, @@ -197,19 +197,13 @@ export default function QueryEditor(props: QueryEditorProps) { queryId, }); + queryManagerInstance.registerQuery(query); + dispatch(setShowPreview(false)); dispatchResultVisibilityState(PaneVisibilityActionTypes.triggerExpand); }); - const handleCancelRunningQuery = React.useCallback(() => { - if (isStreamingEnabled && runningQueryRef.current) { - runningQueryRef.current.abort(); - } else if (result?.queryId) { - sendCancelQuery({queryId: result?.queryId, database: tenantName}); - } - }, [isStreamingEnabled, result?.queryId, sendCancelQuery, tenantName]); - const onCollapseResultHandler = () => { dispatchResultVisibilityState(PaneVisibilityActionTypes.triggerCollapse); }; @@ -229,6 +223,9 @@ export default function QueryEditor(props: QueryEditorProps) { isLoading={Boolean(result?.isLoading)} handleGetExplainQueryClick={handleGetExplainQueryClick} highlightedAction={lastUsedQueryAction} + tenantName={tenantName} + queryId={result?.queryId} + isStreamingEnabled={isStreamingEnabled} /> ); }; @@ -270,12 +267,10 @@ export default function QueryEditor(props: QueryEditorProps) { theme={theme} key={result?.queryId} result={result} - cancelQueryResponse={cancelQueryResponse} tenantName={tenantName} path={path} showPreview={showPreview} queryText={lastExecutedQueryText} - onCancelRunningQuery={handleCancelRunningQuery} tableSettings={tableSettings} /> @@ -292,17 +287,14 @@ interface ResultProps { type?: EPathType; theme: string; result?: QueryResult; - cancelQueryResponse?: Pick; tenantName: string; path: string; showPreview?: boolean; queryText: string; tableSettings?: Partial; - onCancelRunningQuery: VoidFunction; } function Result({ resultVisibilityState, - cancelQueryResponse, onExpandResultHandler, onCollapseResultHandler, type, @@ -313,7 +305,6 @@ function Result({ showPreview, queryText, tableSettings, - onCancelRunningQuery, }: ResultProps) { if (showPreview) { return ; @@ -327,13 +318,10 @@ function Result({ theme={theme} tenantName={tenantName} isResultsCollapsed={resultVisibilityState.collapsed} - isCancelError={Boolean(cancelQueryResponse?.error)} - isCancelling={Boolean(cancelQueryResponse?.isLoading)} tableSettings={tableSettings} onExpandResults={onExpandResultHandler} onCollapseResults={onCollapseResultHandler} queryText={queryText} - onCancelRunningQuery={onCancelRunningQuery} /> ); } diff --git a/src/containers/Tenant/Query/QueryEditor/helpers.ts b/src/containers/Tenant/Query/QueryEditor/helpers.ts index 34c0009587..22af5639a9 100644 --- a/src/containers/Tenant/Query/QueryEditor/helpers.ts +++ b/src/containers/Tenant/Query/QueryEditor/helpers.ts @@ -114,3 +114,24 @@ export function useCodeAssistHelpers() { monacoGhostConfig, }; } + +class QueryManager { + private query: {abort: VoidFunction} | null; + + constructor() { + this.query = null; + } + + registerQuery(query: {abort: VoidFunction}) { + this.query = query; + } + + abortQuery() { + if (this.query) { + this.query.abort(); + this.query = null; + } + } +} + +export const queryManagerInstance = new QueryManager(); diff --git a/src/containers/Tenant/Query/CancelQueryButton/CancelQueryButton.scss b/src/containers/Tenant/Query/QueryEditorControls/EditorButton.scss similarity index 56% rename from src/containers/Tenant/Query/CancelQueryButton/CancelQueryButton.scss rename to src/containers/Tenant/Query/QueryEditorControls/EditorButton.scss index a688c80a47..5495f93db5 100644 --- a/src/containers/Tenant/Query/CancelQueryButton/CancelQueryButton.scss +++ b/src/containers/Tenant/Query/QueryEditorControls/EditorButton.scss @@ -1,6 +1,12 @@ @use '../../../../styles/mixins.scss'; -.cancel-query-button { +.ydb-query-editor-button { + &__explain-button, + &__stop-button, + &__run-button { + width: 92px; + } + &__stop-button { &_error { @include mixins.query-buttons-animations(); diff --git a/src/containers/Tenant/Query/QueryEditorControls/EditorButton.tsx b/src/containers/Tenant/Query/QueryEditorControls/EditorButton.tsx new file mode 100644 index 0000000000..8cdf124b73 --- /dev/null +++ b/src/containers/Tenant/Query/QueryEditorControls/EditorButton.tsx @@ -0,0 +1,80 @@ +import {Binoculars, CirclePlay, CircleStop, Gear} from '@gravity-ui/icons'; +import type {ButtonProps} from '@gravity-ui/uikit'; +import {Button, Icon, Tooltip} from '@gravity-ui/uikit'; + +import QuerySettingsDescription from '../../../../components/QuerySettingsDescription/QuerySettingsDescription'; +import {cn} from '../../../../utils/cn'; +import {useChangedQuerySettings} from '../../../../utils/hooks/useChangedQuerySettings'; +import i18n from '../i18n'; + +import './EditorButton.scss'; + +const b = cn('ydb-query-editor-button'); + +const Run = (props: ButtonProps) => ( + +); + +const Stop = (props: ButtonProps & {error?: boolean}) => ( + +); + +const Explain = (props: ButtonProps) => ( + +); + +interface SettingsButtonProps { + onClick: () => void; + isLoading: boolean; +} + +const Settings = ({onClick, isLoading}: SettingsButtonProps) => { + const {changedCurrentSettings, changedCurrentSettingsDescriptions} = useChangedQuerySettings(); + + const extraGearProps = + changedCurrentSettings.length > 0 + ? ({view: 'outlined-info', selected: true} as const) + : null; + + return ( + + } + openDelay={0} + placement={['top-start']} + > + + + ); +}; + +export const EditorButton = { + Run, + Stop, + Explain, + Settings, +}; diff --git a/src/containers/Tenant/Query/QueryEditorControls/QueryEditorControls.scss b/src/containers/Tenant/Query/QueryEditorControls/QueryEditorControls.scss index 4821b8ec2b..3bddff3081 100644 --- a/src/containers/Tenant/Query/QueryEditorControls/QueryEditorControls.scss +++ b/src/containers/Tenant/Query/QueryEditorControls/QueryEditorControls.scss @@ -1,12 +1,11 @@ .ydb-query-editor-controls { display: flex; - flex: 0 0 40px; + flex: 0 0 60px; justify-content: space-between; - align-items: flex-end; + align-items: center; gap: 24px; - min-height: 40px; - padding: 5px 0px; + min-height: 60px; &__right, &__left { diff --git a/src/containers/Tenant/Query/QueryEditorControls/QueryEditorControls.tsx b/src/containers/Tenant/Query/QueryEditorControls/QueryEditorControls.tsx index 8b7eec4de3..cd2cd45784 100644 --- a/src/containers/Tenant/Query/QueryEditorControls/QueryEditorControls.tsx +++ b/src/containers/Tenant/Query/QueryEditorControls/QueryEditorControls.tsx @@ -1,117 +1,191 @@ -import {Gear, PlayFill} from '@gravity-ui/icons'; -import type {ButtonView} from '@gravity-ui/uikit'; -import {Button, Icon, Tooltip} from '@gravity-ui/uikit'; +import React from 'react'; -import QuerySettingsDescription from '../../../../components/QuerySettingsDescription/QuerySettingsDescription'; +import {cancelQueryApi} from '../../../../store/reducers/cancelQuery'; import {selectUserInput} from '../../../../store/reducers/query/query'; import type {QueryAction} from '../../../../types/store/query'; import {cn} from '../../../../utils/cn'; +import createToast from '../../../../utils/createToast'; import {useTypedSelector} from '../../../../utils/hooks'; -import {useChangedQuerySettings} from '../../../../utils/hooks/useChangedQuerySettings'; import {NewSQL} from '../NewSQL/NewSQL'; +import {queryManagerInstance} from '../QueryEditor/helpers'; import {SaveQuery} from '../SaveQuery/SaveQuery'; import i18n from '../i18n'; +import {EditorButton} from './EditorButton'; + import './QueryEditorControls.scss'; const b = cn('ydb-query-editor-controls'); -interface SettingsButtonProps { - onClick: () => void; - runIsLoading: boolean; -} - -const SettingsButton = ({onClick, runIsLoading}: SettingsButtonProps) => { - const {changedCurrentSettings, changedCurrentSettingsDescriptions} = useChangedQuerySettings(); - - const extraGearProps = - changedCurrentSettings.length > 0 - ? ({view: 'outlined-info', selected: true} as const) - : null; - - return ( - - } - openDelay={0} - placement={['top-start']} - > - - - ); -}; - interface QueryEditorControlsProps { isLoading: boolean; disabled?: boolean; highlightedAction: QueryAction; + queryId?: string; + tenantName: string; + isStreamingEnabled?: boolean; handleGetExplainQueryClick: (text: string) => void; handleSendExecuteClick: (text: string) => void; onSettingsButtonClick: () => void; } +const STOP_APPEAR_TIMEOUT = 400; +const STOP_AUTO_HIDE_TIMEOUT = 5000; + +interface ActionButtonProps { + type: 'run' | 'explain'; + isHighlighted: boolean; + isLoading: boolean; + isStoppable: boolean; + controlsDisabled: boolean; + onActionClick: () => void; + renderStopButton: () => React.ReactNode; +} + +const ActionButton = ({ + type, + isHighlighted, + isLoading, + isStoppable, + controlsDisabled, + onActionClick, + renderStopButton, +}: ActionButtonProps) => { + if (isStoppable && isLoading && isHighlighted) { + return renderStopButton(); + } + + const ButtonComponent = type === 'run' ? EditorButton.Run : EditorButton.Explain; + + return ( + + ); +}; + +const CANCEL_ERROR_ANIMATION_DURATION = 500; + export const QueryEditorControls = ({ disabled, isLoading, highlightedAction, + queryId, + tenantName, + isStreamingEnabled, handleSendExecuteClick, onSettingsButtonClick, handleGetExplainQueryClick, }: QueryEditorControlsProps) => { const input = useTypedSelector(selectUserInput); - const runView: ButtonView | undefined = highlightedAction === 'execute' ? 'action' : undefined; - const explainView: ButtonView | undefined = - highlightedAction === 'explain' ? 'action' : undefined; + const [sendCancelQuery, cancelQueryResponse] = cancelQueryApi.useCancelQueryMutation(); + const [isStoppable, setIsStoppable] = React.useState(isLoading); + const stopButtonAppearRef = React.useRef(null); + const cancelErrorAnimationRef = React.useRef(null); + const [cancelQueryError, setCancelQueryError] = React.useState(false); + + const onStopButtonClick = React.useCallback(async () => { + if (queryId) { + try { + if (isStreamingEnabled) { + queryManagerInstance.abortQuery(); + } else if (queryId) { + await sendCancelQuery({queryId, database: tenantName}).unwrap(); + } + } catch { + createToast({ + name: 'stop-error', + title: '', + content: i18n('toaster.stop-error'), + type: 'error', + autoHiding: STOP_AUTO_HIDE_TIMEOUT, + }); + setCancelQueryError(true); + + if (cancelErrorAnimationRef.current) { + window.clearTimeout(cancelErrorAnimationRef.current); + } + cancelErrorAnimationRef.current = window.setTimeout(() => { + setCancelQueryError(false); + }, CANCEL_ERROR_ANIMATION_DURATION); + } + } + }, [isStreamingEnabled, queryId, sendCancelQuery, tenantName]); + + const isRunHighlighted = highlightedAction === 'execute'; + const isExplainHighlighted = highlightedAction === 'explain'; + + const runSetStoppableTimeout = React.useCallback(() => { + if (stopButtonAppearRef.current) { + window.clearTimeout(stopButtonAppearRef.current); + } - const onRunButtonClick = () => { + setIsStoppable(false); + stopButtonAppearRef.current = window.setTimeout(() => { + setIsStoppable(true); + }, STOP_APPEAR_TIMEOUT); + }, []); + + const onRunButtonClick = React.useCallback(() => { handleSendExecuteClick(input); - }; + runSetStoppableTimeout(); + }, [handleSendExecuteClick, input, runSetStoppableTimeout]); - const onExplainButtonClick = () => { + const onExplainButtonClick = React.useCallback(() => { handleGetExplainQueryClick(input); - }; + runSetStoppableTimeout(); + }, [handleGetExplainQueryClick, input, runSetStoppableTimeout]); + + React.useEffect(() => { + return () => { + if (stopButtonAppearRef.current) { + window.clearTimeout(stopButtonAppearRef.current); + } + + if (cancelErrorAnimationRef.current) { + window.clearTimeout(cancelErrorAnimationRef.current); + } + }; + }, []); const controlsDisabled = disabled || !input; + const renderStopButton = () => ( + + ); + return (
- - - + + +
diff --git a/src/containers/Tenant/Query/QueryResult/QueryResultViewer.scss b/src/containers/Tenant/Query/QueryResult/QueryResultViewer.scss index 04a4b5b687..8523bf1e50 100644 --- a/src/containers/Tenant/Query/QueryResult/QueryResultViewer.scss +++ b/src/containers/Tenant/Query/QueryResult/QueryResultViewer.scss @@ -9,7 +9,7 @@ align-items: center; height: 53px; - padding: 12px 20px; + padding: var(--g-spacing-3) var(--g-spacing-4); border-bottom: 1px solid var(--g-color-line-generic); background-color: var(--g-color-base-background); diff --git a/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx b/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx index 7543d6de17..3ce1e918a3 100644 --- a/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx +++ b/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx @@ -2,27 +2,24 @@ import React from 'react'; import type {Settings} from '@gravity-ui/react-data-table'; import type {ControlGroupOption} from '@gravity-ui/uikit'; -import {ClipboardButton, RadioButton} from '@gravity-ui/uikit'; +import {ClipboardButton, Flex, RadioButton, Text} from '@gravity-ui/uikit'; -import Divider from '../../../../components/Divider/Divider'; -import ElapsedTime from '../../../../components/ElapsedTime/ElapsedTime'; import EnableFullscreenButton from '../../../../components/EnableFullscreenButton/EnableFullscreenButton'; import Fullscreen from '../../../../components/Fullscreen/Fullscreen'; +import {Illustration} from '../../../../components/Illustration'; import {LoaderWrapper} from '../../../../components/LoaderWrapper/LoaderWrapper'; import {QueryExecutionStatus} from '../../../../components/QueryExecutionStatus'; import {disableFullscreen} from '../../../../store/reducers/fullscreen'; import type {QueryResult} from '../../../../store/reducers/query/types'; import type {ValueOf} from '../../../../types/common'; import type {QueryAction} from '../../../../types/store/query'; -import {valueIsDefined} from '../../../../utils'; import {cn} from '../../../../utils/cn'; import {USE_SHOW_PLAN_SVG_KEY} from '../../../../utils/constants'; import {getStringifiedData} from '../../../../utils/dataFormatters/dataFormatters'; import {useSetting, useTypedDispatch} from '../../../../utils/hooks'; import {PaneVisibilityToggleButtons} from '../../utils/paneVisibilityToggleHelpers'; -import {CancelQueryButton} from '../CancelQueryButton/CancelQueryButton'; -import {QueryDuration} from '../QueryDuration/QueryDuration'; import {QuerySettingsBanner} from '../QuerySettingsBanner/QuerySettingsBanner'; +import {QueryStoppedBanner} from '../QueryStoppedBanner/QueryStoppedBanner'; import {getPreparedResult} from '../utils/getPreparedResult'; import {isQueryCancelledError} from '../utils/isQueryCancelledError'; @@ -85,9 +82,6 @@ interface ExecuteResultProps { queryText?: string; tableSettings?: Partial; - isCancelling: boolean; - isCancelError: boolean; - onCancelRunningQuery?: VoidFunction; onCollapseResults: VoidFunction; onExpandResults: VoidFunction; } @@ -99,10 +93,7 @@ export function QueryResultViewer({ theme, tenantName, queryText, - isCancelling, - isCancelError, tableSettings, - onCancelRunningQuery, onCollapseResults, onExpandResults, }: ExecuteResultProps) { @@ -226,8 +217,30 @@ export function QueryResultViewer({ ); }; + const renderCommonErrorView = (isStopped: boolean) => { + return ( + + + + + {isStopped ? i18n('stopped.title') : i18n('error.title')} + + + {isStopped ? i18n('stopped.description') : i18n('error.description')} + + + + ); + }; + const renderResultSection = () => { + const isStopped = isQueryCancelledError(error); + if (activeSection === RESULT_OPTIONS_IDS.result) { + if (error && isStopped && !resultSets?.length) { + return renderCommonErrorView(isStopped); + } + return ( ; + return isExecute || isStopped ? ( + renderCommonErrorView(isStopped) + ) : ( + + ); } if (activeSection === RESULT_OPTIONS_IDS.schema) { @@ -280,34 +297,14 @@ export function QueryResultViewer({ const renderLeftControls = () => { return (
- - {!error && !isLoading && ( - - {valueIsDefined(stats?.DurationUs) ? ( - - ) : null} - {radioButtonOptions.length && activeSection ? ( - - - - - ) : null} - - )} - {isLoading ? ( - - - - + {radioButtonOptions.length && activeSection ? ( + ) : null} + {data?.traceId && isExecute ? : null}
); @@ -329,14 +326,17 @@ export function QueryResultViewer({ ); }; + const isCancelled = isQueryCancelledError(error); + return (
{renderLeftControls()} {renderRightControls()}
- {isLoading || isQueryCancelledError(error) ? null : } - + {isLoading || isCancelled ? null : } + {isCancelled && data.resultSets?.length ? : null} + {renderResultSection()}
diff --git a/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.scss b/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.scss index 07b1c53289..86c532db7c 100644 --- a/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.scss +++ b/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.scss @@ -1,5 +1,6 @@ .ydb-query-result-error { &__message { - padding: 15px 10px; + padding-top: var(--g-spacing-4); + padding-left: var(--g-spacing-4); } } diff --git a/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.tsx b/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.tsx index e949d4a2af..31113874f5 100644 --- a/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.tsx +++ b/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.tsx @@ -6,7 +6,7 @@ import {isQueryCancelledError} from '../../../utils/isQueryCancelledError'; import './QueryResultError.scss'; -const b = cn('ydb-query-result-error '); +const b = cn('ydb-query-result-error'); export function QueryResultError({error}: {error: unknown}) { const parsedError = parseQueryError(error); @@ -22,7 +22,11 @@ export function QueryResultError({error}: {error: unknown}) { } if (typeof parsedError === 'object') { - return ; + return ( +
+ +
+ ); } return
{parsedError}
; diff --git a/src/containers/Tenant/Query/QueryResult/components/ResultSetsViewer/ResultSetsViewer.scss b/src/containers/Tenant/Query/QueryResult/components/ResultSetsViewer/ResultSetsViewer.scss index 7b302e6025..28ee7e8f1d 100644 --- a/src/containers/Tenant/Query/QueryResult/components/ResultSetsViewer/ResultSetsViewer.scss +++ b/src/containers/Tenant/Query/QueryResult/components/ResultSetsViewer/ResultSetsViewer.scss @@ -2,15 +2,15 @@ .ydb-query-result-sets-viewer { &__tabs { - padding-left: 10px; - } - - &__head { - margin-top: var(--g-spacing-4); + margin-bottom: var(--g-spacing-1); + padding-top: var(--g-spacing-1); + padding-left: var(--g-spacing-4); } - &__row-count { - margin-left: var(--g-spacing-1); + &__title { + padding-top: var(--g-spacing-4); + padding-bottom: var(--g-spacing-4); + padding-left: var(--g-spacing-4); } &__result-wrapper { diff --git a/src/containers/Tenant/Query/QueryResult/components/ResultSetsViewer/ResultSetsViewer.tsx b/src/containers/Tenant/Query/QueryResult/components/ResultSetsViewer/ResultSetsViewer.tsx index 0319340ec5..788b594640 100644 --- a/src/containers/Tenant/Query/QueryResult/components/ResultSetsViewer/ResultSetsViewer.tsx +++ b/src/containers/Tenant/Query/QueryResult/components/ResultSetsViewer/ResultSetsViewer.tsx @@ -1,11 +1,10 @@ import type {Settings} from '@gravity-ui/react-data-table'; -import {Tabs, Text} from '@gravity-ui/uikit'; +import type {TabsItemProps} from '@gravity-ui/uikit'; +import {Flex, Tabs, Text} from '@gravity-ui/uikit'; import {QueryResultTable} from '../../../../../../components/QueryResultTable'; import type {ParsedResultSet} from '../../../../../../types/store/query'; -import {getArray} from '../../../../../../utils'; import {cn} from '../../../../../../utils/cn'; -import i18n from '../../i18n'; import {QueryResultError} from '../QueryResultError/QueryResultError'; import './ResultSetsViewer.scss'; @@ -23,58 +22,64 @@ interface ResultSetsViewerProps { export function ResultSetsViewer(props: ResultSetsViewerProps) { const {selectedResultSet, setSelectedResultSet, resultSets, error} = props; - const resultsSetsCount = resultSets?.length || 0; const currentResult = resultSets?.[selectedResultSet]; const renderTabs = () => { - if (resultsSetsCount > 1) { - const tabsItems = getArray(resultsSetsCount).map((item) => ({ - id: String(item), - title: `Result #${item + 1}${resultSets?.[item]?.truncated ? ' (T)' : ''}`, - })); + const tabsItems: TabsItemProps[] = + resultSets?.map((_, index) => { + const resultSet = resultSets?.[index]; + return { + id: String(index), + title: ( + + + {`Result #${index + 1}${resultSets?.[index]?.truncated ? '(T)' : ''}`} + + {resultSet.result?.length || 0} + + ), + }; + }) || []; - return ( -
- setSelectedResultSet(Number(tabId))} - /> -
- ); - } - - return null; + return ( + setSelectedResultSet(Number(tabId))} + /> + ); }; - const renderResultHeadWithCount = () => { + const renderSingleResult = () => { + const result = resultSets?.[0]; return ( -
- - {currentResult?.truncated ? i18n('title.truncated') : i18n('title.result')} - - {currentResult?.result ? ( - - {`(${currentResult?.result.length}${ - currentResult.streamMetrics?.rowsPerSecond - ? `, ${currentResult.streamMetrics.rowsPerSecond.toFixed(0)} rows/s` - : '' - })`} - - ) : null} -
+ + {result?.truncated ? 'Truncated' : 'Result'} + {result?.result?.length || 0} + ); }; + const renderResults = () => { + if (resultSets?.length) { + if (resultSets?.length > 1) { + return renderTabs(); + } else { + return renderSingleResult(); + } + } + + return null; + }; + return (
- {renderTabs()} {props.error ? : null} + {renderResults()} {currentResult ? (
- {renderResultHeadWithCount()} ( + QUERY_STOPPED_BANNER_CLOSED_KEY, + ); + + const closeBanner = React.useCallback(() => { + setIsQueryStoppedBannerClosed(true); + }, [setIsQueryStoppedBannerClosed]); + + return isQueryStoppedBannerClosed ? null : ( + {i18n('banner.query-stopped.message')}
} + layout="horizontal" + actions={ + + + {i18n('banner.query-stopped.never-show')} + + + } + /> + ); +} diff --git a/src/containers/Tenant/Query/i18n/en.json b/src/containers/Tenant/Query/i18n/en.json index 5e5f6e70dd..d036e3db6f 100644 --- a/src/containers/Tenant/Query/i18n/en.json +++ b/src/containers/Tenant/Query/i18n/en.json @@ -43,19 +43,23 @@ "statistics-mode-description.full": "Collect statistics and query plan", "statistics-mode-description.profile": "Collect statistics for individual tasks", - "query-duration.description": "Duration of server-side query execution", - "action.send-query": "Send query", "action.send-selected-query": "Send selected query", "action.previous-query": "Previous query in history", "action.next-query": "Next query in history", "action.save-query": "Save query", "action.stop": "Stop", + "action.run": "Run", + "action.explain": "Explain", "filter.text.placeholder": "Search by query text...", "gear.tooltip": "Query execution settings have been changed for ", "banner.query-settings.message": "Query was executed with modified settings: ", + "banner.query-stopped.message": "Data is not up to date because the request was not completed.", + "banner.query-stopped.never-show": "Never show again", + + "toaster.stop-error": "Something went wrong. Unable to stop request processing. Please wait.", "history.queryText": "Query text", "history.endTime": "End time", diff --git a/src/services/settings.ts b/src/services/settings.ts index a33095d7f7..594ebf70f1 100644 --- a/src/services/settings.ts +++ b/src/services/settings.ts @@ -17,6 +17,7 @@ import { PARTITIONS_HIDDEN_COLUMNS_KEY, QUERY_EXECUTION_SETTINGS_KEY, QUERY_SETTINGS_BANNER_LAST_CLOSED_KEY, + QUERY_STOPPED_BANNER_CLOSED_KEY, SAVED_QUERIES_KEY, SHOW_DOMAIN_DATABASE_KEY, TENANT_INITIAL_PAGE_KEY, @@ -51,6 +52,7 @@ export const DEFAULT_USER_SETTINGS = { [AUTO_REFRESH_INTERVAL]: 0, [CASE_SENSITIVE_JSON_SEARCH]: false, [SHOW_DOMAIN_DATABASE_KEY]: false, + [QUERY_STOPPED_BANNER_CLOSED_KEY]: false, [LAST_QUERY_EXECUTION_SETTINGS_KEY]: undefined, [QUERY_SETTINGS_BANNER_LAST_CLOSED_KEY]: undefined, [QUERY_EXECUTION_SETTINGS_KEY]: DEFAULT_QUERY_SETTINGS, diff --git a/src/store/configureStore.ts b/src/store/configureStore.ts index ee45dfec3d..1e3bff38c4 100644 --- a/src/store/configureStore.ts +++ b/src/store/configureStore.ts @@ -28,13 +28,17 @@ function _configureStore< preloadedState, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ - immutableCheck: { - ignoredPaths: ['tooltip.currentHoveredRef'], - }, - serializableCheck: { - ignoredPaths: ['tooltip.currentHoveredRef', 'api'], - ignoredActions: [UPDATE_REF, 'api/sendQuery/rejected'], - }, + immutableCheck: process.env.REACT_APP_DISABLE_CHECKS + ? false + : { + ignoredPaths: ['tooltip.currentHoveredRef'], + }, + serializableCheck: process.env.REACT_APP_DISABLE_CHECKS + ? false + : { + ignoredPaths: ['tooltip.currentHoveredRef', 'api'], + ignoredActions: [UPDATE_REF, 'api/sendQuery/rejected'], + }, }).concat(locationMiddleware, ...middleware), }); diff --git a/src/store/reducers/query/query.ts b/src/store/reducers/query/query.ts index 1b5e6d3e50..0362dc5390 100644 --- a/src/store/reducers/query/query.ts +++ b/src/store/reducers/query/query.ts @@ -131,6 +131,10 @@ const slice = createSlice({ selectors: { selectQueriesHistoryFilter: (state) => state.history.filter || '', selectTenantPath: (state) => state.tenantPath, + selectQueryDuration: (state) => ({ + startTime: state.result?.startTime, + endTime: state.result?.endTime, + }), selectResult: (state) => state.result, selectQueriesHistory: (state) => { const items = state.history.queries; @@ -167,6 +171,7 @@ export const { selectTenantPath, selectResult, selectUserInput, + selectQueryDuration, } = slice.selectors; interface SendQueryParams extends QueryRequestParams { @@ -178,6 +183,9 @@ interface SendQueryParams extends QueryRequestParams { enableTracingLevel?: boolean; } +// Stream query receives queryId from session chunk. +type StreamQueryParams = Omit; + interface QueryStats { durationUs?: string | number; endTime?: string | number; @@ -188,12 +196,20 @@ const DEFAULT_CONCURRENT_RESULTS = false; export const queryApi = api.injectEndpoints({ endpoints: (build) => ({ - useStreamQuery: build.mutation({ + useStreamQuery: build.mutation({ queryFn: async ( - {query, database, querySettings = {}, enableTracingLevel, queryId}, + {query, database, querySettings = {}, enableTracingLevel}, {signal, dispatch, getState}, ) => { - dispatch(setQueryResult({type: 'execute', queryId, isLoading: true})); + const startTime = Date.now(); + dispatch( + setQueryResult({ + type: 'execute', + queryId: '', + isLoading: true, + startTime, + }), + ); const {action, syntax} = getActionAndSyntaxFromQueryMode( 'execute', @@ -238,18 +254,21 @@ export const queryApi = api.injectEndpoints({ }, { signal, - onQueryResponseChunk: (chunk) => { - dispatch(setStreamQueryResponse(chunk)); - }, + // First chunk is session chunk onSessionChunk: (chunk) => { dispatch(setStreamSession(chunk)); }, + // Data chunks follow session chunk onStreamDataChunk: (chunk) => { streamDataChunkBatch.push(chunk); if (!batchTimeout) { batchTimeout = window.requestAnimationFrame(flushBatch); } }, + // Last chunk is query response chunk + onQueryResponseChunk: (chunk) => { + dispatch(setStreamQueryResponse(chunk)); + }, }, ); @@ -268,7 +287,9 @@ export const queryApi = api.injectEndpoints({ type: 'execute', error, isLoading: false, - queryId, + startTime, + endTime: Date.now(), + queryId: state.query.result?.queryId || '', }), ); return {error}; @@ -287,7 +308,15 @@ export const queryApi = api.injectEndpoints({ }, {signal, dispatch}, ) => { - dispatch(setQueryResult({type: actionType, queryId, isLoading: true})); + const startTime = Date.now(); + dispatch( + setQueryResult({ + type: actionType, + queryId, + isLoading: true, + startTime, + }), + ); const {action, syntax} = getActionAndSyntaxFromQueryMode( actionType, @@ -329,6 +358,8 @@ export const queryApi = api.injectEndpoints({ error: response, isLoading: false, queryId, + startTime, + endTime: Date.now(), }), ); return {error: response}; @@ -358,6 +389,8 @@ export const queryApi = api.injectEndpoints({ data, isLoading: false, queryId, + startTime, + endTime: Date.now(), }), ); return {data: null}; @@ -368,6 +401,8 @@ export const queryApi = api.injectEndpoints({ error, isLoading: false, queryId, + startTime, + endTime: Date.now(), }), ); return {error}; diff --git a/src/store/reducers/query/streamingReducers.ts b/src/store/reducers/query/streamingReducers.ts index 493d200ae8..d807d01f04 100644 --- a/src/store/reducers/query/streamingReducers.ts +++ b/src/store/reducers/query/streamingReducers.ts @@ -1,6 +1,5 @@ import type {PayloadAction} from '@reduxjs/toolkit'; -import type {StreamMetrics} from '../../../types/store/query'; import type { QueryResponseChunk, SessionChunk, @@ -56,28 +55,8 @@ export const setStreamQueryResponse = ( state.result.data.plan = chunk.plan; state.result.data.stats = chunk.stats; } -}; - -const updateStreamMetrics = (metrics: StreamMetrics, totalNewRows: number) => { - const currentTime = Date.now(); - const WINDOW_SIZE = 5000; // 5 seconds in milliseconds - - metrics.recentChunks.push({timestamp: currentTime, rowCount: totalNewRows}); - metrics.recentChunks = metrics.recentChunks.filter( - (chunk) => currentTime - chunk.timestamp <= WINDOW_SIZE, - ); - - if (metrics.recentChunks.length > 0) { - const oldestChunkTime = metrics.recentChunks[0].timestamp; - const timeWindow = (currentTime - oldestChunkTime) / 1000; - const totalRows = metrics.recentChunks.reduce( - (sum: number, chunk) => sum + chunk.rowCount, - 0, - ); - metrics.rowsPerSecond = timeWindow > 0 ? totalRows / timeWindow : 0; - } - metrics.lastUpdateTime = currentTime; + state.result.endTime = Date.now(); }; const getEmptyResultSet = () => { @@ -85,11 +64,6 @@ const getEmptyResultSet = () => { columns: [], result: [], truncated: false, - streamMetrics: { - rowsPerSecond: 0, - lastUpdateTime: Date.now(), - recentChunks: [], - }, }; }; @@ -123,11 +97,6 @@ export const addStreamingChunks = (state: QueryState, action: PayloadAction()); - const totalNewRows = action.payload.reduce( - (sum: number, chunk) => sum + (chunk.result.rows?.length || 0), - 0, - ); - // Process merged chunks for (const [resultIndex, chunk] of mergedChunks.entries()) { const {columns, rows} = chunk.result; @@ -149,9 +118,5 @@ export const addStreamingChunks = (state: QueryState, action: PayloadAction; -} - export interface ParsedResultSet { columns?: ColumnType[]; result?: KeyValueRow[]; truncated?: boolean; - streamMetrics?: StreamMetrics; } export interface IQueryResult { diff --git a/src/utils/constants.ts b/src/utils/constants.ts index a74b6a4398..98957ef76c 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -98,6 +98,7 @@ export const TENANT_OVERVIEW_TABLES_SETTINGS = { export const QUERY_EXECUTION_SETTINGS_KEY = 'queryExecutionSettings'; export const LAST_QUERY_EXECUTION_SETTINGS_KEY = 'last_query_execution_settings'; export const QUERY_SETTINGS_BANNER_LAST_CLOSED_KEY = 'querySettingsBannerLastClosed'; +export const QUERY_STOPPED_BANNER_CLOSED_KEY = 'queryStoppedBannerClosed'; export const LAST_USED_QUERY_ACTION_KEY = 'last_used_query_action'; diff --git a/src/utils/createToast.tsx b/src/utils/createToast.tsx index d2a0e3c3af..35853fda05 100644 --- a/src/utils/createToast.tsx +++ b/src/utils/createToast.tsx @@ -7,16 +7,17 @@ interface CreateToastProps { title?: string; content?: string; type: 'error' | 'success'; + autoHiding?: number; } -function createToast({name, title, type, content}: CreateToastProps) { +function createToast({name, title, type, content, autoHiding}: CreateToastProps) { return toaster.add({ name: name ?? 'Request succeeded', title: title ?? 'Request succeeded', theme: type === 'error' ? 'danger' : 'success', content: content, isClosable: true, - autoHiding: type === 'success' ? 5000 : false, + autoHiding: autoHiding || (type === 'success' ? 5000 : false), }); } diff --git a/tests/suites/tenant/constants.ts b/tests/suites/tenant/constants.ts index 5dfcc89eca..265e859aec 100644 --- a/tests/suites/tenant/constants.ts +++ b/tests/suites/tenant/constants.ts @@ -2,10 +2,20 @@ // May cause Memory exceed on real database export const simpleQuery = 'SELECT 1;'; -export const longTableSelect = 'SELECT * FROM `.sys/pg_class`'; +export const longTableSelect = (limit?: number) => + 'SELECT * FROM `.sys/pg_class`' + (limit ? ` LIMIT ${limit};` : ';'); // 400 is pretty enough export const longRunningQuery = new Array(400).fill(simpleQuery).join(''); +export const longRunningStreamQuery = `$sample = AsList(AsStruct(ListFromRange(1, 100000) AS value1, ListFromRange(1, 1000) AS value2, CAST(1 AS Uint32) AS id)); + +SELECT value1, value2, id FROM as_table($sample) FLATTEN BY (value1); +`; +export const longerRunningStreamQuery = `$sample = AsList(AsStruct(ListFromRange(1, 1000000) AS value1, ListFromRange(1, 10000) AS value2, CAST(1 AS Uint32) AS id)); + +SELECT value1, value2, id FROM as_table($sample) FLATTEN BY (value1); +`; +export const selectFromMyRowTableQuery = 'select * from `my_row_table`'; export const createTableQuery = ` CREATE TABLE \`/local/ydb_row_table\` ( diff --git a/tests/suites/tenant/queryEditor/models/QueryEditor.ts b/tests/suites/tenant/queryEditor/models/QueryEditor.ts index dbc7c8d08e..e37036cddd 100644 --- a/tests/suites/tenant/queryEditor/models/QueryEditor.ts +++ b/tests/suites/tenant/queryEditor/models/QueryEditor.ts @@ -50,9 +50,9 @@ export class QueryEditor { private runButton: Locator; private explainButton: Locator; private stopButton: Locator; + private stopBanner: Locator; private saveButton: Locator; private gearButton: Locator; - private indicatorIcon: Locator; private banner: Locator; private executionStatus: Locator; private radioButton: Locator; @@ -65,15 +65,13 @@ export class QueryEditor { this.editorTextArea = this.selector.locator('.query-editor__monaco textarea'); this.runButton = this.selector.getByRole('button', {name: ButtonNames.Run}); this.stopButton = this.selector.getByRole('button', {name: ButtonNames.Stop}); + this.stopBanner = this.selector.locator('.ydb-query-stopped-banner'); this.explainButton = this.selector.getByRole('button', {name: ButtonNames.Explain}); this.saveButton = this.selector.getByRole('button', {name: ButtonNames.Save}); - this.gearButton = this.selector.locator('.ydb-query-editor-controls__gear-button'); - this.executionStatus = this.selector.locator('.kv-query-execution-status'); + this.gearButton = this.selector.locator('.ydb-query-editor-button__gear-button'); + this.executionStatus = this.selector.locator('.kv-query-execution-status .g-text'); this.resultsControls = this.selector.locator('.ydb-query-result__controls'); - this.indicatorIcon = this.selector.locator( - '.kv-query-execution-status__query-settings-icon', - ); - this.elapsedTimeLabel = this.selector.locator('.ydb-query-elapsed-time'); + this.elapsedTimeLabel = this.selector.locator('.kv-query-execution-status .g-label__value'); this.radioButton = this.selector.locator('.query-editor__pane-wrapper .g-radio-button'); this.banner = this.page.locator('.ydb-query-settings-banner'); @@ -237,6 +235,11 @@ export class QueryEditor { return true; } + async isStopBannerVisible() { + await this.stopBanner.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } + async isResultsControlsVisible() { await this.resultsControls.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); return true; @@ -295,16 +298,6 @@ export class QueryEditor { return true; } - async isIndicatorIconVisible() { - await this.indicatorIcon.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - return true; - } - - async isIndicatorIconHidden() { - await this.indicatorIcon.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); - return true; - } - async waitForStatus(expectedStatus: string, timeout = VISIBILITY_TIMEOUT) { await this.executionStatus.waitFor({state: 'visible', timeout}); diff --git a/tests/suites/tenant/queryEditor/models/ResultTable.ts b/tests/suites/tenant/queryEditor/models/ResultTable.ts index 23b69b2c23..dfd36a5941 100644 --- a/tests/suites/tenant/queryEditor/models/ResultTable.ts +++ b/tests/suites/tenant/queryEditor/models/ResultTable.ts @@ -25,11 +25,13 @@ export class ResultTable { private preview: Locator; private resultHead: Locator; private resultWrapper: Locator; + private resultTitle: Locator; constructor(selector: Locator) { this.table = selector.locator('.ydb-query-result-sets-viewer__result'); this.preview = selector.locator('.kv-preview__result'); this.resultHead = selector.locator('.ydb-query-result-sets-viewer__head'); + this.resultTitle = selector.locator('.ydb-query-result-sets-viewer__title'); this.resultWrapper = selector.locator('.ydb-query-result-sets-viewer__result-wrapper'); } @@ -68,11 +70,6 @@ export class ResultTable { return true; } - async getResultHeadText() { - await this.resultHead.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - return this.resultHead.innerText(); - } - async getResultTabs() { const tabs = this.resultWrapper.locator( '.ydb-query-result-sets-viewer__tabs .g-tabs__item', @@ -86,20 +83,27 @@ export class ResultTable { return tabs.count(); } - async getResultTabTitle(index: number) { + async getResultTitleText() { + await this.resultTitle.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return this.resultTitle.locator('.g-text').first().textContent(); + } + + async getResultTitleCount() { + await this.resultTitle.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return this.resultTitle.locator('.g-text').nth(1).textContent(); + } + + async getResultTabTitleText(index: number) { const tabs = await this.getResultTabs(); const tab = tabs.nth(index); await tab.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - return tab.getAttribute('title'); + return tab.locator('.g-text').first().textContent(); } - async hasMultipleResultTabs() { - const tabs = this.resultWrapper.locator('.ydb-query-result-sets-viewer__tabs'); - try { - await tabs.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - return true; - } catch { - return false; - } + async getResultTabTitleCount(index: number) { + const tabs = await this.getResultTabs(); + const tab = tabs.nth(index); + await tab.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return tab.locator('.g-text').nth(1).textContent(); } } diff --git a/tests/suites/tenant/queryEditor/queryEditor.test.ts b/tests/suites/tenant/queryEditor/queryEditor.test.ts index 74352efe3c..1a305d5519 100644 --- a/tests/suites/tenant/queryEditor/queryEditor.test.ts +++ b/tests/suites/tenant/queryEditor/queryEditor.test.ts @@ -3,8 +3,15 @@ import {expect, test} from '@playwright/test'; import {QUERY_MODES, STATISTICS_MODES} from '../../../../src/utils/query'; import {getClipboardContent} from '../../../utils/clipboard'; import {tenantName} from '../../../utils/constants'; +import {toggleExperiment} from '../../../utils/toggleExperiment'; import {NavigationTabs, TenantPage, VISIBILITY_TIMEOUT} from '../TenantPage'; -import {createTableQuery, longRunningQuery, longTableSelect} from '../constants'; +import { + createTableQuery, + longRunningQuery, + longRunningStreamQuery, + longTableSelect, + longerRunningStreamQuery, +} from '../constants'; import { ButtonNames, @@ -68,7 +75,7 @@ test.describe('Test Query Editor', async () => { await expect(explainAST).toBeVisible({timeout: VISIBILITY_TIMEOUT}); }); - test('Error is displayed for invalid query', async ({page}) => { + test('Error is displayed for invalid query for run', async ({page}) => { const queryEditor = new QueryEditor(page); const invalidQuery = 'Select d'; @@ -80,6 +87,18 @@ test.describe('Test Query Editor', async () => { await expect(errorMessage).toContain('Column references are not allowed without FROM'); }); + test('Error is displayed for invalid query for explain', async ({page}) => { + const queryEditor = new QueryEditor(page); + + const invalidQuery = 'Select d'; + await queryEditor.setQuery(invalidQuery); + await queryEditor.clickExplainButton(); + + await expect(queryEditor.waitForStatus('Failed')).resolves.toBe(true); + const errorMessage = await queryEditor.getErrorMessage(); + await expect(errorMessage).toContain('Column references are not allowed without FROM'); + }); + test('Run and Explain buttons are disabled when query is empty', async ({page}) => { const queryEditor = new QueryEditor(page); @@ -102,29 +121,51 @@ test.describe('Test Query Editor', async () => { await expect(queryEditor.isElapsedTimeVisible()).resolves.toBe(true); }); - test('Stop button and elapsed time label disappear after query is stopped', async ({page}) => { + test('Query streaming finishes in reasonable time', async ({page}) => { + const queryEditor = new QueryEditor(page); + await toggleExperiment(page, 'on', 'Query Streaming'); + + await queryEditor.setQuery(longRunningStreamQuery); + await queryEditor.clickRunButton(); + + await expect(queryEditor.waitForStatus('Completed')).resolves.toBe(true); + }); + + test('Query execution is terminated when stop button is clicked', async ({page}) => { const queryEditor = new QueryEditor(page); await queryEditor.setQuery(longRunningQuery); await queryEditor.clickRunButton(); await expect(queryEditor.isStopButtonVisible()).resolves.toBe(true); - await queryEditor.clickStopButton(); - await expect(queryEditor.isStopButtonHidden()).resolves.toBe(true); - await expect(queryEditor.isElapsedTimeHidden()).resolves.toBe(true); + await expect(queryEditor.waitForStatus('Stopped')).resolves.toBe(true); }); - test('Query execution is terminated when stop button is clicked', async ({page}) => { + test('Streaming query shows some results and banner when stop button is clicked', async ({ + page, + browserName, + }) => { + // For some reason Safari handles large numbers list bad in Safari + // Will be investigated here https://github.com/ydb-platform/ydb-embedded-ui/issues/1989 + test.skip(browserName === 'webkit', 'This test is skipped in Safari'); const queryEditor = new QueryEditor(page); + await toggleExperiment(page, 'on', 'Query Streaming'); - await queryEditor.setQuery(longRunningQuery); + await queryEditor.setQuery(longerRunningStreamQuery); await queryEditor.clickRunButton(); await expect(queryEditor.isStopButtonVisible()).resolves.toBe(true); + await page.waitForTimeout(1000); + await queryEditor.clickStopButton(); + await expect(queryEditor.isStopBannerVisible()).resolves.toBe(true); + await expect(queryEditor.resultTable.getResultTitleText()).resolves.toBe('Result'); + await expect( + Promise.resolve(Number(await queryEditor.resultTable.getResultTitleCount())), + ).resolves.toBeGreaterThan(100); await expect(queryEditor.waitForStatus('Stopped')).resolves.toBe(true); }); @@ -215,7 +256,8 @@ test.describe('Test Query Editor', async () => { const queryEditor = new QueryEditor(page); await queryEditor.setQuery(testQuery); await queryEditor.clickRunButton(); - await expect(queryEditor.resultTable.getResultHeadText()).resolves.toBe('Result(1)'); + await expect(queryEditor.resultTable.getResultTitleText()).resolves.toBe('Result'); + await expect(queryEditor.resultTable.getResultTitleCount()).resolves.toBe('1'); }); test('No result head value for no result', async ({page}) => { @@ -228,12 +270,27 @@ test.describe('Test Query Editor', async () => { test('Truncated head value is 1 for 1 row truncated result', async ({page}) => { const queryEditor = new QueryEditor(page); - await queryEditor.setQuery(longTableSelect); + await queryEditor.setQuery(longTableSelect()); await queryEditor.clickGearButton(); await queryEditor.settingsDialog.changeLimitRows(1); await queryEditor.settingsDialog.clickButton(ButtonNames.Save); await queryEditor.clickRunButton(); - await expect(queryEditor.resultTable.getResultHeadText()).resolves.toBe('Truncated(1)'); + await expect(queryEditor.resultTable.getResultTitleText()).resolves.toBe('Truncated'); + await expect(queryEditor.resultTable.getResultTitleCount()).resolves.toBe('1'); + }); + + test('Truncated results for multiple tabs', async ({page}) => { + const queryEditor = new QueryEditor(page); + await queryEditor.setQuery(`${longTableSelect(2)}${longTableSelect(2)}`); + await queryEditor.clickGearButton(); + await queryEditor.settingsDialog.changeLimitRows(3); + await queryEditor.settingsDialog.clickButton(ButtonNames.Save); + await queryEditor.clickRunButton(); + await expect(queryEditor.resultTable.getResultTabsCount()).resolves.toBe(2); + await expect(queryEditor.resultTable.getResultTabTitleText(1)).resolves.toBe( + 'Result #2(T)', + ); + await expect(queryEditor.resultTable.getResultTabTitleCount(1)).resolves.toBe('1'); }); test('Query execution status changes correctly', async ({page}) => { @@ -257,8 +314,8 @@ test.describe('Test Query Editor', async () => { // Verify there are two result tabs await expect(queryEditor.resultTable.getResultTabsCount()).resolves.toBe(2); - await expect(queryEditor.resultTable.getResultTabTitle(0)).resolves.toBe('Result #1'); - await expect(queryEditor.resultTable.getResultTabTitle(1)).resolves.toBe('Result #2'); + await expect(queryEditor.resultTable.getResultTabTitleText(0)).resolves.toBe('Result #1'); + await expect(queryEditor.resultTable.getResultTabTitleText(1)).resolves.toBe('Result #2'); // Then verify running only selected part produces one result await queryEditor.focusEditor(); @@ -268,8 +325,8 @@ test.describe('Test Query Editor', async () => { await executeSelectedQueryWithKeybinding(page); await expect(queryEditor.waitForStatus('Completed')).resolves.toBe(true); - await expect(queryEditor.resultTable.hasMultipleResultTabs()).resolves.toBe(false); - await expect(queryEditor.resultTable.getResultHeadText()).resolves.toBe('Result(1)'); + await expect(queryEditor.resultTable.getResultTitleText()).resolves.toBe('Result'); + await expect(queryEditor.resultTable.getResultTitleCount()).resolves.toBe('1'); }); test('Running selected query via context menu executes only selected part', async ({page}) => { @@ -283,8 +340,8 @@ test.describe('Test Query Editor', async () => { // Verify there are two result tabs await expect(queryEditor.resultTable.getResultTabsCount()).resolves.toBe(2); - await expect(queryEditor.resultTable.getResultTabTitle(0)).resolves.toBe('Result #1'); - await expect(queryEditor.resultTable.getResultTabTitle(1)).resolves.toBe('Result #2'); + await expect(queryEditor.resultTable.getResultTabTitleText(0)).resolves.toBe('Result #1'); + await expect(queryEditor.resultTable.getResultTabTitleText(1)).resolves.toBe('Result #2'); // Then verify running only selected part produces one result without tabs await queryEditor.focusEditor(); @@ -294,8 +351,8 @@ test.describe('Test Query Editor', async () => { await queryEditor.runSelectedQueryViaContextMenu(); await expect(queryEditor.waitForStatus('Completed')).resolves.toBe(true); - await expect(queryEditor.resultTable.hasMultipleResultTabs()).resolves.toBe(false); - await expect(queryEditor.resultTable.getResultHeadText()).resolves.toBe('Result(1)'); + await expect(queryEditor.resultTable.getResultTitleText()).resolves.toBe('Result'); + await expect(queryEditor.resultTable.getResultTitleCount()).resolves.toBe('1'); }); test('Results controls collapse and expand functionality', async ({page}) => { diff --git a/tests/suites/tenant/queryEditor/querySettings.test.ts b/tests/suites/tenant/queryEditor/querySettings.test.ts index 57946127bc..e950602304 100644 --- a/tests/suites/tenant/queryEditor/querySettings.test.ts +++ b/tests/suites/tenant/queryEditor/querySettings.test.ts @@ -77,45 +77,6 @@ test.describe('Test Query Settings', async () => { await expect(queryEditor.isBannerHidden()).resolves.toBe(true); }); - test('Indicator icon appears after closing banner', async ({page}) => { - const queryEditor = new QueryEditor(page); - - // Change a setting - await queryEditor.clickGearButton(); - await queryEditor.settingsDialog.changeQueryMode(QUERY_MODES.scan); - await queryEditor.settingsDialog.clickButton(ButtonNames.Save); - - // Execute a script to make the banner appear - await queryEditor.setQuery(testQuery); - await queryEditor.clickRunButton(); - - // Close the banner - await queryEditor.closeBanner(); - - await expect(queryEditor.isIndicatorIconVisible()).resolves.toBe(true); - }); - - test('Indicator not appears for running query', async ({page}) => { - const queryEditor = new QueryEditor(page); - - // Change a setting - await queryEditor.clickGearButton(); - await queryEditor.settingsDialog.changeTransactionMode(TRANSACTION_MODES.snapshot); - await queryEditor.settingsDialog.clickButton(ButtonNames.Save); - - // Execute a script to make the banner appear - await queryEditor.setQuery(testQuery); - await queryEditor.clickRunButton(); - - // Close the banner - await queryEditor.closeBanner(); - await queryEditor.setQuery(longRunningQuery); - await queryEditor.clickRunButton(); - await page.waitForTimeout(500); - - await expect(queryEditor.isIndicatorIconHidden()).resolves.toBe(true); - }); - test('Gear button shows number of changed settings', async ({page}) => { const queryEditor = new QueryEditor(page); await queryEditor.clickGearButton();