diff --git a/src/assets/icons/cry-cat.svg b/src/assets/icons/cry-cat.svg new file mode 100644 index 000000000..4bff3ab5b --- /dev/null +++ b/src/assets/icons/cry-cat.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/components/Search/Search.tsx b/src/components/Search/Search.tsx index fb259ae14..d880013dc 100644 --- a/src/components/Search/Search.tsx +++ b/src/components/Search/Search.tsx @@ -15,6 +15,7 @@ interface SearchProps { className?: string; debounce?: number; placeholder?: string; + inputRef?: React.RefObject; } export const Search = ({ @@ -24,6 +25,7 @@ export const Search = ({ className, debounce = 200, placeholder, + inputRef, }: SearchProps) => { const [searchValue, setSearchValue] = React.useState(value); @@ -52,6 +54,7 @@ export const Search = ({ {renderCopyButton()} @@ -96,7 +110,7 @@ export function YDBSyntaxHighlighter({ key={highlighterKey} language={language} style={style} - customStyle={{height: '100%'}} + customStyle={{height: '100%', ...paddingStyles}} > {text} diff --git a/src/containers/Drawer/Drawer.scss b/src/containers/Drawer/Drawer.scss new file mode 100644 index 000000000..15952f720 --- /dev/null +++ b/src/containers/Drawer/Drawer.scss @@ -0,0 +1,15 @@ +.ydb-drawer { + &__drawer-container { + position: relative; + + overflow: hidden; + + height: 100%; + } + + &__item { + z-index: 4; + + height: 100%; + } +} diff --git a/src/containers/Drawer/Drawer.tsx b/src/containers/Drawer/Drawer.tsx new file mode 100644 index 000000000..1a950bd56 --- /dev/null +++ b/src/containers/Drawer/Drawer.tsx @@ -0,0 +1,224 @@ +import React from 'react'; + +import {DrawerItem, Drawer as GravityDrawer} from '@gravity-ui/navigation'; + +import {cn} from '../../utils/cn'; + +const DEFAULT_DRAWER_WIDTH_PERCENTS = 60; +const DEFAULT_DRAWER_WIDTH = 600; +const DRAWER_WIDTH_KEY = 'drawer-width'; +const b = cn('ydb-drawer'); + +import './Drawer.scss'; + +// Create a context for sharing container dimensions +interface DrawerContextType { + containerWidth: number; + setContainerWidth: React.Dispatch>; +} + +const DrawerContext = React.createContext(undefined); + +// Custom hook to use the drawer context +const useDrawerContext = () => { + const context = React.useContext(DrawerContext); + if (context === undefined) { + return {containerWidth: 0, setContainerWidth: () => {}}; + } + return context; +}; + +interface ContentWrapperProps { + isVisible: boolean; + onClose: () => void; + children: React.ReactNode; + drawerId?: string; + storageKey?: string; + direction?: 'left' | 'right'; + className?: string; + detectClickOutside?: boolean; + defaultWidth?: number; + isPercentageWidth?: boolean; +} + +const ContentWrapper = ({ + isVisible, + onClose, + children, + drawerId = 'drawer', + storageKey = DRAWER_WIDTH_KEY, + defaultWidth, + direction = 'right', + className, + detectClickOutside = false, + isPercentageWidth, +}: ContentWrapperProps) => { + const [drawerWidth, setDrawerWidth] = React.useState(() => { + const savedWidth = localStorage.getItem(storageKey); + return savedWidth ? Number(savedWidth) : defaultWidth; + }); + + const drawerRef = React.useRef(null); + const {containerWidth} = useDrawerContext(); + // Calculate drawer width based on container width percentage if specified + const calculatedWidth = React.useMemo(() => { + if (isPercentageWidth && containerWidth > 0) { + return Math.round( + (containerWidth * (drawerWidth || DEFAULT_DRAWER_WIDTH_PERCENTS)) / 100, + ); + } + return drawerWidth || DEFAULT_DRAWER_WIDTH; + }, [containerWidth, isPercentageWidth, drawerWidth]); + + React.useEffect(() => { + if (!detectClickOutside) { + return undefined; + } + + const handleClickOutside = (event: MouseEvent) => { + if ( + isVisible && + drawerRef.current && + !drawerRef.current.contains(event.target as Node) + ) { + onClose(); + } + }; + + document.addEventListener('click', handleClickOutside); + return () => { + document.removeEventListener('click', handleClickOutside); + }; + }, [isVisible, onClose, detectClickOutside]); + + const handleResizeDrawer = (width: number) => { + if (isPercentageWidth && containerWidth > 0) { + const percentageWidth = Math.round((width / containerWidth) * 100); + setDrawerWidth(percentageWidth); + localStorage.setItem(storageKey, percentageWidth.toString()); + } else { + setDrawerWidth(width); + localStorage.setItem(storageKey, width.toString()); + } + }; + + return ( + + + {children} + + + ); +}; + +interface ContainerProps { + children: React.ReactNode; + className?: string; +} + +interface ItemWrapperProps { + children: React.ReactNode; + renderDrawerContent: () => React.ReactNode; + isDrawerVisible: boolean; + onCloseDrawer: () => void; + drawerId?: string; + storageKey?: string; + defaultWidth?: number; + direction?: 'left' | 'right'; + className?: string; + detectClickOutside?: boolean; + isPercentageWidth?: boolean; +} + +export const Drawer = { + Container: ({children, className}: ContainerProps) => { + const [containerWidth, setContainerWidth] = React.useState(0); + const containerRef = React.useRef(null); + + React.useEffect(() => { + if (!containerRef.current) { + return undefined; + } + + const updateWidth = () => { + if (containerRef.current) { + setContainerWidth(containerRef.current.clientWidth); + } + }; + + // Set initial width + updateWidth(); + + // Update width on resize + const resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(containerRef.current); + + return () => { + if (containerRef.current) { + resizeObserver.disconnect(); + } + }; + }, []); + + return ( + + + {children} + + + ); + }, + + ItemWrapper: ({ + children, + renderDrawerContent, + isDrawerVisible, + onCloseDrawer, + drawerId, + storageKey, + defaultWidth, + direction, + className, + detectClickOutside, + isPercentageWidth, + }: ItemWrapperProps) => { + React.useEffect(() => { + return () => { + onCloseDrawer(); + }; + }, [onCloseDrawer]); + return ( + + {children} + + {renderDrawerContent()} + + + ); + }, +}; diff --git a/src/containers/Tenant/Diagnostics/Diagnostics.scss b/src/containers/Tenant/Diagnostics/Diagnostics.scss index 070d3d16c..2d296ddb0 100644 --- a/src/containers/Tenant/Diagnostics/Diagnostics.scss +++ b/src/containers/Tenant/Diagnostics/Diagnostics.scss @@ -8,7 +8,7 @@ height: 100%; &__header-wrapper { - padding: 0 20px 16px; + padding: 0 var(--g-spacing-5); background-color: var(--g-color-base-background); } @@ -39,7 +39,8 @@ flex-grow: 1; width: 100%; - padding: 0 20px; + height: 100%; + margin: var(--g-spacing-4) var(--g-spacing-5) 0 var(--g-spacing-5); .ydb-table-with-controls-layout { &__controls { diff --git a/src/containers/Tenant/Diagnostics/Diagnostics.tsx b/src/containers/Tenant/Diagnostics/Diagnostics.tsx index 5c69d3f28..72815d950 100644 --- a/src/containers/Tenant/Diagnostics/Diagnostics.tsx +++ b/src/containers/Tenant/Diagnostics/Diagnostics.tsx @@ -13,6 +13,7 @@ import type {AdditionalNodesProps, AdditionalTenantsProps} from '../../../types/ import type {EPathType} from '../../../types/api/schema'; import {cn} from '../../../utils/cn'; import {useTypedDispatch, useTypedSelector} from '../../../utils/hooks'; +import {Drawer} from '../../Drawer/Drawer'; import {Heatmap} from '../../Heatmap'; import {Nodes} from '../../Nodes/Nodes'; import {Operations} from '../../Operations'; @@ -194,9 +195,11 @@ function Diagnostics(props: DiagnosticsProps) { ) : null} {renderTabs()} - - {renderTabContent()} - + + + {renderTabContent()} + + ); } diff --git a/src/containers/Tenant/Diagnostics/TopQueries/QueryDetails/CopyLinkButton.tsx b/src/containers/Tenant/Diagnostics/TopQueries/QueryDetails/CopyLinkButton.tsx new file mode 100644 index 000000000..87a247228 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TopQueries/QueryDetails/CopyLinkButton.tsx @@ -0,0 +1,83 @@ +import React from 'react'; + +import {Link} from '@gravity-ui/icons'; +import type {ButtonProps, CopyToClipboardStatus} from '@gravity-ui/uikit'; +import {ActionTooltip, Button, CopyToClipboard, Icon} from '@gravity-ui/uikit'; + +import {cn} from '../../../../../utils/cn'; +import i18n from '../i18n'; + +import './QueryDetails.scss'; + +const b = cn('kv-query-details'); + +interface LinkButtonComponentProps extends ButtonProps { + size?: ButtonProps['size']; + hasTooltip?: boolean; + status: CopyToClipboardStatus; + closeDelay?: number; +} + +const DEFAULT_TIMEOUT = 1200; +const TOOLTIP_ANIMATION = 200; + +const LinkButtonComponent = (props: LinkButtonComponentProps) => { + const {size = 'm', hasTooltip = true, status, closeDelay, ...rest} = props; + + return ( + + + + + + + + ); +}; + +export interface CopyLinkButtonProps extends ButtonProps { + text: string; +} + +export function CopyLinkButton(props: CopyLinkButtonProps) { + const {text, ...buttonProps} = props; + + const timerIdRef = React.useRef(); + const [tooltipCloseDelay, setTooltipCloseDelay] = React.useState(undefined); + const [tooltipDisabled, setTooltipDisabled] = React.useState(false); + const timeout = DEFAULT_TIMEOUT; + + React.useEffect(() => window.clearTimeout(timerIdRef.current), []); + + const handleCopy = React.useCallback(() => { + setTooltipDisabled(false); + setTooltipCloseDelay(timeout); + + window.clearTimeout(timerIdRef.current); + + timerIdRef.current = window.setTimeout(() => { + setTooltipDisabled(true); + }, timeout - TOOLTIP_ANIMATION); + }, [timeout]); + + return ( + + {(status) => ( + + )} + + ); +} diff --git a/src/containers/Tenant/Diagnostics/TopQueries/QueryDetails/QueryDetails.scss b/src/containers/Tenant/Diagnostics/TopQueries/QueryDetails/QueryDetails.scss new file mode 100644 index 000000000..23dfaa569 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TopQueries/QueryDetails/QueryDetails.scss @@ -0,0 +1,71 @@ +@import '../../../../../styles/mixins.scss'; + +.kv-query-details { + display: flex; + flex-direction: column; + + height: 100%; + + color: var(--g-color-text-primary); + background-color: var(--g-color-base-background-dark); + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + + padding: var(--g-spacing-5) var(--g-spacing-6) 0 var(--g-spacing-6); + } + + &__title { + margin: 0; + + font-size: 16px; + font-weight: 500; + } + + &__actions { + display: flex; + gap: var(--g-spacing-2); + } + + &__content { + overflow: auto; + flex: 1; + + padding: var(--g-spacing-5) var(--g-spacing-4) var(--g-spacing-5) var(--g-spacing-6); + } + + &__query-header { + display: flex; + justify-content: space-between; + align-items: center; + + padding: var(--g-spacing-2) var(--g-spacing-3); + + border-bottom: 1px solid var(--g-color-line-generic); + } + + &__query-title { + font-size: 14px; + font-weight: 500; + } + + &__query-content { + position: relative; + + display: flex; + flex: 1; + flex-direction: column; + + margin-top: var(--g-spacing-5); + + border-radius: 4px; + background-color: var(--code-background-color); + } + + &__icon { + // prevent button icon from firing onMouseEnter/onFocus through parent button's handler + pointer-events: none; + } +} diff --git a/src/containers/Tenant/Diagnostics/TopQueries/QueryDetails/QueryDetails.tsx b/src/containers/Tenant/Diagnostics/TopQueries/QueryDetails/QueryDetails.tsx new file mode 100644 index 000000000..9870bd5f8 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TopQueries/QueryDetails/QueryDetails.tsx @@ -0,0 +1,77 @@ +import React from 'react'; + +import {Code, Xmark} from '@gravity-ui/icons'; +import {Button, Flex, Icon} from '@gravity-ui/uikit'; + +import Fullscreen from '../../../../../components/Fullscreen/Fullscreen'; +import type {InfoViewerItem} from '../../../../../components/InfoViewer'; +import {InfoViewer} from '../../../../../components/InfoViewer'; +import {YDBSyntaxHighlighter} from '../../../../../components/SyntaxHighlighter/YDBSyntaxHighlighter'; +import {cn} from '../../../../../utils/cn'; +import i18n from '../i18n'; + +import {CopyLinkButton} from './CopyLinkButton'; + +import './QueryDetails.scss'; + +const b = cn('kv-query-details'); + +interface QueryDetailsProps { + queryText: string; + infoItems: InfoViewerItem[]; + onClose: () => void; + onOpenInEditor: () => void; + getTopQueryUrl?: () => string; +} + +export const QueryDetails = ({ + queryText, + infoItems, + onClose, + onOpenInEditor, + getTopQueryUrl, +}: QueryDetailsProps) => { + const topQueryUrl = React.useMemo(() => getTopQueryUrl?.(), [getTopQueryUrl]); + + return ( + + + Query + + {topQueryUrl && } + + + + + + + + + + + + + + {i18n('query-details.query.title')} + + + + {i18n('query-details.open-in-editor')} + + + + + + + + ); +}; diff --git a/src/containers/Tenant/Diagnostics/TopQueries/QueryDetails/QueryDetailsDrawerContent.tsx b/src/containers/Tenant/Diagnostics/TopQueries/QueryDetails/QueryDetailsDrawerContent.tsx new file mode 100644 index 000000000..e7dc7e245 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TopQueries/QueryDetails/QueryDetailsDrawerContent.tsx @@ -0,0 +1,85 @@ +import React from 'react'; + +import {Button, Icon, Text} from '@gravity-ui/uikit'; +import {useHistory, useLocation} from 'react-router-dom'; + +import {parseQuery} from '../../../../../routes'; +import {changeUserInput, setIsDirty} from '../../../../../store/reducers/query/query'; +import { + TENANT_PAGE, + TENANT_PAGES_IDS, + TENANT_QUERY_TABS_ID, +} from '../../../../../store/reducers/tenant/constants'; +import type {KeyValueRow} from '../../../../../types/api/query'; +import {cn} from '../../../../../utils/cn'; +import {useTypedDispatch} from '../../../../../utils/hooks'; +import {TenantTabsGroups, getTenantPath} from '../../../TenantPages'; +import i18n from '../i18n'; +import {createQueryInfoItems} from '../utils'; + +import {QueryDetails} from './QueryDetails'; + +import CryCatIcon from '../../../../../assets/icons/cry-cat.svg'; + +const b = cn('kv-top-queries'); + +interface QueryDetailsDrawerContentProps { + row: KeyValueRow | null; + onClose: () => void; + getTopQueryUrl?: () => string; +} + +export const QueryDetailsDrawerContent = ({ + row, + onClose, + getTopQueryUrl, +}: QueryDetailsDrawerContentProps) => { + const dispatch = useTypedDispatch(); + const location = useLocation(); + const history = useHistory(); + + const handleOpenInEditor = React.useCallback(() => { + if (row) { + const input = row.QueryText as string; + dispatch(changeUserInput({input})); + dispatch(setIsDirty(false)); + + const queryParams = parseQuery(location); + + const queryPath = getTenantPath({ + ...queryParams, + [TENANT_PAGE]: TENANT_PAGES_IDS.query, + [TenantTabsGroups.queryTab]: TENANT_QUERY_TABS_ID.newQuery, + }); + + history.push(queryPath); + } + }, [dispatch, history, location, row]); + + if (row) { + return ( + + ); + } + + return ( + + + + {i18n('query-details.not-found.title')} + + + {i18n('query-details.not-found.description')} + + + {i18n('query-details.close')} + + + ); +}; diff --git a/src/containers/Tenant/Diagnostics/TopQueries/RunningQueriesData.tsx b/src/containers/Tenant/Diagnostics/TopQueries/RunningQueriesData.tsx index e63a8dbb0..4a9868a67 100644 --- a/src/containers/Tenant/Diagnostics/TopQueries/RunningQueriesData.tsx +++ b/src/containers/Tenant/Diagnostics/TopQueries/RunningQueriesData.tsx @@ -2,6 +2,7 @@ import React from 'react'; import type {Column} from '@gravity-ui/react-data-table'; import {TableColumnSetup} from '@gravity-ui/uikit'; +import {isEqual} from 'lodash'; import {ResponseError} from '../../../../components/Errors/ResponseError'; import {ResizeableDataTable} from '../../../../components/ResizeableDataTable/ResizeableDataTable'; @@ -13,7 +14,9 @@ import {cn} from '../../../../utils/cn'; import {useAutoRefreshInterval, useTypedSelector} from '../../../../utils/hooks'; import {useSelectedColumns} from '../../../../utils/hooks/useSelectedColumns'; import {parseQueryErrorToString} from '../../../../utils/query'; +import {Drawer} from '../../../Drawer/Drawer'; +import {QueryDetailsDrawerContent} from './QueryDetails/QueryDetailsDrawerContent'; import {getRunningQueriesColumns} from './columns/columns'; import { DEFAULT_RUNNING_QUERIES_COLUMNS, @@ -30,18 +33,19 @@ const b = cn('kv-top-queries'); interface RunningQueriesDataProps { tenantName: string; renderQueryModeControl: () => React.ReactNode; - onRowClick: (query: string) => void; handleTextSearchUpdate: (text: string) => void; } export const RunningQueriesData = ({ tenantName, renderQueryModeControl, - onRowClick, handleTextSearchUpdate, }: RunningQueriesDataProps) => { const [autoRefreshInterval] = useAutoRefreshInterval(); const filters = useTypedSelector((state) => state.executeTopQueries); + // Internal state for selected row + // null is reserved for not found state + const [selectedRow, setSelectedRow] = React.useState(undefined); // Get columns for running queries const columns: Column[] = React.useMemo(() => { @@ -69,44 +73,86 @@ export const RunningQueriesData = ({ {pollingInterval: autoRefreshInterval}, ); - const handleRowClick = (row: KeyValueRow) => { - return onRowClick(row.QueryText as string); - }; + const rows = data?.resultSets?.[0]?.result; + + const isDrawerVisible = selectedRow !== undefined; + + const handleCloseDetails = React.useCallback(() => { + setSelectedRow(undefined); + }, [setSelectedRow]); + + const renderDrawerContent = React.useCallback(() => { + if (!isDrawerVisible) { + return null; + } + return ; + }, [isDrawerVisible, selectedRow, handleCloseDetails]); + + const onRowClick = React.useCallback( + ( + row: KeyValueRow | null, + _index?: number, + event?: React.MouseEvent, + ) => { + event?.stopPropagation(); + setSelectedRow(row); + }, + [setSelectedRow], + ); + + const inputRef = React.useRef(null); + + React.useEffect(() => { + if (isDrawerVisible) { + inputRef.current?.blur(); + } + }, [isDrawerVisible]); return ( - - - {renderQueryModeControl()} - - - - - {error ? : null} - - b('row')} - sortOrder={tableSort} - onSort={handleTableSort} - /> - - + + + + {renderQueryModeControl()} + + + + + {error ? : null} + + b('row', {active: isEqual(row, selectedRow)})} + sortOrder={tableSort} + onSort={handleTableSort} + /> + + + ); }; diff --git a/src/containers/Tenant/Diagnostics/TopQueries/TopQueries.scss b/src/containers/Tenant/Diagnostics/TopQueries/TopQueries.scss index 7f7d2b2fa..7cc2a9334 100644 --- a/src/containers/Tenant/Diagnostics/TopQueries/TopQueries.scss +++ b/src/containers/Tenant/Diagnostics/TopQueries/TopQueries.scss @@ -14,6 +14,14 @@ &__row { cursor: pointer; + + &_active { + background-color: var(--g-color-base-selection); + + &:hover { + background: var(--g-color-base-selection-hover) !important; + } + } } &__query { @@ -32,4 +40,30 @@ text-overflow: ellipsis; } + + &__drawer { + margin-top: calc(-1 * var(--g-spacing-4)); + } + + &__empty-state-icon { + color: var(--g-color-text-primary); + } + + &__not-found-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + height: 100%; + padding: var(--g-spacing-5) 0; + } + + &__not-found-description { + margin-top: var(--g-spacing-2); + } + + &__not-found-close { + margin-top: var(--g-spacing-5); + } } diff --git a/src/containers/Tenant/Diagnostics/TopQueries/TopQueries.tsx b/src/containers/Tenant/Diagnostics/TopQueries/TopQueries.tsx index 1f430c473..a91cce888 100644 --- a/src/containers/Tenant/Diagnostics/TopQueries/TopQueries.tsx +++ b/src/containers/Tenant/Diagnostics/TopQueries/TopQueries.tsx @@ -2,23 +2,13 @@ import React from 'react'; import type {RadioButtonOption} from '@gravity-ui/uikit'; import {RadioButton} from '@gravity-ui/uikit'; -import {useHistory, useLocation} from 'react-router-dom'; import {StringParam, useQueryParam} from 'use-query-params'; import {z} from 'zod'; import type {DateRangeValues} from '../../../../components/DateRange'; -import {parseQuery} from '../../../../routes'; import {setTopQueriesFilters} from '../../../../store/reducers/executeTopQueries/executeTopQueries'; import type {TimeFrame} from '../../../../store/reducers/executeTopQueries/types'; -import {changeUserInput, setIsDirty} from '../../../../store/reducers/query/query'; -import { - TENANT_PAGE, - TENANT_PAGES_IDS, - TENANT_QUERY_TABS_ID, -} from '../../../../store/reducers/tenant/constants'; import {useTypedDispatch} from '../../../../utils/hooks'; -import {useChangeInputWithConfirmation} from '../../../../utils/hooks/withConfirmation/useChangeInputWithConfirmation'; -import {TenantTabsGroups, getTenantPath} from '../../TenantPages'; import {RunningQueriesData} from './RunningQueriesData'; import {TopQueriesData} from './TopQueriesData'; @@ -56,8 +46,6 @@ interface TopQueriesProps { export const TopQueries = ({tenantName}: TopQueriesProps) => { const dispatch = useTypedDispatch(); - const location = useLocation(); - const history = useHistory(); const [_queryMode = QueryModeIds.top, setQueryMode] = useQueryParam('queryMode', StringParam); const [_timeFrame = TimeFrameIds.hour, setTimeFrame] = useQueryParam('timeFrame', StringParam); @@ -66,26 +54,6 @@ export const TopQueries = ({tenantName}: TopQueriesProps) => { const isTopQueries = queryMode === QueryModeIds.top; - const applyRowClick = React.useCallback( - (input: string) => { - dispatch(changeUserInput({input})); - dispatch(setIsDirty(false)); - - const queryParams = parseQuery(location); - - const queryPath = getTenantPath({ - ...queryParams, - [TENANT_PAGE]: TENANT_PAGES_IDS.query, - [TenantTabsGroups.queryTab]: TENANT_QUERY_TABS_ID.newQuery, - }); - - history.push(queryPath); - }, - [dispatch, history, location], - ); - - const onRowClick = useChangeInputWithConfirmation(applyRowClick); - const handleTextSearchUpdate = (text: string) => { dispatch(setTopQueriesFilters({text})); }; @@ -109,7 +77,6 @@ export const TopQueries = ({tenantName}: TopQueriesProps) => { tenantName={tenantName} timeFrame={timeFrame} renderQueryModeControl={renderQueryModeControl} - onRowClick={onRowClick} handleTimeFrameChange={handleTimeFrameChange} handleDateRangeChange={handleDateRangeChange} handleTextSearchUpdate={handleTextSearchUpdate} @@ -118,7 +85,6 @@ export const TopQueries = ({tenantName}: TopQueriesProps) => { ); diff --git a/src/containers/Tenant/Diagnostics/TopQueries/TopQueriesData.tsx b/src/containers/Tenant/Diagnostics/TopQueries/TopQueriesData.tsx index debd299a6..020237966 100644 --- a/src/containers/Tenant/Diagnostics/TopQueries/TopQueriesData.tsx +++ b/src/containers/Tenant/Diagnostics/TopQueries/TopQueriesData.tsx @@ -2,6 +2,7 @@ import React from 'react'; import type {Column} from '@gravity-ui/react-data-table'; import {Select, TableColumnSetup} from '@gravity-ui/uikit'; +import {isEqual} from 'lodash'; import type {DateRangeValues} from '../../../../components/DateRange'; import {DateRange} from '../../../../components/DateRange'; @@ -16,7 +17,9 @@ import {cn} from '../../../../utils/cn'; import {useAutoRefreshInterval, useTypedSelector} from '../../../../utils/hooks'; import {useSelectedColumns} from '../../../../utils/hooks/useSelectedColumns'; import {parseQueryErrorToString} from '../../../../utils/query'; +import {Drawer} from '../../../Drawer/Drawer'; +import {QueryDetailsDrawerContent} from './QueryDetails/QueryDetailsDrawerContent'; import {getTopQueriesColumns} from './columns/columns'; import { DEFAULT_TOP_QUERIES_COLUMNS, @@ -26,8 +29,11 @@ import { TOP_QUERIES_SELECTED_COLUMNS_LS_KEY, } from './columns/constants'; import {DEFAULT_TIME_FILTER_VALUE, TIME_FRAME_OPTIONS} from './constants'; +import {useGetSelectedRowTableSort} from './hooks/useGetSelectedRowTableSort'; +import {useSetSelectedTopQueryRowFromParams} from './hooks/useSetSelectedTopQueryRowFromParams'; import i18n from './i18n'; import {TOP_QUERIES_TABLE_SETTINGS, useTopQueriesSort} from './utils'; +import {generateShareableUrl} from './utils/generateShareableUrl'; const b = cn('kv-top-queries'); @@ -35,7 +41,6 @@ interface TopQueriesDataProps { tenantName: string; timeFrame: TimeFrame; renderQueryModeControl: () => React.ReactNode; - onRowClick: (query: string) => void; handleTimeFrameChange: (value: string[]) => void; handleDateRangeChange: (value: DateRangeValues) => void; handleTextSearchUpdate: (text: string) => void; @@ -45,13 +50,15 @@ export const TopQueriesData = ({ tenantName, timeFrame, renderQueryModeControl, - onRowClick, handleTimeFrameChange, handleDateRangeChange, handleTextSearchUpdate, }: TopQueriesDataProps) => { const [autoRefreshInterval] = useAutoRefreshInterval(); const filters = useTypedSelector((state) => state.executeTopQueries); + // Internal state for selected row + // null is reserved for not found state + const [selectedRow, setSelectedRow] = React.useState(undefined); // Get columns for top queries const columns: Column[] = React.useMemo(() => { @@ -67,9 +74,9 @@ export const TopQueriesData = ({ REQUIRED_TOP_QUERIES_COLUMNS, ); - const {tableSort, handleTableSort, backendSort} = useTopQueriesSort(); - - const {currentData, data, isFetching, isLoading, error} = topQueriesApi.useGetTopQueriesQuery( + const initialTableSort = useGetSelectedRowTableSort(); + const {tableSort, handleTableSort, backendSort} = useTopQueriesSort(initialTableSort); + const {currentData, isFetching, isLoading, error} = topQueriesApi.useGetTopQueriesQuery( { database: tenantName, filters, @@ -79,55 +86,111 @@ export const TopQueriesData = ({ {pollingInterval: autoRefreshInterval}, ); - const handleRowClick = (row: KeyValueRow) => { - return onRowClick(row.QueryText as string); - }; + const rows = currentData?.resultSets?.[0]?.result; + useSetSelectedTopQueryRowFromParams(setSelectedRow, rows); + + const handleCloseDetails = React.useCallback(() => { + setSelectedRow(undefined); + }, [setSelectedRow]); + + const isDrawerVisible = selectedRow !== undefined; + + const getTopQueryUrl = React.useCallback(() => { + if (selectedRow) { + return generateShareableUrl(selectedRow, tableSort); + } + return ''; + }, [selectedRow, tableSort]); + + const renderDrawerContent = React.useCallback(() => { + if (!isDrawerVisible) { + return null; + } + return ( + + ); + }, [isDrawerVisible, selectedRow, handleCloseDetails, getTopQueryUrl]); + + const onRowClick = React.useCallback( + ( + row: KeyValueRow | null, + _index?: number, + event?: React.MouseEvent, + ) => { + event?.stopPropagation(); + setSelectedRow(row); + }, + [setSelectedRow], + ); + + const inputRef = React.useRef(null); + + React.useEffect(() => { + if (isDrawerVisible) { + inputRef.current?.blur(); + } + }, [isDrawerVisible]); return ( - - - {renderQueryModeControl()} - - - - - - - {error ? : null} - - b('row')} - sortOrder={tableSort} - onSort={handleTableSort} - /> - - + + + + {renderQueryModeControl()} + + + + + + + {error ? : null} + + b('row', {active: isEqual(row, selectedRow)})} + sortOrder={tableSort} + onSort={handleTableSort} + /> + + + ); }; diff --git a/src/containers/Tenant/Diagnostics/TopQueries/columns/columns.tsx b/src/containers/Tenant/Diagnostics/TopQueries/columns/columns.tsx index b33825d71..760a0790b 100644 --- a/src/containers/Tenant/Diagnostics/TopQueries/columns/columns.tsx +++ b/src/containers/Tenant/Diagnostics/TopQueries/columns/columns.tsx @@ -152,7 +152,13 @@ export function getTenantOverviewTopQueriesColumns() { return [queryHashColumn, oneLineQueryTextColumn, cpuTimeUsColumn]; } export function getRunningQueriesColumns() { - const columns = [userSIDColumn, queryStartColumn, queryTextColumn, applicationColumn]; + const columns = [ + queryHashColumn, + userSIDColumn, + queryStartColumn, + queryTextColumn, + applicationColumn, + ]; return columns.map((column) => ({ ...column, diff --git a/src/containers/Tenant/Diagnostics/TopQueries/columns/constants.ts b/src/containers/Tenant/Diagnostics/TopQueries/columns/constants.ts index eef617545..b191b9869 100644 --- a/src/containers/Tenant/Diagnostics/TopQueries/columns/constants.ts +++ b/src/containers/Tenant/Diagnostics/TopQueries/columns/constants.ts @@ -37,6 +37,7 @@ export const DEFAULT_TOP_QUERIES_COLUMNS: QueriesColumnId[] = [ export const REQUIRED_TOP_QUERIES_COLUMNS: QueriesColumnId[] = ['CPUTime', 'QueryText']; export const DEFAULT_RUNNING_QUERIES_COLUMNS: QueriesColumnId[] = [ + 'QueryHash', 'UserSID', 'QueryStartAt', 'QueryText', diff --git a/src/containers/Tenant/Diagnostics/TopQueries/hooks/useGetSelectedRowTableSort.ts b/src/containers/Tenant/Diagnostics/TopQueries/hooks/useGetSelectedRowTableSort.ts new file mode 100644 index 000000000..5c91927f7 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TopQueries/hooks/useGetSelectedRowTableSort.ts @@ -0,0 +1,13 @@ +import type {SortOrder} from '@gravity-ui/react-data-table'; +import {StringParam, useQueryParams} from 'use-query-params'; + +export function useGetSelectedRowTableSort(): SortOrder[] | undefined { + const [queryParams] = useQueryParams({ + selectedRow: StringParam, + }); + const searchParamsQuery: {tableSort?: SortOrder[]} = queryParams.selectedRow + ? JSON.parse(decodeURIComponent(queryParams.selectedRow)) + : {}; + + return searchParamsQuery.tableSort; +} diff --git a/src/containers/Tenant/Diagnostics/TopQueries/hooks/useSetSelectedTopQueryRowFromParams.ts b/src/containers/Tenant/Diagnostics/TopQueries/hooks/useSetSelectedTopQueryRowFromParams.ts new file mode 100644 index 000000000..a1c20cf0b --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TopQueries/hooks/useSetSelectedTopQueryRowFromParams.ts @@ -0,0 +1,53 @@ +import React from 'react'; + +import {StringParam, useQueryParams} from 'use-query-params'; + +import type {KeyValueRow} from '../../../../../types/api/query'; +import {getTopQueryRowQueryParams} from '../utils/getTopQueryRowQueryParams'; + +export interface SearchParamsQueryParams { + rank?: string; + intervalEnd?: string; + endTime?: string; + queryHash?: string; +} + +export function useSetSelectedTopQueryRowFromParams( + setSelectedRow: (row: KeyValueRow | null) => void, + rows?: KeyValueRow[] | null, +) { + const [queryParams, setQueryParams] = useQueryParams({ + selectedRow: StringParam, + }); + + // Handle initialization from URL params + React.useEffect(() => { + if (rows && queryParams.selectedRow) { + const searchParamsQuery: SearchParamsQueryParams = JSON.parse( + decodeURIComponent(queryParams.selectedRow), + ); + const matchedRow = rows.find((row) => { + const params = getTopQueryRowQueryParams(row); + return ( + params.rank === searchParamsQuery.rank && + params.intervalEnd === searchParamsQuery.intervalEnd && + params.endTime === searchParamsQuery.endTime && + searchParamsQuery.queryHash === params.queryHash + ); + }); + + if (matchedRow) { + setSelectedRow(matchedRow); + } else { + // If we had a selectedRow in URL but couldn't find a matching row, + // explicitly set selectedRow to null to indicate empty state + setSelectedRow(null); + } + + // Clear URL params after using them + setQueryParams({selectedRow: undefined}); + } + }, [queryParams.selectedRow, setQueryParams, rows, setSelectedRow]); + + return null; +} diff --git a/src/containers/Tenant/Diagnostics/TopQueries/i18n/en.json b/src/containers/Tenant/Diagnostics/TopQueries/i18n/en.json index 5dbcec47d..7771e1b4c 100644 --- a/src/containers/Tenant/Diagnostics/TopQueries/i18n/en.json +++ b/src/containers/Tenant/Diagnostics/TopQueries/i18n/en.json @@ -4,5 +4,15 @@ "mode_top": "Top", "mode_running": "Running", "timeframe_hour": "Per hour", - "timeframe_minute": "Per minute" + "timeframe_minute": "Per minute", + "query-details.title": "Query Details", + "query-details.open-in-editor": "Open in Editor", + "query-details.close": "Close", + "query-details.query.title": "Query Text", + "query-details.not-found.title": "Not found", + "query-details.not-found.description": "This query no longer exists", + "query-not-found": "Query Not Found", + "query-not-found.description": "The selected query is no longer available in the current dataset", + "query-details.copy-link": "Copy link to query", + "query-details.link-copied": "Copied to clipboard" } diff --git a/src/containers/Tenant/Diagnostics/TopQueries/utils.ts b/src/containers/Tenant/Diagnostics/TopQueries/utils.ts index 35660e3b7..d93cb3006 100644 --- a/src/containers/Tenant/Diagnostics/TopQueries/utils.ts +++ b/src/containers/Tenant/Diagnostics/TopQueries/utils.ts @@ -1,9 +1,14 @@ import React from 'react'; -import type {Settings} from '@gravity-ui/react-data-table'; +import type {Settings, SortOrder} from '@gravity-ui/react-data-table'; import DataTable from '@gravity-ui/react-data-table'; +import type {InfoViewerItem} from '../../../../components/InfoViewer'; +import type {KeyValueRow} from '../../../../types/api/query'; +import {formatDateTime, formatNumber} from '../../../../utils/dataFormatters/dataFormatters'; +import {generateHash} from '../../../../utils/generateHash'; import {prepareBackendSortFieldsFromTableSort, useTableSort} from '../../../../utils/hooks'; +import {formatToMs, parseUsToMs} from '../../../../utils/timeParsers'; import {QUERY_TABLE_SETTINGS} from '../../utils/constants'; import { @@ -11,16 +16,17 @@ import { getRunningQueriesColumnSortField, getTopQueriesColumnSortField, } from './columns/constants'; +import columnsI18n from './columns/i18n'; export const TOP_QUERIES_TABLE_SETTINGS: Settings = { ...QUERY_TABLE_SETTINGS, disableSortReset: true, }; -export function useTopQueriesSort() { +export function useTopQueriesSort(initialSort?: SortOrder[]) { const [tableSort, handleTableSort] = useTableSort({ - initialSortColumn: QUERIES_COLUMNS_IDS.CPUTime, - initialSortOrder: DataTable.DESCENDING, + initialSortColumn: initialSort?.[0]?.columnId || QUERIES_COLUMNS_IDS.CPUTime, + initialSortOrder: initialSort?.[0]?.order || DataTable.DESCENDING, multiple: true, fixedOrderType: DataTable.DESCENDING, }); @@ -35,6 +41,82 @@ export function useTopQueriesSort() { }; } +export function createQueryInfoItems(data: KeyValueRow): InfoViewerItem[] { + const items: InfoViewerItem[] = []; + + if (data.QueryText) { + items.push({ + label: columnsI18n('query-hash'), + value: generateHash(String(data.QueryText)), + }); + } + + if (data.CPUTimeUs !== undefined) { + items.push({ + label: columnsI18n('cpu-time'), + value: formatToMs(parseUsToMs(data.CPUTimeUs ?? undefined)), + }); + } + + if (data.Duration !== undefined) { + items.push({ + label: columnsI18n('duration'), + value: formatToMs(parseUsToMs(data.Duration ?? undefined)), + }); + } + + if (data.ReadBytes !== undefined) { + items.push({ + label: columnsI18n('read-bytes'), + value: formatNumber(data.ReadBytes), + }); + } + + if (data.RequestUnits !== undefined) { + items.push({ + label: columnsI18n('request-units'), + value: formatNumber(data.RequestUnits), + }); + } + + if (data.EndTime) { + items.push({ + label: columnsI18n('end-time'), + value: formatDateTime(new Date(data.EndTime as string).getTime()), + }); + } + + if (data.ReadRows !== undefined) { + items.push({ + label: columnsI18n('read-rows'), + value: formatNumber(data.ReadRows), + }); + } + + if (data.UserSID) { + items.push({ + label: columnsI18n('user'), + value: data.UserSID, + }); + } + + if (data.ApplicationName) { + items.push({ + label: columnsI18n('application'), + value: data.ApplicationName, + }); + } + + if (data.QueryStartAt) { + items.push({ + label: columnsI18n('start-time'), + value: formatDateTime(new Date(data.QueryStartAt as string).getTime()), + }); + } + + return items; +} + export function useRunningQueriesSort() { const [tableSort, handleTableSort] = useTableSort({ initialSortColumn: QUERIES_COLUMNS_IDS.QueryStartAt, diff --git a/src/containers/Tenant/Diagnostics/TopQueries/utils/generateShareableUrl.ts b/src/containers/Tenant/Diagnostics/TopQueries/utils/generateShareableUrl.ts new file mode 100644 index 000000000..2a4c52761 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TopQueries/utils/generateShareableUrl.ts @@ -0,0 +1,41 @@ +import type {SortOrder} from '@gravity-ui/react-data-table'; + +import type {KeyValueRow} from '../../../../../types/api/query'; + +import {getTopQueryRowQueryParams} from './getTopQueryRowQueryParams'; + +/** + * Generates a shareable URL with query parameters for a top query row + * @param row The top query row data + * @param tableSort Optional sort configuration to include in the URL + * @returns A shareable URL string with the row's parameters and sort order encoded in the URL + */ +export function generateShareableUrl(row: KeyValueRow, tableSort?: SortOrder[]): string { + const params = getTopQueryRowQueryParams(row); + + // Get current URL without query parameters + const url = new URL(window.location.href); + + // Create URLSearchParams object from current search params + const searchParams = new URLSearchParams(url.search); + + // Add our parameters + // Set a single selectedRow parameter with all query parameters + searchParams.set( + 'selectedRow', + encodeURIComponent( + JSON.stringify({ + rank: params.rank || undefined, + intervalEnd: params.intervalEnd || undefined, + endTime: params.endTime || undefined, + queryHash: params.queryHash || undefined, + tableSort: tableSort || undefined, // Include the table sort order + }), + ), + ); + + // Update URL search params + url.search = searchParams.toString(); + + return url.toString(); +} diff --git a/src/containers/Tenant/Diagnostics/TopQueries/utils/getTopQueryRowQueryParams.ts b/src/containers/Tenant/Diagnostics/TopQueries/utils/getTopQueryRowQueryParams.ts new file mode 100644 index 000000000..c764985eb --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TopQueries/utils/getTopQueryRowQueryParams.ts @@ -0,0 +1,19 @@ +import type {KeyValueRow} from '../../../../../types/api/query'; +import {generateHash} from '../../../../../utils/generateHash'; +import type {SearchParamsQueryParams} from '../hooks/useSetSelectedTopQueryRowFromParams'; + +/** + * Extract query parameters from a row for use in URL search params + * @param row The top query row data + * @returns Parameters for URL search params + */ +export function getTopQueryRowQueryParams(row: KeyValueRow): SearchParamsQueryParams { + const queryHash = generateHash(String(row.QueryText)); + + return { + rank: String(row.Rank), + intervalEnd: String(row.IntervalEnd), + endTime: String(row.EndTime), + queryHash, + }; +} diff --git a/src/store/reducers/executeTopQueries/executeTopQueries.ts b/src/store/reducers/executeTopQueries/executeTopQueries.ts index aa50b3568..f1f2bfddc 100644 --- a/src/store/reducers/executeTopQueries/executeTopQueries.ts +++ b/src/store/reducers/executeTopQueries/executeTopQueries.ts @@ -66,7 +66,8 @@ const getQueryText = ( ReadBytes, UserSID, Duration, - RequestUnits + RequestUnits, + Rank FROM \`${tableName}\` WHERE ${filterConditions || 'true'} AND QueryText NOT LIKE '%${QUERY_TECHNICAL_MARK}%' ${orderBy} @@ -90,7 +91,8 @@ SELECT UserSID, QueryStartAt, Query as QueryText, - ApplicationName + ApplicationName, + SessionId FROM \`.sys/query_sessions\` WHERE ${filterConditions || 'true'} AND Query NOT LIKE '%${QUERY_TECHNICAL_MARK}%' AND QueryStartAt is not null ${orderBy}