Compare commits

...

2 Commits

Author SHA1 Message Date
Abhi Kumar
9f53d614fa fix: fixed drilldown menu ui 2026-04-23 16:58:28 +05:30
Abhi Kumar
1f602a216e chore: broke down drilldown navigate into a saperate hook 2026-04-23 13:46:19 +05:30
7 changed files with 444 additions and 131 deletions

View File

@@ -104,7 +104,12 @@ export const usePanelContextMenu = ({
}
if (data && data?.record?.queryName) {
onClick(data.coord, { ...data.record, label: data.label, timeRange });
onClick(data.coord, {
...data.record,
label: data.label,
seriesColor: data.seriesColor,
timeRange,
});
}
},
[onClick, queryResponse],

View File

@@ -0,0 +1,282 @@
import { renderHook } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getViewQuery } from '../drilldownUtils';
import { AggregateData } from '../useAggregateDrilldown';
import useBaseDrilldownNavigate, {
buildDrilldownUrl,
getRoute,
} from '../useBaseDrilldownNavigate';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({ safeNavigate: mockSafeNavigate }),
}));
jest.mock('../drilldownUtils', () => ({
...jest.requireActual('../drilldownUtils'),
getViewQuery: jest.fn(),
}));
const mockGetViewQuery = getViewQuery as jest.Mock;
// ─── Fixtures ────────────────────────────────────────────────────────────────
const MOCK_QUERY: Query = {
id: 'q1',
queryType: 'builder' as any,
builder: {
queryData: [
{
queryName: 'A',
dataSource: 'metrics' as any,
groupBy: [],
expression: '',
disabled: false,
functions: [],
legend: '',
having: [],
limit: null,
stepInterval: undefined,
orderBy: [],
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
};
const MOCK_VIEW_QUERY: Query = {
...MOCK_QUERY,
builder: {
...MOCK_QUERY.builder,
queryData: [
{
...MOCK_QUERY.builder.queryData[0],
filters: { items: [], op: 'AND' },
},
],
},
};
const MOCK_AGGREGATE_DATA: AggregateData = {
queryName: 'A',
filters: [{ filterKey: 'service_name', filterValue: 'auth', operator: '=' }],
timeRange: { startTime: 1000000, endTime: 2000000 },
};
// ─── getRoute ─────────────────────────────────────────────────────────────────
describe('getRoute', () => {
it.each([
['view_logs', ROUTES.LOGS_EXPLORER],
['view_metrics', ROUTES.METRICS_EXPLORER],
['view_traces', ROUTES.TRACES_EXPLORER],
])('maps %s to the correct explorer route', (key, expected) => {
expect(getRoute(key)).toBe(expected);
});
it('returns empty string for an unknown key', () => {
expect(getRoute('view_dashboard')).toBe('');
});
});
// ─── buildDrilldownUrl ────────────────────────────────────────────────────────
describe('buildDrilldownUrl', () => {
beforeEach(() => {
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
});
afterEach(() => {
jest.clearAllMocks();
});
it('returns null for an unknown drilldown key', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_dashboard');
expect(url).toBeNull();
});
it('returns null when getViewQuery returns null', () => {
mockGetViewQuery.mockReturnValue(null);
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).toBeNull();
});
it('returns a URL starting with the logs explorer route for view_logs', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).not.toBeNull();
expect(url).toContain(ROUTES.LOGS_EXPLORER);
});
it('returns a URL starting with the traces explorer route for view_traces', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_traces');
expect(url).toContain(ROUTES.TRACES_EXPLORER);
});
it('includes compositeQuery param in the URL', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).toContain('compositeQuery=');
});
it('includes startTime and endTime when aggregateData has a timeRange', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).toContain('startTime=1000000');
expect(url).toContain('endTime=2000000');
});
it('omits startTime and endTime when aggregateData has no timeRange', () => {
const { timeRange: _, ...withoutTimeRange } = MOCK_AGGREGATE_DATA;
const url = buildDrilldownUrl(MOCK_QUERY, withoutTimeRange, 'view_logs');
expect(url).not.toContain('startTime=');
expect(url).not.toContain('endTime=');
});
it('includes summaryFilters param for view_metrics', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_metrics');
expect(url).toContain(ROUTES.METRICS_EXPLORER);
expect(url).toContain('summaryFilters=');
});
it('does not include summaryFilters param for non-metrics routes', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).not.toContain('summaryFilters=');
});
it('handles null aggregateData by passing empty filters and empty queryName', () => {
const url = buildDrilldownUrl(MOCK_QUERY, null, 'view_logs');
expect(url).not.toBeNull();
expect(mockGetViewQuery).toHaveBeenCalledWith(MOCK_QUERY, [], 'view_logs', '');
});
it('passes aggregateData filters and queryName to getViewQuery', () => {
buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(mockGetViewQuery).toHaveBeenCalledWith(
MOCK_QUERY,
MOCK_AGGREGATE_DATA.filters,
'view_logs',
MOCK_AGGREGATE_DATA.queryName,
);
});
});
// ─── useBaseDrilldownNavigate ─────────────────────────────────────────────────
describe('useBaseDrilldownNavigate', () => {
beforeEach(() => {
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
});
afterEach(() => {
jest.clearAllMocks();
});
it('calls safeNavigate with the built URL on a valid key', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
}),
);
result.current('view_logs');
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain('compositeQuery=');
});
it('opens the explorer in a new tab', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
}),
);
result.current('view_traces');
expect(mockSafeNavigate).toHaveBeenCalledWith(
expect.any(String),
{ newTab: true },
);
});
it('calls callback after successful navigation', () => {
const callback = jest.fn();
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
callback,
}),
);
result.current('view_logs');
expect(callback).toHaveBeenCalledTimes(1);
});
it('does not call safeNavigate for an unknown key', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
}),
);
result.current('view_dashboard');
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('still calls callback when the key is unknown', () => {
const callback = jest.fn();
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
callback,
}),
);
result.current('view_dashboard');
expect(callback).toHaveBeenCalledTimes(1);
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('still calls callback when getViewQuery returns null', () => {
mockGetViewQuery.mockReturnValue(null);
const callback = jest.fn();
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
callback,
}),
);
result.current('view_logs');
expect(callback).toHaveBeenCalledTimes(1);
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('handles null aggregateData without throwing', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: null,
}),
);
expect(() => result.current('view_logs')).not.toThrow();
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
});
});

