diff --git a/frontend/src/components/CeleryTask/CeleryTaskDetail/CeleryTaskDetail.tsx b/frontend/src/components/CeleryTask/CeleryTaskDetail/CeleryTaskDetail.tsx index 8f358536cb..df6fc1aa43 100644 --- a/frontend/src/components/CeleryTask/CeleryTaskDetail/CeleryTaskDetail.tsx +++ b/frontend/src/components/CeleryTask/CeleryTaskDetail/CeleryTaskDetail.tsx @@ -10,10 +10,11 @@ import { X } from 'lucide-react'; import { useState } from 'react'; import { Widgets } from 'types/api/dashboard/getAll'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; +import { DataSource } from 'types/common/queryBuilder'; import CeleryTaskGraph from '../CeleryTaskGraph/CeleryTaskGraph'; import { createFiltersFromData } from '../CeleryUtils'; -import { useNavigateToTraces } from '../useNavigateToTraces'; +import { useNavigateToExplorer } from '../useNavigateToExplorer'; export type CeleryTaskData = { entity: string; @@ -55,7 +56,7 @@ export default function CeleryTaskDetail({ const startTime = taskData.timeRange[0]; const endTime = taskData.timeRange[1]; - const navigateToTrace = useNavigateToTraces(); + const navigateToExplorer = useNavigateToExplorer(); return ( - onGraphClick(celerySlowestTasksTableWidgetData, ...args) + onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => + onGraphClick( + celerySlowestTasksTableWidgetData, + xValue, + yValue, + mouseX, + mouseY, + data, + ) } customSeries={getCustomSeries} dataAvailable={checkIfDataExists} @@ -198,8 +205,15 @@ function CeleryTaskBar({ headerMenuList={[...ViewMenuAction]} onDragSelect={onDragSelect} isQueryEnabled={queryEnabled} - onClickHandler={(...args): void => - onGraphClick(celeryFailedTasksTableWidgetData, ...args) + onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => + onGraphClick( + celeryFailedTasksTableWidgetData, + xValue, + yValue, + mouseX, + mouseY, + data, + ) } customSeries={getCustomSeries} /> @@ -210,8 +224,15 @@ function CeleryTaskBar({ headerMenuList={[...ViewMenuAction]} onDragSelect={onDragSelect} isQueryEnabled={queryEnabled} - onClickHandler={(...args): void => - onGraphClick(celeryRetryTasksTableWidgetData, ...args) + onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => + onGraphClick( + celeryRetryTasksTableWidgetData, + xValue, + yValue, + mouseX, + mouseY, + data, + ) } customSeries={getCustomSeries} /> @@ -222,8 +243,15 @@ function CeleryTaskBar({ headerMenuList={[...ViewMenuAction]} onDragSelect={onDragSelect} isQueryEnabled={queryEnabled} - onClickHandler={(...args): void => - onGraphClick(celerySuccessTasksTableWidgetData, ...args) + onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => + onGraphClick( + celerySuccessTasksTableWidgetData, + xValue, + yValue, + mouseX, + mouseY, + data, + ) } customSeries={getCustomSeries} /> diff --git a/frontend/src/components/CeleryTask/CeleryTaskGraph/CeleryTaskLatencyGraph.tsx b/frontend/src/components/CeleryTask/CeleryTaskGraph/CeleryTaskLatencyGraph.tsx index b45299093b..8ff4b16351 100644 --- a/frontend/src/components/CeleryTask/CeleryTaskGraph/CeleryTaskLatencyGraph.tsx +++ b/frontend/src/components/CeleryTask/CeleryTaskGraph/CeleryTaskLatencyGraph.tsx @@ -18,6 +18,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { useHistory, useLocation } from 'react-router-dom'; import { UpdateTimeInterval } from 'store/actions'; import { AppState } from 'store/reducers'; +import { DataSource } from 'types/common/queryBuilder'; import { GlobalReducer } from 'types/reducer/globalTime'; import { @@ -25,7 +26,7 @@ import { createFiltersFromData, getFiltersFromQueryParams, } from '../CeleryUtils'; -import { useNavigateToTraces } from '../useNavigateToTraces'; +import { useNavigateToExplorer } from '../useNavigateToExplorer'; import { celeryTaskLatencyWidgetData } from './CeleryTaskGraphUtils'; interface TabData { @@ -145,15 +146,21 @@ function CeleryTaskLatencyGraph({ [handleSetTimeStamp], ); - const navigateToTraces = useNavigateToTraces(); + const navigateToExplorer = useNavigateToExplorer(); const goToTraces = useCallback(() => { const { start, end } = getStartAndEndTimesInMilliseconds(selectedTimeStamp); const filters = createFiltersFromData({ [entityData?.entity as string]: entityData?.value, }); - navigateToTraces(filters, start, end, true); - }, [entityData, navigateToTraces, selectedTimeStamp]); + navigateToExplorer({ + filters, + dataSource: DataSource.TRACES, + startTime: start, + endTime: end, + sameTab: true, + }); + }, [entityData, navigateToExplorer, selectedTimeStamp]); return ( void { + const { currentQuery } = useQueryBuilder(); + const { minTime, maxTime } = useSelector( + (state) => state.globalTime, + ); + + const prepareQuery = useCallback( + (selectedFilters: TagFilterItem[], dataSource: DataSource): Query => ({ + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: currentQuery.builder.queryData + .map((item) => ({ + ...item, + dataSource, + aggregateOperator: MetricAggregateOperator.NOOP, + filters: { + ...item.filters, + items: selectedFilters, + }, + groupBy: [], + disabled: false, + })) + .slice(0, 1), + queryFormulas: [], + }, + }), + [currentQuery], + ); + + const { getUpdatedQuery } = useUpdatedQuery(); + const { selectedDashboard } = useDashboard(); + const { notifications } = useNotifications(); + + return useCallback( + async (props: NavigateToExplorerProps): Promise => { + const { + filters, + dataSource, + startTime, + endTime, + sameTab, + shouldResolveQuery, + } = props; + const urlParams = new URLSearchParams(); + if (startTime && endTime) { + urlParams.set(QueryParams.startTime, startTime.toString()); + urlParams.set(QueryParams.endTime, endTime.toString()); + } else { + urlParams.set(QueryParams.startTime, (minTime / 1000000).toString()); + urlParams.set(QueryParams.endTime, (maxTime / 1000000).toString()); + } + + let preparedQuery = prepareQuery(filters, dataSource); + + if (shouldResolveQuery) { + await getUpdatedQuery({ + widgetConfig: { + query: preparedQuery, + panelTypes: PANEL_TYPES.TIME_SERIES, + timePreferance: 'GLOBAL_TIME', + }, + selectedDashboard, + }) + .then((query) => { + preparedQuery = query; + }) + .catch(() => { + notifications.error({ + message: 'Unable to resolve variables', + }); + }); + } + + const JSONCompositeQuery = encodeURIComponent(JSON.stringify(preparedQuery)); + + const basePath = + dataSource === DataSource.TRACES + ? ROUTES.TRACES_EXPLORER + : ROUTES.LOGS_EXPLORER; + const newExplorerPath = `${basePath}?${urlParams.toString()}&${ + QueryParams.compositeQuery + }=${JSONCompositeQuery}`; + + window.open(newExplorerPath, sameTab ? '_self' : '_blank'); + }, + [ + prepareQuery, + minTime, + maxTime, + getUpdatedQuery, + selectedDashboard, + notifications, + ], + ); +} diff --git a/frontend/src/components/CeleryTask/useNavigateToTraces.ts b/frontend/src/components/CeleryTask/useNavigateToTraces.ts deleted file mode 100644 index 4d5885af89..0000000000 --- a/frontend/src/components/CeleryTask/useNavigateToTraces.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { QueryParams } from 'constants/query'; -import ROUTES from 'constants/routes'; -import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; -import { useCallback } from 'react'; -import { useSelector } from 'react-redux'; -import { AppState } from 'store/reducers'; -import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; -import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder'; -import { GlobalReducer } from 'types/reducer/globalTime'; - -export function useNavigateToTraces(): ( - filters: TagFilterItem[], - startTime?: number, - endTime?: number, - sameTab?: boolean, -) => void { - const { currentQuery } = useQueryBuilder(); - const { minTime, maxTime } = useSelector( - (state) => state.globalTime, - ); - - const prepareQuery = useCallback( - (selectedFilters: TagFilterItem[]): Query => ({ - ...currentQuery, - builder: { - ...currentQuery.builder, - queryData: currentQuery.builder.queryData.map((item) => ({ - ...item, - dataSource: DataSource.TRACES, - aggregateOperator: MetricAggregateOperator.NOOP, - filters: { - ...item.filters, - items: selectedFilters, - }, - })), - }, - }), - [currentQuery], - ); - - return useCallback( - ( - filters: TagFilterItem[], - startTime?: number, - endTime?: number, - sameTab?: boolean, - ): void => { - const urlParams = new URLSearchParams(); - if (startTime && endTime) { - urlParams.set(QueryParams.startTime, startTime.toString()); - urlParams.set(QueryParams.endTime, endTime.toString()); - } else { - urlParams.set(QueryParams.startTime, (minTime / 1000000).toString()); - urlParams.set(QueryParams.endTime, (maxTime / 1000000).toString()); - } - - const JSONCompositeQuery = encodeURIComponent( - JSON.stringify(prepareQuery(filters)), - ); - - const newTraceExplorerPath = `${ - ROUTES.TRACES_EXPLORER - }?${urlParams.toString()}&${ - QueryParams.compositeQuery - }=${JSONCompositeQuery}`; - - window.open(newTraceExplorerPath, sameTab ? '_self' : '_blank'); - }, - [minTime, maxTime, prepareQuery], - ); -} diff --git a/frontend/src/container/GridCardLayout/GridCard/FullView/index.tsx b/frontend/src/container/GridCardLayout/GridCard/FullView/index.tsx index c9ab985f7f..03e01c01c2 100644 --- a/frontend/src/container/GridCardLayout/GridCard/FullView/index.tsx +++ b/frontend/src/container/GridCardLayout/GridCard/FullView/index.tsx @@ -48,6 +48,8 @@ function FullView({ tableProcessedDataRef, isDependedDataLoaded = false, onToggleModelHandler, + onClickHandler, + setCurrentGraphRef, }: FullViewProps): JSX.Element { const { safeNavigate } = useSafeNavigate(); const { selectedTime: globalSelectedTime } = useSelector< @@ -60,6 +62,10 @@ function FullView({ const fullViewRef = useRef(null); + useEffect(() => { + setCurrentGraphRef(fullViewRef); + }, [setCurrentGraphRef]); + const { selectedDashboard, isDashboardLocked } = useDashboard(); const getSelectedTime = useCallback( @@ -249,6 +255,7 @@ function FullView({ onDragSelect={onDragSelect} tableProcessedDataRef={tableProcessedDataRef} searchTerm={searchTerm} + onClickHandler={onClickHandler} /> diff --git a/frontend/src/container/GridCardLayout/GridCard/FullView/types.ts b/frontend/src/container/GridCardLayout/GridCard/FullView/types.ts index c4e638d717..c75129a275 100644 --- a/frontend/src/container/GridCardLayout/GridCard/FullView/types.ts +++ b/frontend/src/container/GridCardLayout/GridCard/FullView/types.ts @@ -4,7 +4,7 @@ import { UplotProps } from 'components/Uplot/Uplot'; import { PANEL_TYPES } from 'constants/queryBuilder'; import { RowData } from 'lib/query/createTableColumnsFromQuery'; import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin'; -import { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import { Dispatch, MutableRefObject, RefObject, SetStateAction } from 'react'; import { Widgets } from 'types/api/dashboard/getAll'; import uPlot from 'uplot'; @@ -57,6 +57,7 @@ export interface FullViewProps { yAxisUnit?: string; isDependedDataLoaded?: boolean; onToggleModelHandler?: GraphManagerProps['onToggleModelHandler']; + setCurrentGraphRef: Dispatch | null>>; } export interface GraphManagerProps extends UplotProps { diff --git a/frontend/src/container/GridCardLayout/GridCard/WidgetGraphComponent.tsx b/frontend/src/container/GridCardLayout/GridCard/WidgetGraphComponent.tsx index c4b9f09ac4..26446c9e6a 100644 --- a/frontend/src/container/GridCardLayout/GridCard/WidgetGraphComponent.tsx +++ b/frontend/src/container/GridCardLayout/GridCard/WidgetGraphComponent.tsx @@ -2,6 +2,7 @@ import '../GridCardLayout.styles.scss'; import { Skeleton, Typography } from 'antd'; import cx from 'classnames'; +import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer'; import { ToggleGraphProps } from 'components/Graph/types'; import { SOMETHING_WENT_WRONG } from 'constants/api'; import { QueryParams } from 'constants/query'; @@ -17,6 +18,7 @@ import { RowData } from 'lib/query/createTableColumnsFromQuery'; import { useDashboard } from 'providers/Dashboard/Dashboard'; import { Dispatch, + RefObject, SetStateAction, useCallback, useEffect, @@ -25,13 +27,16 @@ import { } from 'react'; import { useLocation } from 'react-router-dom'; import { Dashboard } from 'types/api/dashboard/getAll'; +import { DataSource } from 'types/common/queryBuilder'; import { v4 } from 'uuid'; +import { useGraphClickToShowButton } from '../useGraphClickToShowButton'; +import useNavigateToExplorerPages from '../useNavigateToExplorerPages'; import WidgetHeader from '../WidgetHeader'; import FullView from './FullView'; import { Modal } from './styles'; import { WidgetGraphComponentProps } from './types'; -import { getLocalStorageGraphVisibilityState } from './utils'; +import { getLocalStorageGraphVisibilityState, handleGraphClick } from './utils'; function WidgetGraphComponent({ widget, @@ -67,6 +72,11 @@ function WidgetGraphComponent({ ); const graphRef = useRef(null); + const [ + currentGraphRef, + setCurrentGraphRef, + ] = useState | null>(graphRef); + useEffect(() => { if (!lineChartRef.current) return; @@ -78,6 +88,8 @@ function WidgetGraphComponent({ const tableProcessedDataRef = useRef([]); + const navigateToExplorerPages = useNavigateToExplorerPages(); + const { setLayouts, selectedDashboard, setSelectedDashboard } = useDashboard(); const onToggleModal = useCallback( @@ -230,6 +242,40 @@ function WidgetGraphComponent({ const [searchTerm, setSearchTerm] = useState(''); + const graphClick = useGraphClickToShowButton({ + graphRef: currentGraphRef?.current ? currentGraphRef : graphRef, + isButtonEnabled: (widget?.query?.builder?.queryData ?? []).some( + (q) => + q.dataSource === DataSource.TRACES || q.dataSource === DataSource.LOGS, + ), + buttonClassName: 'view-onclick-show-button', + }); + + const navigateToExplorer = useNavigateToExplorer(); + + const graphClickHandler = ( + xValue: number, + yValue: number, + mouseX: number, + mouseY: number, + metric?: { [key: string]: string }, + queryData?: { queryName: string; inFocusOrNot: boolean }, + ): void => { + handleGraphClick({ + xValue, + yValue, + mouseX, + mouseY, + metric, + queryData, + widget, + navigateToExplorerPages, + navigateToExplorer, + notifications, + graphClick, + }); + }; + return (
@@ -322,7 +370,7 @@ function WidgetGraphComponent({ setRequestData={setRequestData} setGraphVisibility={setGraphVisibility} graphVisibility={graphVisibility} - onClickHandler={onClickHandler} + onClickHandler={onClickHandler ?? graphClickHandler} onDragSelect={onDragSelect} tableProcessedDataRef={tableProcessedDataRef} customTooltipElement={customTooltipElement} diff --git a/frontend/src/container/GridCardLayout/GridCard/utils.ts b/frontend/src/container/GridCardLayout/GridCard/utils.ts index ec60e662fa..2d2041b604 100644 --- a/frontend/src/container/GridCardLayout/GridCard/utils.ts +++ b/frontend/src/container/GridCardLayout/GridCard/utils.ts @@ -1,10 +1,17 @@ /* eslint-disable sonarjs/cognitive-complexity */ +import { NotificationInstance } from 'antd/es/notification/interface'; +import { NavigateToExplorerProps } from 'components/CeleryTask/useNavigateToExplorer'; import { LOCALSTORAGE } from 'constants/localStorage'; import { PANEL_TYPES } from 'constants/queryBuilder'; import getLabelName from 'lib/getLabelName'; +import { Widgets } from 'types/api/dashboard/getAll'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; +import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; import { QueryData } from 'types/api/widgets/getQuery'; +import { DataSource } from 'types/common/queryBuilder'; +import { GraphClickProps } from '../useGraphClickToShowButton'; +import { NavigateToExplorerPagesProps } from '../useNavigateToExplorerPages'; import { LegendEntryProps } from './FullView/types'; import { showAllDataSet, @@ -151,3 +158,83 @@ export const isDataAvailableByPanelType = ( return Boolean(getPanelData()?.length); }; + +interface HandleGraphClickParams { + xValue: number; + yValue: number; + mouseX: number; + mouseY: number; + metric?: { [key: string]: string }; + queryData?: { queryName: string; inFocusOrNot: boolean }; + widget: Widgets; + navigateToExplorerPages: ( + props: NavigateToExplorerPagesProps, + ) => Promise<{ + [queryName: string]: { + filters: TagFilterItem[]; + dataSource?: string; + }; + }>; + navigateToExplorer: (props: NavigateToExplorerProps) => void; + notifications: NotificationInstance; + graphClick: (props: GraphClickProps) => void; +} + +export const handleGraphClick = async ({ + xValue, + yValue, + mouseX, + mouseY, + metric, + queryData, + widget, + navigateToExplorerPages, + navigateToExplorer, + notifications, + graphClick, +}: HandleGraphClickParams): Promise => { + const { stepInterval } = widget?.query?.builder?.queryData?.[0] ?? {}; + + try { + const result = await navigateToExplorerPages({ + widget, + requestData: { + ...metric, + queryName: queryData?.queryName || '', + inFocusOrNot: queryData?.inFocusOrNot || false, + }, + }); + + const keys = Object.keys(result); + const menuItems = keys.map((key) => ({ + text: + keys.length === 1 + ? `View ${ + (result[key].dataSource as DataSource) === DataSource.TRACES + ? 'Traces' + : 'Logs' + }` + : `View ${ + (result[key].dataSource as DataSource) === DataSource.TRACES + ? 'Traces' + : 'Logs' + }: ${key}`, + onClick: (): void => + navigateToExplorer({ + filters: result[key].filters, + dataSource: result[key].dataSource as DataSource, + startTime: xValue, + endTime: xValue + (stepInterval ?? 60), + shouldResolveQuery: true, + }), + })); + + graphClick({ xValue, yValue, mouseX, mouseY, metric, queryData, menuItems }); + } catch (error) { + notifications.error({ + message: 'Failed to process graph click', + description: + error instanceof Error ? error.message : 'Unknown error occurred', + }); + } +}; diff --git a/frontend/src/container/GridCardLayout/GridCardLayout.styles.scss b/frontend/src/container/GridCardLayout/GridCardLayout.styles.scss index c450801537..6f2ae25bab 100644 --- a/frontend/src/container/GridCardLayout/GridCardLayout.styles.scss +++ b/frontend/src/container/GridCardLayout/GridCardLayout.styles.scss @@ -291,6 +291,38 @@ } } +.view-onclick-show-button { + position: absolute; + background: var(--bg-vanilla-100); + border: 2px solid var(--bg-vanilla-300); + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + box-shadow: none; + list-style-type: none; + padding: 4px; + color: var(--bg-ink-100); + display: flex; + flex-direction: column; + gap: 2px; + overflow: auto; + max-height: 150px; + width: max-content; + max-width: 200px; + + .menu-item { + cursor: pointer; + padding: 4px; + padding-right: 12px; + + &:hover { + background-color: var(--bg-vanilla-200); + border-radius: 2px; + } + } +} + .lightMode { .fullscreen-grid-container { .react-grid-layout { @@ -374,4 +406,16 @@ } } } + + .view-onclick-show-button { + background: var(--bg-ink-400); + border-color: var(--bg-ink-300); + color: var(--bg-vanilla-100); + + .menu-item { + &:hover { + background-color: var(--bg-ink-100); + } + } + } } diff --git a/frontend/src/container/GridCardLayout/useGraphClickToShowButton.ts b/frontend/src/container/GridCardLayout/useGraphClickToShowButton.ts new file mode 100644 index 0000000000..69d8f06f54 --- /dev/null +++ b/frontend/src/container/GridCardLayout/useGraphClickToShowButton.ts @@ -0,0 +1,191 @@ +import './GridCardLayout.styles.scss'; + +import { isUndefined } from 'lodash-es'; +import { useCallback, useEffect, useRef } from 'react'; + +interface ClickToShowButtonProps { + graphRef: React.RefObject; + buttonClassName?: string; + isButtonEnabled?: boolean; +} + +export interface GraphClickProps { + xValue: number; + yValue: number; + mouseX: number; + mouseY: number; + metric?: { [key: string]: string }; + queryData?: { queryName: string; inFocusOrNot: boolean }; + menuItems?: Array<{ + text: string; + onClick: ( + xValue: number, + yValue: number, + mouseX: number, + mouseY: number, + metric?: { [key: string]: string }, + queryData?: { queryName: string; inFocusOrNot: boolean }, + ) => void; + }>; +} + +export const useGraphClickToShowButton = ({ + graphRef, + buttonClassName = 'view-onclick-show-button', + isButtonEnabled = true, +}: ClickToShowButtonProps): ((props: GraphClickProps) => void) => { + const activeButtonRef = useRef( + null, + ); + + const hideTooltips = (): void => { + const elements = [ + { id: 'overlay', selector: '#overlay' }, + { className: 'uplot-tooltip', selector: '.uplot-tooltip' }, + ]; + + elements.forEach(({ selector }) => { + const element = document.querySelector(selector) as HTMLElement; + if (element) { + element.style.display = 'none'; + } + }); + }; + + const cleanup = useCallback((): void => { + if (activeButtonRef.current) { + activeButtonRef.current.remove(); + activeButtonRef.current = null; + } + + // Restore tooltips + ['#overlay', '.uplot-tooltip'].forEach((selector) => { + const element = document.querySelector(selector) as HTMLElement; + if (element) { + element.style.display = 'block'; + } + }); + }, []); + + const createMenu = ( + xValue: number, + yValue: number, + mouseX: number, + mouseY: number, + menuItems: Array<{ + text: string; + onClick: ( + xValue: number, + yValue: number, + mouseX: number, + mouseY: number, + metric?: { [key: string]: string }, + queryData?: { queryName: string; inFocusOrNot: boolean }, + ) => void; + }>, + metric?: { [key: string]: string }, + queryData?: { queryName: string; inFocusOrNot: boolean }, + ): void => { + const menuList = document.createElement('ul'); + menuList.className = buttonClassName; + menuList.style.position = 'absolute'; + menuList.style.zIndex = '9999'; + + const graphBounds = graphRef.current?.getBoundingClientRect(); + if (!graphBounds) return; + + graphRef.current?.appendChild(menuList); + + // After appending, get menu dimensions and adjust if needed so it stays within the graph boundaries + const menuBounds = menuList.getBoundingClientRect(); + + // Calculate position considering menu dimensions + let finalLeft = mouseX; + let finalTop = mouseY; + + // Adjust horizontal position if menu would overflow + if (mouseX + menuBounds.width > graphBounds.width) { + finalLeft = mouseX - menuBounds.width; + } + // Ensure menu doesn't go off the left edge + finalLeft = Math.max(0, finalLeft); + + // Adjust vertical position if menu would overflow + if (mouseY + menuBounds.height > graphBounds.height) { + finalTop = mouseY - menuBounds.height; + } + // Ensure menu doesn't go off the top edge + finalTop = Math.max(0, finalTop); + + menuList.style.left = `${finalLeft}px`; + menuList.style.top = `${finalTop}px`; + + // Create a list item for each menu option provided in props + menuItems.forEach((item) => { + const listItem = document.createElement('li'); + listItem.textContent = item.text; + listItem.className = 'menu-item'; + // Style the list item as needed (padding, cursor, etc.) + listItem.onclick = (e: MouseEvent): void => { + e.stopPropagation(); + // Execute the provided onClick handler for this menu item + item.onClick(xValue, yValue, mouseX, mouseY, metric, queryData); + cleanup(); + }; + menuList.appendChild(listItem); + }); + + activeButtonRef.current = menuList; + }; + + useEffect(() => { + const handleOutsideClick = (e: MouseEvent): void => { + if (!graphRef.current?.contains(e.target as Node)) { + cleanup(); + } + }; + + document.addEventListener('click', handleOutsideClick); + return (): void => { + document.removeEventListener('click', handleOutsideClick); + cleanup(); + }; + }, [cleanup, graphRef]); + + return useCallback( + (props: GraphClickProps) => { + cleanup(); + const { + xValue, + yValue, + mouseX, + mouseY, + metric, + queryData, + menuItems, + } = props; + + if ( + isButtonEnabled && + !isUndefined(props.xValue) && + props.queryData && + queryData?.inFocusOrNot && + Object.keys(queryData).length > 0 + ) { + hideTooltips(); + // createButton(xValue, yValue, mouseX, mouseY, metric, queryData); + createMenu( + xValue, + yValue, + mouseX, + mouseY, + menuItems || [], + metric, + queryData, + ); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [buttonClassName, graphRef, isButtonEnabled, cleanup], + ); +}; diff --git a/frontend/src/container/GridCardLayout/useNavigateToExplorerPages.ts b/frontend/src/container/GridCardLayout/useNavigateToExplorerPages.ts new file mode 100644 index 0000000000..5fbee873e0 --- /dev/null +++ b/frontend/src/container/GridCardLayout/useNavigateToExplorerPages.ts @@ -0,0 +1,148 @@ +import { useNotifications } from 'hooks/useNotifications'; +import { useDashboard } from 'providers/Dashboard/Dashboard'; +import { useCallback } from 'react'; +import { Widgets } from 'types/api/dashboard/getAll'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { + IBuilderQuery, + Query, + TagFilterItem, +} from 'types/api/queryBuilder/queryBuilderData'; +import { v4 } from 'uuid'; + +import { extractQueryNamesFromExpression } from './utils'; + +type GraphClickMetaData = { + [key: string]: string | boolean; + queryName: string; + inFocusOrNot: boolean; +}; +export interface NavigateToExplorerPagesProps { + widget: Widgets; + requestData?: GraphClickMetaData; +} + +// Helper to create group by filters from request data +const createGroupByFilters = ( + groupBy: BaseAutocompleteData[], + requestData: GraphClickMetaData, +): TagFilterItem[] => + groupBy + .map((gb) => { + const value = requestData[gb.key]; + return value + ? [ + { + id: v4(), + key: gb, + op: '=', + value, + }, + ] + : []; + }) + .flat(); + +// Helper to build filters for a single query, give priority to group by filters +const buildQueryFilters = ( + queryData: IBuilderQuery, + groupByFilters: TagFilterItem[], +): { filters: TagFilterItem[]; dataSource?: string } => { + const existingFilters = queryData.filters?.items || []; + const uniqueFilters = existingFilters.filter( + (filter) => + !groupByFilters.some( + (groupFilter) => groupFilter.key?.key === filter?.key?.key, + ), + ); + + return { + filters: [...uniqueFilters, ...groupByFilters], + dataSource: queryData.dataSource, + }; +}; + +// Main function to build filters +export const buildFilters = ( + query: Query, + requestData?: GraphClickMetaData, +): { + [queryName: string]: { filters: TagFilterItem[]; dataSource?: string }; +} => { + // Handle specific query navigation + if (requestData?.queryName) { + const queryData = query.builder.queryData.find( + (q) => q.queryName === requestData.queryName, + ); + + // Direct query match + if (queryData) { + const groupByFilters = createGroupByFilters(queryData.groupBy, requestData); + return { + [requestData.queryName]: buildQueryFilters(queryData, groupByFilters), + }; + } + + // Formula query handling + const formulaQuery = query.builder.queryFormulas.find( + (q) => q.queryName === requestData.queryName, + ); + + if (!formulaQuery) return {}; + + const queryNames = extractQueryNamesFromExpression(formulaQuery.expression); + const filteredQueryData = query.builder.queryData.filter((q) => + queryNames.includes(q.queryName), + ); + + const returnObject: { + [queryName: string]: { filters: TagFilterItem[]; dataSource?: string }; + } = {}; + + filteredQueryData.forEach((q) => { + const groupByFilters = createGroupByFilters(q.groupBy, requestData); + returnObject[q.queryName] = buildQueryFilters(q, groupByFilters); + }); + + return returnObject; + } + + return {}; +}; + +/** + * Custom hook for handling navigation to explorer pages with query data + * @returns A function to handle navigation with query processing + */ +function useNavigateToExplorerPages(): ( + props: NavigateToExplorerPagesProps, +) => Promise<{ + [queryName: string]: { filters: TagFilterItem[]; dataSource?: string }; +}> { + const { selectedDashboard } = useDashboard(); + const { notifications } = useNotifications(); + + return useCallback( + async ({ widget, requestData }: NavigateToExplorerPagesProps) => { + try { + // Return the finalFilters + return buildFilters( + widget.query, + requestData ?? { queryName: '', inFocusOrNot: false }, + ); + } catch (error) { + notifications.error({ + message: 'Error navigating to explorer', + description: + error instanceof Error ? error.message : 'Unknown error occurred', + }); + // Return empty object in case of error + return {}; + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectedDashboard, notifications], + ); +} + +export default useNavigateToExplorerPages; diff --git a/frontend/src/container/GridCardLayout/useResolveQuery.ts b/frontend/src/container/GridCardLayout/useResolveQuery.ts new file mode 100644 index 0000000000..945d8b1508 --- /dev/null +++ b/frontend/src/container/GridCardLayout/useResolveQuery.ts @@ -0,0 +1,66 @@ +import { getQueryRangeFormat } from 'api/dashboard/queryRangeFormat'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems'; +import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables'; +import { prepareQueryRangePayload } from 'lib/dashboard/prepareQueryRangePayload'; +import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi'; +import { useCallback } from 'react'; +import { useMutation } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import { GlobalReducer } from 'types/reducer/globalTime'; +import { getGraphType } from 'utils/getGraphType'; + +interface UseUpdatedQueryOptions { + widgetConfig: { + query: Query; + panelTypes: PANEL_TYPES; + timePreferance: timePreferenceType; + }; + selectedDashboard?: any; +} + +interface UseUpdatedQueryResult { + getUpdatedQuery: (options: UseUpdatedQueryOptions) => Promise; + isLoading: boolean; +} + +function useUpdatedQuery(): UseUpdatedQueryResult { + const { selectedTime: globalSelectedInterval } = useSelector< + AppState, + GlobalReducer + >((state) => state.globalTime); + + const queryRangeMutation = useMutation(getQueryRangeFormat); + + const getUpdatedQuery = useCallback( + async ({ + widgetConfig, + selectedDashboard, + }: UseUpdatedQueryOptions): Promise => { + // Prepare query payload with resolved variables + const { queryPayload } = prepareQueryRangePayload({ + query: widgetConfig.query, + graphType: getGraphType(widgetConfig.panelTypes), + selectedTime: widgetConfig.timePreferance, + globalSelectedInterval, + variables: getDashboardVariables(selectedDashboard?.data?.variables), + }); + + // Execute query and process results + const queryResult = await queryRangeMutation.mutateAsync(queryPayload); + + // Map query data from API response + return mapQueryDataFromApi(queryResult.compositeQuery, widgetConfig?.query); + }, + [globalSelectedInterval, queryRangeMutation], + ); + + return { + getUpdatedQuery, + isLoading: queryRangeMutation.isLoading, + }; +} + +export default useUpdatedQuery; diff --git a/frontend/src/container/GridCardLayout/utils.ts b/frontend/src/container/GridCardLayout/utils.ts index 2623e5b623..102c54caf1 100644 --- a/frontend/src/container/GridCardLayout/utils.ts +++ b/frontend/src/container/GridCardLayout/utils.ts @@ -1,3 +1,4 @@ +import { FORMULA_REGEXP } from 'constants/regExp'; import { Layout } from 'react-grid-layout'; export const removeUndefinedValuesFromLayout = (layout: Layout[]): Layout[] => @@ -6,3 +7,21 @@ export const removeUndefinedValuesFromLayout = (layout: Layout[]): Layout[] => Object.entries(obj).filter(([, value]) => value !== undefined), ), ) as Layout[]; + +export const isFormula = (queryName: string): boolean => + FORMULA_REGEXP.test(queryName); + +/** + * Extracts query names from a formula expression + * Specifically targets capital letters A-Z as query names, as after Z we dont have any query names + */ +export function extractQueryNamesFromExpression(expression: string): string[] { + if (!expression) return []; + + // Use regex to match standalone capital letters + // Uses word boundaries to ensure we only get standalone letters + const queryNameRegex = /\b[A-Z]\b/g; + + // Extract matches and deduplicate + return [...new Set(expression.match(queryNameRegex) || [])]; +} diff --git a/frontend/src/container/NewWidget/LeftContainer/WidgetGraph/WidgetGraphs.tsx b/frontend/src/container/NewWidget/LeftContainer/WidgetGraph/WidgetGraphs.tsx index 2d6e46f1f8..ab4e67120f 100644 --- a/frontend/src/container/NewWidget/LeftContainer/WidgetGraph/WidgetGraphs.tsx +++ b/frontend/src/container/NewWidget/LeftContainer/WidgetGraph/WidgetGraphs.tsx @@ -1,8 +1,13 @@ +import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer'; import { QueryParams } from 'constants/query'; import { PANEL_TYPES } from 'constants/queryBuilder'; +import { handleGraphClick } from 'container/GridCardLayout/GridCard/utils'; +import { useGraphClickToShowButton } from 'container/GridCardLayout/useGraphClickToShowButton'; +import useNavigateToExplorerPages from 'container/GridCardLayout/useNavigateToExplorerPages'; import PanelWrapper from 'container/PanelWrapper/PanelWrapper'; import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/config'; import { useIsDarkMode } from 'hooks/useDarkMode'; +import { useNotifications } from 'hooks/useNotifications'; import { useSafeNavigate } from 'hooks/useSafeNavigate'; import useUrlQuery from 'hooks/useUrlQuery'; import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults'; @@ -22,6 +27,7 @@ import { UpdateTimeInterval } from 'store/actions'; import { SuccessResponse } from 'types/api'; import { Widgets } from 'types/api/dashboard/getAll'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; +import { DataSource } from 'types/common/queryBuilder'; function WidgetGraph({ selectedWidget, @@ -88,6 +94,43 @@ function WidgetGraph({ const isDarkMode = useIsDarkMode(); + // context redirection to explorer pages + const graphClick = useGraphClickToShowButton({ + graphRef, + isButtonEnabled: (selectedWidget?.query?.builder?.queryData ?? []).some( + (q) => + q.dataSource === DataSource.TRACES || q.dataSource === DataSource.LOGS, + ), + buttonClassName: 'view-onclick-show-button', + }); + + const navigateToExplorer = useNavigateToExplorer(); + const navigateToExplorerPages = useNavigateToExplorerPages(); + const { notifications } = useNotifications(); + + const graphClickHandler = ( + xValue: number, + yValue: number, + mouseX: number, + mouseY: number, + metric?: { [key: string]: string }, + queryData?: { queryName: string; inFocusOrNot: boolean }, + ): void => { + handleGraphClick({ + xValue, + yValue, + mouseX, + mouseY, + metric, + queryData, + widget: selectedWidget, + navigateToExplorerPages, + navigateToExplorer, + notifications, + graphClick, + }); + }; + return (
); diff --git a/frontend/src/lib/uPlotLib/plugins/onClickPlugin.ts b/frontend/src/lib/uPlotLib/plugins/onClickPlugin.ts index fafc4afe64..d4a81ace79 100644 --- a/frontend/src/lib/uPlotLib/plugins/onClickPlugin.ts +++ b/frontend/src/lib/uPlotLib/plugins/onClickPlugin.ts @@ -9,6 +9,10 @@ export interface OnClickPluginOpts { data?: { [key: string]: string; }, + queryData?: { + queryName: string; + inFocusOrNot: boolean; + }, ) => void; apiResponse?: MetricRangePayloadProps; } @@ -31,6 +35,10 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin { let metric = {}; const { series } = u; const apiResult = opts.apiResponse?.data?.result || []; + const outputMetric = { + queryName: '', + inFocusOrNot: false, + }; // this is to get the metric value of the focused series if (Array.isArray(series) && series.length > 0) { @@ -38,13 +46,15 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore if (item?.show && item?._focus) { - const { metric: focusedMetric } = apiResult[index - 1] || []; + const { metric: focusedMetric, queryName } = apiResult[index - 1] || []; metric = focusedMetric; + outputMetric.queryName = queryName; + outputMetric.inFocusOrNot = true; } }); } - opts.onClick(xValue, yValue, mouseX, mouseY, metric); + opts.onClick(xValue, yValue, mouseX, mouseY, metric, outputMetric); }; u.over.addEventListener('click', handleClick); }, diff --git a/frontend/src/pages/Celery/CeleryOverview/CeleryOverviewDetail/OverviewRightPanelGraph.tsx b/frontend/src/pages/Celery/CeleryOverview/CeleryOverviewDetail/OverviewRightPanelGraph.tsx index fb573c9bec..d4b3f2ba40 100644 --- a/frontend/src/pages/Celery/CeleryOverview/CeleryOverviewDetail/OverviewRightPanelGraph.tsx +++ b/frontend/src/pages/Celery/CeleryOverview/CeleryOverviewDetail/OverviewRightPanelGraph.tsx @@ -2,7 +2,7 @@ import { Color } from '@signozhq/design-tokens'; import { Card } from 'antd'; import logEvent from 'api/common/logEvent'; import { useGetGraphCustomSeries } from 'components/CeleryTask/useGetGraphCustomSeries'; -import { useNavigateToTraces } from 'components/CeleryTask/useNavigateToTraces'; +import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer'; import { QueryParams } from 'constants/query'; import { ViewMenuAction } from 'container/GridCardLayout/config'; import GridCard from 'container/GridCardLayout/GridCard'; @@ -18,6 +18,7 @@ import { AppState } from 'store/reducers'; import { Widgets } from 'types/api/dashboard/getAll'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; import { GlobalReducer } from 'types/reducer/globalTime'; import { @@ -82,7 +83,7 @@ export default function OverviewRightPanelGraph({ setSelectedTimeStamp(selectTime); }, []); - const navigateToTraces = useNavigateToTraces(); + const navigateToExplorer = useNavigateToExplorer(); const onGraphClickHandler = useGraphClickHandler(handleSetTimeStamp); @@ -100,13 +101,14 @@ export default function OverviewRightPanelGraph({ const goToTraces = useCallback( (widget: Widgets) => { const { stepInterval } = widget?.query?.builder?.queryData?.[0] ?? {}; - navigateToTraces( - filters ?? [], - selectedTimeStamp, - selectedTimeStamp + (stepInterval ?? 60), - ); + navigateToExplorer({ + filters: filters ?? [], + dataSource: DataSource.TRACES, + startTime: selectedTimeStamp, + endTime: selectedTimeStamp + (stepInterval ?? 60), + }); }, - [navigateToTraces, filters, selectedTimeStamp], + [navigateToExplorer, filters, selectedTimeStamp], ); const { getCustomSeries } = useGetGraphCustomSeries({ diff --git a/frontend/src/pages/Celery/CeleryOverview/CeleryOverviewDetail/ValueInfo.tsx b/frontend/src/pages/Celery/CeleryOverview/CeleryOverviewDetail/ValueInfo.tsx index 708fc7b456..0fbaa879c8 100644 --- a/frontend/src/pages/Celery/CeleryOverview/CeleryOverviewDetail/ValueInfo.tsx +++ b/frontend/src/pages/Celery/CeleryOverview/CeleryOverviewDetail/ValueInfo.tsx @@ -3,7 +3,7 @@ import './ValueInfo.styles.scss'; import { FileSearchOutlined } from '@ant-design/icons'; import { Button, Card, Col, Row } from 'antd'; import logEvent from 'api/common/logEvent'; -import { useNavigateToTraces } from 'components/CeleryTask/useNavigateToTraces'; +import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer'; import { ENTITY_VERSION_V4 } from 'constants/app'; import { PANEL_TYPES } from 'constants/queryBuilder'; import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults'; @@ -15,6 +15,7 @@ import { SuccessResponse } from 'types/api'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; import { GlobalReducer } from 'types/reducer/globalTime'; import { v4 as uuidv4 } from 'uuid'; @@ -82,7 +83,7 @@ export default function ValueInfo({ [isLoading, getValues], ); - const navigateToTrace = useNavigateToTraces(); + const navigateToExplorer = useNavigateToExplorer(); const avgLatencyInMs = useMemo(() => { if (avgLatency === 'NaN') return 'NaN'; @@ -144,7 +145,12 @@ export default function ValueInfo({ maxTime, source: 'request rate', }); - navigateToTrace(filters ?? []); + navigateToExplorer({ + filters: filters ?? [], + dataSource: DataSource.TRACES, + startTime: minTime, + endTime: maxTime, + }); }} > View Traces @@ -174,22 +180,27 @@ export default function ValueInfo({ maxTime, source: 'error rate', }); - navigateToTrace([ - ...(filters ?? []), - { - id: uuidv4(), - key: { - dataType: DataTypes.bool, - id: 'has_error--bool----true', - isColumn: true, - isJSON: false, - key: 'has_error', - type: '', + navigateToExplorer({ + filters: [ + ...(filters ?? []), + { + id: uuidv4(), + key: { + dataType: DataTypes.bool, + id: 'has_error--bool----true', + isColumn: true, + isJSON: false, + key: 'has_error', + type: '', + }, + op: '=', + value: 'true', }, - op: '=', - value: 'true', - }, - ]); + ], + dataSource: DataSource.TRACES, + startTime: minTime, + endTime: maxTime, + }); }} > View Traces @@ -219,7 +230,12 @@ export default function ValueInfo({ maxTime, source: 'average latency', }); - navigateToTrace(filters ?? []); + navigateToExplorer({ + filters: filters ?? [], + dataSource: DataSource.TRACES, + startTime: minTime, + endTime: maxTime, + }); }} > View Traces diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 18db88688f..0f574942cf 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -143,7 +143,7 @@ body { // ================================================================= // AntD style overrides .ant-dropdown-menu { - margin-top: 2px !important; + margin-top: 2px; min-width: 160px; border-radius: 4px; @@ -180,6 +180,14 @@ body { } } +// these are default styles but are overridden by above dropdown styles +.ant-dropdown-menu-submenu-popup { + padding: 0 !important; + z-index: 1050 !important; + box-shadow: none !important; + border: 0 !important; +} + // https://github.com/ant-design/ant-design/issues/41307 .ant-picker-panels > *:first-child button.ant-picker-header-next-btn { visibility: visible !important;