Compare commits

..

4 Commits

Author SHA1 Message Date
aks07
c7567128a7 feat(metrics-explorer): mount alert and dashboard actions in the header
One chart per query hands the split queries over, so each one can be
exported on its own like the bar did.
2026-09-25 00:51:28 +05:30
aks07
0e3cd51d48 feat(traces-explorer): mount alert and dashboard actions in the toolbar 2026-09-25 00:51:28 +05:30
aks07
f7cd0c0d8a feat(logs-explorer): mount alert and dashboard actions in the controls row 2026-09-25 00:51:28 +05:30
aks07
defa17f960 feat(explorer): create alert and add to dashboard buttons
Standalone replacements for the two ExplorerOptions actions. The page
hands over the export queries; two or more render a per-query picker.
Alert shaping is source agnostic: every noop becomes count and list
style panels drop their order by.
2026-09-25 00:51:28 +05:30
11 changed files with 1184 additions and 11 deletions

View File

@@ -0,0 +1,118 @@
import { useState } from 'react';
import { Grid2X2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ExportPanelContainer from 'container/ExportPanel/ExportPanelContainer';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import {
EXPLORER_ACTION_EVENTS,
getExportPanelType,
getQueryName,
} from './utils';
function AddToDashboardButton({
queries,
sourcepage,
panelType,
}: {
queries: Query[] | null;
sourcepage: DataSource;
panelType?: PANEL_TYPES;
}): JSX.Element {
const [queryToExport, setQueryToExport] = useState<Query | null>(null);
const { panelType: contextPanelType } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const disabled = !queries?.length;
const oneChartPerQuery = (queries?.length ?? 0) > 1;
const open = (query: Query): void => {
void logEvent(EXPLORER_ACTION_EVENTS.addToDashboard, {
sourcepage,
panelType: contextPanelType,
oneChartPerQuery,
});
setQueryToExport(query);
};
const handleExport = (
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
): void => {
if (!dashboard || !queryToExport) {
return;
}
const exportPanelType = panelType ?? getExportPanelType(contextPanelType);
void logEvent(EXPLORER_ACTION_EVENTS.exported, {
sourcepage,
panelType: exportPanelType,
oneChartPerQuery,
isNewDashboard,
dashboardName: dashboard.title,
});
const link = getExportToDashboardLink({
query: queryToExport,
panelType: exportPanelType,
dashboardId: dashboard.id,
widgetId: v4(),
});
if (link) {
safeNavigate(link);
}
};
const button = (
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={disabled}
onClick={queries?.length === 1 ? (): void => open(queries[0]) : undefined}
aria-label="Add to dashboard"
data-testid="explorer-add-to-dashboard"
>
<Grid2X2 size={16} />
</Button>
);
return (
<>
{oneChartPerQuery && queries ? (
<DropdownMenuSimple
menu={{
items: queries.map((query) => ({
key: query.id,
label: getQueryName(query),
onClick: (): void => open(query),
})),
}}
align="end"
>
{button}
</DropdownMenuSimple>
) : (
<TooltipSimple title="Add to dashboard">{button}</TooltipSimple>
)}
<ExportPanelContainer
open={queryToExport !== null}
onClose={(): void => setQueryToExport(null)}
query={queryToExport}
onExport={handleExport}
/>
</>
);
}
export default AddToDashboardButton;

View File

@@ -0,0 +1,72 @@
import { useHistory } from 'react-router-dom';
import { ConciergeBell } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import logEvent from 'api/common/logEvent';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
EXPLORER_ACTION_EVENTS,
getCreateAlertLink,
getQueryName,
} from './utils';
function CreateAlertButton({
queries,
sourcepage,
}: {
queries: Query[] | null;
sourcepage: DataSource;
}): JSX.Element {
const history = useHistory();
const { panelType } = useQueryBuilder();
const disabled = !queries?.length;
const createAlert = (query: Query): void => {
void logEvent(EXPLORER_ACTION_EVENTS.createAlert, {
sourcepage,
panelType,
oneChartPerQuery: (queries?.length ?? 0) > 1,
});
history.push(getCreateAlertLink({ query, panelType }));
};
const button = (
<Button
variant="ghost"
color="secondary"
size="md"
disabled={disabled}
onClick={
queries?.length === 1 ? (): void => createAlert(queries[0]) : undefined
}
data-testid="explorer-create-alert"
>
<ConciergeBell size={16} />
Create an alert
</Button>
);
if (!queries || queries.length <= 1) {
return button;
}
return (
<DropdownMenuSimple
menu={{
items: queries.map((query) => ({
key: query.id,
label: getQueryName(query),
onClick: (): void => createAlert(query),
})),
}}
align="end"
>
{button}
</DropdownMenuSimple>
);
}
export default CreateAlertButton;

