Compare commits

..

3 Commits

Author SHA1 Message Date
amlannandy
c84749db67 chore: add notification summary input to alerts v2 flow 2025-12-17 14:16:34 +07:00
Yunus M
529a9e7009 fix: handle default columns in logs and traces explorer (#9722)
* fix: handle default columns in logs and traces explorer

* fix: filter out selected columns based on signal in logs and traces explorer
2025-12-16 13:32:18 +05:30
Nikhil Mantri
b00687b43f chore(metrics-explorer): API for the dashboards with metric_name (#9638) 2025-12-16 12:08:00 +05:30
34 changed files with 893 additions and 422 deletions

View File

@@ -132,117 +132,34 @@ function CeleryTaskBar({
[selectedFilters, celerySuccessStateData],
);
const onGraphClick = useCallback(
(
widgetData: Widgets,
xValue: number,
_yValue: number,
_mouseX: number,
_mouseY: number,
data?: {
[key: string]: string;
},
): void => {
const { start, end } = getStartAndEndTimesInMilliseconds(xValue);
// Extract entity and value from data
const [firstDataPoint] = Object.entries(data || {});
const [entity, value] = (firstDataPoint || ([] as unknown)) as [
string,
string,
];
if (!isEmpty(entity) || !isEmpty(value)) {
onClick?.({
entity,
value,
timeRange: [start, end],
widgetData,
});
}
const onGraphClick = (
widgetData: Widgets,
xValue: number,
_yValue: number,
_mouseX: number,
_mouseY: number,
data?: {
[key: string]: string;
},
[onClick],
);
): void => {
const { start, end } = getStartAndEndTimesInMilliseconds(xValue);
const onAllStateClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data?: any,
): void => {
onGraphClick(
celerySlowestTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
);
},
[onGraphClick],
);
// Extract entity and value from data
const [firstDataPoint] = Object.entries(data || {});
const [entity, value] = (firstDataPoint || ([] as unknown)) as [
string,
string,
];
const onFailedStateClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data?: any,
): void => {
onGraphClick(
celeryFailedTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
);
},
[onGraphClick],
);
const onRetryStateClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data?: any,
): void => {
onGraphClick(
celeryRetryTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
);
},
[onGraphClick],
);
const onSuccessStateClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data?: any,
): void => {
onGraphClick(
celerySuccessTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
);
},
[onGraphClick],
);
if (!isEmpty(entity) || !isEmpty(value)) {
onClick?.({
entity,
value,
timeRange: [start, end],
widgetData,
});
}
};
const { getCustomSeries } = useGetGraphCustomSeries({
isDarkMode,
@@ -268,7 +185,16 @@ function CeleryTaskBar({
headerMenuList={[...ViewMenuAction]}
onDragSelect={onDragSelect}
isQueryEnabled={queryEnabled}
onClickHandler={onAllStateClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void =>
onGraphClick(
celerySlowestTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
)
}
customSeries={getCustomSeries}
dataAvailable={checkIfDataExists}
/>
@@ -279,7 +205,16 @@ function CeleryTaskBar({
headerMenuList={[...ViewMenuAction]}
onDragSelect={onDragSelect}
isQueryEnabled={queryEnabled}
onClickHandler={onFailedStateClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void =>
onGraphClick(
celeryFailedTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
)
}
customSeries={getCustomSeries}
/>
)}
@@ -289,7 +224,16 @@ function CeleryTaskBar({
headerMenuList={[...ViewMenuAction]}
onDragSelect={onDragSelect}
isQueryEnabled={queryEnabled}
onClickHandler={onRetryStateClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void =>
onGraphClick(
celeryRetryTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
)
}
customSeries={getCustomSeries}
/>
)}
@@ -299,7 +243,16 @@ function CeleryTaskBar({
headerMenuList={[...ViewMenuAction]}
onDragSelect={onDragSelect}
isQueryEnabled={queryEnabled}
onClickHandler={onSuccessStateClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void =>
onGraphClick(
celerySuccessTasksTableWidgetData,
xValue,
yValue,
mouseX,
mouseY,
data,
)
}
customSeries={getCustomSeries}
/>
)}

View File

@@ -423,7 +423,7 @@ describe('Footer utils', () => {
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
summary:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
'The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}',
},
condition: {
alertOnAbsent: false,

View File

@@ -278,7 +278,7 @@ export function buildCreateThresholdAlertRulePayload(
labels: basicAlertState.labels,
annotations: {
description: notificationSettings.description,
summary: notificationSettings.description,
summary: notificationSettings.summary,
},
notificationSettings: notificationSettingsProps,
version: 'v5',

View File

@@ -11,6 +11,7 @@ import AdvancedOptionItem from '../EvaluationSettings/AdvancedOptionItem';
import Stepper from '../Stepper';
import MultipleNotifications from './MultipleNotifications';
import NotificationMessage from './NotificationMessage';
import NotificationSummary from './NotificationSummary';
function NotificationSettings(): JSX.Element {
const {
@@ -84,6 +85,7 @@ function NotificationSettings(): JSX.Element {
<div className="notification-settings-container">
<Stepper stepNumber={3} label="Notification settings" />
<NotificationMessage />
<NotificationSummary />
<div className="notification-settings-content">
<MultipleNotifications />
<AdvancedOptionItem

View File

@@ -0,0 +1,43 @@
import { Tooltip, Typography } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import { Info } from 'lucide-react';
import { useCreateAlertState } from '../context';
function NotificationSummary(): JSX.Element {
const {
notificationSettings,
setNotificationSettings,
} = useCreateAlertState();
return (
<div className="notification-summary-container">
<div className="notification-summary-header">
<div className="notification-summary-header-content">
<Typography.Text className="notification-summary-header-title">
Notification Summary
<Tooltip title="Customize the summary content sent in alert notifications. Template variables like {{alertname}}, {{value}}, and {{threshold}} will be replaced with actual values when the alert fires.">
<Info size={16} />
</Tooltip>
</Typography.Text>
<Typography.Text className="notification-summary-header-description">
Custom summary content for alert notifications. Use template variables to
include dynamic information.
</Typography.Text>
</div>
</div>
<TextArea
value={notificationSettings.summary}
onChange={(e): void =>
setNotificationSettings({
type: 'SET_SUMMARY',
payload: e.target.value,
})
}
placeholder="Enter notification summary..."
/>
</div>
);
}
export default NotificationSummary;

View File

@@ -0,0 +1,75 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as createAlertContext from 'container/CreateAlertV2/context';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import NotificationSummary from '../NotificationSummary';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const mockSetNotificationSettings = jest.fn();
const initialNotificationSettingsState = createMockAlertContextState()
.notificationSettings;
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
notificationSettings: {
...initialNotificationSettingsState,
summary: '',
},
setNotificationSettings: mockSetNotificationSettings,
}),
);
describe('NotificationSummary', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders textarea with message and placeholder', () => {
render(<NotificationSummary />);
expect(screen.getByText('Notification Summary')).toBeInTheDocument();
const textarea = screen.getByPlaceholderText('Enter notification summary...');
expect(textarea).toBeInTheDocument();
});
it('updates notification summary when textarea value changes', async () => {
const user = userEvent.setup();
render(<NotificationSummary />);
const textarea = screen.getByPlaceholderText('Enter notification summary...');
await user.type(textarea, 'x');
expect(mockSetNotificationSettings).toHaveBeenLastCalledWith({
type: 'SET_SUMMARY',
payload: 'x',
});
});
it('displays existing description value', () => {
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
() =>
({
notificationSettings: {
summary: 'Existing summary',
},
setNotificationSettings: mockSetNotificationSettings,
} as any),
);
render(<NotificationSummary />);
const textarea = screen.getByDisplayValue('Existing summary');
expect(textarea).toBeInTheDocument();
});
});

View File

@@ -63,6 +63,66 @@
}
}
.notification-summary-container {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: -8px;
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
padding: 16px;
.notification-summary-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.notification-summary-header-content {
display: flex;
flex-direction: column;
gap: 8px;
.notification-summary-header-title {
display: flex;
gap: 8px;
align-items: center;
color: var(--bg-vanilla-300);
font-family: Inter;
font-size: 14px;
font-weight: 500;
}
.notification-summary-header-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-weight: 400;
}
}
.notification-summary-header-actions {
.ant-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
color: var(--bg-robin-400);
}
}
}
textarea {
height: 150px;
background: var(--bg-ink-400);
border: 1px solid var(--bg-slate-200);
border-radius: 4px;
color: var(--bg-vanilla-400) !important;
font-family: Inter;
font-size: 14px;
}
}
.notification-settings-content {
display: flex;
flex-direction: column;
@@ -290,6 +350,35 @@
}
}
.notification-summary-container {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
.notification-summary-header {
.notification-summary-header-content {
.notification-summary-header-title {
color: var(--bg-ink-300);
}
.notification-summary-header-description {
color: var(--bg-ink-400);
}
}
.notification-summary-header-actions {
.ant-btn {
color: var(--bg-robin-500);
}
}
}
textarea {
background: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400) !important;
}
}
.notification-settings-content {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);