View File

@@ -166,7 +166,7 @@ export const getAggregateColumnHeader = (
};
};
const getFiltersFromMetric = (metric: any): FilterData[] =>
export const getFiltersFromMetric = (metric: any): FilterData[] =>
Object.keys(metric).map((key) => ({
filterKey: key,
filterValue: metric[key],
@@ -196,6 +196,7 @@ export const getUplotClickData = ({
coord: { x: number; y: number };
record: { queryName: string; filters: FilterData[] };
label: string | React.ReactNode;
seriesColor?: string;
} | null => {
if (!queryData?.queryName || !metric) {
return null;
@@ -208,6 +209,7 @@ export const getUplotClickData = ({
// Generate label from focusedSeries data
let label: string | React.ReactNode = '';
const seriesColor = focusedSeries?.color;
if (focusedSeries && focusedSeries.seriesName) {
label = (
<span style={{ color: focusedSeries.color }}>
@@ -223,6 +225,7 @@ export const getUplotClickData = ({
},
record,
label,
seriesColor,
};
};
@@ -237,6 +240,7 @@ export const getPieChartClickData = (
queryName: string;
filters: FilterData[];
label: string | React.ReactNode;
seriesColor?: string;
} | null => {
const { metric, queryName } = arc.data.record;
if (!queryName || !metric) {
@@ -248,6 +252,7 @@ export const getPieChartClickData = (
queryName,
filters: getFiltersFromMetric(metric), // TODO: add where clause query as well.
label,
seriesColor: arc.data.color,
};
};

View File

@@ -22,6 +22,7 @@ export interface AggregateData {
endTime: number;
};
label?: string | React.ReactNode;
seriesColor?: string;
}
const useAggregateDrilldown = ({

View File

@@ -2,14 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { LinkOutlined, LoadingOutlined } from '@ant-design/icons';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
import useContextVariables from 'hooks/dashboard/useContextVariables';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
@@ -17,9 +13,10 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ContextMenuItem } from './contextConfig';
import { getDataLinks } from './dataLinksUtils';
import { getAggregateColumnHeader, getViewQuery } from './drilldownUtils';
import { getAggregateColumnHeader } from './drilldownUtils';
import { getBaseContextConfig } from './menuOptions';
import { AggregateData } from './useAggregateDrilldown';
import useBaseDrilldownNavigate from './useBaseDrilldownNavigate';
interface UseBaseAggregateOptionsProps {
query: Query;
@@ -37,19 +34,6 @@ interface BaseAggregateOptionsConfig {
items?: ContextMenuItem;
}
const getRoute = (key: string): string => {
switch (key) {
case 'view_logs':
return ROUTES.LOGS_EXPLORER;
case 'view_metrics':
return ROUTES.METRICS_EXPLORER;
case 'view_traces':
return ROUTES.TRACES_EXPLORER;
default:
return '';
}
};
const useBaseAggregateOptions = ({
query,
onClose,
@@ -62,10 +46,8 @@ const useBaseAggregateOptions = ({
baseAggregateOptionsConfig: BaseAggregateOptionsConfig;
} => {
const [resolvedQuery, setResolvedQuery] = useState<Query>(query);
const {
getUpdatedQuery,
isLoading: isResolveQueryLoading,
} = useUpdatedQuery();
const { getUpdatedQuery, isLoading: isResolveQueryLoading } =
useUpdatedQuery();
const { dashboardData } = useDashboardStore();
useEffect(() => {
@@ -87,8 +69,6 @@ const useBaseAggregateOptions = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, aggregateData, panelType]);
const { safeNavigate } = useSafeNavigate();
// Use the new useContextVariables hook
const { processedVariables } = useContextVariables({
maxValues: 2,
@@ -122,50 +102,16 @@ const useBaseAggregateOptions = ({
{label}
</ContextMenu.Item>
));
} catch (error) {
} catch {
return [];
}
}, [contextLinks, processedVariables, onClose, aggregateData, query]);
const handleBaseDrilldown = useCallback(
(key: string): void => {
const route = getRoute(key);
const timeRange = aggregateData?.timeRange;
const filtersToAdd = aggregateData?.filters || [];
const viewQuery = getViewQuery(
resolvedQuery,
filtersToAdd,
key,
aggregateData?.queryName || '',
);
let queryParams = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange?.startTime.toString(),
[QueryParams.endTime]: timeRange?.endTime.toString(),
}),
} as Record<string, string>;
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery?.builder.queryData[0].filters,
),
};
}
if (route) {
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
newTab: true,
});
}
onClose();
},
[resolvedQuery, safeNavigate, onClose, aggregateData],
);
const handleBaseDrilldown = useBaseDrilldownNavigate({
resolvedQuery,
aggregateData,
callback: onClose,
});
const { pathname } = useLocation();
@@ -229,7 +175,15 @@ const useBaseAggregateOptions = ({
return (
<ContextMenu.Item
key={key}
icon={isLoading ? <LoadingOutlined spin /> : icon}
icon={
isLoading ? (
<LoadingOutlined spin />
) : (
<span style={{ color: aggregateData?.seriesColor }}>
{icon}
</span>
)
}
onClick={(): void => onClick()}
disabled={isLoading}
>

View File

@@ -0,0 +1,117 @@
import { useCallback } from 'react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getViewQuery } from './drilldownUtils';
import { AggregateData } from './useAggregateDrilldown';
type DrilldownKey = 'view_logs' | 'view_metrics' | 'view_traces';
const DRILLDOWN_ROUTE_MAP: Record<DrilldownKey, string> = {
view_logs: ROUTES.LOGS_EXPLORER,
view_metrics: ROUTES.METRICS_EXPLORER,
view_traces: ROUTES.TRACES_EXPLORER,
};
const getRoute = (key: string): string =>
DRILLDOWN_ROUTE_MAP[key as DrilldownKey] ?? '';
interface UseBaseDrilldownNavigateProps {
resolvedQuery: Query;
aggregateData: AggregateData | null;
callback?: () => void;
}
const useBaseDrilldownNavigate = ({
resolvedQuery,
aggregateData,
callback,
}: UseBaseDrilldownNavigateProps): ((key: string) => void) => {
const { safeNavigate } = useSafeNavigate();
return useCallback(
(key: string): void => {
const route = getRoute(key);
const viewQuery = getViewQuery(
resolvedQuery,
aggregateData?.filters ?? [],
key,
aggregateData?.queryName ?? '',
);
if (!viewQuery || !route) {
callback?.();
return;
}
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
}),
};
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery.builder.queryData[0].filters,
),
};
}
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
newTab: true,
});
callback?.();
},
[resolvedQuery, safeNavigate, callback, aggregateData],
);
};
export function buildDrilldownUrl(
resolvedQuery: Query,
aggregateData: AggregateData | null,
key: string,
): string | null {
const route = getRoute(key);
const viewQuery = getViewQuery(
resolvedQuery,
aggregateData?.filters ?? [],
key,
aggregateData?.queryName ?? '',
);
if (!viewQuery || !route) {
return null;
}
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
}),
};
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery.builder.queryData[0].filters,
),
};
}
return `${route}?${createQueryParams(queryParams)}`;
}
export { getRoute };
export default useBaseDrilldownNavigate;