View File

@@ -0,0 +1,384 @@
import logEvent from 'api/common/logEvent';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import {
getExportQueryData as getLogsExportQuery,
getQueryByPanelType as getLogsQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import { OptionsQuery } from 'container/OptionsMenu/types';
import {
getExportQueryData as getTracesExportQuery,
getQueryByPanelType as getTracesQueryByPanelType,
} from 'container/TracesExplorer/explorerUtils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { buildExportPanelLink } from 'pages/DashboardPage/DashboardContainer/PanelEditor/newPanelRoute';
import { render, screen, userEvent } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import AddToDashboardButton from '../AddToDashboardButton';
import { EXPLORER_ACTION_EVENTS, getExportPanelType } from '../utils';
const DASHBOARD = { id: 'dash-1', title: 'Dash 1' };
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: jest.fn(),
}));
jest.mock('uuid', () => ({ v4: (): string => 'widget-1' }));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve()),
}));
// The picker is the dialog's business; here it just hands a dashboard back.
jest.mock('container/ExportPanel/ExportPanelContainer', () => ({
__esModule: true,
default: ({
open,
query,
onExport,
}: {
open: boolean;
query: Query | null;
onExport: (dashboard: { id: string; title: string }) => void;
}): JSX.Element | null =>
open ? (
<button
type="button"
data-testid="export-stub"
data-query={JSON.stringify(query)}
onClick={(): void => onExport({ id: 'dash-1', title: 'Dash 1' })}
>
export
</button>
) : null,
}));
// The menu is the design system's; here each item is a plain button.
jest.mock('@signozhq/ui/dropdown-menu', () => ({
DropdownMenuSimple: ({
menu,
children,
}: {
menu: { items: { key: string; label: string; onClick: () => void }[] };
children: React.ReactNode;
}): JSX.Element => (
<div>
{children}
{menu.items.map((item) => (
<button
type="button"
key={item.key}
data-testid={`menu-${item.label}`}
onClick={item.onClick}
>
{item.label}
</button>
))}
</div>
),
}));
const mockSafeNavigate = jest.fn();
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
const mockedUseSafeNavigate = jest.mocked(useSafeNavigate);
const mockedLogEvent = jest.mocked(logEvent);
const FILTER = "service.name = 'frontend'";
const COLUMNS = [{ name: 'service.name' }, { name: 'name' }];
const options = { selectColumns: COLUMNS } as unknown as OptionsQuery;
function stagedQuery(dataSource: DataSource, queryName = 'A'): Query {
const base = initialQueriesMap[dataSource];
return {
...base,
id: `query-${queryName}`,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
queryName,
aggregateOperator: StringOperators.COUNT,
filter: { expression: FILTER },
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
},
],
},
} as Query;
}
function setPanelType(panelType: PANEL_TYPES): void {
mockedUseQueryBuilder.mockReturnValue({ panelType } as ReturnType<
typeof useQueryBuilder
>);
}
async function exportTo(
queries: Query[] | null,
sourcepage: DataSource,
panelType: PANEL_TYPES,
panelTypeProp?: PANEL_TYPES,
): Promise<void> {
setPanelType(panelType);
render(
<AddToDashboardButton
queries={queries}
sourcepage={sourcepage}
panelType={panelTypeProp}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('explorer-add-to-dashboard'));
await user.click(screen.getByTestId('export-stub'));
}
function expectedLink(query: Query, panelType: PANEL_TYPES): string | null {
return buildExportPanelLink({
query,
panelType,
dashboardId: DASHBOARD.id,
});
}
describe('AddToDashboardButton', () => {
beforeEach(() => {
mockSafeNavigate.mockReset();
mockedLogEvent.mockClear();
mockedUseSafeNavigate.mockReturnValue({ safeNavigate: mockSafeNavigate });
});
it.each([null, []])(
'is disabled with %p and the picker stays closed',
async (queries) => {
setPanelType(PANEL_TYPES.LIST);
render(
<AddToDashboardButton queries={queries} sourcepage={DataSource.LOGS} />,
);
expect(screen.getByTestId('explorer-add-to-dashboard')).toBeDisabled();
expect(screen.queryByTestId('export-stub')).not.toBeInTheDocument();
},
);
it('hands the picker the same query it will export', async () => {
const query = stagedQuery(DataSource.LOGS);
setPanelType(PANEL_TYPES.TIME_SERIES);
render(
<AddToDashboardButton queries={[query]} sourcepage={DataSource.LOGS} />,
);
await userEvent
.setup()
.click(screen.getByTestId('explorer-add-to-dashboard'));
expect(screen.getByTestId('export-stub')).toHaveAttribute(
'data-query',
JSON.stringify(query),
);
});
it('logs open and success with the source page', async () => {
const query = stagedQuery(DataSource.TRACES);
await exportTo([query], DataSource.TRACES, PANEL_TYPES.TABLE);
expect(mockedLogEvent).toHaveBeenCalledWith(
EXPLORER_ACTION_EVENTS.addToDashboard,
{
sourcepage: DataSource.TRACES,
panelType: PANEL_TYPES.TABLE,
oneChartPerQuery: false,
},
);
expect(mockedLogEvent).toHaveBeenCalledWith(EXPLORER_ACTION_EVENTS.exported, {
sourcepage: DataSource.TRACES,
panelType: PANEL_TYPES.TABLE,
oneChartPerQuery: false,
isNewDashboard: undefined,
dashboardName: DASHBOARD.title,
});
});
it('a panel type from the page wins over the fold of the context one', async () => {
const query = stagedQuery(DataSource.METRICS);
// context says list, the page says time series
await exportTo(
[query],
DataSource.METRICS,
PANEL_TYPES.LIST,
PANEL_TYPES.TIME_SERIES,
);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(query, PANEL_TYPES.TIME_SERIES),
);
});
describe('logs, the query the page hands over per view', () => {
const staged = stagedQuery(DataSource.LOGS);
it('list: the list request shaping with timestamp desc, panel type list', async () => {
const listRequest = getLogsQueryByPanelType(staged, PANEL_TYPES.LIST, {
page: 1,
pageSize: 100,
filters: { items: [], op: 'AND' },
filter: { expression: FILTER },
});
const exportQuery = getLogsExportQuery(
listRequest,
PANEL_TYPES.LIST,
) as Query;
await exportTo([exportQuery], DataSource.LOGS, PANEL_TYPES.LIST);
expect(exportQuery.builder.queryData[0].orderBy).toStrictEqual([
{ columnName: 'timestamp', order: 'desc' },
]);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(exportQuery, PANEL_TYPES.LIST),
);
});
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
'%s: staged query untouched, same panel type',
async (panelType) => {
const exportQuery = getLogsExportQuery(staged, panelType) as Query;
await exportTo([exportQuery], DataSource.LOGS, panelType);
expect(exportQuery).toBe(staged);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(staged, panelType),
);
},
);
});
describe('traces, the query the page hands over per view', () => {
const staged = stagedQuery(DataSource.TRACES);
it('list: list shaping plus the selected columns, panel type list', async () => {
const exportQuery = getTracesExportQuery(
getTracesQueryByPanelType(staged, PANEL_TYPES.LIST),
getExportPanelType(PANEL_TYPES.LIST),
options,
);
await exportTo([exportQuery], DataSource.TRACES, PANEL_TYPES.LIST);
const [queryData] = exportQuery.builder.queryData;
expect(queryData.selectColumns).toStrictEqual(COLUMNS);
expect(queryData.groupBy).toStrictEqual([]);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(exportQuery, PANEL_TYPES.LIST),
);
});
it('trace: list shaping, no columns, panel type folds to time series', async () => {
const exportQuery = getTracesExportQuery(
getTracesQueryByPanelType(staged, PANEL_TYPES.TRACE),
getExportPanelType(PANEL_TYPES.TRACE),
options,
);
await exportTo([exportQuery], DataSource.TRACES, PANEL_TYPES.TRACE);
expect(exportQuery.builder.queryData[0].selectColumns).toBeUndefined();
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(exportQuery, PANEL_TYPES.TIME_SERIES),
);
});
// Same as the alert: the list / trace order lives in ListView state and the
// page shapes the export without it, so the panel query has no order by.
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'%s: order by is not carried into the panel query',
async (panelType) => {
expect(staged.builder.queryData[0].orderBy).toHaveLength(1);
const exportQuery = getTracesExportQuery(
getTracesQueryByPanelType(staged, panelType),
getExportPanelType(panelType),
options,
);
await exportTo([exportQuery], DataSource.TRACES, panelType);
expect(exportQuery.builder.queryData[0].orderBy).toStrictEqual([]);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(exportQuery, getExportPanelType(panelType)),
);
},
);
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
'%s: staged query untouched, same panel type',
async (panelType) => {
const exportQuery = getTracesExportQuery(
getTracesQueryByPanelType(staged, panelType),
getExportPanelType(panelType),
options,
);
await exportTo([exportQuery], DataSource.TRACES, panelType);
expect(exportQuery).toBe(staged);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(staged, panelType),
);
},
);
});
describe('metrics, one chart per query', () => {
const queryA = stagedQuery(DataSource.METRICS, 'A');
const queryB = stagedQuery(DataSource.METRICS, 'B');
it('two or more queries render a picker; the chosen one goes to the picker and the link', async () => {
setPanelType(PANEL_TYPES.TIME_SERIES);
render(
<AddToDashboardButton
queries={[queryA, queryB]}
sourcepage={DataSource.METRICS}
panelType={PANEL_TYPES.TIME_SERIES}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('menu-Query B'));
expect(screen.getByTestId('export-stub')).toHaveAttribute(
'data-query',
JSON.stringify(queryB),
);
await user.click(screen.getByTestId('export-stub'));
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(queryB, PANEL_TYPES.TIME_SERIES),
);
expect(mockedLogEvent).toHaveBeenCalledWith(
EXPLORER_ACTION_EVENTS.addToDashboard,
expect.objectContaining({
sourcepage: DataSource.METRICS,
oneChartPerQuery: true,
}),
);
});
it('a single query is a plain button, no picker', async () => {
await exportTo(
[queryA],
DataSource.METRICS,
PANEL_TYPES.TIME_SERIES,
PANEL_TYPES.TIME_SERIES,
);
expect(screen.queryByTestId('menu-Query A')).not.toBeInTheDocument();
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(queryA, PANEL_TYPES.TIME_SERIES),
);
});
});
});

