/
githubmirror
/
ydb-embedded-ui
Обзор
Документация
Войти
/
githubmirror
/
ydb-embedded-ui
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx
362 строки
13 KB
Anton Standrik
feat: add explain analyze query action (#4096)
05 авг 2026, 21:29
Не верифицирован
05 авг 2026, 21:29
1f24bef
Код
Авторство
О чём код?
import React from 'react'; import type {Settings} from '@gravity-ui/react-data-table'; import type {ControlGroupOption, CopyToClipboardStatus} from '@gravity-ui/uikit'; import { ActionTooltip, Button, ClipboardIcon, Flex, SegmentedRadioGroup, Text, } from '@gravity-ui/uikit'; import {EnableFullscreenButton} from '../../../../components/EnableFullscreenButton/EnableFullscreenButton'; import {Fullscreen} from '../../../../components/Fullscreen/Fullscreen'; import {LoaderWrapper} from '../../../../components/LoaderWrapper/LoaderWrapper'; import {QueryExecutionStatus} from '../../../../components/QueryExecutionStatus'; import {disableFullscreen} from '../../../../store/reducers/fullscreen'; import {selectResultTab, setResultTab} from '../../../../store/reducers/query/query'; import type {QueryResult} from '../../../../store/reducers/query/types'; import {SETTING_KEYS} from '../../../../store/reducers/settings/constants'; import type {QueryAction} from '../../../../types/store/query'; import {cn} from '../../../../utils/cn'; import {useSetting, useTypedDispatch, useTypedSelector} from '../../../../utils/hooks'; import {getIllustration} from '../../../../utils/illustrations'; import {isExecutionQueryAction} from '../../../../utils/query'; import {PaneVisibilityToggleButtons} from '../../utils/paneVisibilityToggleHelpers'; import {QuerySettingsBanner} from '../QuerySettingsBanner/QuerySettingsBanner'; import {QueryStoppedBanner} from '../QueryStoppedBanner/QueryStoppedBanner'; import {copyResultToClipboard, copyTextDataToClipboard} from '../utils/copyToClipboard'; import {isQueryCancelledError} from '../utils/isQueryCancelledError'; import {PlanSection} from './components/PlanSection/PlanSection'; import {QueryInfoDropdown} from './components/QueryInfoDropdown/QueryInfoDropdown'; import {QueryResultError} from './components/QueryResultError/QueryResultError'; import {ResultSetsViewer} from './components/ResultSetsViewer/ResultSetsViewer'; import {TraceButton} from './components/TraceButton/TraceButton'; import type {SectionID} from './constants'; import {RESULT_OPTIONS_IDS, getDefaultResultSection, getResultSections} from './constants'; import i18n from './i18n'; import './QueryResultViewer.scss'; const b = cn('ydb-query-result'); const COPY_STATUS_RESET_TIMEOUT = 1500; const RESULT_OPTIONS_TITLES: Record<SectionID, string> = { get result() { return i18n('action.result'); }, get schema() { return i18n('action.schema'); }, get simplified() { return i18n('action.explain-plan'); }, get json() { return i18n('action.json'); }, get stats() { return i18n('action.stats'); }, get ast() { return i18n('action.ast'); }, }; interface ExecuteResultProps { result: QueryResult; resultType?: QueryAction; isResultsCollapsed?: boolean; theme?: string; database: string; queryText?: string; tableSettings?: Partial<Settings>; onCollapseResults: VoidFunction; onExpandResults: VoidFunction; } export function QueryResultViewer({ result, resultType = 'execute', isResultsCollapsed, theme, database, queryText, tableSettings, onCollapseResults, onExpandResults, }: ExecuteResultProps) { const InternalErrorImage = getIllustration('InternalError'); const dispatch = useTypedDispatch(); const selectedTabs = useTypedSelector(selectResultTab); const isExecutionResult = isExecutionQueryAction(resultType); const hasResultSection = getResultSections(resultType).includes(RESULT_OPTIONS_IDS.result); const [selectedResultSet, setSelectedResultSet] = React.useState(0); const [useShowPlanToSvg] = useSetting<boolean>(SETTING_KEYS.USE_SHOW_PLAN_SVG); const [copyStatus, setCopyStatus] = React.useState<CopyToClipboardStatus>('pending'); const copyTimeoutRef = React.useRef<number>(); const activeSection: SectionID = React.useMemo(() => { const savedTab = selectedTabs?.[resultType]; if (savedTab) { const validSections = getResultSections(resultType); if (validSections.includes(savedTab as SectionID)) { return savedTab as SectionID; } } return getDefaultResultSection(resultType); }, [selectedTabs, resultType]); const {error, isLoading, streamingStatus, data = {}} = result; const {preparedPlan, simplifiedPlan, stats, resultSets, ast} = data; React.useEffect(() => { return () => { dispatch(disableFullscreen()); window.clearTimeout(copyTimeoutRef.current); }; }, [dispatch]); const onSelectSection = (value: SectionID) => { dispatch(setResultTab({queryType: resultType, tabId: value})); setCopyStatus('pending'); window.clearTimeout(copyTimeoutRef.current); }; const radioButtonOptions: ControlGroupOption<SectionID>[] = React.useMemo(() => { const sections = getResultSections(resultType); return sections.map((section) => ({ value: section, content: RESULT_OPTIONS_TITLES[section], })); }, [resultType]); const hasCopyableData = React.useCallback((): boolean => { switch (activeSection) { case RESULT_OPTIONS_IDS.result: return Boolean(data?.resultSets?.[selectedResultSet]?.result?.length); case RESULT_OPTIONS_IDS.json: return Boolean(preparedPlan?.pristine); case RESULT_OPTIONS_IDS.simplified: return Boolean(simplifiedPlan?.pristine); case RESULT_OPTIONS_IDS.stats: return Boolean(stats); case RESULT_OPTIONS_IDS.ast: return Boolean(ast); default: return false; } }, [ activeSection, data?.resultSets, selectedResultSet, preparedPlan?.pristine, simplifiedPlan?.pristine, stats, ast, ]); const handleCopy = React.useCallback(async () => { window.clearTimeout(copyTimeoutRef.current); let success = false; switch (activeSection) { case RESULT_OPTIONS_IDS.result: { const currentResult = data?.resultSets?.[selectedResultSet]; success = await copyResultToClipboard(currentResult?.result); break; } case RESULT_OPTIONS_IDS.json: success = await copyTextDataToClipboard(preparedPlan?.pristine); break; case RESULT_OPTIONS_IDS.simplified: success = await copyTextDataToClipboard(simplifiedPlan?.pristine); break; case RESULT_OPTIONS_IDS.stats: success = await copyTextDataToClipboard(stats); break; case RESULT_OPTIONS_IDS.ast: success = await copyTextDataToClipboard(ast); break; } setCopyStatus(success ? 'success' : 'error'); copyTimeoutRef.current = window.setTimeout(() => { setCopyStatus('pending'); }, COPY_STATUS_RESET_TIMEOUT); }, [ activeSection, data?.resultSets, selectedResultSet, preparedPlan?.pristine, simplifiedPlan?.pristine, stats, ast, ]); const getCopyTooltipTitle = (status: CopyToClipboardStatus): string => { if (status === 'success') { return i18n('action.copy-success'); } if (status === 'error') { return i18n('action.copy-error'); } return i18n('action.copy', {activeSection}); }; const renderCopyButton = () => { if (isLoading || !hasCopyableData()) { return null; } return ( <ActionTooltip title={getCopyTooltipTitle(copyStatus)}> <Button view="flat-secondary" onClick={handleCopy} aria-label={i18n('action.copy', {activeSection})} > <Button.Icon> <ClipboardIcon status={copyStatus} size={16} /> </Button.Icon> </Button> </ActionTooltip> ); }; const renderQueryInfoDropdown = () => { if (isLoading || isQueryCancelledError(error)) { return null; } return ( <QueryInfoDropdown queryResultsInfo={{ queryText, ast: data.ast, stats: data.stats, plan: data.plan, }} error={error} database={database} hasPlanToSvg={Boolean(data?.plan && useShowPlanToSvg && isExecutionResult)} /> ); }; const renderCommonErrorView = (isStopped: boolean) => { return ( <Flex justifyContent="center" alignItems="center" width="100%" gap={8}> <InternalErrorImage width={230} height={230} className={b('illustration')} /> <Flex direction="column" gap={2}> <Text variant="subheader-2"> {isStopped ? i18n('stopped.title') : i18n('error.title')} </Text> <Text color="complementary"> {isStopped ? i18n('stopped.description') : i18n('error.description')} </Text> </Flex> </Flex> ); }; const renderResultSection = () => { const isStopped = isQueryCancelledError(error); if (activeSection === RESULT_OPTIONS_IDS.result) { if (error && isStopped && !resultSets?.length) { return renderCommonErrorView(isStopped); } return ( <ResultSetsViewer resultSets={resultSets} error={error} selectedResultSet={selectedResultSet} tableSettings={tableSettings} setSelectedResultSet={setSelectedResultSet} sourcePosition={result.sourcePosition} /> ); } if (error) { if ((isExecutionResult && hasResultSection) || isStopped) { return renderCommonErrorView(isStopped); } return <QueryResultError error={error} sourcePosition={result.sourcePosition} />; } return ( <PlanSection activeSection={activeSection} sectionTitle={RESULT_OPTIONS_TITLES[activeSection]} preparedPlan={preparedPlan} simplifiedPlan={simplifiedPlan} stats={stats} ast={ast} theme={theme} /> ); }; const renderLeftControls = () => { return ( <div className={b('controls-left')}> {radioButtonOptions.length && activeSection ? ( <SegmentedRadioGroup options={radioButtonOptions} value={activeSection} onUpdate={onSelectSection} /> ) : null} <QueryExecutionStatus error={error} loading={isLoading} streamingStatus={streamingStatus} /> {data?.traceId && isExecutionResult ? <TraceButton traceId={data.traceId} /> : null} </div> ); }; const renderRightControls = () => { return ( <div className={b('controls-right')}> {renderQueryInfoDropdown()} {renderCopyButton()} <EnableFullscreenButton /> <PaneVisibilityToggleButtons onCollapse={onCollapseResults} onExpand={onExpandResults} isCollapsed={isResultsCollapsed} initialDirection="bottom" /> </div> ); }; const isCancelled = isQueryCancelledError(error); return ( <React.Fragment> <div className={b('controls')}> {renderLeftControls()} {renderRightControls()} </div> {isLoading || isCancelled ? null : <QuerySettingsBanner />} {isCancelled && data.resultSets?.length ? <QueryStoppedBanner /> : null} <LoaderWrapper loading={isLoading && (!data.resultSets || activeSection !== 'result')}> <Fullscreen className={b('result')}>{renderResultSection()}</Fullscreen> </LoaderWrapper> </React.Fragment> ); }