View File

@@ -198,6 +198,8 @@ describe('CreateAlertV2 utils', () => {
},
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
summary:
'The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}',
routingPolicies: true,
});
});

View File

@@ -626,6 +626,21 @@ describe('CreateAlertV2 Context Utils', () => {
});
});
it('should set summary', () => {
const summary = 'Custom alert summary with {{$value}}';
const result = notificationSettingsReducer(
INITIAL_NOTIFICATION_SETTINGS_STATE,
{
type: 'SET_SUMMARY',
payload: summary,
},
);
expect(result).toEqual({
...INITIAL_NOTIFICATION_SETTINGS_STATE,
summary,
});
});
it(TEST_RESET_TO_INITIAL_STATE, () => {
const modifiedState: NotificationSettingsState = {
multipleNotifications: ['channel1'],
@@ -636,6 +651,7 @@ describe('CreateAlertV2 Context Utils', () => {
conditions: ['firing'],
},
description: 'Modified description',
summary: 'Modified summary',
routingPolicies: true,
};
const result = notificationSettingsReducer(modifiedState, {
@@ -654,6 +670,7 @@ describe('CreateAlertV2 Context Utils', () => {
conditions: ['nodata'],
},
description: 'New description',
summary: 'New summary',
routingPolicies: true,
};
const result = notificationSettingsReducer(

View File

@@ -179,6 +179,8 @@ export const RE_NOTIFICATION_TIME_UNIT_OPTIONS = [
export const NOTIFICATION_MESSAGE_PLACEHOLDER =
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})';
export const NOTIFICATION_SUMMARY_PLACEHOLDER =
'The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}';
export const RE_NOTIFICATION_CONDITION_OPTIONS = [
{ value: 'firing', label: 'Firing' },
@@ -194,5 +196,6 @@ export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
conditions: [],
},
description: NOTIFICATION_MESSAGE_PLACEHOLDER,
summary: NOTIFICATION_SUMMARY_PLACEHOLDER,
routingPolicies: false,
};

View File

@@ -251,6 +251,7 @@ export interface NotificationSettingsState {
conditions: ('firing' | 'nodata')[];
};
description: string;
summary: string;
routingPolicies: boolean;
}
@@ -269,6 +270,7 @@ export type NotificationSettingsAction =
};
}
| { type: 'SET_DESCRIPTION'; payload: string }
| { type: 'SET_SUMMARY'; payload: string }
| { type: 'SET_ROUTING_POLICIES'; payload: boolean }
| { type: 'SET_INITIAL_STATE'; payload: NotificationSettingsState }
| { type: 'RESET' };

View File

@@ -241,6 +241,8 @@ export const notificationSettingsReducer = (
return { ...state, reNotification: action.payload };
case 'SET_DESCRIPTION':
return { ...state, description: action.payload };
case 'SET_SUMMARY':
return { ...state, summary: action.payload };
case 'SET_ROUTING_POLICIES':
return { ...state, routingPolicies: action.payload };
case 'RESET':

View File

@@ -178,6 +178,7 @@ export function getNotificationSettingsStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): NotificationSettingsState {
const description = alertDef.annotations?.description || '';
const summary = alertDef.annotations?.summary || '';
const multipleNotifications = alertDef.notificationSettings?.groupBy || [];
const routingPolicies = alertDef.notificationSettings?.usePolicy || false;
@@ -197,6 +198,7 @@ export function getNotificationSettingsStateFromAlertDef(
return {
...INITIAL_NOTIFICATION_SETTINGS_STATE,
description,
summary,
multipleNotifications,
routingPolicies,
reNotification: {

View File

@@ -393,15 +393,21 @@ function ExplorerOptions({
backwardCompatibleOptions = omit(options, 'version');
}
// Use the correct default columns based on the current data source
const defaultColumns =
sourcepage === DataSource.TRACES
? defaultTraceSelectedColumns
: defaultLogsSelectedColumns;
if (extraData.selectColumns?.length) {
handleOptionsChange({
...backwardCompatibleOptions,
selectColumns: extraData.selectColumns,
});
} else if (!isEqual(defaultTraceSelectedColumns, options.selectColumns)) {
} else if (!isEqual(defaultColumns, options.selectColumns)) {
handleOptionsChange({
...backwardCompatibleOptions,
selectColumns: defaultTraceSelectedColumns,
selectColumns: defaultColumns,
});
}
};

View File

@@ -26,7 +26,6 @@ import {
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -77,7 +76,6 @@ function WidgetGraphComponent({
const isFullViewOpen = params.get(QueryParams.expandedWidgetId) === widget.id;
const lineChartRef = useRef<ToggleGraphProps>();
const [graphVisibility, setGraphVisibility] = useState<boolean[]>(
Array(queryResponse.data?.payload?.data?.result?.length || 0).fill(true),
);
@@ -112,7 +110,7 @@ function WidgetGraphComponent({
const updateDashboardMutation = useUpdateDashboard();
const onDeleteHandler = useCallback((): void => {
const onDeleteHandler = (): void => {
if (!selectedDashboard) return;
const updatedWidgets = selectedDashboard?.data?.widgets?.filter(
@@ -140,15 +138,9 @@ function WidgetGraphComponent({
setDeleteModal(false);
},
});
}, [
selectedDashboard,
widget.id,
updateDashboardMutation,
setLayouts,
setSelectedDashboard,
]);
};
const onCloneHandler = useCallback(async (): Promise<void> => {
const onCloneHandler = async (): Promise<void> => {
if (!selectedDashboard) return;
const uuid = v4();
@@ -212,18 +204,9 @@ function WidgetGraphComponent({
},
},
);
}, [
selectedDashboard,
widget,
updateDashboardMutation,
setLayouts,
setSelectedDashboard,
notifications,
safeNavigate,
pathname,
]);
};
const handleOnView = useCallback((): void => {
const handleOnView = (): void => {
const queryParams = {
[QueryParams.expandedWidgetId]: widget.id,
};
@@ -242,17 +225,17 @@ function WidgetGraphComponent({
pathname,
search: newSearch,
});
}, [widget.id, search, pathname, safeNavigate]);
};
const handleOnDelete = useCallback((): void => {
const handleOnDelete = (): void => {
onToggleModal(setDeleteModal);
}, [onToggleModal]);
};
const onDeleteModelHandler = useCallback((): void => {
const onDeleteModelHandler = (): void => {
onToggleModal(setDeleteModal);
}, [onToggleModal]);
};
const onToggleModelHandler = useCallback((): void => {
const onToggleModelHandler = (): void => {
const existingSearchParams = new URLSearchParams(search);
existingSearchParams.delete(QueryParams.expandedWidgetId);
existingSearchParams.delete(QueryParams.compositeQuery);
@@ -271,84 +254,63 @@ function WidgetGraphComponent({
pathname,
search: createQueryParams(updatedQueryParams),
});
}, [search, queryResponse.data?.payload, widget.id, pathname, safeNavigate]);
};
const [searchTerm, setSearchTerm] = useState<string>('');
// Memoize the isButtonEnabled value to prevent recalculation
const isGraphClickButtonEnabled = useMemo(
() =>
(widget?.query?.builder?.queryData &&
Array.isArray(widget.query.builder.queryData)
? widget.query.builder.queryData
: []
).some(
(q) =>
q.dataSource === DataSource.TRACES || q.dataSource === DataSource.LOGS,
),
[widget?.query?.builder?.queryData],
);
const graphClick = useGraphClickToShowButton({
graphRef: currentGraphRef?.current ? currentGraphRef : graphRef,
isButtonEnabled: isGraphClickButtonEnabled,
isButtonEnabled: (widget?.query?.builder?.queryData &&
Array.isArray(widget.query.builder.queryData)
? widget.query.builder.queryData
: []
).some(
(q) =>
q.dataSource === DataSource.TRACES || q.dataSource === DataSource.LOGS,
),
buttonClassName: 'view-onclick-show-button',
});
const navigateToExplorer = useNavigateToExplorer();
const graphClickHandler = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
metric?: { [key: string]: string },
queryData?: { queryName: string; inFocusOrNot: boolean },
): void => {
const customTracesTimeRange = getCustomTimeRangeWindowSweepInMS(
customTimeRangeWindowForCoRelation,
);
const { start, end } = getStartAndEndTimesInMilliseconds(
xValue,
customTracesTimeRange,
);
handleGraphClick({
xValue,
yValue,
mouseX,
mouseY,
metric,
queryData,
widget,
navigateToExplorerPages,
navigateToExplorer,
notifications,
graphClick,
...(customTimeRangeWindowForCoRelation
? { customTracesTimeRange: { start, end } }
: {}),
});
},
[
const graphClickHandler = (
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
metric?: { [key: string]: string },
queryData?: { queryName: string; inFocusOrNot: boolean },
): void => {
const customTracesTimeRange = getCustomTimeRangeWindowSweepInMS(
customTimeRangeWindowForCoRelation,
);
const { start, end } = getStartAndEndTimesInMilliseconds(
xValue,
customTracesTimeRange,
);
handleGraphClick({
xValue,
yValue,
mouseX,
mouseY,
metric,
queryData,
widget,
navigateToExplorerPages,
navigateToExplorer,
notifications,
graphClick,
],
);
...(customTimeRangeWindowForCoRelation
? { customTracesTimeRange: { start, end } }
: {}),
});
};
const { truncatedText, fullText } = useGetResolvedText({
text: widget.title as string,
maxLength: 100,
});
// Use the provided onClickHandler if available, otherwise use the default graphClickHandler
// Both should be stable references due to useCallback
const clickHandler = onClickHandler ?? graphClickHandler;
return (
<div
style={{
@@ -404,7 +366,7 @@ function WidgetGraphComponent({
yAxisUnit={widget.yAxisUnit}
onToggleModelHandler={onToggleModelHandler}
tableProcessedDataRef={tableProcessedDataRef}
onClickHandler={clickHandler}
onClickHandler={onClickHandler ?? graphClickHandler}
customOnDragSelect={customOnDragSelect}
setCurrentGraphRef={setCurrentGraphRef}
enableDrillDown={
@@ -454,7 +416,7 @@ function WidgetGraphComponent({
setRequestData={setRequestData}
setGraphVisibility={setGraphVisibility}
graphVisibility={graphVisibility}
onClickHandler={clickHandler}
onClickHandler={onClickHandler ?? graphClickHandler}
onDragSelect={onDragSelect}
tableProcessedDataRef={tableProcessedDataRef}
customTooltipElement={customTooltipElement}

View File

@@ -5,7 +5,7 @@ import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useMemo, useRef } from 'react';
import { useCallback, useMemo } from 'react';
import { useMutation } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@@ -46,11 +46,6 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
[selectedDashboard],
);
// Use ref to access latest mutateAsync without recreating the callback
// queryRangeMutation object recreates on every render, but mutateAsync is stable
const mutateAsyncRef = useRef(queryRangeMutation.mutateAsync);
mutateAsyncRef.current = queryRangeMutation.mutateAsync;
const getUpdatedQuery = useCallback(
async ({
widgetConfig,
@@ -68,12 +63,12 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
});
// Execute query and process results
const queryResult = await mutateAsyncRef.current(queryPayload);
const queryResult = await queryRangeMutation.mutateAsync(queryPayload);
// Map query data from API response
return mapQueryDataFromApi(queryResult.data.compositeQuery);
},
[dynamicVariables, globalSelectedInterval],
[dynamicVariables, globalSelectedInterval, queryRangeMutation],
);
return {

View File

@@ -238,86 +238,6 @@ function External(): JSX.Element {
setSelectedData,
);
const onErrorPercentageClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data: any,
): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_error_percentage',
data,
);
},
[onGraphClickHandler],
);
const onDurationClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data: any,
): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_duration',
data,
);
},
[onGraphClickHandler],
);
const onRPSByAddressClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data: any,
): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_rps_by_address',
data,
);
},
[onGraphClickHandler],
);
const onDurationByAddressClick = useCallback(
(
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
data: any,
): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_duration_by_address',
data,
);
},
[onGraphClickHandler],
);
return (
<>
<Row gutter={24}>
@@ -346,7 +266,16 @@ function External(): JSX.Element {
<Graph
headerMenuList={MENU_ITEMS}
widget={externalCallErrorWidget}
onClickHandler={onErrorPercentageClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_error_percentage',
data,
);
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
/>
@@ -380,7 +309,16 @@ function External(): JSX.Element {
<Graph
headerMenuList={MENU_ITEMS}
widget={externalCallDurationWidget}
onClickHandler={onDurationClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_duration',
data,
);
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
/>
@@ -415,7 +353,16 @@ function External(): JSX.Element {
<Graph
widget={externalCallRPSWidget}
headerMenuList={MENU_ITEMS}
onClickHandler={onRPSByAddressClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): Promise<void> =>
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_rps_by_address',
data,
)
}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
/>
@@ -449,7 +396,16 @@ function External(): JSX.Element {
<Graph
widget={externalCallDurationAddressWidget}
headerMenuList={MENU_ITEMS}
onClickHandler={onDurationByAddressClick}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_duration_by_address',
data,
);
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
/>

View File

@@ -1,5 +1,4 @@
import isEqual from 'lodash-es/isEqual';
import { FC, memo } from 'react';
import { FC } from 'react';
import { PanelTypeVsPanelWrapper } from './constants';
import { PanelWrapperProps } from './panelWrapper.types';
@@ -56,36 +55,4 @@ function PanelWrapper({
);
}
function arePropsEqual(
prevProps: PanelWrapperProps,
nextProps: PanelWrapperProps,
): boolean {
// Destructure to separate props that need deep comparison from the rest
const {
widget: prevWidget,
queryResponse: prevQueryResponse,
...prevRest
} = prevProps;
const {
widget: nextWidget,
queryResponse: nextQueryResponse,
...nextRest
} = nextProps;
// Shallow equality check for all other props (primitives, functions, refs, arrays)
const restKeys = Object.keys(prevRest) as Array<
keyof Omit<PanelWrapperProps, 'widget' | 'queryResponse'>
>;
if (restKeys.some((key) => prevRest[key] !== nextRest[key])) {
return false;
}
// Deep equality only for widget config and query response data payload
return (
isEqual(prevWidget, nextWidget) &&
isEqual(prevQueryResponse.data?.payload, nextQueryResponse.data?.payload)
);
}
export default memo(PanelWrapper, arePropsEqual);
export default PanelWrapper;

View File

@@ -132,21 +132,11 @@ function UplotPanelWrapper({
[selectedGraph, widget?.panelTypes, widget?.stackedBarChart],
);
// Memoize chartData to prevent unnecessary recalculations
const chartData = useMemo(
() =>
getUPlotChartData(
queryResponse?.data?.payload,
widget.fillSpans,
stackedBarChart,
hiddenGraph,
),
[
queryResponse?.data?.payload,
widget.fillSpans,
stackedBarChart,
hiddenGraph,
],
const chartData = getUPlotChartData(
queryResponse?.data?.payload,
widget.fillSpans,
stackedBarChart,
hiddenGraph,
);
useEffect(() => {

View File

@@ -77,20 +77,6 @@ function MessagingQueuesGraph(): JSX.Element {
});
}
};
const onClickHandler = useCallback(
(
xValue: number,
_yValue: number,
_mouseX: number,
_mouseY: number,
data?: any,
): void => {
setSelectedTimelineQuery(urlQuery, xValue, location, history, data);
},
[urlQuery, location, history],
);
return (
<Card
isDarkMode={isDarkMode}
@@ -100,7 +86,9 @@ function MessagingQueuesGraph(): JSX.Element {
<GridCard
widget={widgetData}
headerMenuList={[...ViewMenuAction]}
onClickHandler={onClickHandler}
onClickHandler={(xValue, _yValue, _mouseX, _mouseY, data): void => {
setSelectedTimelineQuery(urlQuery, xValue, location, history, data);
}}
onDragSelect={onDragSelect}
customTooltipElement={messagingQueueCustomTooltipText()}
dataAvailable={checkIfDataExists}

View File

@@ -18,6 +18,16 @@ jest.mock('api/browser/localstorage/get', () => ({
default: jest.fn((key: string) => mockLocalStorage[key] || null),
}));
const mockLogsColumns = [
{ name: 'timestamp', signal: 'logs', fieldContext: 'log' },
{ name: 'body', signal: 'logs', fieldContext: 'log' },
];
const mockTracesColumns = [
{ name: 'service.name', signal: 'traces', fieldContext: 'resource' },
{ name: 'name', signal: 'traces', fieldContext: 'span' },
];
describe('logsLoaderConfig', () => {
// Save original location object
const originalWindowLocation = window.location;
@@ -157,4 +167,83 @@ describe('logsLoaderConfig', () => {
} as FormattingOptions,
});
});
describe('Column validation - filtering Traces columns', () => {
it('should filter out Traces columns (name with traces signal) from URL', async () => {
const mixedColumns = [...mockLogsColumns, ...mockTracesColumns];
mockedLocation.search = `?options=${encodeURIComponent(
JSON.stringify({
selectColumns: mixedColumns,
}),
)}`;
const result = await logsLoaderConfig.url();
// Should only keep logs columns
expect(result.columns).toEqual(mockLogsColumns);
});
it('should filter out Traces columns from localStorage', async () => {
const tracesColumns = [...mockTracesColumns];
mockLocalStorage[LOCALSTORAGE.LOGS_LIST_OPTIONS] = JSON.stringify({
selectColumns: tracesColumns,
});
const result = await logsLoaderConfig.local();
// Should filter out all Traces columns
expect(result.columns).toEqual([]);
});
it('should accept valid Logs columns from URL', async () => {
const logsColumns = [...mockLogsColumns];
mockedLocation.search = `?options=${encodeURIComponent(
JSON.stringify({
selectColumns: logsColumns,
}),
)}`;
const result = await logsLoaderConfig.url();
expect(result.columns).toEqual(logsColumns);
});
it('should fall back to defaults when all columns are filtered out from URL', async () => {
const tracesColumns = [...mockTracesColumns];
mockedLocation.search = `?options=${encodeURIComponent(
JSON.stringify({
selectColumns: tracesColumns,
}),
)}`;
const result = await logsLoaderConfig.url();
// Should return empty array, which triggers fallback to defaults in preferencesLoader
expect(result.columns).toEqual([]);
});
it('should handle columns without signal field (legacy data)', async () => {
const columnsWithoutSignal = [
{ name: 'body', fieldContext: 'log' },
{ name: 'service.name', fieldContext: 'resource' },
];
mockedLocation.search = `?options=${encodeURIComponent(
JSON.stringify({
selectColumns: columnsWithoutSignal,
}),
)}`;
const result = await logsLoaderConfig.url();
// Without signal field, columns pass through validation
// This matches the current implementation behavior where only columns
// with signal !== 'logs' are filtered out
expect(result.columns).toEqual(columnsWithoutSignal);
});
});
});

View File

@@ -1,3 +1,4 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { LOCALSTORAGE } from 'constants/localStorage';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import {
@@ -126,4 +127,112 @@ describe('tracesLoaderConfig', () => {
columns: defaultTraceSelectedColumns as TelemetryFieldKey[],
});
});
describe('Column validation - filtering Logs columns', () => {
it('should filter out Logs columns (body) from URL', async () => {
const logsColumns = [
{ name: 'timestamp', signal: 'logs', fieldContext: 'log' },
{ name: 'body', signal: 'logs', fieldContext: 'log' },
];
mockedLocation.search = `?options=${encodeURIComponent(
JSON.stringify({
selectColumns: logsColumns,
}),
)}`;
const result = await tracesLoaderConfig.url();
// Should filter out all Logs columns
expect(result.columns).toEqual([]);
});
it('should filter out Logs columns (timestamp with logs signal) from URL', async () => {
const mixedColumns = [
{ name: 'timestamp', signal: 'logs', fieldContext: 'log' },
{ name: 'service.name', signal: 'traces', fieldContext: 'resource' },
];
mockedLocation.search = `?options=${encodeURIComponent(
JSON.stringify({
selectColumns: mixedColumns,
}),
)}`;
const result = await tracesLoaderConfig.url();
// Should only keep trace columns
expect(result.columns).toEqual([
{ name: 'service.name', signal: 'traces', fieldContext: 'resource' },
]);
});
it('should filter out Logs columns from localStorage', async () => {
const logsColumns = [
{ name: 'body', signal: 'logs', fieldContext: 'log' },
{ name: 'timestamp', signal: 'logs', fieldContext: 'log' },
];
mockLocalStorage[LOCALSTORAGE.TRACES_LIST_OPTIONS] = JSON.stringify({
selectColumns: logsColumns,
});
const result = await tracesLoaderConfig.local();
// Should filter out all Logs columns
expect(result.columns).toEqual([]);
});
it('should accept valid Trace columns from URL', async () => {
const traceColumns = [
{ name: 'service.name', signal: 'traces', fieldContext: 'resource' },
{ name: 'name', signal: 'traces', fieldContext: 'span' },
];
mockedLocation.search = `?options=${encodeURIComponent(
JSON.stringify({
selectColumns: traceColumns,
}),
)}`;
const result = await tracesLoaderConfig.url();
expect(result.columns).toEqual(traceColumns);
});
it('should fall back to defaults when all columns are filtered out from URL', async () => {
const logsColumns = [{ name: 'body', signal: 'logs' }];
mockedLocation.search = `?options=${encodeURIComponent(
JSON.stringify({
selectColumns: logsColumns,
}),
)}`;
const result = await tracesLoaderConfig.url();
// Should return empty array, which triggers fallback to defaults in preferencesLoader
expect(result.columns).toEqual([]);
});
it('should handle columns without signal field (legacy data)', async () => {
const columnsWithoutSignal = [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'body', fieldContext: 'log' },
];
mockedLocation.search = `?options=${encodeURIComponent(
JSON.stringify({
selectColumns: columnsWithoutSignal,
}),
)}`;
const result = await tracesLoaderConfig.url();
// Without signal field, columns pass through validation
// This matches the current implementation behavior where only columns
// with signal !== 'traces' are filtered out
expect(result.columns).toEqual(columnsWithoutSignal);
});
});
});