View File

@@ -0,0 +1,281 @@
import { useHistory } from 'react-router-dom';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
getExportQueryData as getLogsExportQuery,
getQueryByPanelType as getLogsQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import { getQueryByPanelType as getTracesQueryByPanelType } from 'container/TracesExplorer/explorerUtils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { render, screen, userEvent } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import CreateAlertButton from '../CreateAlertButton';
import { EXPLORER_ACTION_EVENTS } from '../utils';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: jest.fn(),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve()),
}));
// The menu is the design system's; here each item is a plain button.
jest.mock('@signozhq/ui/dropdown-menu', () => ({
DropdownMenuSimple: ({
menu,
children,
}: {
menu: { items: { key: string; label: string; onClick: () => void }[] };
children: React.ReactNode;
}): JSX.Element => (
<div>
{children}
{menu.items.map((item) => (
<button
type="button"
key={item.key}
data-testid={`menu-${item.label}`}
onClick={item.onClick}
>
{item.label}
</button>
))}
</div>
),
}));
const mockPush = jest.fn();
const mockedUseHistory = jest.mocked(useHistory);
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
const mockedLogEvent = jest.mocked(logEvent);
const FILTER = "service.name = 'frontend'";
const ORDER_BY = [{ columnName: 'timestamp', order: 'asc' }];
function stagedQuery(
dataSource: DataSource,
aggregateOperator: StringOperators,
queryName = 'A',
): Query {
const base = initialQueriesMap[dataSource];
return {
...base,
id: `query-${queryName}`,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
queryName,
aggregateOperator,
filter: { expression: FILTER },
orderBy: ORDER_BY,
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
},
],
},
} as Query;
}
function pushedQuery(): Query {
expect(mockPush).toHaveBeenCalledTimes(1);
const [path, search] = (mockPush.mock.calls[0][0] as string).split('?');
expect(path).toBe(ROUTES.ALERTS_NEW);
const raw = new URLSearchParams(search).get(QueryParams.compositeQuery);
return JSON.parse(raw as string);
}
function setPanelType(panelType: PANEL_TYPES): void {
mockedUseQueryBuilder.mockReturnValue({ panelType } as ReturnType<
typeof useQueryBuilder
>);
}
async function clickCreateAlert(
queries: Query[] | null,
sourcepage: DataSource,
panelType: PANEL_TYPES,
): Promise<void> {
setPanelType(panelType);
render(<CreateAlertButton queries={queries} sourcepage={sourcepage} />);
await userEvent.setup().click(screen.getByTestId('explorer-create-alert'));
}
describe('CreateAlertButton', () => {
beforeEach(() => {
mockPush.mockReset();
mockedLogEvent.mockClear();
mockedUseHistory.mockReturnValue({ push: mockPush } as unknown as ReturnType<
typeof useHistory
>);
});
it.each([null, []])(
'is disabled and does nothing with %p',
async (queries) => {
await clickCreateAlert(queries, DataSource.LOGS, PANEL_TYPES.LIST);
expect(screen.getByTestId('explorer-create-alert')).toBeDisabled();
expect(mockPush).not.toHaveBeenCalled();
},
);
it('logs one event with the source page', async () => {
const query = stagedQuery(DataSource.TRACES, StringOperators.COUNT);
await clickCreateAlert([query], DataSource.TRACES, PANEL_TYPES.TIME_SERIES);
expect(mockedLogEvent).toHaveBeenCalledWith(
EXPLORER_ACTION_EVENTS.createAlert,
{
sourcepage: DataSource.TRACES,
panelType: PANEL_TYPES.TIME_SERIES,
oneChartPerQuery: false,
},
);
});
describe('logs, the query the page hands over per view', () => {
const staged = stagedQuery(DataSource.LOGS, StringOperators.NOOP);
it('list: count aggregation, no order by, filter and pagination as the page sent them', async () => {
const listRequest = getLogsQueryByPanelType(staged, PANEL_TYPES.LIST, {
page: 1,
pageSize: 100,
filters: { items: [], op: 'AND' },
filter: { expression: FILTER },
});
const exportQuery = getLogsExportQuery(
listRequest,
PANEL_TYPES.LIST,
) as Query;
await clickCreateAlert([exportQuery], DataSource.LOGS, PANEL_TYPES.LIST);
const [queryData] = pushedQuery().builder.queryData;
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
expect(queryData.orderBy).toStrictEqual([]);
expect(queryData.groupBy).toStrictEqual([]);
expect(queryData.filter).toStrictEqual({ expression: FILTER });
expect(queryData.pageSize).toBe(100);
});
it('time series: staged query as is, order by and group by kept', async () => {
const tsStaged = stagedQuery(DataSource.LOGS, StringOperators.COUNT);
const exportQuery = getLogsExportQuery(
tsStaged,
PANEL_TYPES.TIME_SERIES,
) as Query;
await clickCreateAlert(
[exportQuery],
DataSource.LOGS,
PANEL_TYPES.TIME_SERIES,
);
const [queryData] = pushedQuery().builder.queryData;
expect(queryData).toStrictEqual(tsStaged.builder.queryData[0]);
});
it('table: staged query as is', async () => {
const tableStaged = stagedQuery(DataSource.LOGS, StringOperators.COUNT);
const exportQuery = getLogsExportQuery(
tableStaged,
PANEL_TYPES.TABLE,
) as Query;
await clickCreateAlert([exportQuery], DataSource.LOGS, PANEL_TYPES.TABLE);
expect(pushedQuery().builder).toStrictEqual(tableStaged.builder);
});
});
describe('traces, the query the page hands over per view', () => {
const staged = stagedQuery(DataSource.TRACES, StringOperators.NOOP);
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'%s: count aggregation, group by cleared by the list shaping, filter kept',
async (panelType) => {
const exportQuery = getTracesQueryByPanelType(staged, panelType);
await clickCreateAlert([exportQuery], DataSource.TRACES, panelType);
const [queryData] = pushedQuery().builder.queryData;
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
expect(queryData.groupBy).toStrictEqual([]);
expect(queryData.filter).toStrictEqual({ expression: FILTER });
},
);
// The list / trace views keep their order in ListView state, and the page
// shapes the export without it, so the alert never sees an order by.
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'%s: order by is not carried, even when the staged query has one',
async (panelType) => {
expect(staged.builder.queryData[0].orderBy).toStrictEqual(ORDER_BY);
const exportQuery = getTracesQueryByPanelType(staged, panelType);
await clickCreateAlert([exportQuery], DataSource.TRACES, panelType);
expect(pushedQuery().builder.queryData[0].orderBy).toStrictEqual([]);
},
);
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
'%s: staged query as is',
async (panelType) => {
const aggStaged = stagedQuery(DataSource.TRACES, StringOperators.COUNT);
const exportQuery = getTracesQueryByPanelType(aggStaged, panelType);
await clickCreateAlert([exportQuery], DataSource.TRACES, panelType);
expect(pushedQuery().builder).toStrictEqual(aggStaged.builder);
},
);
});
describe('metrics, one chart per query', () => {
const queryA = stagedQuery(DataSource.METRICS, StringOperators.COUNT, 'A');
const queryB = stagedQuery(DataSource.METRICS, StringOperators.COUNT, 'B');
it('two or more queries render a picker and the chosen one is exported', async () => {
setPanelType(PANEL_TYPES.TIME_SERIES);
render(
<CreateAlertButton
queries={[queryA, queryB]}
sourcepage={DataSource.METRICS}
/>,
);
await userEvent.setup().click(screen.getByTestId('menu-Query B'));
expect(pushedQuery().builder.queryData[0].queryName).toBe('B');
expect(mockedLogEvent).toHaveBeenCalledWith(
EXPLORER_ACTION_EVENTS.createAlert,
expect.objectContaining({
sourcepage: DataSource.METRICS,
oneChartPerQuery: true,
}),
);
});
it('a single query is a plain button, no picker', async () => {
await clickCreateAlert(
[queryA],
DataSource.METRICS,
PANEL_TYPES.TIME_SERIES,
);
expect(screen.queryByTestId('menu-Query A')).not.toBeInTheDocument();
expect(pushedQuery().builder).toStrictEqual(queryA.builder);
});
});
});