View File

@@ -4,7 +4,7 @@
gap: 8px;
padding: 8px;
cursor: pointer;
color: var(--foreground);
color: var(--muted-foreground);
font-family: Inter;
font-size: var(--font-size-sm);
font-weight: 600;
@@ -20,13 +20,10 @@
overflow: hidden;
text-overflow: ellipsis;
&:hover {
background-color: var(--l1-background);
}
&:hover,
&:focus {
outline: none;
background-color: var(--l1-background);
background-color: var(--l2-background-hover);
}
&.disabled {
@@ -47,7 +44,8 @@
}
&:hover {
background-color: var(--bg-cherry-100);
background-color: var(--danger-background);
color: var(--l1-foreground);
}
}
@@ -74,73 +72,24 @@
}
.context-menu-header {
padding-bottom: 4px;
border-bottom: 1px solid var(--l1-border);
padding: 8px 12px;
border-bottom: 1px solid var(--l2-border);
color: var(--muted-foreground);
}
// Target the popover inner specifically for context menu
.context-menu .ant-popover-inner {
padding: 12px 8px !important;
// max-height: 254px !important;
max-width: 300px !important;
padding: 0;
border-radius: 6px;
max-width: 300px;
background: var(--l2-background) !important;
border: 1px solid var(--l2-border) !important;
}
// Dark mode support
.darkMode {
.context-menu-item {
color: var(--muted-foreground);
&:hover,
&:focus {
background-color: var(--l2-background);
}
&.danger {
color: var(--bg-cherry-400);
.icon {
color: var(--bg-cherry-400);
}
&:hover {
background-color: var(--danger-background);
color: var(--l1-foreground);
}
}
.icon {
color: var(--bg-robin-400);
}
}
.context-menu-header {
border-bottom: 1px solid var(--l1-border);
color: var(--muted-foreground);
}
// Set the menu popover background
.context-menu .ant-popover-inner {
background: var(--l1-background) !important;
border: 1px solid var(--border) !important;
}
}
// Context menu backdrop overlay
.context-menu-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
inset: 0;
z-index: 9999;
background: transparent;
cursor: default;
// Prevent any pointer events from reaching elements behind
pointer-events: auto;
// Ensure it covers the entire viewport including any scrollable areas
position: fixed !important;
inset: 0;
}