View File

@@ -8,6 +8,18 @@ import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteRe
import { FormattingOptions } from '../types';
/**
* Validates if a column is valid for Logs Explorer
* Filters out Traces-specific columns that would cause query failures
*/
const isValidLogColumn = (col: {
name?: string;
signal?: string;
[key: string]: unknown;
}): boolean =>
// If column has signal field, it must be 'logs'
!(col?.signal && col.signal !== 'logs');
// --- LOGS preferences loader config ---
const logsLoaders = {
local: (): {
@@ -18,8 +30,14 @@ const logsLoaders = {
if (local) {
try {
const parsed = JSON.parse(local);
const localColumns = parsed.selectColumns || [];
// Filter out invalid columns (e.g., Logs columns that might have been incorrectly stored)
const validLogColumns = localColumns.filter(isValidLogColumn);
return {
columns: parsed.selectColumns || [],
columns: validLogColumns.length > 0 ? validLogColumns : [],
formatting: {
maxLines: parsed.maxLines ?? 2,
format: parsed.format ?? 'table',
@@ -38,8 +56,14 @@ const logsLoaders = {
const urlParams = new URLSearchParams(window.location.search);
try {
const options = JSON.parse(urlParams.get('options') || '{}');
const urlColumns = options.selectColumns || [];
// Filter out invalid columns (e.g., Logs columns that might have been incorrectly stored)
const validLogColumns = urlColumns.filter(isValidLogColumn);
return {
columns: options.selectColumns || [],
columns: validLogColumns.length > 0 ? validLogColumns : [],
formatting: {
maxLines: options.maxLines ?? 2,
format: options.format ?? 'table',

View File

@@ -5,6 +5,18 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
/**
* Validates if a column is valid for Traces Explorer
* Filters out Logs-specific columns that would cause query failures
*/
const isValidTraceColumn = (col: {
name?: string;
signal?: string;
[key: string]: unknown;
}): boolean =>
// If column has signal field, it must be 'traces'
!(col?.signal && col.signal !== 'traces');
// --- TRACES preferences loader config ---
const tracesLoaders = {
local: (): {
@@ -14,8 +26,13 @@ const tracesLoaders = {
if (local) {
try {
const parsed = JSON.parse(local);
const localColumns = parsed.selectColumns || [];
// Filter out invalid columns (e.g., Logs columns that might have been incorrectly stored)
const validTraceColumns = localColumns.filter(isValidTraceColumn);
return {
columns: parsed.selectColumns || [],
columns: validTraceColumns.length > 0 ? validTraceColumns : [],
};
} catch {}
}
@@ -27,8 +44,15 @@ const tracesLoaders = {
const urlParams = new URLSearchParams(window.location.search);
try {
const options = JSON.parse(urlParams.get('options') || '{}');
const urlColumns = options.selectColumns || [];
// Filter out invalid columns (e.g., Logs columns)
// Only accept columns that are valid for Traces (signal='traces' or columns without signal that aren't logs-specific)
const validTraceColumns = urlColumns.filter(isValidTraceColumn);
// Only return columns if we have valid trace columns, otherwise return empty to fall back to defaults
return {
columns: options.selectColumns || [],
columns: validTraceColumns.length > 0 ? validTraceColumns : [],
};
} catch {}
return { columns: [] };

View File

@@ -3,7 +3,7 @@ package impldashboard
import (
"context"
"maps"
"strings"
"slices"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/errors"
@@ -11,30 +11,34 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/role"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct {
store dashboardtypes.Store
settings factory.ScopedProviderSettings
analytics analytics.Analytics
orgGetter organization.Getter
role role.Module
store dashboardtypes.Store
settings factory.ScopedProviderSettings
analytics analytics.Analytics
orgGetter organization.Getter
role role.Module
queryParser queryparser.QueryParser
}
func NewModule(sqlstore sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, role role.Module) dashboard.Module {
func NewModule(sqlstore sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, role role.Module, queryParser queryparser.QueryParser) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/modules/impldashboard")
return &module{
store: NewStore(sqlstore),
settings: scopedProviderSettings,
analytics: analytics,
orgGetter: orgGetter,
role: role,
store: NewStore(sqlstore),
settings: scopedProviderSettings,
analytics: analytics,
orgGetter: orgGetter,
role: role,
queryParser: queryParser,
}
}
@@ -269,13 +273,10 @@ func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, m
return nil, err
}
// Initialize result map for each metric
result := make(map[string][]map[string]string)
// Process the JSON data in Go
for _, dashboard := range dashboards {
var dashData = dashboard.Data
dashData := dashboard.Data
dashTitle, _ := dashData["title"].(string)
widgets, ok := dashData["widgets"].([]interface{})
if !ok {
@@ -296,44 +297,22 @@ func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, m
continue
}
builder, ok := query["builder"].(map[string]interface{})
if !ok {
continue
}
// Track which metrics were found in this widget
foundMetrics := make(map[string]bool)
queryData, ok := builder["queryData"].([]interface{})
if !ok {
continue
}
// Check all three query types
module.checkBuilderQueriesForMetricNames(query, metricNames, foundMetrics)
module.checkClickHouseQueriesForMetricNames(ctx, query, metricNames, foundMetrics)
module.checkPromQLQueriesForMetricNames(ctx, query, metricNames, foundMetrics)
for _, qd := range queryData {
data, ok := qd.(map[string]interface{})
if !ok {
continue
}
if dataSource, ok := data["dataSource"].(string); !ok || dataSource != "metrics" {
continue
}
aggregateAttr, ok := data["aggregateAttribute"].(map[string]interface{})
if !ok {
continue
}
if key, ok := aggregateAttr["key"].(string); ok {
// Check if this metric is in our list of interest
for _, metricName := range metricNames {
if strings.TrimSpace(key) == metricName {
result[metricName] = append(result[metricName], map[string]string{
"dashboard_id": dashboard.ID,
"widget_name": widgetTitle,
"widget_id": widgetID,
"dashboard_name": dashTitle,
})
}
}
}
// Add widget to results for all found metrics
for metricName := range foundMetrics {
result[metricName] = append(result[metricName], map[string]string{
"dashboard_id": dashboard.ID,
"widget_name": widgetTitle,
"widget_id": widgetID,
"dashboard_name": dashTitle,
})
}
}
}
@@ -361,3 +340,120 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
func (module *module) MustGetTypeables() []authtypes.Typeable {
return []authtypes.Typeable{dashboardtypes.TypeableMetaResourceDashboard, dashboardtypes.TypeableMetaResourcesDashboards}
}
// checkBuilderQueriesForMetricNames checks builder.queryData[] for aggregations[].metricName
func (module *module) checkBuilderQueriesForMetricNames(query map[string]interface{}, metricNames []string, foundMetrics map[string]bool) {
builder, ok := query["builder"].(map[string]interface{})
if !ok {
return
}
queryData, ok := builder["queryData"].([]interface{})
if !ok {
return
}
for _, qd := range queryData {
data, ok := qd.(map[string]interface{})
if !ok {
continue
}
// Check dataSource is metrics
if dataSource, ok := data["dataSource"].(string); !ok || dataSource != "metrics" {
continue
}
// Check aggregations[].metricName
aggregations, ok := data["aggregations"].([]interface{})
if !ok {
continue
}
for _, agg := range aggregations {
aggMap, ok := agg.(map[string]interface{})
if !ok {
continue
}
metricName, ok := aggMap["metricName"].(string)
if !ok || metricName == "" {
continue
}
if slices.Contains(metricNames, metricName) {
foundMetrics[metricName] = true
}
}
}
}
// checkClickHouseQueriesForMetricNames checks clickhouse_sql[] array for metric names in query strings
func (module *module) checkClickHouseQueriesForMetricNames(ctx context.Context, query map[string]interface{}, metricNames []string, foundMetrics map[string]bool) {
clickhouseSQL, ok := query["clickhouse_sql"].([]interface{})
if !ok {
return
}
for _, chQuery := range clickhouseSQL {
chQueryMap, ok := chQuery.(map[string]interface{})
if !ok {
continue
}
queryStr, ok := chQueryMap["query"].(string)
if !ok || queryStr == "" {
continue
}
// Parse query to extract metric names
result, err := module.queryParser.AnalyzeQueryFilter(ctx, qbtypes.QueryTypeClickHouseSQL, queryStr)
if err != nil {
// Log warning and continue - parsing errors shouldn't break the search
module.settings.Logger().WarnContext(ctx, "failed to parse ClickHouse query", "query", queryStr, "error", err)
continue
}
// Check if any of the search metric names are in the extracted metric names
for _, metricName := range metricNames {
if slices.Contains(result.MetricNames, metricName) {
foundMetrics[metricName] = true
}
}
}
}
// checkPromQLQueriesForMetricNames checks promql[] array for metric names in query strings
func (module *module) checkPromQLQueriesForMetricNames(ctx context.Context, query map[string]interface{}, metricNames []string, foundMetrics map[string]bool) {
promQL, ok := query["promql"].([]interface{})
if !ok {
return
}
for _, promQuery := range promQL {
promQueryMap, ok := promQuery.(map[string]interface{})
if !ok {
continue
}
queryStr, ok := promQueryMap["query"].(string)
if !ok || queryStr == "" {
continue
}
// Parse query to extract metric names
result, err := module.queryParser.AnalyzeQueryFilter(ctx, qbtypes.QueryTypePromQL, queryStr)
if err != nil {
// Log warning and continue - parsing errors shouldn't break the search
module.settings.Logger().WarnContext(ctx, "failed to parse PromQL query", "query", queryStr, "error", err)
continue
}
// Check if any of the search metric names are in the extracted metric names
for _, metricName := range metricNames {
if slices.Contains(result.MetricNames, metricName) {
foundMetrics[metricName] = true
}
}
}
}

View File

@@ -137,6 +137,28 @@ func (h *handler) GetMetricMetadata(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, metadata)
}
func (h *handler) GetMetricDashboards(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
metricName := strings.TrimSpace(req.URL.Query().Get("metricName"))
if metricName == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName query parameter is required"))
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
out, err := h.module.GetMetricDashboards(req.Context(), orgID, metricName)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, out)
}
func (h *handler) GetMetricHighlights(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
@@ -165,7 +187,6 @@ func (h *handler) GetMetricAttributes(rw http.ResponseWriter, req *http.Request)
render.Error(rw, err)
return
}
var in metricsexplorertypes.MetricAttributesRequest
if err := binding.JSON.BindBody(req.Body, &in); err != nil {
render.Error(rw, err)

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
@@ -32,11 +33,12 @@ type module struct {
condBuilder qbtypes.ConditionBuilder
logger *slog.Logger
cache cache.Cache
dashboardModule dashboard.Module
config metricsexplorer.Config
}
// NewModule constructs the metrics module with the provided dependencies.
func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetrytypes.MetadataStore, cache cache.Cache, providerSettings factory.ProviderSettings, cfg metricsexplorer.Config) metricsexplorer.Module {
func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetrytypes.MetadataStore, cache cache.Cache, dashboardModule dashboard.Module, providerSettings factory.ProviderSettings, cfg metricsexplorer.Config) metricsexplorer.Module {
fieldMapper := telemetrymetrics.NewFieldMapper()
condBuilder := telemetrymetrics.NewConditionBuilder(fieldMapper)
return &module{
@@ -46,6 +48,7 @@ func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetr
logger: providerSettings.Logger,
telemetryMetadataStore: telemetryMetadataStore,
cache: cache,
dashboardModule: dashboardModule,
config: cfg,
}
}
@@ -194,6 +197,34 @@ func (m *module) UpdateMetricMetadata(ctx context.Context, orgID valuer.UUID, re
return nil
}
func (m *module) GetMetricDashboards(ctx context.Context, orgID valuer.UUID, metricName string) (*metricsexplorertypes.MetricDashboardsResponse, error) {
if metricName == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
}
data, err := m.dashboardModule.GetByMetricNames(ctx, orgID, []string{metricName})
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to get dashboards for metric")
}
dashboards := make([]metricsexplorertypes.MetricDashboard, 0)
if dashboardList, ok := data[metricName]; ok {
dashboards = make([]metricsexplorertypes.MetricDashboard, 0, len(dashboardList))
for _, item := range dashboardList {
dashboards = append(dashboards, metricsexplorertypes.MetricDashboard{
DashboardName: item["dashboard_name"],
DashboardID: item["dashboard_id"],
WidgetID: item["widget_id"],
WidgetName: item["widget_name"],
})
}
}
return &metricsexplorertypes.MetricDashboardsResponse{
Dashboards: dashboards,
}, nil
}
// GetMetricHighlights returns highlights for a metric including data points, last received, total time series, and active time series.
func (m *module) GetMetricHighlights(ctx context.Context, orgID valuer.UUID, metricName string) (*metricsexplorertypes.MetricHighlightsResponse, error) {
if metricName == "" {

View File

@@ -15,6 +15,7 @@ type Handler interface {
GetMetricMetadata(http.ResponseWriter, *http.Request)
GetMetricAttributes(http.ResponseWriter, *http.Request)
UpdateMetricMetadata(http.ResponseWriter, *http.Request)
GetMetricDashboards(http.ResponseWriter, *http.Request)
GetMetricHighlights(http.ResponseWriter, *http.Request)
}
@@ -24,6 +25,7 @@ type Module interface {
GetTreemap(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.TreemapRequest) (*metricsexplorertypes.TreemapResponse, error)
GetMetricMetadataMulti(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string]*metricsexplorertypes.MetricMetadata, error)
UpdateMetricMetadata(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.UpdateMetricMetadataRequest) error
GetMetricDashboards(ctx context.Context, orgID valuer.UUID, metricName string) (*metricsexplorertypes.MetricDashboardsResponse, error)
GetMetricHighlights(ctx context.Context, orgID valuer.UUID, metricName string) (*metricsexplorertypes.MetricHighlightsResponse, error)
GetMetricAttributes(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.MetricAttributesRequest) (*metricsexplorertypes.MetricAttributesResponse, error)
}

View File

@@ -630,6 +630,7 @@ func (ah *APIHandler) MetricExplorerRoutes(router *mux.Router, am *middleware.Au
router.HandleFunc("/api/v2/metrics/metadata", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetMetricMetadata)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/metrics/{metric_name}/metadata", am.EditAccess(ah.Signoz.Handlers.MetricsExplorer.UpdateMetricMetadata)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/metric/highlights", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetMetricHighlights)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/metric/dashboards", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetMetricDashboards)).Methods(http.MethodGet)
}
func Intersection(a, b []int) (c []int) {

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/sharder/noopsharder"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -35,8 +36,9 @@ func TestNewHandlers(t *testing.T) {
require.NoError(t, err)
tokenizer := tokenizertest.New()
emailing := emailingtest.New()
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, Config{})
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{})
handlers := NewHandlers(modules, providerSettings, nil, nil)

View File

@@ -38,6 +38,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/tokenizer"
@@ -79,12 +80,14 @@ func NewModules(
authNs map[authtypes.AuthNProvider]authn.AuthN,
authz authz.AuthZ,
cache cache.Cache,
queryParser queryparser.QueryParser,
config Config,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
user := impluser.NewModule(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, analytics)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings))
dashboard := impldashboard.NewModule(sqlstore, providerSettings, analytics, orgGetter, implrole.NewModule(implrole.NewStore(sqlstore), authz, nil), queryParser)
return Modules{
OrgGetter: orgGetter,
@@ -92,7 +95,7 @@ func NewModules(
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(sqlstore),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: impldashboard.NewModule(sqlstore, providerSettings, analytics, orgGetter, implrole.NewModule(implrole.NewStore(sqlstore), authz, nil)),
Dashboard: dashboard,
User: user,
UserGetter: userGetter,
QuickFilter: quickfilter,
@@ -102,6 +105,6 @@ func NewModules(
Session: implsession.NewModule(providerSettings, authNs, user, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs), tokenizer, orgGetter),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, providerSettings, config.MetricsExplorer),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, dashboard, providerSettings, config.MetricsExplorer),
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/sharder/noopsharder"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -35,8 +36,9 @@ func TestNewModules(t *testing.T) {
require.NoError(t, err)
tokenizer := tokenizertest.New()
emailing := emailingtest.New()
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, Config{})
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{})
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -346,7 +346,7 @@ func New(
)
// Initialize all modules
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, config)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config)
// Initialize all handlers for the modules
handlers := NewHandlers(modules, providerSettings, querier, licensing)

View File

@@ -221,6 +221,19 @@ type TreemapResponse struct {
Samples []TreemapEntry `json:"samples"`
}
// MetricDashboard represents a dashboard/widget referencing a metric.
type MetricDashboard struct {
DashboardName string `json:"dashboardName"`
DashboardID string `json:"dashboardId"`
WidgetID string `json:"widgetId"`
WidgetName string `json:"widgetName"`
}
// MetricDashboardsResponse represents the response for metric dashboards endpoint.
type MetricDashboardsResponse struct {
Dashboards []MetricDashboard `json:"dashboards"`
}
// MetricHighlightsResponse is the output structure for the metric highlights endpoint.
type MetricHighlightsResponse struct {
DataPoints uint64 `json:"dataPoints"`