View File

@@ -0,0 +1,190 @@
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { StringOperators } from 'types/common/queryBuilder';
import {
getCreateAlertLink,
getExportPanelType,
getExportQueries,
getQueryName,
} from '../utils';
function withFirstQuery(
base: Query,
overrides: Partial<Query['builder']['queryData'][number]>,
): Query {
return {
...base,
builder: {
...base.builder,
queryData: [{ ...base.builder.queryData[0], ...overrides }],
},
};
}
function decodeQuery(link: string): Query {
const search = link.split('?')[1];
const raw = new URLSearchParams(search).get(QueryParams.compositeQuery);
return JSON.parse(raw as string);
}
describe('getExportPanelType', () => {
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE, PANEL_TYPES.LIST])(
'keeps %s',
(panelType) => {
expect(getExportPanelType(panelType)).toBe(panelType);
},
);
it.each([PANEL_TYPES.BAR, PANEL_TYPES.PIE, PANEL_TYPES.TRACE, null])(
'folds %s to time series',
(panelType) => {
expect(getExportPanelType(panelType)).toBe(PANEL_TYPES.TIME_SERIES);
},
);
});
describe('getExportQueries', () => {
const query = initialQueriesMap.metrics;
const split = [initialQueriesMap.metrics, initialQueriesMap.logs];
it('is null without a query, whatever the split says', () => {
expect(getExportQueries(null)).toBeNull();
expect(getExportQueries(null, split)).toBeNull();
});
it('wraps the one query when there is no split', () => {
expect(getExportQueries(query)).toStrictEqual([query]);
expect(getExportQueries(query, undefined)).toStrictEqual([query]);
});
it('ignores a split of one and returns the query itself', () => {
expect(getExportQueries(query, [split[0]])).toStrictEqual([query]);
});
it('returns the split when it has two or more queries', () => {
expect(getExportQueries(query, split)).toBe(split);
});
});
describe('getQueryName', () => {
it('names a builder query by its query name', () => {
expect(getQueryName(initialQueriesMap.metrics)).toBe('Query A');
});
it('names a formula split by the formula name', () => {
const withFormula = {
...initialQueriesMap.metrics,
builder: {
...initialQueriesMap.metrics.builder,
queryFormulas: [{ queryName: 'F1', expression: 'A / B' }],
},
} as Query;
expect(getQueryName(withFormula)).toBe('Formula F1');
});
});
describe('getCreateAlertLink', () => {
const orderBy = [{ columnName: 'timestamp', order: 'desc' }];
it('points at the new alert route with the query in the url', () => {
const query = initialQueriesMap.traces;
const link = getCreateAlertLink({
query,
panelType: PANEL_TYPES.TIME_SERIES,
});
expect(link.startsWith(`${ROUTES.ALERTS_NEW}?`)).toBe(true);
expect(decodeQuery(link)).toStrictEqual(query);
});
it('logs list: noop becomes count and order by is dropped', () => {
const query = withFirstQuery(initialQueriesMap.logs, {
aggregateOperator: StringOperators.NOOP,
orderBy,
});
const [queryData] = decodeQuery(
getCreateAlertLink({
query,
panelType: PANEL_TYPES.LIST,
}),
).builder.queryData;
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
expect(queryData.orderBy).toStrictEqual([]);
});
it('logs time series keeps order by', () => {
const query = withFirstQuery(initialQueriesMap.logs, {
aggregateOperator: StringOperators.COUNT,
orderBy,
});
const [queryData] = decodeQuery(
getCreateAlertLink({
query,
panelType: PANEL_TYPES.TIME_SERIES,
}),
).builder.queryData;
expect(queryData.orderBy).toStrictEqual(orderBy);
});
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'%s drops order by whatever the source',
(panelType) => {
const query = withFirstQuery(initialQueriesMap.traces, {
aggregateOperator: StringOperators.NOOP,
orderBy,
});
const [queryData] = decodeQuery(getCreateAlertLink({ query, panelType }))
.builder.queryData;
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
expect(queryData.orderBy).toStrictEqual([]);
},
);
it('converts a noop on any query, not only the first', () => {
const first = initialQueriesMap.logs.builder.queryData[0];
const query: Query = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{ ...first, aggregateOperator: StringOperators.COUNT },
{ ...first, queryName: 'B', aggregateOperator: StringOperators.NOOP },
],
},
};
const operators = decodeQuery(
getCreateAlertLink({ query, panelType: PANEL_TYPES.TIME_SERIES }),
).builder.queryData.map((item) => item.aggregateOperator);
expect(operators).toStrictEqual([
StringOperators.COUNT,
StringOperators.COUNT,
]);
});
it('does not mutate the query it is given', () => {
const query = withFirstQuery(initialQueriesMap.logs, {
aggregateOperator: StringOperators.NOOP,
orderBy,
});
const snapshot = JSON.stringify(query);
getCreateAlertLink({
query,
panelType: PANEL_TYPES.LIST,
});
expect(JSON.stringify(query)).toBe(snapshot);
});
});

