feat: added context redirection from panels to explorer pages (#7141)

* feat: added context redirection from panels to explorer pages

* feat: added graph coordinate - context redirection

* feat: fixed tooltip overlapping the button

* feat: code fix

* feat: removed unneccesary comment

* feat: added logic to resolve variables

* feat: added better logic to handle specific and panel redirection using query

* feat: added multi query support by datasource to panels redirction

* feat: fixing createbutton display logic

* feat: added logic and ui for specific line redirection

* feat: added logic to compute query with groupby

* feat: code fix and added aysnc await

* feat: added context redirection to fullview and edit view panels (#7252)

* feat: added context redirection to fullview and edit view panels

* feat: restricted redirection query to have only one query

* feat: added is buttonEnabled logic of graphs

* feat: code cleanup

* feat: for one query removed the queryname from onclick button

* feat: removed redirection option from action menu

* feat: redesign the format api flow to avoid delay in clickbutton appearance

* feat: updated the create filter logic for groupBys

* feat: handled the error on format api
This commit is contained in:
SagarRajput-7 2025-03-20 11:29:31 +05:30 committed by GitHub
parent e04e58d8b3
commit 0320285a25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 901 additions and 119 deletions

View File

@ -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 (
<Drawer
@ -105,7 +106,12 @@ export default function CeleryTaskDetail({
endTime,
source: widgetData.title,
});
navigateToTrace(filters, startTime, endTime);
navigateToExplorer({
filters,
dataSource: DataSource.TRACES,
startTime,
endTime,
});
}}
start={startTime}
end={endTime}

View File

@ -185,8 +185,15 @@ function CeleryTaskBar({
headerMenuList={[...ViewMenuAction]}
onDragSelect={onDragSelect}
isQueryEnabled={queryEnabled}
onClickHandler={(...args): void =>
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}
/>

View File

@ -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 (
<Card

View File

@ -0,0 +1,121 @@
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import { useDashboard } from 'providers/Dashboard/Dashboard';
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 interface NavigateToExplorerProps {
filters: TagFilterItem[];
dataSource: DataSource;
startTime?: number;
endTime?: number;
sameTab?: boolean;
shouldResolveQuery?: boolean;
}
export function useNavigateToExplorer(): (
props: NavigateToExplorerProps,
) => void {
const { currentQuery } = useQueryBuilder();
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(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<void> => {
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,
],
);
}

View File

@ -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<AppState, GlobalReducer>(
(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],
);
}

View File

@ -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<HTMLDivElement>(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}
/>
</GraphContainer>
</div>

View File

@ -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<SetStateAction<RefObject<HTMLDivElement> | null>>;
}
export interface GraphManagerProps extends UplotProps {

View File

@ -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<HTMLDivElement>(null);
const [
currentGraphRef,
setCurrentGraphRef,
] = useState<RefObject<HTMLDivElement> | null>(graphRef);
useEffect(() => {
if (!lineChartRef.current) return;
@ -78,6 +88,8 @@ function WidgetGraphComponent({
const tableProcessedDataRef = useRef<RowData[]>([]);
const navigateToExplorerPages = useNavigateToExplorerPages();
const { setLayouts, selectedDashboard, setSelectedDashboard } = useDashboard();
const onToggleModal = useCallback(
@ -230,6 +242,40 @@ function WidgetGraphComponent({
const [searchTerm, setSearchTerm] = useState<string>('');
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 (
<div
style={{
@ -280,6 +326,8 @@ function WidgetGraphComponent({
yAxisUnit={widget.yAxisUnit}
onToggleModelHandler={onToggleModelHandler}
tableProcessedDataRef={tableProcessedDataRef}
onClickHandler={onClickHandler ?? graphClickHandler}
setCurrentGraphRef={setCurrentGraphRef}
/>
</Modal>
@ -322,7 +370,7 @@ function WidgetGraphComponent({
setRequestData={setRequestData}
setGraphVisibility={setGraphVisibility}
graphVisibility={graphVisibility}
onClickHandler={onClickHandler}
onClickHandler={onClickHandler ?? graphClickHandler}
onDragSelect={onDragSelect}
tableProcessedDataRef={tableProcessedDataRef}
customTooltipElement={customTooltipElement}

View File

@ -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<void> => {
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',
});
}
};

View File

@ -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);
}
}
}
}

View File

@ -0,0 +1,191 @@
import './GridCardLayout.styles.scss';
import { isUndefined } from 'lodash-es';
import { useCallback, useEffect, useRef } from 'react';
interface ClickToShowButtonProps {
graphRef: React.RefObject<HTMLDivElement>;
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<HTMLButtonElement | HTMLUListElement | null>(
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],
);
};

View File

@ -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;

View File

@ -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<Query>;
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<Query> => {
// 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;

View File

@ -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) || [])];
}

View File

@ -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 (
<div
ref={graphRef}
@ -110,6 +153,7 @@ function WidgetGraph({
setRequestData={setRequestData}
onDragSelect={onDragSelect}
selectedGraph={selectedGraph}
onClickHandler={graphClickHandler}
/>
</div>
);

View File

@ -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);
},

View File

@ -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({

View File

@ -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,7 +180,8 @@ export default function ValueInfo({
maxTime,
source: 'error rate',
});
navigateToTrace([
navigateToExplorer({
filters: [
...(filters ?? []),
{
id: uuidv4(),
@ -189,7 +196,11 @@ export default function ValueInfo({
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

View File

@ -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;