diff --git a/package-lock.json b/package-lock.json index c485bd6f4..862dea0f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "tslib": "^2.6.3", "url": "^0.11.3", "use-query-params": "^2.2.1", + "uuid": "^10.0.0", "web-vitals": "^1.1.2", "ydb-ui-components": "^4.2.0", "zod": "^3.23.8" @@ -73,6 +74,7 @@ "@types/react-dom": "^18.3.0", "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", + "@types/uuid": "^10.0.0", "copyfiles": "^2.4.1", "http-proxy-middleware": "^2.0.6", "husky": "^9.0.11", @@ -6295,6 +6297,13 @@ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.5.4", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", @@ -25893,6 +25902,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", diff --git a/package.json b/package.json index e9e873b28..03c025a19 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,11 @@ "@gravity-ui/navigation": "^2.16.0", "@gravity-ui/paranoid": "^2.0.1", "@gravity-ui/react-data-table": "^2.1.1", + "@gravity-ui/table": "^0.5.0", "@gravity-ui/uikit": "^6.20.1", "@gravity-ui/websql-autocomplete": "^9.1.0", "@reduxjs/toolkit": "^2.2.3", + "@tanstack/react-table": "^8.19.3", "axios": "^1.7.2", "axios-retry": "^4.4.0", "colord": "^2.9.3", @@ -50,11 +52,10 @@ "tslib": "^2.6.3", "url": "^0.11.3", "use-query-params": "^2.2.1", + "uuid": "^10.0.0", "web-vitals": "^1.1.2", "ydb-ui-components": "^4.2.0", - "zod": "^3.23.8", - "@gravity-ui/table": "^0.5.0", - "@tanstack/react-table": "^8.19.3" + "zod": "^3.23.8" }, "scripts": { "analyze": "source-map-explorer 'build/static/js/*.js'", @@ -144,6 +145,7 @@ "@types/react-dom": "^18.3.0", "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", + "@types/uuid": "^10.0.0", "copyfiles": "^2.4.1", "http-proxy-middleware": "^2.0.6", "husky": "^9.0.11", diff --git a/src/components/ElapsedTime/ElapsedTime.scss b/src/components/ElapsedTime/ElapsedTime.scss new file mode 100644 index 000000000..01da8c07b --- /dev/null +++ b/src/components/ElapsedTime/ElapsedTime.scss @@ -0,0 +1,3 @@ +.ydb-query-elapsed-time { + visibility: visible; +} diff --git a/src/components/ElapsedTime/ElapsedTime.tsx b/src/components/ElapsedTime/ElapsedTime.tsx new file mode 100644 index 000000000..42c7cf48c --- /dev/null +++ b/src/components/ElapsedTime/ElapsedTime.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import {duration} from '@gravity-ui/date-utils'; +import {Label} from '@gravity-ui/uikit'; + +import {HOUR_IN_SECONDS, SECOND_IN_MS, cn} from '../../lib'; + +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.tsx b/src/components/QueryExecutionStatus/QueryExecutionStatus.tsx index 7471c5f34..1a34d60d4 100644 --- a/src/components/QueryExecutionStatus/QueryExecutionStatus.tsx +++ b/src/components/QueryExecutionStatus/QueryExecutionStatus.tsx @@ -1,10 +1,17 @@ import React from 'react'; -import {CircleCheck, CircleInfo, CircleQuestionFill, CircleXmark} from '@gravity-ui/icons'; -import {Icon, Tooltip} from '@gravity-ui/uikit'; +import { + CircleCheck, + CircleInfo, + CircleQuestionFill, + CircleStop, + CircleXmark, +} from '@gravity-ui/icons'; +import {Icon, Spin, Tooltip} from '@gravity-ui/uikit'; import {isAxiosError} from 'axios'; import i18n from '../../containers/Tenant/Query/i18n'; +import {isQueryCancelledError} from '../../containers/Tenant/Query/utils/isQueryCancelledError'; import {cn} from '../../utils/cn'; import {useChangedQuerySettings} from '../../utils/hooks/useChangedQuerySettings'; import QuerySettingsDescription from '../QuerySettingsDescription/QuerySettingsDescription'; @@ -16,6 +23,7 @@ const b = cn('kv-query-execution-status'); interface QueryExecutionStatusProps { className?: string; error?: unknown; + loading?: boolean; } const QuerySettingsIndicator = () => { @@ -40,13 +48,19 @@ const QuerySettingsIndicator = () => { ); }; -export const QueryExecutionStatus = ({className, error}: QueryExecutionStatusProps) => { +export const QueryExecutionStatus = ({className, error, loading}: QueryExecutionStatusProps) => { let icon: React.ReactNode; let label: string; - if (isAxiosError(error) && error.code === 'ECONNABORTED') { + if (loading) { + icon = ; + label = 'Running'; + } else if (isAxiosError(error) && error.code === 'ECONNABORTED') { icon = ; label = 'Connection aborted'; + } else if (isQueryCancelledError(error)) { + icon = ; + label = 'Stopped'; } else { const hasError = Boolean(error); icon = ( @@ -62,7 +76,7 @@ export const QueryExecutionStatus = ({className, error}: QueryExecutionStatusPro
{icon} {label} - + {isQueryCancelledError(error) || loading ? null : }
); }; diff --git a/src/containers/Tenant/Query/ExecuteResult/ExecuteResult.scss b/src/containers/Tenant/Query/ExecuteResult/ExecuteResult.scss index 225f25974..52ec4bdb1 100644 --- a/src/containers/Tenant/Query/ExecuteResult/ExecuteResult.scss +++ b/src/containers/Tenant/Query/ExecuteResult/ExecuteResult.scss @@ -48,14 +48,17 @@ &__controls-right { display: flex; + align-items: center; gap: 12px; height: 100%; } + &__controls-left { display: flex; gap: 4px; } + &__inspector { overflow: auto; @@ -71,4 +74,14 @@ width: 100%; height: 100%; } + + &__elapsed-label { + margin-left: var(--g-spacing-3); + } + + &__stop-button { + &_error { + @include query-buttons-animations(); + } + } } diff --git a/src/containers/Tenant/Query/ExecuteResult/ExecuteResult.tsx b/src/containers/Tenant/Query/ExecuteResult/ExecuteResult.tsx index 72ab9bc72..883c4ba3f 100644 --- a/src/containers/Tenant/Query/ExecuteResult/ExecuteResult.tsx +++ b/src/containers/Tenant/Query/ExecuteResult/ExecuteResult.tsx @@ -1,18 +1,21 @@ import React from 'react'; +import {StopFill} from '@gravity-ui/icons'; import type {ControlGroupOption} from '@gravity-ui/uikit'; -import {RadioButton, Tabs} from '@gravity-ui/uikit'; +import {Button, Icon, RadioButton, Tabs} from '@gravity-ui/uikit'; import JSONTree from 'react-json-inspector'; import {ClipboardButton} from '../../../../components/ClipboardButton'; 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 {YDBGraph} from '../../../../components/Graph/Graph'; +import {LoaderWrapper} from '../../../../components/LoaderWrapper/LoaderWrapper'; import {QueryExecutionStatus} from '../../../../components/QueryExecutionStatus'; import {QueryResultTable} from '../../../../components/QueryResultTable/QueryResultTable'; import {disableFullscreen} from '../../../../store/reducers/fullscreen'; -import type {ColumnType, KeyValueRow} from '../../../../types/api/query'; +import type {ColumnType, KeyValueRow, TKqpStatsQuery} from '../../../../types/api/query'; import type {ValueOf} from '../../../../types/common'; import type {IQueryResult} from '../../../../types/store/query'; import {getArray} from '../../../../utils'; @@ -26,6 +29,7 @@ import {ResultIssues} from '../Issues/Issues'; import {QueryDuration} from '../QueryDuration/QueryDuration'; import {QuerySettingsBanner} from '../QuerySettingsBanner/QuerySettingsBanner'; import {getPreparedResult} from '../utils/getPreparedResult'; +import {isQueryCancelledError} from '../utils/isQueryCancelledError'; import i18n from './i18n'; import {getPlan} from './utils'; @@ -46,25 +50,33 @@ type SectionID = ValueOf; interface ExecuteResultProps { data: IQueryResult | undefined; error: unknown; + cancelError: unknown; isResultsCollapsed?: boolean; onCollapseResults: VoidFunction; onExpandResults: VoidFunction; + onStopButtonClick: VoidFunction; theme?: string; + loading?: boolean; + cancelQueryLoading?: boolean; } export function ExecuteResult({ data, error, + cancelError, isResultsCollapsed, onCollapseResults, onExpandResults, + onStopButtonClick, theme, + loading, + cancelQueryLoading, }: ExecuteResultProps) { const [selectedResultSet, setSelectedResultSet] = React.useState(0); const [activeSection, setActiveSection] = React.useState(resultOptionsIds.result); const dispatch = useTypedDispatch(); - const stats = data?.stats; + const stats: TKqpStatsQuery | undefined = data?.stats; const resultsSetsCount = data?.resultSets?.length; const isMulti = resultsSetsCount && resultsSetsCount > 0; const currentResult = isMulti ? data?.resultSets?.[selectedResultSet].result : data?.result; @@ -93,8 +105,8 @@ export function ExecuteResult({ }; }, [dispatch]); - const onSelectSection = (value: string) => { - setActiveSection(value as SectionID); + const onSelectSection = (value: SectionID) => { + setActiveSection(value); }; const renderResultTable = ( @@ -207,7 +219,7 @@ export function ExecuteResult({ }; const renderResultSection = () => { - if (error) { + if (error && !isQueryCancelledError(error)) { return renderIssues(); } if (activeSection === resultOptionsIds.result) { @@ -230,18 +242,38 @@ export function ExecuteResult({
- - {stats && !error && ( + + + {!error && !loading && ( - - - + {stats?.DurationUs !== undefined && ( + + )} + {resultOptions && activeSection && ( + + + + + )} )} + {loading ? ( + + + + + ) : null}
{renderClipboardButton()} @@ -254,8 +286,10 @@ export function ExecuteResult({ />
- - {renderResultSection()} + {loading || isQueryCancelledError(error) ? null : } + + {renderResultSection()} +
); } diff --git a/src/containers/Tenant/Query/ExecuteResult/i18n/en.json b/src/containers/Tenant/Query/ExecuteResult/i18n/en.json index 009c16459..35989f129 100644 --- a/src/containers/Tenant/Query/ExecuteResult/i18n/en.json +++ b/src/containers/Tenant/Query/ExecuteResult/i18n/en.json @@ -3,6 +3,7 @@ "action.result": "Result", "action.stats": "Stats", "action.schema": "Schema", + "action.stop": "Stop", "action.explain-plan": "Explain Plan", "action.copy": "Copy {{activeSection}}" } diff --git a/src/containers/Tenant/Query/ExplainResult/ExplainResult.scss b/src/containers/Tenant/Query/ExplainResult/ExplainResult.scss index a7b37db4d..375ced444 100644 --- a/src/containers/Tenant/Query/ExplainResult/ExplainResult.scss +++ b/src/containers/Tenant/Query/ExplainResult/ExplainResult.scss @@ -1,3 +1,5 @@ +@import '../../../../styles/mixins.scss'; + .ydb-query-explain-result { &__result { display: flex; @@ -24,22 +26,27 @@ border-bottom: 1px solid var(--g-color-line-generic); background-color: var(--g-color-base-background); } + &__controls-right { display: flex; + align-items: center; gap: 12px; height: 100%; } + &__controls-left { display: flex; gap: 4px; } - &__loader { - display: flex; - justify-content: center; - align-items: center; - width: 100%; - margin-top: 20px; + &__elapsed-label { + margin-left: var(--g-spacing-3); + } + + &__stop-button { + &_error { + @include query-buttons-animations(); + } } } diff --git a/src/containers/Tenant/Query/ExplainResult/ExplainResult.tsx b/src/containers/Tenant/Query/ExplainResult/ExplainResult.tsx index 143a9e048..fd02e3a50 100644 --- a/src/containers/Tenant/Query/ExplainResult/ExplainResult.tsx +++ b/src/containers/Tenant/Query/ExplainResult/ExplainResult.tsx @@ -1,8 +1,11 @@ import React from 'react'; -import {ClipboardButton, RadioButton} from '@gravity-ui/uikit'; +import {StopFill} from '@gravity-ui/icons'; +import {Button, Icon, RadioButton} from '@gravity-ui/uikit'; +import {ClipboardButton} from '../../../../components/ClipboardButton'; 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 {LoaderWrapper} from '../../../../components/LoaderWrapper/LoaderWrapper'; @@ -15,7 +18,9 @@ import {getStringifiedData} from '../../../../utils/dataFormatters/dataFormatter import {useTypedDispatch} from '../../../../utils/hooks'; import {parseQueryErrorToString} from '../../../../utils/query'; import {PaneVisibilityToggleButtons} from '../../utils/paneVisibilityToggleHelpers'; +import {QueryDuration} from '../QueryDuration/QueryDuration'; import {QuerySettingsBanner} from '../QuerySettingsBanner/QuerySettingsBanner'; +import {isQueryCancelledError} from '../utils/isQueryCancelledError'; import {Ast} from './components/Ast/Ast'; import {Graph} from './components/Graph/Graph'; @@ -55,14 +60,17 @@ const explainOptions = [ interface ExplainResultProps { theme: string; - explain?: PreparedExplainResponse['plan']; + explain?: PreparedExplainResponse['plan'] & {DurationUs?: number}; simplifiedPlan?: PreparedExplainResponse['simplifiedPlan']; ast?: string; loading?: boolean; + cancelQueryLoading?: boolean; isResultsCollapsed?: boolean; error: unknown; + cancelError: unknown; onCollapseResults: VoidFunction; onExpandResults: VoidFunction; + onStopButtonClick: VoidFunction; } export function ExplainResult({ @@ -70,9 +78,12 @@ export function ExplainResult({ ast, theme, error, + cancelError, loading, + cancelQueryLoading, onCollapseResults, onExpandResults, + onStopButtonClick, isResultsCollapsed, simplifiedPlan, }: ExplainResultProps) { @@ -99,6 +110,10 @@ export function ExplainResult({ }; const renderContent = () => { + if (isQueryCancelledError(error)) { + return null; + } + if (error) { return
{parseQueryErrorToString(error)}
; } @@ -148,49 +163,63 @@ export function ExplainResult({ }; const statsToCopy = getStatsToCopy(); - const copyText = getStringifiedData(statsToCopy); return (
- {!loading && ( - -
- - {!error && ( - - - { - startTransition(() => setActiveOption(tabId)); - }} - /> - +
+ + + {!error && !loading && ( + + {explain?.DurationUs !== undefined && ( + )} -
-
- {copyText && ( - + + { + startTransition(() => setActiveOption(tabId)); + }} /> - )} - - -
- - )} + + + )} + {loading ? ( + + + + + ) : null} +
+
+ {copyText && ( + + )} + + +
- + {loading || isQueryCancelledError(error) ? null : } {renderContent()} diff --git a/src/containers/Tenant/Query/ExplainResult/i18n/en.json b/src/containers/Tenant/Query/ExplainResult/i18n/en.json index 15bd21a2e..1befd1a5d 100644 --- a/src/containers/Tenant/Query/ExplainResult/i18n/en.json +++ b/src/containers/Tenant/Query/ExplainResult/i18n/en.json @@ -5,5 +5,6 @@ "action.explain-plan": "Explain Plan", "action.json": "JSON", "action.ast": "AST", - "action.copy": "Copy {{activeOption}}" + "action.copy": "Copy {{activeOption}}", + "action.stop": "Stop" } diff --git a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx index cce539394..c4d0d92f7 100644 --- a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx +++ b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx @@ -4,10 +4,12 @@ import {isEqual} from 'lodash'; import throttle from 'lodash/throttle'; import type Monaco from 'monaco-editor'; import {connect} from 'react-redux'; +import {v4 as uuidv4} from 'uuid'; import {MonacoEditor} from '../../../../components/MonacoEditor/MonacoEditor'; import SplitPane from '../../../../components/SplitPane'; import type {RootState} from '../../../../store'; +import {cancelQueryApi} from '../../../../store/reducers/cancelQuery'; import { executeQueryApi, goToNextQuery, @@ -113,6 +115,7 @@ function QueryEditor(props: QueryEditorProps) { const [sendExecuteQuery, executeQueryResult] = executeQueryApi.useExecuteQueryMutation(); const [sendExplainQuery, explainQueryResult] = explainQueryApi.useExplainQueryMutation(); + const [sendCancelQuery, cancelQueryResult] = cancelQueryApi.useCancelQueryMutation(); React.useEffect(() => { if (savedPath !== tenantName) { @@ -157,17 +160,20 @@ function QueryEditor(props: QueryEditorProps) { resetBanner(); setLastQueryExecutionSettings(querySettings); } - + const queryId = uuidv4(); setResultType(RESULT_TYPES.EXECUTE); + sendExecuteQuery({ query, database: tenantName, querySettings, schema, enableTracingLevel, + queryId, }); setIsResultLoaded(true); props.setShowPreview(false); + cancelQueryResult.reset(); // Don't save partial queries in history if (!text) { @@ -194,18 +200,34 @@ function QueryEditor(props: QueryEditorProps) { setLastQueryExecutionSettings(querySettings); } + const queryId = uuidv4(); setResultType(RESULT_TYPES.EXPLAIN); + sendExplainQuery({ query: input, database: tenantName, querySettings, enableTracingLevel, + queryId, }); + setIsResultLoaded(true); props.setShowPreview(false); + cancelQueryResult.reset(); + dispatchResultVisibilityState(PaneVisibilityActionTypes.triggerExpand); }); + const currentQueryId = executeQueryResult.isLoading + ? executeQueryResult.originalArgs?.queryId + : explainQueryResult.originalArgs?.queryId; + + const handleStopButtonClick = React.useCallback(() => { + if (currentQueryId) { + sendCancelQuery({queryId: currentQueryId, database: tenantName}); + } + }, [currentQueryId, sendCancelQuery, tenantName]); + const handleSendQuery = useEventHandler(() => { if (lastUsedQueryAction === QUERY_ACTIONS.explain) { handleGetExplainQueryClick(); @@ -314,9 +336,8 @@ function QueryEditor(props: QueryEditorProps) { @@ -357,12 +378,17 @@ function QueryEditor(props: QueryEditorProps) { void; - onCollapseResultHandler: () => void; + onExpandResultHandler: VoidFunction; + onCollapseResultHandler: VoidFunction; + onStopButtonClick: VoidFunction; type?: EPathType; theme: string; resultType: ValueOf | undefined; @@ -415,12 +446,16 @@ interface ResultProps { function Result({ executeQueryData, executeQueryError, + cancelQueryError, explainQueryData, explainQueryError, explainQueryLoading, + executeResultLoading, + cancelQueryLoading, resultVisibilityState, onExpandResultHandler, onCollapseResultHandler, + onStopButtonClick, type, theme, resultType, @@ -433,20 +468,20 @@ function Result({ } if (resultType === RESULT_TYPES.EXECUTE) { - if (executeQueryData || executeQueryError) { - return ( - - ); - } - - return null; + return ( + + ); } if (resultType === RESULT_TYPES.EXPLAIN) { @@ -455,14 +490,17 @@ function Result({ return ( ); } diff --git a/src/containers/Tenant/Query/QueryEditorControls/QueryEditorControls.tsx b/src/containers/Tenant/Query/QueryEditorControls/QueryEditorControls.tsx index a8146465d..97ae967a8 100644 --- a/src/containers/Tenant/Query/QueryEditorControls/QueryEditorControls.tsx +++ b/src/containers/Tenant/Query/QueryEditorControls/QueryEditorControls.tsx @@ -54,23 +54,23 @@ const SettingsButton = ({onClick, runIsLoading}: SettingsButtonProps) => { }; interface QueryEditorControlsProps { - handleSendExecuteClick: () => void; - onSettingsButtonClick: () => void; - runIsLoading: boolean; - handleGetExplainQueryClick: () => void; - explainIsLoading: boolean; + isLoading: boolean; disabled: boolean; highlightedAction: QueryAction; + + handleGetExplainQueryClick: () => void; + handleSendExecuteClick: () => void; + onSettingsButtonClick: () => void; } export const QueryEditorControls = ({ + disabled, + isLoading, + highlightedAction, + handleSendExecuteClick, onSettingsButtonClick, - runIsLoading, handleGetExplainQueryClick, - explainIsLoading, - disabled, - highlightedAction, }: QueryEditorControlsProps) => { const runView: ButtonView | undefined = highlightedAction === 'execute' ? 'action' : undefined; const explainView: ButtonView | undefined = @@ -90,7 +90,7 @@ export const QueryEditorControls = ({ - + diff --git a/src/containers/Tenant/Query/QuerySettingsDialog/QuerySettingsDialog.tsx b/src/containers/Tenant/Query/QuerySettingsDialog/QuerySettingsDialog.tsx index 66a0b575b..3c33446fd 100644 --- a/src/containers/Tenant/Query/QuerySettingsDialog/QuerySettingsDialog.tsx +++ b/src/containers/Tenant/Query/QuerySettingsDialog/QuerySettingsDialog.tsx @@ -164,7 +164,7 @@ function QuerySettingsForm({initialValues, onSubmit, onClose}: QuerySettingsForm -
+
{ + queryId: string; +} + +export const cancelQueryApi = api.injectEndpoints({ + endpoints: (build) => ({ + cancelQuery: build.mutation({ + queryFn: async ({queryId, database}, {signal}) => { + const action: CancelActions = 'cancel-query'; + + try { + const response = await window.api.sendQuery( + { + database, + action, + query_id: queryId, + }, + {signal}, + ); + + if (isQueryErrorResponse(response)) { + return {error: response}; + } + + const data = parseQueryAPIExecuteResponse(response); + return {data}; + } catch (error) { + return {error}; + } + }, + }), + }), + overrideExisting: 'throw', +}); diff --git a/src/store/reducers/executeQuery.ts b/src/store/reducers/executeQuery.ts index aa3967438..d31fa5e79 100644 --- a/src/store/reducers/executeQuery.ts +++ b/src/store/reducers/executeQuery.ts @@ -130,6 +130,7 @@ const executeQuery: Reducer = ( }; interface SendQueryParams extends QueryRequestParams { + queryId: string; querySettings?: Partial; schema?: Schemas; // flag whether to send new tracing header or not @@ -140,13 +141,17 @@ interface SendQueryParams extends QueryRequestParams { export const executeQueryApi = api.injectEndpoints({ endpoints: (build) => ({ executeQuery: build.mutation({ - queryFn: async ({ - query, - database, - querySettings = {}, - schema = 'modern', - enableTracingLevel, - }) => { + queryFn: async ( + { + query, + database, + querySettings = {}, + schema = 'modern', + enableTracingLevel, + queryId, + }, + {signal}, + ) => { let action: ExecuteActions = 'execute'; let syntax: QuerySyntax = QUERY_SYNTAX.yql; @@ -158,22 +163,26 @@ export const executeQueryApi = api.injectEndpoints({ } try { - const response = await window.api.sendQuery({ - schema, - query, - database, - action, - syntax, - stats: querySettings.statisticsMode, - tracingLevel: - querySettings.tracingLevel && enableTracingLevel - ? TracingLevelNumber[querySettings.tracingLevel] + const response = await window.api.sendQuery( + { + schema, + query, + database, + action, + syntax, + stats: querySettings.statisticsMode, + tracingLevel: + querySettings.tracingLevel && enableTracingLevel + ? TracingLevelNumber[querySettings.tracingLevel] + : undefined, + transaction_mode: querySettings.isolationLevel, + timeout: isNumeric(querySettings.timeout) + ? Number(querySettings.timeout) * 1000 : undefined, - transaction_mode: querySettings.isolationLevel, - timeout: isNumeric(querySettings.timeout) - ? Number(querySettings.timeout) * 1000 - : undefined, - }); + query_id: queryId, + }, + {signal}, + ); if (isQueryErrorResponse(response)) { return {error: response}; diff --git a/src/store/reducers/explainQuery/explainQuery.ts b/src/store/reducers/explainQuery/explainQuery.ts index 1717b0648..3b3831935 100644 --- a/src/store/reducers/explainQuery/explainQuery.ts +++ b/src/store/reducers/explainQuery/explainQuery.ts @@ -9,6 +9,7 @@ import type {PreparedExplainResponse} from './types'; import {prepareExplainResponse} from './utils'; interface ExplainQueryParams extends QueryRequestParams { + queryId: string; querySettings?: Partial; // flag whether to send new tracing header or not // default: not send @@ -18,7 +19,10 @@ interface ExplainQueryParams extends QueryRequestParams { export const explainQueryApi = api.injectEndpoints({ endpoints: (build) => ({ explainQuery: build.mutation({ - queryFn: async ({query, database, querySettings, enableTracingLevel}) => { + queryFn: async ( + {query, database, querySettings, enableTracingLevel, queryId}, + {signal}, + ) => { let action: ExplainActions = 'explain'; let syntax: QuerySyntax = QUERY_SYNTAX.yql; @@ -30,21 +34,25 @@ export const explainQueryApi = api.injectEndpoints({ } try { - const response = await window.api.sendQuery({ - query, - database, - action, - syntax, - stats: querySettings?.statisticsMode, - tracingLevel: - querySettings?.tracingLevel && enableTracingLevel - ? TracingLevelNumber[querySettings?.tracingLevel] + const response = await window.api.sendQuery( + { + query, + database, + action, + syntax, + stats: querySettings?.statisticsMode, + tracingLevel: + querySettings?.tracingLevel && enableTracingLevel + ? TracingLevelNumber[querySettings?.tracingLevel] + : undefined, + transaction_mode: querySettings?.isolationLevel, + timeout: isNumeric(querySettings?.timeout) + ? Number(querySettings?.timeout) * 1000 : undefined, - transaction_mode: querySettings?.isolationLevel, - timeout: isNumeric(querySettings?.timeout) - ? Number(querySettings?.timeout) * 1000 - : undefined, - }); + query_id: queryId, + }, + {signal}, + ); if (isQueryErrorResponse(response)) { return {error: response}; diff --git a/src/styles/mixins.scss b/src/styles/mixins.scss index c015f9947..535e49c27 100644 --- a/src/styles/mixins.scss +++ b/src/styles/mixins.scss @@ -298,3 +298,28 @@ box-shadow: inset 0 -1px 0 0 var(--g-color-line-generic); } + +@mixin query-buttons-animations() { + animation: errorAnimation 500ms linear; + + @keyframes errorAnimation { + 8%, + 41% { + transform: translateX(-2px); + } + 25%, + 58% { + transform: translateX(2px); + } + 75% { + transform: translateX(-1px); + } + 92% { + transform: translateX(1px); + } + 0%, + 100% { + transform: translateX(0); + } + } +} diff --git a/src/types/api/query.ts b/src/types/api/query.ts index aca9a1d64..362ffac7b 100644 --- a/src/types/api/query.ts +++ b/src/types/api/query.ts @@ -244,7 +244,9 @@ export type ExplainActions = | 'explain-query' | 'explain-ast'; -export type Actions = ExecuteActions | ExplainActions; +export type CancelActions = 'cancel-query'; + +export type Actions = ExecuteActions | ExplainActions | CancelActions; // ==== Error response ==== @@ -329,6 +331,10 @@ export type ExecuteResponse : ExecuteScriptResponse; +export type CancelResponse = { + stats?: TKqpStatsQuery; +}; + // ==== Combined API response ==== export type QueryAPIResponse< Action extends Actions, @@ -337,7 +343,9 @@ export type QueryAPIResponse< ? ExplainResponse : Action extends ExecuteActions ? ExecuteResponse - : unknown; + : Action extends CancelActions + ? CancelResponse + : never; // ==== types to use in query result preparation ==== export type AnyExplainResponse = ExplainQueryResponse | ExplainScriptResponse; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index bbf70667b..04c409805 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -18,6 +18,7 @@ export const MEGABYTE = 1_000_000; export const GIGABYTE = 1_000_000_000; export const TERABYTE = 1_000_000_000_000; +export const SECOND_IN_MS = 1000; export const MINUTE_IN_SECONDS = 60; export const HOUR_IN_SECONDS = 60 * MINUTE_IN_SECONDS; export const DAY_IN_SECONDS = 24 * HOUR_IN_SECONDS; diff --git a/tests/suites/tenant/queryEditor/QueryEditor.ts b/tests/suites/tenant/queryEditor/QueryEditor.ts index 4a93cf822..85db3e31f 100644 --- a/tests/suites/tenant/queryEditor/QueryEditor.ts +++ b/tests/suites/tenant/queryEditor/QueryEditor.ts @@ -18,6 +18,7 @@ export enum ButtonNames { Explain = 'Explain', Cancel = 'Cancel', Save = 'Save', + Stop = 'Stop', } export class SettingsDialog { @@ -51,6 +52,17 @@ export class SettingsDialog { await this.page.waitForTimeout(1000); } + async changeStatsLevel(mode: string) { + const dropdown = this.dialog.locator( + '.ydb-query-settings-dialog__control-wrapper_statisticsMode', + ); + await dropdown.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await dropdown.click(); + const popup = this.page.locator('.ydb-query-settings-select__popup'); + await popup.getByText(mode).first().click(); + await this.page.waitForTimeout(1000); + } + async clickButton(buttonName: ButtonNames) { const button = this.dialog.getByRole('button', {name: buttonName}); await button.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); @@ -105,20 +117,28 @@ export class QueryEditor { private editorTextArea: Locator; private runButton: Locator; private explainButton: Locator; + private stopButton: Locator; private gearButton: Locator; private indicatorIcon: Locator; private banner: Locator; + private executionStatus: Locator; + private radioButton: Locator; + private elapsedTimeLabel: Locator; constructor(page: Page) { this.page = page; this.selector = page.locator('.query-editor'); 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.explainButton = this.selector.getByRole('button', {name: ButtonNames.Explain}); this.gearButton = this.selector.locator('.ydb-query-editor-controls__gear-button'); + this.executionStatus = this.selector.locator('.kv-query-execution-status'); this.indicatorIcon = this.selector.locator( '.kv-query-execution-status__query-settings-icon', ); + this.elapsedTimeLabel = this.selector.locator('.ydb-query-elapsed-time'); + this.radioButton = this.selector.locator('.query-editor__pane-wrapper .g-radio-button'); this.banner = this.page.locator('.ydb-query-settings-banner'); this.settingsDialog = new SettingsDialog(page); @@ -146,6 +166,11 @@ export class QueryEditor { return this.gearButton.innerText(); } + async clickStopButton() { + await this.stopButton.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await this.stopButton.click(); + } + async clickRunButton() { await this.runButton.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); await this.runButton.click(); @@ -157,7 +182,7 @@ export class QueryEditor { } async getExplainResult(type: ExplainResultType) { - await this.selectExplainResultType(type); + await this.selectResultTypeRadio(type); const resultArea = this.selector.locator('.ydb-query-explain-result__result'); switch (type) { case ExplainResultType.Schema: @@ -169,6 +194,17 @@ export class QueryEditor { } } + async getErrorMessage() { + const errorMessage = this.selector.locator('.kv-result-issues__error-message-text'); + await errorMessage.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return errorMessage.innerText(); + } + + async getExecutionStatus() { + await this.executionStatus.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return this.executionStatus.innerText(); + } + async focusEditor() { await this.editorTextArea.focus(); } @@ -194,13 +230,32 @@ export class QueryEditor { await this.editorTextArea.fill(query); } - async selectExplainResultType(type: ExplainResultType) { - const radio = this.selector.locator('.ydb-query-explain-result__controls .g-radio-button'); - const typeButton = radio.getByLabel(type); + async selectResultTypeRadio(type: ExplainResultType) { + const typeButton = this.radioButton.getByLabel(type); await typeButton.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); await typeButton.click(); } + async isElapsedTimeVisible() { + await this.elapsedTimeLabel.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } + + async isElapsedTimeHidden() { + await this.elapsedTimeLabel.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); + return true; + } + + async isStopButtonVisible() { + await this.stopButton.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } + + async isStopButtonHidden() { + await this.stopButton.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); + return true; + } + async isRunButtonEnabled() { return this.runButton.isEnabled({timeout: VISIBILITY_TIMEOUT}); } @@ -224,6 +279,11 @@ export class QueryEditor { return true; } + async isIndicatorIconHidden() { + await this.indicatorIcon.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); + return true; + } + async retry(action: () => Promise, maxAttempts = 3, delay = 1000): Promise { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { diff --git a/tests/suites/tenant/queryEditor/constants.ts b/tests/suites/tenant/queryEditor/constants.ts new file mode 100644 index 000000000..3238b159c --- /dev/null +++ b/tests/suites/tenant/queryEditor/constants.ts @@ -0,0 +1,32 @@ +// Long running query for tests +// May cause Memory exceed on real database + +export const longRunningQuery = ` +PRAGMA TablePathPrefix(""); + +SELECT COUNT(*) AS total_count +FROM ( + SELECT 1 AS dummy + FROM + (SELECT 1 AS d UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL + SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1) AS t1 + CROSS JOIN + (SELECT 1 AS d UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL + SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1) AS t2 + CROSS JOIN + (SELECT 1 AS d UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL + SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1) AS t3 + CROSS JOIN + (SELECT 1 AS d UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL + SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1) AS t4 + CROSS JOIN + (SELECT 1 AS d UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL + SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1) AS t5 + CROSS JOIN + (SELECT 1 AS d UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL + SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1) AS t6 + CROSS JOIN + (SELECT 1 AS d UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL + SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1) AS t7 +) AS large_table + `; diff --git a/tests/suites/tenant/queryEditor/queryEditor.test.ts b/tests/suites/tenant/queryEditor/queryEditor.test.ts index 525c89abd..1f7a6b586 100644 --- a/tests/suites/tenant/queryEditor/queryEditor.test.ts +++ b/tests/suites/tenant/queryEditor/queryEditor.test.ts @@ -10,6 +10,7 @@ import { QueryMode, VISIBILITY_TIMEOUT, } from './QueryEditor'; +import {longRunningQuery} from './constants'; test.describe('Test Query Editor', async () => { const testQuery = 'SELECT 1, 2, 3, 4, 5;'; @@ -87,6 +88,20 @@ test.describe('Test Query Editor', async () => { await expect(explainAST).toBeVisible({timeout: VISIBILITY_TIMEOUT}); }); + test('Error is displayed for invalid query', async ({page}) => { + const queryEditor = new QueryEditor(page); + + const invalidQuery = 'Select d'; + await queryEditor.setQuery(invalidQuery); + await queryEditor.clickRunButton(); + + const statusElement = await queryEditor.getExecutionStatus(); + await expect(statusElement).toBe('Failed'); + + const errorMessage = await queryEditor.getErrorMessage(); + await expect(errorMessage).toContain('Column references are not allowed without FROM'); + }); + test('Banner appears after executing script with changed settings', async ({page}) => { const queryEditor = new QueryEditor(page); @@ -103,6 +118,23 @@ test.describe('Test Query Editor', async () => { await expect(queryEditor.isBannerVisible()).resolves.toBe(true); }); + test('Banner not appears for running query', async ({page}) => { + const queryEditor = new QueryEditor(page); + + // Change a setting + await queryEditor.clickGearButton(); + await queryEditor.settingsDialog.changeQueryMode(QueryMode.Scan); + await queryEditor.settingsDialog.clickButton(ButtonNames.Save); + + // Execute a script + await queryEditor.setQuery(longRunningQuery); + await queryEditor.clickRunButton(); + await page.waitForTimeout(500); + + // Check if banner appears + await expect(queryEditor.isBannerHidden()).resolves.toBe(true); + }); + test('Indicator icon appears after closing banner', async ({page}) => { const queryEditor = new QueryEditor(page); @@ -121,6 +153,27 @@ test.describe('Test Query Editor', async () => { 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.changeIsolationLevel('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(); @@ -155,4 +208,84 @@ test.describe('Test Query Editor', async () => { await expect(queryEditor.isBannerHidden()).resolves.toBe(true); }); + + test('Stop button and elapsed time label appears when query is running', async ({page}) => { + const queryEditor = new QueryEditor(page); + + await queryEditor.setQuery(longRunningQuery); + await queryEditor.clickRunButton(); + + await expect(queryEditor.isStopButtonVisible()).resolves.toBe(true); + await expect(queryEditor.isElapsedTimeVisible()).resolves.toBe(true); + }); + + test('Stop button and elapsed time label disappears after query is stopped', 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); + }); + + 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 page.waitForTimeout(1000); // Wait for the editor to initialize + + // Check for a message or indicator that the query was stopped + const statusElement = await queryEditor.getExecutionStatus(); + await expect(statusElement).toBe('Stopped'); + }); + + test('Stop button is not visible for quick queries', async ({page}) => { + const queryEditor = new QueryEditor(page); + + const quickQuery = 'SELECT 1;'; + await queryEditor.setQuery(quickQuery); + await queryEditor.clickRunButton(); + await page.waitForTimeout(1000); // Wait for the editor to initialize + + await expect(queryEditor.isStopButtonHidden()).resolves.toBe(true); + }); + + test('Stop button works for Execute mode', async ({page}) => { + const queryEditor = new QueryEditor(page); + + // Test for Execute mode + await queryEditor.setQuery(longRunningQuery); + await queryEditor.clickRunButton(); + + await expect(queryEditor.isStopButtonVisible()).resolves.toBe(true); + await queryEditor.clickStopButton(); + await expect(queryEditor.isStopButtonHidden()).resolves.toBe(true); + }); + + test('Stop button works for Explain mode', async ({page}) => { + const queryEditor = new QueryEditor(page); + + // Test for Execute mode + await queryEditor.setQuery(longRunningQuery); + await queryEditor.clickGearButton(); + await queryEditor.settingsDialog.changeStatsLevel('Profile'); + await queryEditor.settingsDialog.clickButton(ButtonNames.Save); + + // Test for Explain mode + await queryEditor.clickExplainButton(); + + await expect(queryEditor.isStopButtonVisible()).resolves.toBe(true); + await queryEditor.clickStopButton(); + await expect(queryEditor.isStopButtonHidden()).resolves.toBe(true); + }); });