View File

@@ -0,0 +1,66 @@
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { cloneDeep } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { StringOperators } from 'types/common/queryBuilder';
export const EXPLORER_ACTION_EVENTS = {
createAlert: 'Explorer: Create alert clicked',
addToDashboard: 'Explorer: Add to dashboard clicked',
exported: 'Explorer: Add to dashboard successful',
} as const;
export function getExportQueries(
query: Query | null,
splitQueries?: Query[],
): Query[] | null {
if (!query) {
return null;
}
if (splitQueries && splitQueries.length > 1) {
return splitQueries;
}
return [query];
}
export function getExportPanelType(panelType: PANEL_TYPES | null): PANEL_TYPES {
return panelType && AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
? panelType
: PANEL_TYPES.TIME_SERIES;
}
export function getQueryName(query: Query): string {
if (query.builder.queryFormulas.length > 0) {
return `Formula ${query.builder.queryFormulas[0].queryName}`;
}
return `Query ${query.builder.queryData[0].queryName}`;
}
// Alerts need an aggregation, and list style views carry an order the alert
// cannot use.
export function getCreateAlertLink({
query,
panelType,
}: {
query: Query;
panelType: PANEL_TYPES | null;
}): string {
const isListStyle =
panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE;
const alertQuery = cloneDeep(query);
alertQuery.builder.queryData = alertQuery.builder.queryData.map((item) => ({
...item,
aggregateOperator:
item.aggregateOperator === StringOperators.NOOP
? StringOperators.COUNT
: item.aggregateOperator,
orderBy: isListStyle ? [] : item.orderBy,
}));
return `${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
JSON.stringify(alertQuery),
)}`;
}

View File

@@ -7,9 +7,12 @@ import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOp
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { LOCALSTORAGE } from 'constants/localStorage';
import { PANEL_TYPES } from 'constants/queryBuilder';
import AddToDashboardButton from 'container/ExplorerActions/AddToDashboardButton';
import CreateAlertButton from 'container/ExplorerActions/CreateAlertButton';
import { useOptionsMenu } from 'container/OptionsMenu';
import { LOGS_REQUIRED_COLUMNS } from 'container/OptionsMenu/constants';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
function LogsActionsContainer({
@@ -19,6 +22,7 @@ function LogsActionsContainer({
handleToggleFrequencyChart,
orderBy,
setOrderBy,
exportQueries,
}: {
listQuery: any;
selectedPanelType: PANEL_TYPES;
@@ -26,6 +30,7 @@ function LogsActionsContainer({
handleToggleFrequencyChart: () => void;
orderBy: string;
setOrderBy: (value: string) => void;
exportQueries: Query[] | null;
}): JSX.Element {
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
@@ -73,6 +78,11 @@ function LogsActionsContainer({
</div>
<div className="tab-options-right">
<CreateAlertButton queries={exportQueries} sourcepage={DataSource.LOGS} />
<AddToDashboardButton
queries={exportQueries}
sourcepage={DataSource.LOGS}
/>
{selectedPanelType === PANEL_TYPES.LIST && (
<>
<div className="order-by-container">

View File

@@ -37,6 +37,7 @@ import {
getListQuery,
getQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import { getExportQueries } from 'container/ExplorerActions/utils';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
@@ -424,6 +425,7 @@ function LogsExplorerViewsContainer({
handleToggleFrequencyChart={handleToggleFrequencyChart}
orderBy={orderBy}
setOrderBy={setOrderBy}
exportQueries={getExportQueries(exportDefaultQuery)}
/>
)}

View File

@@ -10,6 +10,9 @@ import WarningPopover from 'components/WarningPopover/WarningPopover';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import AddToDashboardButton from 'container/ExplorerActions/AddToDashboardButton';
import CreateAlertButton from 'container/ExplorerActions/CreateAlertButton';
import { getExportQueries } from 'container/ExplorerActions/utils';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
@@ -302,6 +305,15 @@ function Explorer(): JSX.Element {
[stagedQuery, metricNames, units],
);
const exportQueries = useMemo(
() =>
getExportQueries(
stagedQuery ? exportDefaultQuery : null,
showOneChartPerQuery ? splitedQueries : undefined,
),
[stagedQuery, exportDefaultQuery, showOneChartPerQuery, splitedQueries],
);
const [selectedMetricName, setSelectedMetricName] = useState<string | null>(
null,
);
@@ -365,6 +377,15 @@ function Explorer(): JSX.Element {
<div className="explore-header-right-actions">
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
<DateTimeSelector showAutoRefresh />
<CreateAlertButton
queries={exportQueries}
sourcepage={DataSource.METRICS}
/>
<AddToDashboardButton
queries={exportQueries}
sourcepage={DataSource.METRICS}
panelType={PANEL_TYPES.TIME_SERIES}
/>
<RightToolbarActions
onStageRunQuery={(): void => handleRunQuery()}
isLoadingQueries={isLoadingQueries}

View File

@@ -4,6 +4,7 @@ import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { render, screen } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
@@ -146,9 +147,11 @@ function renderExplorer(): void {
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
<TooltipProvider>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
</TooltipProvider>
</Provider>
</MemoryRouter>
</QueryClientProvider>,

View File

@@ -13,6 +13,12 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import AddToDashboardButton from 'container/ExplorerActions/AddToDashboardButton';
import CreateAlertButton from 'container/ExplorerActions/CreateAlertButton';
import {
getExportPanelType,
getExportQueries,
} from 'container/ExplorerActions/utils';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
@@ -194,6 +200,16 @@ function TracesExplorer(): JSX.Element {
[stagedQuery, panelType],
);
const exportDashboardQuery = useMemo(
() =>
getExportQueryData(
exportDefaultQuery,
getExportPanelType(panelType),
options,
),
[exportDefaultQuery, panelType, options],
);
const handleExport = useCallback(
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
if (!dashboard || !panelType) {
@@ -289,14 +305,24 @@ function TracesExplorer(): JSX.Element {
!isEmpty(warning) ? <WarningPopover warningData={warning} /> : <div />
}
rightActions={
<RightToolbarActions
onStageRunQuery={(): void => {
setIsCancelled(false);
handleRunQuery();
}}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
/>
<>
<CreateAlertButton
queries={getExportQueries(stagedQuery ? exportDefaultQuery : null)}
sourcepage={DataSource.TRACES}
/>
<AddToDashboardButton
queries={getExportQueries(stagedQuery ? exportDashboardQuery : null)}
sourcepage={DataSource.TRACES}
/>
<RightToolbarActions
onStageRunQuery={(): void => {
setIsCancelled(false);
handleRunQuery();
}}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
/>
</>
}
/>
</div>