Compare commits

..

28 Commits

Author SHA1 Message Date
Naman Verma
d4ae156dc4 fix: make threshold value non-nullable 2026-06-29 23:18:17 +05:30
Naman Verma
d6bdf9c2b2 fix: catch error from decodeTelemetryFields 2026-06-29 23:02:40 +05:30
Naman Verma
7ea654f1aa test: fix unit tests 2026-06-29 22:57:40 +05:30
Naman Verma
3fd7d013a1 fix: adjust to schema changes in variables 2026-06-29 22:56:36 +05:30
Naman Verma
fb921dd381 fix: read threshold value properly during migration 2026-06-29 22:56:12 +05:30
Naman Verma
58020d9e00 fix: note error in query parsing 2026-06-29 22:53:40 +05:30
Naman Verma
7a5933e822 fix: ignore react placeholders in layout 2026-06-29 22:52:48 +05:30
Naman Verma
2533683de6 fix: allow zero value for threshold values 2026-06-29 22:51:56 +05:30
Naman Verma
2670d53170 Merge branch 'main' into nv/dashboard-migration 2026-06-29 20:28:39 +05:30
Naman Verma
8943a9454b Merge branch 'main' into nv/dashboard-migration 2026-06-26 02:15:22 +05:30
Naman Verma
9a7ed5b711 feat: note down all errors in migration 2026-06-26 02:07:06 +05:30
Naman Verma
2d75e3d32d chore: separate error type for migration 2026-06-26 01:07:59 +05:30
Naman Verma
1d6eabf927 chore: remove spec md file 2026-06-25 22:37:52 +05:30
Naman Verma
082d7b1b77 test: fix ut to check for v2 internal name 2026-06-25 22:34:39 +05:30
Naman Verma
5019dee2d7 Merge branch 'main' into nv/dashboard-migration 2026-06-25 22:30:55 +05:30
Naman Verma
216de973fb fix: remove nil nil return 2026-06-25 03:07:58 +05:30
Naman Verma
18c0eec5e2 chore: catch typecast errors 2026-06-19 15:49:01 +05:30
Naman Verma
2ccdeb3631 chore: add a catch all panic check to log migration error 2026-06-19 15:15:17 +05:30
Naman Verma
ad12e50bbc fix: extract row and widget positions to build expanded sections 2026-06-19 15:00:32 +05:30
Naman Verma
e247bf3864 fix: sanitize tags instead of throwing error 2026-06-19 14:17:36 +05:30
Naman Verma
f4651ea134 fix: match with lower case signal for variables 2026-06-19 12:17:42 +05:30
Naman Verma
d449a2dbf2 fix: generate internal name from title 2026-06-19 11:24:40 +05:30
Naman Verma
d4b9f91062 Merge branch 'main' into nv/dashboard-migration 2026-06-18 12:28:22 +05:30
Naman Verma
530710b7bc Merge branch 'nv/dashboard-migration' of https://github.com/SigNoz/signoz into nv/dashboard-migration 2026-06-17 12:42:24 +05:30
Naman Verma
4fb5eec08d fix: move WrapInV5Envelope to types package 2026-06-17 12:42:20 +05:30
Naman Verma
f889d36f0f Merge branch 'main' into nv/dashboard-migration 2026-06-17 12:32:08 +05:30
Naman Verma
db12d44523 Merge branch 'main' into nv/dashboard-migration 2026-06-17 07:30:34 +05:30
Naman Verma
86fc0e81ba chore: add migration script from current to perses dashboard 2026-06-14 22:58:08 +05:30
83 changed files with 2875 additions and 1739 deletions

View File

@@ -2626,7 +2626,6 @@ components:
unit:
type: string
value:
format: double
type: number
required:
- value
@@ -3568,7 +3567,6 @@ components:
unit:
type: string
value:
format: double
type: number
required:
- value
@@ -3605,7 +3603,6 @@ components:
unit:
type: string
value:
format: double
type: number
required:
- value

View File

@@ -3338,7 +3338,6 @@ export interface DashboardtypesThresholdWithLabelDTO {
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
@@ -3866,7 +3865,6 @@ export interface DashboardtypesComparisonThresholdDTO {
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
@@ -4147,7 +4145,6 @@ export interface DashboardtypesTableThresholdDTO {
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}

View File

@@ -6,10 +6,6 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { useNotifications } from 'hooks/useNotifications';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { AppState } from 'store/reducers';
@@ -128,13 +124,15 @@ export function useNavigateToExplorer(): (
});
}
applySerializedParams(serialize(preparedQuery), urlParams);
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(preparedQuery));
const basePath =
dataSource === DataSource.TRACES
? ROUTES.TRACES_EXPLORER
: ROUTES.LOGS_EXPLORER;
const newExplorerPath = `${basePath}?${urlParams.toString()}`;
const newExplorerPath = `${basePath}?${urlParams.toString()}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
},

View File

@@ -32,7 +32,6 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { cloneDeep } from 'lodash-es';
import {
@@ -253,7 +252,7 @@ function LogDetailInner({
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: minTime?.toString() || '',
[QueryParams.endTime]: maxTime?.toString() || '',
...serializeToParams(
[QueryParams.compositeQuery]: JSON.stringify(
updateAllQueriesOperators(
initialQueriesMap[DataSource.LOGS],
PANEL_TYPES.LIST,

View File

@@ -18,6 +18,7 @@ export enum QueryParams {
q = 'q',
activeLogId = 'activeLogId',
timeRange = 'timeRange',
compositeQuery = 'compositeQuery',
panelTypes = 'panelTypes',
pageSize = 'pageSize',
viewMode = 'viewMode',

View File

@@ -1,7 +1,5 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getAutoContexts } from '../getAutoContexts';
@@ -50,48 +48,6 @@ describe('getAutoContexts', () => {
]);
});
it('includes the query in alert edit context', () => {
const ruleId = 'rule-edit';
const query = { queryType: 'builder', builder: { queryData: [] } };
const serializedParams = serialize(query as unknown as Query);
const search = `?${QueryParams.ruleId}=${ruleId}&${serializedParams.toString()}`;
const contexts = getAutoContexts(ROUTES.EDIT_ALERTS, search);
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: {
page: 'alert_edit',
ruleId,
query,
},
},
]);
});
it('includes the query in alert new context (no ruleId)', () => {
const query = { queryType: 'builder', builder: { queryData: [] } };
const serializedParams = serialize(query as unknown as Query);
const search = `?${serializedParams.toString()}`;
const contexts = getAutoContexts(ROUTES.ALERTS_NEW, search);
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'alert',
resourceId: null,
metadata: {
page: 'alert_new',
query,
},
},
]);
});
it('returns triggered alerts context on alert history without ruleId', () => {
const contexts = getAutoContexts(ROUTES.ALERT_HISTORY, '');
@@ -191,24 +147,4 @@ describe('getAutoContexts', () => {
),
).toStrictEqual([]);
});
it('decodes the serialized composite query into metadata.query', () => {
const query = { builder: { queryData: [] } } as unknown as Query;
const search = `?${serialize(query).toString()}`;
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
expect(context.metadata?.query).toStrictEqual(query);
});
it('omits metadata.query when no serialized query is in the URL', () => {
// Detection no longer gates on the `compositeQuery` key — it routes
// through `deserialize`/the adapter list — so non-query params (time
// range, etc.) must not be mistaken for a query.
const search = `?${QueryParams.startTime}=1700000000000&${QueryParams.endTime}=1700003600000`;
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
expect(context.metadata).not.toHaveProperty('query');
});
});

View File

@@ -24,7 +24,7 @@ import {
undoExecution,
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { QueryParams } from 'constants/query';
import { openInNewTab } from 'utils/navigation';
import {
ArchiveRestore,
@@ -363,8 +363,8 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → history.push', base);
const params = serialize(normalized);
deps.history.push(`${base}?${params.toString()}`);
const encoded = encodeURIComponent(JSON.stringify(normalized));
deps.history.push(`${base}?${QueryParams.compositeQuery}=${encoded}`);
}
/** Picks the right rollback API call for a given action kind. */

View File

@@ -8,7 +8,6 @@ import { getViewById } from 'api/saveView/getViewById';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { deserialize } from 'lib/compositeQuery/serializer';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
import { DataSource } from 'types/common/queryBuilder';
@@ -219,9 +218,7 @@ describe('buildExplorerNavigationUrl', () => {
);
expect(url).toContain(ROUTES.LOGS_EXPLORER);
const params = new URLSearchParams(new URL(url, 'http://x').search);
expect(deserialize(params)).not.toBeNull();
expect(url).toContain(`${QueryParams.compositeQuery}=`);
expect(url).toContain(`${QueryParams.viewKey}=`);
});
});

View File

@@ -2,10 +2,6 @@ import { getAllViews } from 'api/saveView/getAllViews';
import { getViewById } from 'api/saveView/getViewById';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import { ViewProps } from 'types/api/saveViews/types';
@@ -79,7 +75,10 @@ export function buildExplorerNavigationUrl(
searchParams: Record<string, unknown>,
): string {
const params = new URLSearchParams();
applySerializedParams(serialize(query), params);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
Object.entries(searchParams).forEach(([key, value]) => {
params.set(key, JSON.stringify(value));
});

View File

@@ -1,7 +1,6 @@
import type { MessageContext } from 'api/ai-assistant/chat';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { deserialize } from 'lib/compositeQuery/serializer';
import { AlertListTabs } from 'pages/AlertList/types';
import { matchPath } from 'react-router-dom';
@@ -125,9 +124,7 @@ export function getAutoContexts(
}
}
// Alert edit — `/alerts/edit?ruleId=…`. The form syncs its query-builder
// state to the URL (`useShareBuilderUrl`), so shared metadata carries the
// alert's query + time range, mirroring the dashboard panel editor.
// Alert edit — `/alerts/edit?ruleId=…`.
if (matchPath(pathname, { path: ROUTES.EDIT_ALERTS, exact: true })) {
const ruleId = params.get(QueryParams.ruleId);
if (ruleId) {
@@ -136,21 +133,19 @@ export function getAutoContexts(
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: { page: 'alert_edit', ruleId, ...sharedMetadata },
metadata: { page: 'alert_edit', ruleId },
},
];
}
}
// Alert new — `/alerts/new`. No rule id yet (draft), but the query-builder
// state is on the URL, so shared metadata carries the in-progress query.
if (matchPath(pathname, { path: ROUTES.ALERTS_NEW, exact: true })) {
return [
{
source: 'auto',
type: 'alert',
resourceId: null,
metadata: { page: 'alert_new', ...sharedMetadata },
metadata: { page: 'alert_new' },
},
];
}
@@ -344,9 +339,15 @@ function collectSharedMetadata(
out.timeRange = { start: startTime, end: endTime };
}
const decodedQuery = deserialize(params);
if (decodedQuery) {
out.query = decodedQuery;
// Query Builder state — URL-encoded JSON written by `QueryBuilderProvider`.
const compositeQueryRaw = params.get(QueryParams.compositeQuery);
if (compositeQueryRaw) {
try {
out.query = JSON.parse(decodeURIComponent(compositeQueryRaw));
} catch {
// Malformed JSON in the URL — drop silently rather than throw
// inside a context-collection helper.
}
}
// Saved view selectors (logs / traces explorer) and dashboard variables.

View File

@@ -26,12 +26,7 @@ jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
describe('Should check if the edit alert channel is properly displayed', () => {
beforeEach(() => {
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
render(<EditAlertChannels initialValue={editAlertChannelInitialValue} />);
});
afterEach(() => {
jest.clearAllMocks();

View File

@@ -1,81 +0,0 @@
import EditAlertChannels from 'container/EditAlertChannels';
import { editAlertChannelInitialValue } from 'mocks-server/__mockdata__/alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: { success: jest.fn(), error: jest.fn() },
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
interface EditRequest {
id: string;
body: { name: string; slack_configs: { send_resolved: boolean }[] };
}
// Captures the PUT /channels/:id request the edit form fires, so assertions can
// run against the real HTTP payload instead of a hand-mocked api client.
function mockEditChannel(): { calls: EditRequest[] } {
const result: { calls: EditRequest[] } = { calls: [] };
server.use(
rest.put('http://localhost/api/v1/channels/:id', async (req, res, ctx) => {
result.calls.push({
id: req.params.id as string,
body: await req.json(),
});
return res(
ctx.status(200),
ctx.json({ status: 'success', data: 'channel updated' }),
);
}),
);
return result;
}
describe('EditAlertChannels save', () => {
afterEach(() => jest.clearAllMocks());
it('sends the channelId in the edit request (regression: empty id)', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('3');
});
it('persists send_resolved toggle in the edit request', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
const user = userEvent.setup();
const sendResolved = screen.getByTestId('field-send-resolved-checkbox');
expect(sendResolved).toBeChecked();
await user.click(sendResolved);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('3');
expect(edit.calls[0].body.slack_configs[0].send_resolved).toBe(false);
});
});

View File

@@ -2,8 +2,8 @@ import { memo } from 'react';
import { Card, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES, PANEL_TYPES_INITIAL_QUERY } from 'constants/queryBuilder';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
@@ -28,7 +28,9 @@ function PanelTypeSelectionModal(): JSX.Element {
const queryParams = {
graphType: name,
widgetId: id,
...serializeToParams(PANEL_TYPES_INITIAL_QUERY[name]),
[QueryParams.compositeQuery]: JSON.stringify(
PANEL_TYPES_INITIAL_QUERY[name],
),
};
history.push(

View File

@@ -32,7 +32,6 @@ import APIError from 'types/api/error';
function EditAlertChannels({
initialValue,
channelId: id,
}: EditAlertChannelsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('channels');
@@ -54,6 +53,11 @@ function EditAlertChannels({
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
// Extract channelId from URL pathname since useParams doesn't work in nested routing
const { pathname } = window.location;
const channelIdMatch = pathname.match(/\/settings\/channels\/edit\/([^/]+)/);
const id = channelIdMatch ? channelIdMatch[1] : '';
const [type, setType] = useState<ChannelType>(
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
);
@@ -516,7 +520,6 @@ interface EditAlertChannelsProps {
initialValue: {
[x: string]: unknown;
};
channelId: string;
}
export default EditAlertChannels;

View File

@@ -62,8 +62,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import useErrorNotification from 'hooks/useErrorNotification';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { cloneDeep, isEqual, omit } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
@@ -176,7 +174,7 @@ function ExplorerOptions({
const handleConditionalQueryModification = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(defaultQuery: Query | null): Record<string, string> => {
(defaultQuery: Query | null): string => {
const queryToUse = defaultQuery || query;
if (!queryToUse) {
throw new Error('No query provided');
@@ -186,7 +184,7 @@ function ExplorerOptions({
StringOperators.NOOP &&
sourcepage !== DataSource.LOGS
) {
return serializeToParams(queryToUse);
return JSON.stringify(queryToUse);
}
// Convert NOOP to COUNT for alerts and strip orderBy for logs
@@ -210,7 +208,14 @@ function ExplorerOptions({
);
}
return serializeToParams(modifiedQuery);
try {
return JSON.stringify(modifiedQuery);
} catch (err) {
throw new Error(
'Failed to stringify modified query: ' +
(err instanceof Error ? err.message : String(err)),
);
}
},
[panelType, query, sourcepage],
);
@@ -233,9 +238,13 @@ function ExplorerOptions({
});
}
const serializedParams = handleConditionalQueryModification(defaultQuery);
const stringifiedQuery = handleConditionalQueryModification(defaultQuery);
history.push(`${ROUTES.ALERTS_NEW}?${createQueryParams(serializedParams)}`);
history.push(
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
stringifiedQuery,
)}`,
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[handleConditionalQueryModification, history],

View File

@@ -136,7 +136,6 @@ function FormAlertChannels({
<Form.Item>
<Button
data-testid="save-channel-button"
disabled={savingState}
loading={savingState}
type="primary"
@@ -145,7 +144,6 @@ function FormAlertChannels({
{t('button_save_channel')}
</Button>
<Button
data-testid="test-channel-button"
disabled={testingState}
loading={testingState}
onClick={(): void => onTestHandler(type)}
@@ -153,7 +151,6 @@ function FormAlertChannels({
{t('button_test_channel')}
</Button>
<Button
data-testid="return-button"
onClick={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}

View File

@@ -34,7 +34,6 @@ import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { clearSerializedParams } from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEmpty, isEqual } from 'lodash-es';
@@ -385,7 +384,7 @@ function FormAlertRules({
const onCancelHandler = useCallback(
(e?: React.MouseEvent) => {
clearSerializedParams(urlQuery);
urlQuery.delete(QueryParams.compositeQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);
@@ -611,7 +610,7 @@ function FormAlertRules({
`${ruleId}`,
]);
clearSerializedParams(urlQuery);
urlQuery.delete(QueryParams.compositeQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);

View File

@@ -23,10 +23,6 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
clearSerializedParams,
serializeToParams,
} from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import {
@@ -216,7 +212,9 @@ function WidgetGraphComponent({
[QueryParams.graphType]: clonedWidget?.panelTypes,
[QueryParams.widgetId]: uuid,
...(clonedWidget?.query && {
...serializeToParams(clonedWidget.query),
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(clonedWidget.query),
),
}),
};
safeNavigate(`${pathname}/new?${createQueryParams(queryParams)}`);
@@ -257,7 +255,7 @@ function WidgetGraphComponent({
const onToggleModelHandler = (): void => {
const existingSearchParams = new URLSearchParams(search);
existingSearchParams.delete(QueryParams.expandedWidgetId);
clearSerializedParams(existingSearchParams);
existingSearchParams.delete(QueryParams.compositeQuery);
existingSearchParams.delete(QueryParams.graphType);
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
if (queryResponse.data?.payload) {

View File

@@ -29,10 +29,6 @@ import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import useComponentPermission from 'hooks/useComponentPermission';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { isEmpty } from 'lodash-es';
import { unparse } from 'papaparse';
@@ -90,7 +86,10 @@ function WidgetHeader({
const widgetId = widget.id;
urlQuery.set(QueryParams.widgetId, widgetId);
urlQuery.set(QueryParams.graphType, widget.panelTypes);
applySerializedParams(serialize(widget.query), urlQuery);
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(widget.query)),
);
const generatedUrl = buildAbsolutePath({
relativePath: 'new',
urlQueryString: urlQuery.toString(),

View File

@@ -7,10 +7,6 @@ import { useListRules } from 'api/generated/services/rules';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import history from 'lib/history';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
@@ -138,7 +134,10 @@ export default function AlertRules({
const compositeQuery = mapQueryDataFromApi(
toCompositeMetricQuery(record.condition.compositeQuery),
);
applySerializedParams(serialize(compositeQuery), params);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {

View File

@@ -28,10 +28,6 @@ import {
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import {
@@ -414,7 +410,7 @@ export default function K8sBaseDetails<T>({
},
};
applySerializedParams(serialize(compositeQuery as any), urlQuery);
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
@@ -439,7 +435,7 @@ export default function K8sBaseDetails<T>({
},
};
applySerializedParams(serialize(compositeQuery as any), urlQuery);
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}

View File

@@ -53,7 +53,6 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useGetGlobalConfig } from 'api/generated/services/global';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { serialize } from 'lib/compositeQuery/serializer';
import { cloneDeep, isNil, isUndefined } from 'lodash-es';
import {
ArrowUpRight,
@@ -78,7 +77,6 @@ import {
UpdateLimitProps,
} from 'types/api/ingestionKeys/limits/types';
import { PaginationProps } from 'types/api/ingestionKeys/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { MeterAggregateOperator } from 'types/common/queryBuilder';
import { USER_ROLES } from 'types/roles';
import { getDaysUntilExpiry } from 'utils/timeUtils';
@@ -898,6 +896,8 @@ function MultiIngestionSettings(): JSX.Element {
},
};
const stringifiedQuery = JSON.stringify(query);
const thresholds = cloneDeep(INITIAL_ALERT_THRESHOLD_STATE.thresholds);
thresholds[0].thresholdValue = thresholdValue;
thresholds[0].unit = thresholdUnit;
@@ -907,12 +907,17 @@ function MultiIngestionSettings(): JSX.Element {
? `[ingestion][${signal.signal}] ${keyName} has exceeded daily ingestion limit`
: `[ingestion][${signal.signal}] ${signal.signal} has exceeded daily ingestion limit`;
const params = serialize(query as Query);
params.set(QueryParams.thresholds, JSON.stringify(thresholds));
params.set(QueryParams.ruleName, ruleName);
params.set(QueryParams.yAxisUnit, yAxisUnit);
const URL = `${ROUTES.ALERTS_NEW}?${
QueryParams.compositeQuery
}=${encodeURIComponent(stringifiedQuery)}&${
QueryParams.thresholds
}=${encodeURIComponent(JSON.stringify(thresholds))}&${
QueryParams.ruleName
}=${encodeURIComponent(ruleName)}&${
QueryParams.yAxisUnit
}=${encodeURIComponent(yAxisUnit)}`;
history.push(`${ROUTES.ALERTS_NEW}?${params.toString()}`);
history.push(URL);
};
const columns: AntDTableProps<GatewaytypesIngestionKeyDTO>['columns'] = [

View File

@@ -1,6 +1,5 @@
import { GatewaytypesGettableIngestionKeysDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { deserialize } from 'lib/compositeQuery/serializer';
import { rest, server } from 'mocks-server/server';
import {
fireEvent,
@@ -133,19 +132,17 @@ describe('MultiIngestionSettings Page', () => {
expect(thresholds[0].thresholdValue).toBe(1000);
expect(thresholds[0].unit).toBe('{count}');
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
expect(compositeQuery?.unit).toBe('{count}');
expect(compositeQuery?.builder.queryData).toBeDefined();
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.unit).toBe('{count}');
expect(compositeQuery.builder.queryData).toBeDefined();
const firstQueryData = compositeQuery?.builder.queryData[0];
expect(firstQueryData?.filter?.expression).toContain(
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toContain(
"signoz.workspace.key.id='k1'",
);
const firstAggregation = firstQueryData?.aggregations?.[0] as {
metricName: string;
};
expect(firstAggregation.metricName).toBe(
expect(firstQueryData.aggregations[0].metricName).toBe(
'signoz.meter.metric.datapoint.count',
);
@@ -216,18 +213,18 @@ describe('MultiIngestionSettings Page', () => {
expect(thresholds[0].thresholdValue).toBe(400);
expect(thresholds[0].unit).toBe('GiBy');
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
expect(compositeQuery?.unit).toBe('bytes');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.unit).toBe('bytes');
const firstQueryData = compositeQuery?.builder.queryData[0];
expect(firstQueryData?.filter?.expression).toContain(
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toContain(
"signoz.workspace.key.id='k2'",
);
const firstAggregation = firstQueryData?.aggregations?.[0] as {
metricName: string;
};
expect(firstAggregation.metricName).toBe('signoz.meter.log.size');
expect(firstQueryData.aggregations[0].metricName).toBe(
'signoz.meter.log.size',
);
expect(urlParams.get(QueryParams.yAxisUnit)).toBe('bytes');
expect(urlParams.get(QueryParams.ruleName)).toContain('logs');

View File

@@ -13,9 +13,6 @@
}
.pageHeaderTitle {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
.title {
margin: 0;
font-size: var(--font-size-xl);

View File

@@ -1,7 +1,3 @@
import { Tabs } from '@signozhq/ui/tabs';
import { Typography } from '@signozhq/ui/typography';
import ModelCostTabPanel from './ModelCostTabPanel';
import styles from './LLMObservabilityModelPricing.module.scss';
function LLMObservabilityModelPricing(): JSX.Element {
@@ -12,34 +8,12 @@ function LLMObservabilityModelPricing(): JSX.Element {
>
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<Typography.Text as="h1" size="large" weight="semibold">
Configuration
</Typography.Text>
<Typography.Text color="muted">
<h1 className={styles.title}>Configuration</h1>
<p className={styles.subtitle}>
Model pricing and cost estimation settings
</Typography.Text>
</p>
</div>
</header>
<Tabs
// Model costs is the only enabled tab for now, so default to it. When
// the unpriced-models tab lands, this can become a URL-backed param.
defaultValue="model-costs"
items={[
{
key: 'model-costs',
label: 'Model costs',
children: <ModelCostTabPanel />,
},
{
// Unpriced-models tab lands in a later PR.
key: 'unpriced-models',
label: 'Unpriced models',
disabled: true,
children: null,
},
]}
/>
</div>
);
}

View File

@@ -1,7 +0,0 @@
.pageError {
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
color: var(--text-cherry-400);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,61 +0,0 @@
import { useMemo } from 'react';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
import { useTableParams } from 'components/TanStackTableView';
import { Typography } from '@signozhq/ui/typography';
import { LIMIT_KEY, PAGE_KEY, PAGE_SIZE } from '../constants';
import styles from './ModelCostTabPanel.module.scss';
import ModelCostsTable from './components/ModelCostsTable';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
function ModelCostTabPanel(): JSX.Element {
const { page, limit } = useTableParams(
{ page: PAGE_KEY, limit: LIMIT_KEY },
{ page: 1, limit: PAGE_SIZE },
);
// Search + source filters are intentionally omitted for now — the list API
// doesn't honour them yet. They'll be reintroduced here once it does.
const listParams: ListLLMPricingRulesParams = {
offset: (page - 1) * limit,
limit,
};
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
const rules: LlmpricingruletypesLLMPricingRuleDTO[] = useMemo(
() => data?.data?.items || [],
[data],
);
const total = data?.data?.total ?? 0;
return (
<>
{isError && (
<div className={styles.pageError} role="alert">
Failed to load pricing rules. Please try again.
</div>
)}
{/* Read-only listing. Edit/Add wiring + the drawer land in the next PR. */}
<ModelCostsTable
rules={rules}
isLoading={isLoading}
total={total}
selectedRuleId={null}
canManage={false}
onEdit={(): void => undefined}
onDelete={(): void => undefined}
/>
<footer>
<Typography.Text color="muted" size="small">
All prices per 1M tokens (USD)
</Typography.Text>
</footer>
</>
);
}
export default ModelCostTabPanel;

View File

@@ -1,8 +0,0 @@
.actionButton {
opacity: 0.7;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
}

View File

@@ -1,61 +0,0 @@
import { useMemo } from 'react';
import { Ellipsis } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './ModelCostActionsMenu.module.scss';
interface ModelCostActionsMenuProps {
rule: LlmpricingruletypesLLMPricingRuleDTO;
canManage: boolean;
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
}
// Per-row kebab menu for the model-costs table. Only manage users get actions
// (Edit + Delete); view-only users have nothing to act on, so the cell stays
// empty rather than showing a single-item menu.
function ModelCostActionsMenu({
rule,
canManage,
onEdit,
onDelete,
}: ModelCostActionsMenuProps): JSX.Element | null {
const menuItems = useMemo<MenuItem[]>(
() => [
{
key: 'edit',
label: 'Edit',
onClick: (): void => onEdit(rule),
},
{
key: 'delete',
label: 'Delete',
danger: true,
onClick: (): void => onDelete(rule),
},
],
[onEdit, onDelete, rule],
);
if (!canManage) {
return null;
}
return (
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.actionButton}
testId={`model-cost-actions-${rule.id}`}
>
<Ellipsis size={16} />
</Button>
</DropdownMenuSimple>
);
}
export default ModelCostActionsMenu;

View File

@@ -1,20 +0,0 @@
.modelCostsTable {
margin-top: var(--spacing-8);
--tanstack-table-row-height: 48px;
height: calc(100vh - 250px);
overflow-y: auto;
:global(table) tbody tr {
cursor: default;
}
}
.modelCostsEmpty {
display: flex;
align-items: center;
justify-content: center;
margin-top: var(--spacing-8);
min-height: 400px;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,73 +0,0 @@
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import {
LIMIT_KEY,
PAGE_KEY,
PAGE_SIZE,
SKELETON_ROW_COUNT,
} from '../../../constants';
import styles from './ModelCostsTable.module.scss';
import { getModelCostsColumns } from './TableConfig';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
interface ModelCostsTableProps {
rules: LlmpricingruletypesLLMPricingRuleDTO[];
isLoading: boolean;
total: number;
selectedRuleId: string | null;
canManage: boolean;
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
}
// The table owns its own pagination URL state (page/limit) via enableQueryParams;
// ModelCostsTab reads the same keys to build the list request. Virtual scroll is
// disabled: a plain table renders fine at our page sizes (up to 100 rows) and the
// fixed-height scroll viewport (.modelCostsTable) keeps large pages scrolling
// inside the table.
function ModelCostsTable({
rules,
isLoading,
total,
selectedRuleId,
canManage,
onEdit,
onDelete,
}: ModelCostsTableProps): JSX.Element {
const columns = useMemo(
() => getModelCostsColumns({ canManage, onEdit, onDelete }),
[canManage, onEdit, onDelete],
);
if (!isLoading && rules.length === 0) {
return (
<div className={styles.modelCostsEmpty} data-testid="model-costs-empty">
No model costs yet.
</div>
);
}
return (
<TanStackTable<LlmpricingruletypesLLMPricingRuleDTO>
className={styles.modelCostsTable}
data={rules}
columns={columns}
isLoading={isLoading}
skeletonRowCount={SKELETON_ROW_COUNT}
getRowKey={(row): string => row.id}
isRowActive={(row): boolean => row.id === selectedRuleId}
disableVirtualScroll
testId="model-costs-table"
enableQueryParams={{ page: PAGE_KEY, limit: LIMIT_KEY }}
pagination={{
total,
defaultLimit: PAGE_SIZE,
showTotalCount: true,
totalCountLabel: 'models',
}}
/>
);
}
export default ModelCostsTable;

View File

@@ -1,161 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import type { TableColumnDef } from 'components/TanStackTableView';
import { startCase } from 'lodash-es';
import styles from './tableConfig.module.scss';
import ModelCostActionsMenu from '../ModelCostActionsMenu';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
} from '../../../../utils';
interface ColumnsConfig {
canManage: boolean;
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
}
// Column definitions for the model-costs TanStackTable. Sorting is intentionally
// off across the board — the list API only accepts offset/limit, so there's no
// server-side ordering to back a sortable header yet.
export function getModelCostsColumns({
canManage,
onEdit,
onDelete,
}: ColumnsConfig): TableColumnDef<LlmpricingruletypesLLMPricingRuleDTO>[] {
return [
{
id: 'model',
header: 'Model',
accessorFn: (row): string => row.modelName ?? '',
// Flexes to absorb spare width alongside Extra buckets so the row fills
// the container instead of leaving a gap on the right.
width: { min: 240, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<div className={styles.modelCell}>
<Typography.Text
weight="semibold"
truncate={1}
testId={`model-cell-name-${row.id}`}
>
{row.modelName}
</Typography.Text>
<Typography.Text truncate={1}>{getCanonicalId(row)}</Typography.Text>
</div>
),
},
{
id: 'provider',
header: 'Provider',
accessorKey: 'provider',
width: { min: 140 },
enableMove: false,
cell: ({ row }): string => row.provider ?? '',
},
{
id: 'input',
header: 'Input / 1M',
width: { min: 120 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text>
{formatPricePerMillion(row.pricing?.input)}
</Typography.Text>
),
},
{
id: 'output',
header: 'Output / 1M',
width: { min: 120 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text>
{formatPricePerMillion(row.pricing?.output)}
</Typography.Text>
),
},
{
id: 'extraBuckets',
header: 'Extra buckets',
width: { min: 200, default: '100%' },
enableMove: false,
cell: ({ row }): JSX.Element => {
const buckets = getExtraBuckets(row);
if (buckets.length === 0) {
return (
<Typography.Text color="muted" as="span">
</Typography.Text>
);
}
return (
<div className={styles.extraBuckets}>
{buckets.map((bucket) => (
<Badge
key={bucket.key}
color="vanilla"
variant="outline"
className={styles.extraBucketsChip}
>
<Typography.Text as="span" size="small">
{startCase(bucket.key)}
</Typography.Text>
<Typography.Text as="span" size="small" weight="semibold">
{formatPricePerMillion(bucket.pricePerMillion)}
</Typography.Text>
</Badge>
))}
</div>
);
},
},
{
id: 'source',
header: 'Source',
width: { min: 130 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Badge
color={row.isOverride ? 'amber' : 'robin'}
variant="outline"
className={styles.sourceBadge}
data-testid={`source-badge-${row.id}`}
>
{getSourceLabel(row)}
</Badge>
),
},
{
id: 'lastSeen',
header: 'Last seen',
width: { min: 120 },
enableMove: false,
cell: ({ row }): string => getRelativeLastSeen(row),
},
{
id: 'actions',
header: '',
width: { fixed: '56px', ignoreLastColumnFill: true },
pin: 'right',
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element | null => (
<ModelCostActionsMenu
rule={row}
canManage={canManage}
onEdit={onEdit}
onDelete={onDelete}
/>
),
},
];
}

View File

@@ -1,26 +0,0 @@
.modelCell {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
min-width: 0;
}
.extraBuckets {
display: flex;
// Keep chips on a single line so the row stays at the table's fixed row
// height; the column flexes to 100% so there's room for both.
flex-wrap: nowrap;
gap: var(--spacing-3);
overflow: hidden;
}
.extraBucketsChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
margin: 0;
}
.sourceBadge {
margin: 0;
}

View File

@@ -1 +0,0 @@
export { default } from './ModelCostTabPanel';

View File

@@ -1,6 +0,0 @@
export const PAGE_SIZE = 20;
export const PAGE_KEY = 'page';
export const LIMIT_KEY = 'limit';
export const SKELETON_ROW_COUNT = PAGE_SIZE;

View File

@@ -1,4 +0,0 @@
export interface ExtraBucket {
key: string;
pricePerMillion: number;
}

View File

@@ -1,60 +0,0 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type { ExtraBucket } from './types';
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
dayjs.extend(relativeTime);
const getRelativeTime = (
timestamp: string | number | Date | null | undefined,
): string => {
const parsed = timestamp != null ? dayjs(timestamp) : null;
return parsed?.isValid() ? parsed.fromNow() : '—';
};
// ─── Display helpers ─────────────────────────────────────────────────────────
export const formatPricePerMillion = (value: number | undefined): string => {
if (value === undefined || value === null) {
return '—';
}
// 2dp is enough for per-1M pricing. we can update this later we models have sub-cent pricing.
return `$${value.toFixed(2)}`;
};
export const getExtraBuckets = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): ExtraBucket[] => {
const cache = rule.pricing?.cache;
if (!cache) {
return [];
}
const buckets: ExtraBucket[] = [];
if (typeof cache.read === 'number' && cache.read > 0) {
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
}
if (typeof cache.write === 'number' && cache.write > 0) {
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
}
return buckets;
};
export const getSourceLabel = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): 'Auto' | 'User override' => (rule.isOverride ? 'User override' : 'Auto');
export const getRelativeLastSeen = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
// Canonical id shown under the model name, e.g. "openai:gpt-4o". Both segments
// are lower-cased so the id is consistently normalised (providers/models can
// arrive with mixed casing).
export const getCanonicalId = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => {
const provider = rule.provider?.trim().toLowerCase() || 'unknown';
const model = rule.modelName?.trim().toLowerCase() || 'unknown';
return `${provider}:${model}`;
};

View File

@@ -6,10 +6,6 @@ import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useTableRowClick } from 'hooks/useTableRowClick';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
import { isModifierKeyPressed } from 'utils/app';
@@ -35,7 +31,10 @@ export function useAlertRulesHandlers(
mapQueryDataFromApi(toCompositeMetricQuery(rule.condition.compositeQuery)),
rule.alertType,
);
applySerializedParams(serialize(compositeQuery), params);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = rule.condition.compositeQuery.panelType;
if (panelType) {

View File

@@ -14,10 +14,6 @@ import { FontSize } from 'container/OptionsMenu/types';
import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -115,7 +111,10 @@ function ContextLogRenderer({
(logId: string): void => {
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
applySerializedParams(serialize(query), urlQuery);
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
window.open(withBasePath(link), '_blank', 'noopener,noreferrer');

View File

@@ -247,12 +247,16 @@ function Application(): JSX.Element {
const avialableParams = routeConfig[ROUTES.TRACE];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(
JSON.stringify(apmToTraceQuery),
);
const newPath = generateExplorerPath(
isViewLogsClicked,
urlParams,
servicename,
selectedTraceTags,
apmToTraceQuery,
JSONCompositeQuery,
queryString,
);

View File

@@ -8,10 +8,6 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useClickOutside from 'hooks/useClickOutside';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { resourceAttributesToTracesFilterItems } from 'hooks/useResourceAttribute/utils';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { prepareQueryWithDefaultTimestamp } from 'pages/LogsExplorer/utils';
import { traceFilterKeys } from 'pages/TracesExplorer/Filter/filterUtils';
@@ -64,18 +60,16 @@ export function generateExplorerPath(
urlParams: URLSearchParams,
servicename: string | undefined,
selectedTraceTags: string,
apmToTraceQuery: Query,
JSONCompositeQuery: string,
queryString: string[],
): string {
const basePath = isViewLogsClicked
? ROUTES.LOGS_EXPLORER
: ROUTES.TRACES_EXPLORER;
applySerializedParams(serialize(apmToTraceQuery), urlParams);
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${queryString.join(
'&',
)}`;
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}&${queryString.join('&')}`;
}
// TODO(@rahul-signoz): update the name of this function once we have view logs button in every panel
@@ -111,12 +105,16 @@ export function onViewTracePopupClick({
const avialableParams = routeConfig[ROUTES.TRACE];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(
JSON.stringify(apmToTraceQuery),
);
const newPath = generateExplorerPath(
isViewLogsClicked,
urlParams,
servicename,
selectedTraceTags,
apmToTraceQuery,
JSONCompositeQuery,
queryString,
);

View File

@@ -1,6 +1,5 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { withBasePath } from 'utils/basePath';
import { TopOperationList } from './TopOperationsTable';
@@ -30,11 +29,13 @@ export const navigateToTrace = ({
);
urlParams.set(QueryParams.endTime, Math.floor(maxTime / 1_000_000).toString());
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(apmToTraceQuery));
const newTraceExplorerPath = `${
ROUTES.TRACES_EXPLORER
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${serialize(
apmToTraceQuery,
).toString()}`;
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
if (openInNewTab) {
window.open(withBasePath(newTraceExplorerPath), '_blank');

View File

@@ -33,7 +33,6 @@ import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
@@ -792,7 +791,9 @@ function NewWidget({
const queryParams = {
[QueryParams.expandedWidgetId]: widgetId,
[QueryParams.graphType]: graphType,
...serializeToParams(currentQuery),
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(currentQuery),
),
[QueryParams.variables]: variables,
};

View File

@@ -2,7 +2,6 @@ import { useCallback } from 'react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -50,7 +49,7 @@ const useBaseDrilldownNavigate = ({
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
...serializeToParams(viewQuery),
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
@@ -95,7 +94,7 @@ export function buildDrilldownUrl(
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
...serializeToParams(viewQuery),
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),

View File

@@ -35,11 +35,6 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
import { normalizeTimeToMs } from 'utils/timeUtils';
import {
applySerializedParams,
deserialize,
serialize,
} from 'lib/compositeQuery/serializer';
import { v4 as uuid } from 'uuid';
import AutoRefresh from '../AutoRefreshV2';
@@ -283,7 +278,7 @@ function DateTimeSelection({
return `Refreshed ${secondsDiff} sec ago`;
}, [maxTime, minTime, selectedTime]);
const getUpdatedCompositeQuery = useCallback((): URLSearchParams => {
const getUpdatedCompositeQuery = useCallback((): string => {
let updatedCompositeQuery = cloneDeep(currentQuery);
updatedCompositeQuery.id = uuid();
// Remove the filters
@@ -304,7 +299,7 @@ function DateTimeSelection({
})),
},
};
return serialize(updatedCompositeQuery);
return encodeURIComponent(JSON.stringify(updatedCompositeQuery));
}, [currentQuery]);
const onSelectHandler = useCallback(
@@ -339,9 +334,9 @@ function DateTimeSelection({
// Remove Hidden Filters from URL query parameters on time change
urlQuery.delete(QueryParams.activeLogId);
const staledQuery = deserialize(urlQuery);
if (staledQuery) {
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
if (urlQuery.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
@@ -429,9 +424,9 @@ function DateTimeSelection({
urlQuery.set(QueryParams.endTime, endTime?.toDate().getTime().toString());
urlQuery.delete(QueryParams.relativeTime);
const staledQuery = deserialize(urlQuery);
if (staledQuery) {
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
if (urlQuery.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;

View File

@@ -1,170 +0,0 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import {
areUrlsEffectivelySame,
isDefaultNavigation,
} from 'hooks/useSafeNavigate.utils';
import { serialize } from 'lib/compositeQuery/serializer';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
const BASE = 'http://localhost';
const urlFrom = (pathname: string, params?: URLSearchParams): URL => {
const search = params?.toString();
const query = search ? `?${search}` : '';
return new URL(`${pathname}${query}`, BASE);
};
/** Build params containing the serialized `compositeQuery` plus any extras. */
const withQuery = (
query: Query,
extra: Record<string, string> = {},
): URLSearchParams => {
const params = serialize(query);
Object.entries(extra).forEach(([key, value]) => params.set(key, value));
return params;
};
describe('areUrlsEffectivelySame', () => {
it('returns false when pathnames differ', () => {
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/traces'))).toBe(
false,
);
});
it('returns true for two identical param-less URLs', () => {
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/logs'))).toBe(true);
});
it('returns true when only the compositeQuery is present and identical', () => {
const params = withQuery(initialQueriesMap.logs);
expect(
areUrlsEffectivelySame(
urlFrom('/logs', params),
urlFrom('/logs', new URLSearchParams(params.toString())),
),
).toBe(true);
});
// Regression: a matching compositeQuery must NOT mask differences in other
// params. Previously every param was compared via the decoded query, so any
// two URLs sharing a compositeQuery were judged identical.
it('returns false when compositeQuery matches but another param differs', () => {
const url1 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
);
const url2 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '2000' }),
);
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('returns false when compositeQuery matches but a param exists on only one URL', () => {
const url1 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
);
const url2 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('ignores the volatile id when comparing compositeQuery', () => {
const url1 = urlFrom(
'/logs',
withQuery({ ...initialQueriesMap.logs, id: 'id-1' }),
);
const url2 = urlFrom(
'/logs',
withQuery({ ...initialQueriesMap.logs, id: 'id-2' }),
);
expect(areUrlsEffectivelySame(url1, url2)).toBe(true);
});
it('returns false when compositeQuery is semantically different', () => {
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
const url2 = urlFrom('/metrics', withQuery(initialQueriesMap.metrics));
// Force same pathname so only the query differs.
expect(
areUrlsEffectivelySame(
url1,
urlFrom('/logs', new URLSearchParams(url2.search)),
),
).toBe(false);
});
it('returns false when compositeQuery exists on only one URL', () => {
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
const url2 = urlFrom('/logs');
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('compares non-compositeQuery params directly when no compositeQuery is present', () => {
const same1 = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '2' }),
);
const same2 = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '2' }),
);
expect(areUrlsEffectivelySame(same1, same2)).toBe(true);
const diff = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '3' }),
);
expect(areUrlsEffectivelySame(same1, diff)).toBe(false);
});
it('falls back to raw comparison when compositeQuery cannot be decoded', () => {
const corrupt1 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
);
const corrupt2 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
);
expect(areUrlsEffectivelySame(corrupt1, corrupt2)).toBe(true);
const corrupt3 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bother' }),
);
expect(areUrlsEffectivelySame(corrupt1, corrupt3)).toBe(false);
});
});
describe('isDefaultNavigation', () => {
it('returns false for different pathnames', () => {
expect(isDefaultNavigation(urlFrom('/logs'), urlFrom('/traces'))).toBe(false);
});
it('returns true when a clean URL gains params', () => {
expect(
isDefaultNavigation(
urlFrom('/logs'),
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
),
).toBe(true);
});
it('returns true when the target introduces a new param key', () => {
expect(
isDefaultNavigation(
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
urlFrom('/logs', new URLSearchParams({ startTime: '1', endTime: '2' })),
),
).toBe(true);
});
it('returns false when the target has no new param keys', () => {
expect(
isDefaultNavigation(
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
urlFrom('/logs', new URLSearchParams({ startTime: '9' })),
),
).toBe(false);
});
});

View File

@@ -5,6 +5,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryParams } from 'constants/query';
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { MetricsType } from 'container/MetricsApplication/constant';
@@ -12,7 +13,6 @@ import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSea
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { deserialize } from 'lib/compositeQuery/serializer';
import { getGeneratedFilterQueryString } from 'lib/getGeneratedFilterQueryString';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { AppState } from 'store/reducers';
@@ -58,14 +58,9 @@ export const useActiveLog = (): UseActiveLog => {
const [activeLog, setActiveLog] = useState<ILog | null>(null);
// Close drawer/clear active log when query in URL changes. Track the decoded
// query (not a single raw param) so it stays correct across serializer tiers
// that explode the query into many keys.
// Close drawer/clear active log when query in URL changes
const urlQuery = useUrlQuery();
const compositeQuery = useMemo(() => {
const decoded = deserialize(urlQuery);
return decoded ? JSON.stringify(decoded) : '';
}, [urlQuery]);
const compositeQuery = urlQuery.get(QueryParams.compositeQuery) ?? '';
const prevQueryRef = useRef<string | null>(null);
useEffect(() => {
if (

View File

@@ -2,10 +2,9 @@ import { useMutation } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { act, renderHook } from '@testing-library/react';
import { deserialize } from 'lib/compositeQuery/serializer';
import { QueryParams } from 'constants/query';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Widgets } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import useCreateAlerts from '../useCreateAlerts';
@@ -80,14 +79,14 @@ const buildWidget = (queryType: EQueryType | undefined): Widgets =>
},
}) as unknown as Widgets;
const getCompositeQueryFromLastOpen = (): Query => {
const getCompositeQueryFromLastOpen = (): Record<string, unknown> => {
const [url] = (window.open as jest.Mock).mock.calls[0];
const query = new URLSearchParams((url as string).split('?')[1]);
const composite = deserialize(query);
if (!composite) {
const raw = query.get(QueryParams.compositeQuery);
if (!raw) {
throw new Error('compositeQuery not found in URL');
}
return composite;
return JSON.parse(decodeURIComponent(raw));
};
describe('useCreateAlerts', () => {

View File

@@ -1,26 +0,0 @@
import { renderHook } from '@testing-library/react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
let mockUrlQuery = new URLSearchParams();
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): URLSearchParams => mockUrlQuery,
}));
describe('useGetCompositeQueryParam', () => {
it('decodes a legacy compositeQuery param', () => {
mockUrlQuery = new URLSearchParams({
compositeQuery: encodeURIComponent(JSON.stringify(initialQueriesMap.logs)),
});
const { result } = renderHook(() => useGetCompositeQueryParam());
expect(result.current?.builder.queryData[0].dataSource).toBe('logs');
});
it('returns null when the param is absent', () => {
mockUrlQuery = new URLSearchParams();
const { result } = renderHook(() => useGetCompositeQueryParam());
expect(result.current).toBeNull();
});
});

View File

@@ -14,10 +14,6 @@ import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useNotifications } from 'hooks/useNotifications';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { isEmpty } from 'lodash-es';
@@ -90,7 +86,10 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
}
const params = new URLSearchParams();
applySerializedParams(serialize(updatedQuery), params);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
params.set(QueryParams.panelTypes, widget.panelTypes);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);

View File

@@ -1,10 +1,72 @@
import useUrlQuery from 'hooks/useUrlQuery';
import { deserialize } from 'lib/compositeQuery/serializer';
import { useMemo } from 'react';
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const useGetCompositeQueryParam = (): Query | null => {
const urlQuery = useUrlQuery();
return useMemo(() => deserialize(urlQuery), [urlQuery]);
return useMemo(() => {
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
let parsedCompositeQuery: Query | null = null;
try {
if (!compositeQuery) {
return null;
}
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
parsedCompositeQuery = JSON.parse(
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
);
// Convert old format to new format for each query in builder.queryData
if (parsedCompositeQuery?.builder?.queryData) {
parsedCompositeQuery.builder.queryData =
parsedCompositeQuery.builder.queryData.map((query) => {
const existingExpression = query.filter?.expression || '';
const convertedQuery = { ...query };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
query.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
// Convert having if needed
if (Array.isArray(query.having)) {
const convertedHaving = convertHavingToExpression(query.having);
convertedQuery.having = convertedHaving;
}
// Convert aggregation if needed
if (!query.aggregations && query.aggregateOperator) {
const convertedAggregation = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
}) as any; // Type assertion to handle union type
convertedQuery.aggregations = convertedAggregation;
}
return convertedQuery;
});
}
} catch (e) {
parsedCompositeQuery = null;
}
return parsedCompositeQuery;
}, [urlQuery]);
};

View File

@@ -1,9 +1,6 @@
import {
areUrlsEffectivelySame,
isDefaultNavigation,
} from 'hooks/useSafeNavigate.utils';
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import { cloneDeep, isEqual } from 'lodash-es';
import { withBasePath } from 'utils/basePath';
interface NavigateOptions {
@@ -21,6 +18,77 @@ interface UseSafeNavigateProps {
preventSameUrlNavigation?: boolean;
}
const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
if (url1.pathname !== url2.pathname) {
return false;
}
const params1 = new URLSearchParams(url1.search);
const params2 = new URLSearchParams(url2.search);
const allParams = new Set([...params1.keys(), ...params2.keys()]);
return [...allParams].every((param) => {
if (param === 'compositeQuery') {
try {
const query1 = params1.get('compositeQuery');
const query2 = params2.get('compositeQuery');
if (!query1 || !query2) {
return false;
}
const decoded1 = JSON.parse(decodeURIComponent(query1));
const decoded2 = JSON.parse(decodeURIComponent(query2));
const filtered1 = cloneDeep(decoded1);
const filtered2 = cloneDeep(decoded2);
delete filtered1.id;
delete filtered2.id;
return isEqual(filtered1, filtered2);
} catch (error) {
console.warn('Error comparing compositeQuery:', error);
return false;
}
}
return params1.get(param) === params2.get(param);
});
};
/**
* Determines if this navigation is adding default/initial parameters
* Returns true if:
* 1. We're staying on the same page (same pathname)
* 2. Either:
* - Current URL has no params and target URL has params, or
* - Target URL has new params that didn't exist in current URL
*/
const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
// Different pathnames means it's not a default navigation
if (currentUrl.pathname !== targetUrl.pathname) {
return false;
}
const currentParams = new URLSearchParams(currentUrl.search);
const targetParams = new URLSearchParams(targetUrl.search);
// Case 1: Clean URL getting params for the first time
if (!currentParams.toString() && targetParams.toString()) {
return true;
}
// Case 2: Check for new params that didn't exist before
const currentKeys = new Set(currentParams.keys());
const targetKeys = new Set(targetParams.keys());
// Find keys that exist in target but not in current
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
return newKeys.length > 0;
};
export const useSafeNavigate = (
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
preventSameUrlNavigation: true,

View File

@@ -1,103 +0,0 @@
import { deserialize } from 'lib/compositeQuery/serializer';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import { isEqual } from 'lodash-es';
/**
* Compare the (optional) `compositeQuery` param of two URLSearchParams
* semantically. Its serialized form is not byte-stable — the volatile `id` and
* the adapter choice both vary — so we decode and deep-compare, ignoring `id`.
*
* compositeQuery is not guaranteed to be present: absent on both sides counts
* as equal, present on only one side counts as different. When either side is
* present but can't be decoded, we fall back to comparing the raw values.
*/
const compositeQueriesEqual = (
params1: URLSearchParams,
params2: URLSearchParams,
): boolean => {
const raw1 = params1.get(COMPOSITE_QUERY_KEY);
const raw2 = params2.get(COMPOSITE_QUERY_KEY);
if (!raw1 && !raw2) {
return true;
}
if (!raw1 || !raw2) {
return false;
}
try {
const decoded1 = deserialize(params1);
const decoded2 = deserialize(params2);
if (decoded1 && decoded2) {
// Ignore the volatile `id` when comparing queries.
const { id: _id1, ...rest1 } = decoded1;
const { id: _id2, ...rest2 } = decoded2;
return isEqual(rest1, rest2);
}
} catch (error) {
console.warn('Error comparing compositeQuery:', error);
}
// One or both could not be decoded — compare the raw encoded values.
return raw1 === raw2;
};
export const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
if (url1.pathname !== url2.pathname) {
return false;
}
const params1 = new URLSearchParams(url1.search);
const params2 = new URLSearchParams(url2.search);
// The compositeQuery is compared semantically (it round-trips through a
// non-stable serialized form); every other param is compared by raw value.
if (!compositeQueriesEqual(params1, params2)) {
return false;
}
const otherKeys = new Set(
[...params1.keys(), ...params2.keys()].filter(
(key) => key !== COMPOSITE_QUERY_KEY,
),
);
return [...otherKeys].every((key) => params1.get(key) === params2.get(key));
};
/**
* Determines if this navigation is adding default/initial parameters
* Returns true if:
* 1. We're staying on the same page (same pathname)
* 2. Either:
* - Current URL has no params and target URL has params, or
* - Target URL has new params that didn't exist in current URL
*/
export const isDefaultNavigation = (
currentUrl: URL,
targetUrl: URL,
): boolean => {
// Different pathnames means it's not a default navigation
if (currentUrl.pathname !== targetUrl.pathname) {
return false;
}
const currentParams = new URLSearchParams(currentUrl.search);
const targetParams = new URLSearchParams(targetUrl.search);
// Case 1: Clean URL getting params for the first time
if (!currentParams.toString() && targetParams.toString()) {
return true;
}
// Case 2: Check for new params that didn't exist before
const currentKeys = new Set(currentParams.keys());
const targetKeys = new Set(targetParams.keys());
// Find keys that exist in target but not in current
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
return newKeys.length > 0;
};

View File

@@ -1,51 +0,0 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import {
clearSerializedParams,
deserialize,
serialize,
} from 'lib/compositeQuery/serializer';
describe('composite query serializer', () => {
it('round-trips through serialize/deserialize', () => {
const query = initialQueriesMap.logs;
const decoded = deserialize(serialize(query));
expect(decoded?.builder.queryData[0].dataSource).toBe('logs');
});
it('returns null on corrupt input instead of throwing', () => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
expect(deserialize(params)).toBeNull();
});
it('returns null for empty/missing value', () => {
const params = new URLSearchParams();
expect(deserialize(params)).toBeNull();
});
it('preserves id field through roundtrip', () => {
const query = { ...initialQueriesMap.metrics, id: 'test-query-uuid-123' };
const serialized = serialize(query);
const decoded = deserialize(serialized);
expect(decoded?.id).toBe('test-query-uuid-123');
});
it('clearSerializedParams purges every serialized key, leaving others intact', () => {
const params = serialize(initialQueriesMap.logs);
params.set('panelTypes', 'list');
clearSerializedParams(params);
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
expect(deserialize(params)).toBeNull();
expect(params.get('panelTypes')).toBe('list');
});
it('clearSerializedParams drops a corrupt legacy key via fallback', () => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
params.set('panelTypes', 'list');
clearSerializedParams(params);
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
expect(params.get('panelTypes')).toBe('list');
});
});

View File

@@ -1,69 +0,0 @@
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import {
CompositeQueryAdapter,
COMPOSITE_QUERY_KEY,
} from 'lib/compositeQuery/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
function migrateLegacyFormat(parsed: Query): Query {
if (!parsed?.builder?.queryData) {
return parsed;
}
const next = parsed;
next.builder.queryData = parsed.builder.queryData.map((query) => {
const existingExpression = query.filter?.expression || '';
const convertedQuery = { ...query };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
query.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
if (Array.isArray(query.having)) {
convertedQuery.having = convertHavingToExpression(query.having);
}
if (!query.aggregations && query.aggregateOperator) {
convertedQuery.aggregations = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any;
}
return convertedQuery;
});
return next;
}
export const jsonAdapter: CompositeQueryAdapter = {
name: 'json(legacy)',
encode: (query) => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, JSON.stringify(query));
return params;
},
matches: () => true,
decode: (params) => {
const raw = params.get(COMPOSITE_QUERY_KEY) ?? '';
let parsed: Query;
try {
parsed = JSON.parse(raw);
} catch (e) {
parsed = JSON.parse(decodeURIComponent(raw.replace(/\+/g, ' ')));
}
return migrateLegacyFormat(parsed);
},
};

View File

@@ -1,211 +0,0 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { jsonAdapter } from './index';
const roundTrip = (query: Query): Query =>
jsonAdapter.decode(jsonAdapter.encode(query));
describe('jsonAdapter', () => {
describe('round-trip', () => {
it.each(['metrics', 'logs', 'traces'] as const)(
'round-trips %s baseline preserving dataSource',
(source) => {
const query = initialQueriesMap[source];
const decoded = roundTrip(query);
expect(decoded.builder.queryData[0].dataSource).toBe(source);
},
);
});
describe('encoding', () => {
it('encodes using single URL encoding via URLSearchParams', () => {
const query = initialQueriesMap.logs;
const params = jsonAdapter.encode(query);
const raw = params.get(COMPOSITE_QUERY_KEY) ?? '';
// URLSearchParams.get() returns decoded value, so raw === JSON string
expect(raw).toBe(JSON.stringify(query));
expect(raw.startsWith('{')).toBe(true);
// Full URL shows single encoding
const fullUrl = params.toString();
expect(fullUrl).toContain('%7B'); // encoded {
expect(fullUrl).not.toContain('%257B'); // NOT double-encoded
});
it('decode handles single-encoded format (current)', () => {
const query = initialQueriesMap.logs;
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, JSON.stringify(query));
const decoded = jsonAdapter.decode(params);
expect(decoded.builder.queryData[0].dataSource).toBe('logs');
});
});
describe('legacy double-encoded fallback', () => {
it('decode handles double-encoded format (legacy URLs)', () => {
const query = initialQueriesMap.logs;
// Simulate legacy: JSON -> encodeURIComponent -> set as raw param
const doubleEncoded = encodeURIComponent(JSON.stringify(query));
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, doubleEncoded);
const decoded = jsonAdapter.decode(params);
expect(decoded.builder.queryData[0].dataSource).toBe('logs');
});
it('double-encoded with special chars decodes correctly', () => {
const queryWithSpecialChars = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.logs.builder.queryData[0],
filters: {
op: 'AND',
items: [
{
key: { key: 'message', dataType: 'string', type: 'tag' },
op: '=',
value: 'hello world & foo=bar',
},
],
},
},
],
},
};
const doubleEncoded = encodeURIComponent(
JSON.stringify(queryWithSpecialChars),
);
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, doubleEncoded);
const decoded = jsonAdapter.decode(params);
const filter = decoded.builder.queryData[0].filters?.items[0];
expect(filter?.value).toBe('hello world & foo=bar');
});
});
describe('plus-sign handling', () => {
it('plus signs in double-encoded URLs decode as spaces', () => {
// In URL encoding, + represents space. Legacy URLs may have this.
const query = { queryType: 'builder', test: 'hello world' };
// Manually create double-encoded with + for space
const jsonStr = JSON.stringify(query);
const encoded = encodeURIComponent(jsonStr).replace(/%20/g, '+');
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, encoded);
const decoded = jsonAdapter.decode(params) as any;
expect(decoded.test).toBe('hello world');
});
it('plus signs in filter values preserved after decode', () => {
// Value literally contains + (not space)
const queryWithPlus = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.logs.builder.queryData[0],
filters: {
op: 'AND',
items: [
{
key: { key: 'expr', dataType: 'string', type: 'tag' },
op: '=',
value: '1+2=3',
},
],
},
},
],
},
};
// Current format (single encode) - + becomes %2B
const params = jsonAdapter.encode(queryWithPlus as Query);
const decoded = jsonAdapter.decode(params);
expect(decoded.builder.queryData[0].filters?.items[0]?.value).toBe('1+2=3');
});
it('legacy double-encoded + in values preserved', () => {
const queryWithPlus = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filters: {
op: 'AND',
items: [{ key: { key: 'x' }, op: '=', value: 'a+b' }],
},
},
],
queryFormulas: [],
},
promql: [],
clickhouse_sql: [],
id: 'x',
unit: '',
};
// Double encode: + in JSON becomes %2B, then %252B
const doubleEncoded = encodeURIComponent(JSON.stringify(queryWithPlus));
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, doubleEncoded);
const decoded = jsonAdapter.decode(params);
expect(decoded.builder.queryData[0].filters?.items[0]?.value).toBe('a+b');
});
});
describe('tag matching', () => {
it('matches any value (catch-all fallback)', () => {
const params1 = new URLSearchParams();
params1.set(COMPOSITE_QUERY_KEY, '%7B%22queryType%22%3A%22builder%22%7D');
expect(jsonAdapter.matches(params1)).toBe(true);
const params2 = new URLSearchParams();
params2.set(COMPOSITE_QUERY_KEY, 'z1~abc');
expect(jsonAdapter.matches(params2)).toBe(true);
});
});
describe('migration', () => {
it('migrates old format (filters -> filter.expression)', () => {
const legacy = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filters: { op: 'AND', items: [] },
aggregateOperator: 'count',
aggregateAttribute: { key: '', dataType: '', type: '' },
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
id: 'x',
unit: '',
};
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, JSON.stringify(legacy));
const decoded = jsonAdapter.decode(params);
expect(decoded.builder.queryData[0].filter).toBeDefined();
expect(decoded.builder.queryData[0].aggregations).toBeDefined();
});
});
});

View File

@@ -1,79 +0,0 @@
import { jsonAdapter } from 'lib/compositeQuery/adapters/json';
import {
COMPOSITE_QUERY_KEY,
CompositeQueryAdapter,
} from 'lib/compositeQuery/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
// Order matters for decode: most-specific (tagged) adapters first
const ADAPTERS: CompositeQueryAdapter[] = [jsonAdapter];
// Pick the adapter that owns a given URL. json's `matches` is always true, so
// it serves as the final fallback when no tagged adapter claims the params.
function adapterFor(params: URLSearchParams): CompositeQueryAdapter {
return ADAPTERS.find((adapter) => adapter.matches(params)) ?? jsonAdapter;
}
/**
* Encode a query to the shortest available URLSearchParams.
*/
export function serialize(query: Query): URLSearchParams {
return ADAPTERS[0].encode(query);
}
/**
* Decode URLSearchParams back to a Query. Total: returns null on any failure.
*/
export function deserialize(params: URLSearchParams): Query | null {
const hasParams = Array.from(params.keys()).length > 0;
if (!hasParams) {
return null;
}
try {
return adapterFor(params).decode(params);
} catch {
return null;
}
}
/**
* Apply all params from source into target URLSearchParams.
*/
export function applySerializedParams(
source: URLSearchParams,
target: URLSearchParams,
): void {
source.forEach((value, key) => target.set(key, value));
}
/**
* Remove every serialized-query param from target URLSearchParams. Use instead
* of `target.delete('compositeQuery')` so a stale query is fully purged even
* for adapters that explode a query into many content-dependent keys (e.g.
* `query0.ds`, `query0.fl.it.0.key.key`) which can't be listed statically.
*
* Keys are discovered by round-trip: decode the current params with their
* owning adapter, re-encode, then delete exactly the keys encoding produces.
* If the params don't decode (absent/corrupt), fall back to dropping the legacy
* single key so a stale `compositeQuery` is still cleared.
*/
export function clearSerializedParams(target: URLSearchParams): void {
const adapter = adapterFor(target);
try {
adapter.encode(adapter.decode(target)).forEach((_value, key) => {
target.delete(key);
});
} catch {
target.delete(COMPOSITE_QUERY_KEY);
}
}
/**
* Serialize a query to a plain record of all URL params it produces. Use when
* building a query-param object manually (e.g. for `createQueryParams`), so the
* call site carries every param the adapter emits — not just `compositeQuery`.
* Spread it: `{ ...serializeToParams(query), startTime, endTime }`.
*/
export function serializeToParams(query: Query): Record<string, string> {
return Object.fromEntries(serialize(query));
}

View File

@@ -1,15 +0,0 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const COMPOSITE_QUERY_KEY = 'compositeQuery';
/**
* A serialization tier. `encode` returns URLSearchParams (default key =
* `compositeQuery`). `matches` checks if params belong to this adapter.
* `decode` receives URLSearchParams and returns Query.
*/
export interface CompositeQueryAdapter {
readonly name: string;
encode(query: Query): URLSearchParams;
matches(params: URLSearchParams): boolean;
decode(params: URLSearchParams): Query;
}

View File

@@ -177,8 +177,7 @@ describe('Tooltip', () => {
renderTooltip({ uPlotInstance, content });
const list = screen.getByTestId('uplot-tooltip-list');
// Measured height (200) + the scroll viewport's vertical padding (16)
expect(list).toHaveStyle({ height: '216px' });
expect(list).toHaveStyle({ height: '200px' });
});
it('sets tooltip list height based on content length when Virtuoso reports 0 height', () => {
@@ -189,8 +188,8 @@ describe('Tooltip', () => {
renderTooltip({ uPlotInstance, content });
const list = screen.getByTestId('uplot-tooltip-list');
// Falls back to content length (2 * 38 = 76) + vertical padding (16) = 92px
expect(list).toHaveStyle({ height: '92px' });
// Falls back to content length: 2 items * 38px = 76px
expect(list).toHaveStyle({ height: '76px' });
});
});

View File

@@ -13,11 +13,6 @@ import Styles from './TooltipList.module.scss';
// Fallback per-item height before Virtuoso reports the real total.
const TOOLTIP_ITEM_HEIGHT = 38;
const LIST_MAX_HEIGHT = 300;
// Vertical padding (spacing-4 top + bottom) the SCSS applies to the scroll
// viewport. Virtuoso's reported height covers only the items, so it must be
// added back — otherwise the box is short by this amount, clipping the last
// row and showing a scrollbar even when every row would fit.
const LIST_VERTICAL_PADDING = 16;
interface TooltipListProps {
id: string;
@@ -35,13 +30,13 @@ export default function TooltipList({
// Use the measured height from Virtuoso when available; fall back to a
// per-item estimate on first render. Math.ceil prevents a 1 px
// subpixel rounding gap from triggering a spurious scrollbar.
const height = useMemo(() => {
const contentHeight =
totalListHeight > 0 ? totalListHeight : content.length * TOOLTIP_ITEM_HEIGHT;
return Math.ceil(
Math.min(contentHeight + LIST_VERTICAL_PADDING, LIST_MAX_HEIGHT),
);
}, [totalListHeight, content.length]);
const height = useMemo(
() =>
totalListHeight > 0
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
: Math.min(content.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT),
[totalListHeight, content.length],
);
const handleScroll = useCallback(() => {
if (!isScrollEventTriggered.current) {

View File

@@ -2,7 +2,6 @@
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { matchPath, useLocation } from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
import get from 'api/channels/get';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
@@ -25,10 +24,10 @@ import './ChannelsEdit.styles.scss';
function ChannelsEdit(): JSX.Element {
const { t } = useTranslation();
const { pathname } = useLocation();
const channelId = matchPath<{ channelId: string }>(pathname, {
path: ROUTES.CHANNELS_EDIT,
})?.params?.channelId;
// Extract channelId from URL pathname
const { pathname } = window.location;
const channelIdMatch = pathname.match(/\/alerts\/channels\/edit\/([^/]+)/);
const channelId = channelIdMatch ? channelIdMatch[1] : undefined;
const { isFetching, isError, data, error } = useQuery<
SuccessResponseV2<Channels>,
@@ -148,7 +147,6 @@ function ChannelsEdit(): JSX.Element {
<div className="edit-alert-channels-container">
<EditAlertChannels
{...{
channelId: channelId || '',
initialValue: {
...target.channel,
type: target.type,

View File

@@ -15,7 +15,6 @@ import EditRulesContainer from 'container/EditRules';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { clearSerializedParams } from 'lib/compositeQuery/serializer';
import history from 'lib/history';
import {
NEW_ALERT_SCHEMA_VERSION,
@@ -50,7 +49,7 @@ function EditRules(): JSX.Element {
const { notifications } = useNotifications();
const clickHandler = (): void => {
clearSerializedParams(params);
params.delete(QueryParams.compositeQuery);
params.delete(QueryParams.panelTypes);
params.delete(QueryParams.ruleId);
params.delete(QueryParams.relativeTime);

View File

@@ -28,10 +28,6 @@ import ROUTES from 'constants/routes';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import dayjs from 'dayjs';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import {
TraceDetailEventKeys,
TraceDetailEvents,
@@ -250,7 +246,7 @@ function SpanDetailsContent({
};
const searchParams = new URLSearchParams();
applySerializedParams(serialize(compositeQuery as any), searchParams);
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());

View File

@@ -18,7 +18,6 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { Compass } from '@signozhq/icons';
import { ILog } from 'types/api/logs/log';
@@ -139,7 +138,7 @@ function SpanLogs({
[QueryParams.activeLogId]: `"${log.id}"`,
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
...serializeToParams(updatedQuery),
[QueryParams.compositeQuery]: JSON.stringify(updatedQuery),
};
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;

View File

@@ -38,10 +38,6 @@ import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQue
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
@@ -994,7 +990,10 @@ export function QueryBuilderProvider({
);
}
applySerializedParams(serialize(currentGeneratedQuery), urlQuery);
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(currentGeneratedQuery)),
);
if (searchParams) {
Object.keys(searchParams).forEach((param) =>

View File

@@ -2,7 +2,6 @@ import { generatePath } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
type GenerateExportToDashboardLinkParams = {
@@ -22,4 +21,6 @@ export const generateExportToDashboardLink = ({
dashboardId,
})}/new?${QueryParams.graphType}=${panelType}&${
QueryParams.widgetId
}=${widgetId}&${serialize(query).toString()}`;
}=${widgetId}&${QueryParams.compositeQuery}=${encodeURIComponent(
JSON.stringify(query),
)}`;

View File

@@ -130,7 +130,6 @@ func Error(rw http.ResponseWriter, cause error) {
rw.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(d.Seconds()))))
}
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(httpCode)
_, _ = rw.Write(body)
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type migrateCommon struct {
@@ -23,119 +24,10 @@ func NewMigrateCommon(logger *slog.Logger) *migrateCommon {
}
}
// WrapInV5Envelope delegates to querybuildertypesv5.WrapInV5Envelope; the
// transform is stateless and shared with the v1→v2 dashboard conversion.
func (migration *migrateCommon) WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
return querybuildertypesv5.WrapInV5Envelope(name, queryMap, queryType)
}
func (mc *migrateCommon) updateQueryData(ctx context.Context, queryData map[string]any, version, widgetType string) bool {

View File

@@ -221,8 +221,8 @@ func (role *PostableRole) UnmarshalJSON(data []byte) error {
func (role *UpdatableRole) UnmarshalJSON(data []byte) error {
shadow := struct {
Description *string `json:"description"`
TransactionGroups *json.RawMessage `json:"transactionGroups"`
Description *string `json:"description"`
TransactionGroups TransactionGroups `json:"transactionGroups"`
}{}
if err := json.Unmarshal(data, &shadow); err != nil {
@@ -237,13 +237,8 @@ func (role *UpdatableRole) UnmarshalJSON(data []byte) error {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "transactionGroups is required").WithAdditional("send an empty array to clear the role's transaction groups")
}
transactionGroups, err := NewTransactionGroups(*shadow.TransactionGroups)
if err != nil {
return err
}
role.Description = *shadow.Description
role.TransactionGroups = transactionGroups
role.TransactionGroups = shadow.TransactionGroups
return nil
}

View File

@@ -7,7 +7,6 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -22,6 +21,7 @@ var (
ErrCodeDashboardInvalidSource = errors.MustNewCode("dashboard_invalid_source")
ErrCodeDashboardImmutable = errors.MustNewCode("dashboard_immutable")
ErrCodeDashboardInvalidPatch = errors.MustNewCode("dashboard_invalid_patch")
ErrCodeDashboardMigrationFailed = errors.MustNewCode("dashboard_migration_failed")
)
type StorableDashboard struct {
@@ -406,27 +406,26 @@ func (dashboard *Dashboard) GetWidgetQuery(startTime, endTime, widgetIndex uint6
widgetData := data.Widgets[widgetIndex]
switch widgetData.Query.QueryType {
case "builder":
migrate := transition.NewMigrateCommon(logger)
for _, query := range widgetData.Query.Builder.QueryData {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_query"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_query"))
}
for _, query := range widgetData.Query.Builder.QueryFormulas {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_formula"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_formula"))
}
for _, query := range widgetData.Query.Builder.QueryTraceOperator {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
}
case "clickhouse_sql":
for _, query := range widgetData.Query.ClickhouseSQL {

View File

@@ -1058,6 +1058,34 @@ func TestValidateRequiredFields(t *testing.T) {
}
}
// TestThresholdZeroValueAcceptedMissingRejected documents the *float64 Value:
// a threshold at 0 (or 0.0) is valid, because the pointer lets validate:"required"
// tell a present zero (non-nil) from an absent value (nil) — while a genuinely
// missing value is still rejected.
func TestThresholdZeroValueAcceptedMissingRejected(t *testing.T) {
numberPanel := func(thresholdSpec string) string {
return `{
"panels": {"p1": {"kind": "Panel", "spec": {
"plugin": {"kind": "signoz/NumberPanel", "spec": {"thresholds": [` + thresholdSpec + `]}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}}},
"layouts": []
}`
}
_, errZero := unmarshalDashboard([]byte(numberPanel(`{"value": 0, "operator": "above", "format": "text", "color": "Red"}`)))
require.NoError(t, errZero, `a threshold "value": 0 is valid`)
// "value": 0.0 is the same float64 zero as "value": 0 — JSON has one number
// type — and is accepted identically.
_, errZeroFloat := unmarshalDashboard([]byte(numberPanel(`{"value": 0.0, "operator": "above", "format": "text", "color": "Red"}`)))
require.NoError(t, errZeroFloat, `"value": 0.0 is the same valid zero`)
_, errMissing := unmarshalDashboard([]byte(numberPanel(`{"operator": "above", "format": "text", "color": "Red"}`)))
require.Error(t, errMissing, "a genuinely missing value is still rejected")
require.Contains(t, errMissing.Error(), "Value")
}
func TestTimeSeriesPanelDefaults(t *testing.T) {
data := []byte(`{
"panels": {

View File

@@ -251,14 +251,20 @@ type Legend struct {
}
type ThresholdWithLabel struct {
Value float64 `json:"value" validate:"required" required:"true"`
Unit string `json:"unit"`
Color string `json:"color" validate:"required" required:"true"`
Label string `json:"label"`
// Value is a pointer so a threshold at 0 is valid: validate:"required" treats
// the float64 zero as "missing", but a non-nil *float64 to 0 passes (and nil
// still fails, so a genuinely absent value is still rejected). nullable:"false"
// keeps it a plain required number in the schema — it is never null in valid
// data (validation rejects nil), so the pointer must not leak as `number|null`.
Value *float64 `json:"value" validate:"required" required:"true" nullable:"false"`
Unit string `json:"unit"`
Color string `json:"color" validate:"required" required:"true"`
Label string `json:"label"`
}
type ComparisonThreshold struct {
Value float64 `json:"value" validate:"required" required:"true"`
// Value is a pointer so a threshold at 0 is valid (see ThresholdWithLabel.Value).
Value *float64 `json:"value" validate:"required" required:"true" nullable:"false"`
Operator ComparisonOperator `json:"operator"`
Unit string `json:"unit"`
Color string `json:"color" validate:"required" required:"true"`

View File

@@ -0,0 +1,82 @@
package dashboardtypes
import (
"github.com/SigNoz/signoz/pkg/errors"
)
// V1 → V2 migration. The v1 storable shape is the frontend's `DashboardData`
// (see frontend/src/types/api/dashboard/getAll.ts); v2 is DashboardV2 /
// DashboardSpec.
//
// Assumes the v1 widget query data has already been migrated to v5 shape
// (transition.dashboardMigrateV5). Pre-v5 builder queries will produce
// invalid v2 envelopes — run the v4→v5 migration first.
//
// The conversion is split across sibling files by concern:
// - perses_v1_to_v2_tags.go tags
// - perses_v1_to_v2_panels.go widgets → panels (+ panel field mappers)
// - perses_v1_to_v2_queries.go widget queries
// - perses_v1_to_v2_layouts.go grid layouts and sections
// - perses_v1_to_v2_variables.go variables
// - perses_v1_to_v2_decoder.go v1Decoder: typed field reads + malformed-field detection
// ══════════════════════════════════════════════
// Entry point
// ══════════════════════════════════════════════
func (storable StorableDashboard) IsV2() bool {
metadata, _ := storable.Data["metadata"].(map[string]any)
if metadata == nil {
return false
}
version, _ := metadata["schemaVersion"].(string)
return version == SchemaVersion
}
func (storable StorableDashboard) ConvertV1ToV2() (result *DashboardV2, err error) {
// Legacy v1 data can be arbitrarily malformed. The accessors degrade
// gracefully, but recover from any unforeseen panic so one bad dashboard
// surfaces as an error (to be logged and skipped) rather than crashing the run.
defer func() {
if r := recover(); r != nil {
result, err = nil, errors.Newf(errors.TypeInternal, ErrCodeDashboardMigrationFailed, "panic converting dashboard %s: %v", storable.ID, r)
}
}()
if storable.IsV2() {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardMigrationFailed, "dashboard %s is already in %s schema", storable.ID, SchemaVersion)
}
d := &v1Decoder{}
title := d.readString(storable.Data, "title")
description := d.readString(storable.Data, "description")
image := d.readString(storable.Data, "image")
spec := DashboardSpec{
Display: Display{Name: title, Description: description},
Variables: d.convertV1Variables(storable.Data["variables"]),
Panels: d.convertV1Panels(storable.Data["widgets"]),
Layouts: d.convertV1Layouts(storable.Data),
}
tags := d.convertV1TagsForOrg(storable.OrgID, storable.Data["tags"])
if err := d.errIfHasMalformedFields(); err != nil {
return nil, err
}
return &DashboardV2{
Identifiable: storable.Identifiable,
TimeAuditable: storable.TimeAuditable,
UserAuditable: storable.UserAuditable,
OrgID: storable.OrgID,
Locked: storable.Locked,
Source: storable.Source,
DashboardV2MetadataBase: DashboardV2MetadataBase{
SchemaVersion: SchemaVersion,
Image: image,
},
Name: generateDashboardName(title),
Tags: tags,
Spec: spec,
}, nil
}

View File

@@ -0,0 +1,146 @@
package dashboardtypes
import (
"encoding/json"
"fmt"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
)
// ══════════════════════════════════════════════
// v1 decoder
// ══════════════════════════════════════════════
// v1Decoder reads fields out of the untyped v1 dashboard blob. Every read*
// method follows the same contract: a field that is absent or null yields the
// zero value; a field present with the wrong type yields zero AND records a
// malformed-field error. Conversion proceeds (so one bad field doesn't abort
// the rest) and ConvertV1ToV2 returns d.malformedFieldsErr() at the end so the
// dashboard is logged and skipped.
//
// Polymorphic v1 fields (spanGaps bool|number, selectedValue string|array, …)
// are read with a type switch on the already-extracted value, never through
// these accessors, so they stay lenient by construction.
type v1Decoder struct {
bad []string
seen map[string]struct{}
}
// note records a decoding problem (malformed field, unknown value, swallowed
// sub-parse error), deduping identical messages. ConvertV1ToV2 surfaces these
// via errIfHasMalformedFields.
func (d *v1Decoder) note(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if _, dup := d.seen[msg]; dup {
return
}
if d.seen == nil {
d.seen = make(map[string]struct{})
}
d.seen[msg] = struct{}{}
d.bad = append(d.bad, msg)
}
// noteMalformedField records a v1 field present with the wrong Go type.
func (d *v1Decoder) noteMalformedField(field string, raw any) {
d.note("%q has unexpected type %T", field, raw)
}
func (d *v1Decoder) errIfHasMalformedFields() error {
if len(d.bad) == 0 {
return nil
}
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidData, "malformed v1 dashboard fields: %s", strings.Join(d.bad, "; "))
}
func readField[T any](d *v1Decoder, m map[string]any, key string) T {
var zero T
v, present := m[key]
if !present || v == nil {
return zero
}
t, ok := v.(T)
if !ok {
d.noteMalformedField(key, v)
return zero
}
return t
}
func (d *v1Decoder) readString(m map[string]any, key string) string {
return readField[string](d, m, key)
}
func (d *v1Decoder) readFloat(m map[string]any, key string) float64 {
return readField[float64](d, m, key)
}
func (d *v1Decoder) readBool(m map[string]any, key string) bool { return readField[bool](d, m, key) }
func (d *v1Decoder) readArray(m map[string]any, key string) []any { return readField[[]any](d, m, key) }
func (d *v1Decoder) readObject(m map[string]any, key string) map[string]any {
return readField[map[string]any](d, m, key)
}
// readInt narrows a numeric field to int (JSON numbers decode as float64).
func (d *v1Decoder) readInt(m map[string]any, key string) int { return int(d.readFloat(m, key)) }
func (d *v1Decoder) readFloatPtr(m map[string]any, key string) *float64 {
v, present := m[key]
if !present || v == nil {
return nil
}
f, ok := v.(float64)
if !ok {
d.noteMalformedField(key, v)
return nil
}
return &f
}
func (d *v1Decoder) readStringMap(m map[string]any, key string) map[string]string {
raw := d.readObject(m, key)
if len(raw) == 0 {
return nil
}
out := make(map[string]string, len(raw))
for k, v := range raw {
s, ok := v.(string)
if !ok {
d.noteMalformedField(key+"."+k, v)
continue
}
out[k] = s
}
return out
}
func (d *v1Decoder) readObjects(m map[string]any, key string) []map[string]any {
raw := d.readArray(m, key)
if len(raw) == 0 {
return nil
}
out := make([]map[string]any, 0, len(raw))
for i, item := range raw {
obj, ok := item.(map[string]any)
if !ok {
d.noteMalformedField(fmt.Sprintf("%s[%d]", key, i), item)
continue
}
out = append(out, obj)
}
return out
}
// decodeMapInto converts an untyped map[string]any into a typed T by
// round-tripping through JSON, letting encoding/json (struct tags, custom
// UnmarshalJSON) do the field mapping instead of hand-copying out of the map.
func decodeMapInto[T any](src map[string]any) (T, error) {
var dst T
bytes, err := json.Marshal(src)
if err != nil {
return dst, err
}
if err := json.Unmarshal(bytes, &dst); err != nil {
return dst, err
}
return dst, nil
}

View File

@@ -0,0 +1,150 @@
package dashboardtypes
import (
"fmt"
"sort"
"github.com/perses/spec/go/common"
"github.com/perses/spec/go/dashboard"
)
// ══════════════════════════════════════════════
// Layouts (data.layout + data.panelMap)
// ══════════════════════════════════════════════
// convertV1Layouts groups v1 react-grid-layout entries into v2 grid layouts.
// Membership is positional (as the frontend renders): each row widget owns the
// panels below it until the next row; panels above the first row form an unnamed
// grid with no section header. Collapsed rows are the exception — their children
// live in panelMap[rowID].widgets, not `layout`.
func (d *v1Decoder) convertV1Layouts(data StorableDashboardData) []Layout {
layout := d.readObjects(data, "layout")
if len(layout) == 0 {
return nil
}
rows := d.extractRowsAndCollapsedWidgets(data)
// `layout` ids must correspond to a real widget. react-grid-layout leaks a
// "__dropping-elem__" drag placeholder (and stale entries can outlive a
// deleted widget) into the saved layout; both would otherwise become grid
// items referencing a non-existent panel.
widgetIDs := make(map[string]bool)
for _, w := range d.readObjects(data, "widgets") {
if id := d.readString(w, "id"); id != "" {
widgetIDs[id] = true
}
}
// Skip collapsed-row children a malformed dashboard lists in `layout` too.
isWidgetCollapsed := make(map[string]bool)
for _, row := range rows {
for _, child := range row.collapsedWidgets {
if id := d.readString(child, "i"); id != "" {
isWidgetCollapsed[id] = true
}
}
}
d.sortByPosition(layout)
type section struct {
row *rowInfo // nil for the unnamed grid of ungrouped panels
items []map[string]any
}
topSectionWithoutHeader := &section{}
sectionsWithHeader := make([]*section, 0, len(rows))
currentRowHeader := topSectionWithoutHeader
for _, item := range layout {
id := d.readString(item, "i")
if id == "" || isWidgetCollapsed[id] || !widgetIDs[id] {
continue
}
if row, ok := rows[id]; ok {
newRowHeader := &section{row: row, items: row.collapsedWidgets}
sectionsWithHeader = append(sectionsWithHeader, newRowHeader)
// A collapsed row owns only its stashed children; later panels → ungrouped.
if row.collapsed {
currentRowHeader = topSectionWithoutHeader
} else {
currentRowHeader = newRowHeader
}
continue
}
currentRowHeader.items = append(currentRowHeader.items, item)
}
out := make([]Layout, 0, len(sectionsWithHeader)+1)
if len(topSectionWithoutHeader.items) > 0 {
out = append(out, d.buildV2GridLayout(nil, topSectionWithoutHeader.items))
}
for _, sec := range sectionsWithHeader {
out = append(out, d.buildV2GridLayout(sec.row, sec.items))
}
return out
}
type rowInfo struct {
title string
collapsed bool
collapsedWidgets []map[string]any
}
// extractRowsAndCollapsedWidgets returns the row widgets keyed by id; collapsed
// rows also carry their children stashed under panelMap[id].widgets.
func (d *v1Decoder) extractRowsAndCollapsedWidgets(data StorableDashboardData) map[string]*rowInfo {
panelMap := d.readObject(data, "panelMap")
rows := make(map[string]*rowInfo)
for _, w := range d.readObjects(data, "widgets") {
id := d.readString(w, "id")
if d.readString(w, "panelTypes") != "row" || id == "" {
continue
}
row := &rowInfo{title: d.readString(w, "title")}
if pm := d.readObject(panelMap, id); pm != nil && d.readBool(pm, "collapsed") {
row.collapsed = true
row.collapsedWidgets = d.readObjects(pm, "widgets")
}
rows[id] = row
}
return rows
}
// buildV2GridLayout builds one v2 grid. row is nil for the unnamed grid (no
// display); otherwise the grid takes the row's title and collapse state. Items
// are sorted by (y, x) and their y's normalized so the topmost sits at 0.
func (d *v1Decoder) buildV2GridLayout(row *rowInfo, items []map[string]any) Layout {
d.sortByPosition(items)
spec := dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, 0, len(items))}
if row != nil {
spec.Display = &dashboard.GridLayoutDisplay{
Title: row.title,
Collapse: &dashboard.GridLayoutCollapse{Open: !row.collapsed},
}
}
minY := 0
if len(items) > 0 {
minY = d.readInt(items[0], "y") // sorted by y, so the first item is topmost
}
for _, item := range items {
spec.Items = append(spec.Items, dashboard.GridItem{
X: d.readInt(item, "x"),
Y: d.readInt(item, "y") - minY,
Width: d.readInt(item, "w"),
Height: d.readInt(item, "h"),
Content: &common.JSONRef{Ref: fmt.Sprintf("#/spec/panels/%s", d.readString(item, "i"))},
})
}
return Layout{Kind: dashboard.KindGridLayout, Spec: &spec}
}
func (d *v1Decoder) sortByPosition(items []map[string]any) {
sort.SliceStable(items, func(i, j int) bool {
if yi, yj := d.readInt(items[i], "y"), d.readInt(items[j], "y"); yi != yj {
return yi < yj
}
return d.readInt(items[i], "x") < d.readInt(items[j], "x")
})
}

View File

@@ -0,0 +1,463 @@
package dashboardtypes
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// ══════════════════════════════════════════════
// Widgets → Panels
// ══════════════════════════════════════════════
// convertV1Panels walks the v1 `widgets` array and produces v2 panels keyed by
// the v1 widget id. WidgetRow entries (panelTypes == "row") are dropped here
// and consumed by convertV1Layouts as section headers.
func (d *v1Decoder) convertV1Panels(raw any) map[string]*Panel {
if raw == nil {
return nil
}
widgetsRaw, ok := raw.([]any)
if !ok {
d.noteMalformedField("widgets", raw)
return nil
}
panels := make(map[string]*Panel, len(widgetsRaw))
for i, widgetRaw := range widgetsRaw {
widget, ok := widgetRaw.(map[string]any)
if !ok {
d.noteMalformedField(fmt.Sprintf("widgets[%d]", i), widgetRaw)
continue
}
id := d.readString(widget, "id")
if id == "" {
continue
}
var panel *Panel
panelType := d.readString(widget, "panelTypes")
switch panelType {
case "graph":
panel = d.convertGraphWidget(widget)
case "bar":
panel = d.convertBarWidget(widget)
case "value":
panel = d.convertValueWidget(widget)
case "pie":
panel = d.convertPieWidget(widget)
case "table":
panel = d.convertTableWidget(widget)
case "histogram":
panel = d.convertHistogramWidget(widget)
case "list":
panel = d.convertListWidget(widget)
case "row":
// "row" (section header) is handled by the layout pass;
continue
default:
d.note("widgets[%d] has unknown panel type %q", i, panelType)
}
if panel == nil {
continue
}
panels[id] = panel
}
return panels
}
func (d *v1Decoder) convertGraphWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindTimeSeries,
Spec: &TimeSeriesPanelSpec{
Visualization: TimeSeriesVisualization{
BasicVisualization: d.basicVisualization(w),
FillSpans: d.readBool(w, "fillSpans"),
},
Formatting: d.panelFormatting(w),
ChartAppearance: TimeSeriesChartAppearance{
LineInterpolation: mapV1Enum(d.readString(w, "lineInterpolation"), LineInterpolationSpline,
LineInterpolationLinear, LineInterpolationSpline, LineInterpolationStepAfter, LineInterpolationStepBefore),
ShowPoints: d.readBool(w, "showPoints"),
LineStyle: mapV1Enum(d.readString(w, "lineStyle"), LineStyleSolid, LineStyleSolid, LineStyleDashed),
FillMode: mapV1Enum(d.readString(w, "fillMode"), FillModeSolid, FillModeSolid, FillModeGradient, FillModeNone),
SpanGaps: mapV1SpanGaps(w["spanGaps"]),
},
Axes: d.axesFromWidget(w),
Legend: d.legendFromWidget(w),
Thresholds: d.mapV1ThresholdsWithLabel(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindTimeSeries),
},
}
}
func (d *v1Decoder) convertBarWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindBarChart,
Spec: &BarChartPanelSpec{
Visualization: BarChartVisualization{
BasicVisualization: d.basicVisualization(w),
FillSpans: d.readBool(w, "fillSpans"),
StackedBarChart: d.readBool(w, "stackedBarChart"),
},
Formatting: d.panelFormatting(w),
Axes: d.axesFromWidget(w),
Legend: d.legendFromWidget(w),
Thresholds: d.mapV1ThresholdsWithLabel(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindBarChart),
},
}
}
func (d *v1Decoder) convertValueWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindNumber,
Spec: &NumberPanelSpec{
Visualization: d.basicVisualization(w),
Formatting: d.panelFormatting(w),
Thresholds: d.mapV1ComparisonThresholds(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindNumber),
},
}
}
func (d *v1Decoder) convertPieWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindPieChart,
Spec: &PieChartPanelSpec{
Visualization: d.basicVisualization(w),
Formatting: d.panelFormatting(w),
Legend: d.legendFromWidget(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindPieChart),
},
}
}
func (d *v1Decoder) convertTableWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindTable,
Spec: &TablePanelSpec{
Visualization: d.basicVisualization(w),
Formatting: TableFormatting{
ColumnUnits: d.readStringMap(w, "columnUnits"),
DecimalPrecision: mapV1Precision(w["decimalPrecision"]),
},
Thresholds: d.mapV1TableThresholds(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindTable),
},
}
}
func (d *v1Decoder) convertHistogramWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindHistogram,
Spec: &HistogramPanelSpec{
HistogramBuckets: HistogramBuckets{
BucketCount: d.readFloatPtr(w, "bucketCount"),
BucketWidth: d.readFloatPtr(w, "bucketWidth"),
MergeAllActiveQueries: d.readBool(w, "mergeAllActiveQueries"),
},
Legend: d.legendFromWidget(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindHistogram),
},
}
}
func (d *v1Decoder) convertListWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindList,
Spec: &ListPanelSpec{
SelectFields: d.mapV1SelectFields(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindList),
},
}
}
// ══════════════════════════════════════════════
// Panel-spec shared helpers
// ══════════════════════════════════════════════
func (d *v1Decoder) widgetDisplay(w map[string]any) Display {
return Display{Name: d.readString(w, "title"), Description: d.readString(w, "description")}
}
func (d *v1Decoder) basicVisualization(w map[string]any) BasicVisualization {
return BasicVisualization{TimePreference: mapV1TimePreference(d.readString(w, "timePreferance"))}
}
func (d *v1Decoder) panelFormatting(w map[string]any) PanelFormatting {
return PanelFormatting{Unit: d.readString(w, "yAxisUnit"), DecimalPrecision: mapV1Precision(w["decimalPrecision"])}
}
func (d *v1Decoder) axesFromWidget(w map[string]any) Axes {
return Axes{
SoftMin: d.readFloatPtr(w, "softMin"),
SoftMax: d.readFloatPtr(w, "softMax"),
IsLogScale: d.readBool(w, "isLogScale"),
}
}
func (d *v1Decoder) legendFromWidget(w map[string]any) Legend {
return Legend{
Position: mapV1Enum(d.readString(w, "legendPosition"), LegendPositionBottom, LegendPositionBottom, LegendPositionRight),
CustomColors: d.readStringMap(w, "customLegendColors"),
}
}
func (d *v1Decoder) mapV1SelectFields(w map[string]any) []telemetrytypes.TelemetryFieldKey {
field := "selectedLogFields"
raw := d.readArray(w, field)
if len(raw) == 0 {
field = "selectedTracesFields"
raw = d.readArray(w, field)
}
if len(raw) == 0 {
return nil
}
fields, err := decodeTelemetryFields(raw)
if err != nil {
d.note("widget %q has malformed %s: %v", d.readString(w, "id"), field, err)
return nil
}
return fields
}
func decodeTelemetryFields(raw []any) ([]telemetrytypes.TelemetryFieldKey, error) {
bytes, err := json.Marshal(raw)
if err != nil {
return nil, err
}
var fields []telemetrytypes.TelemetryFieldKey
if err := json.Unmarshal(bytes, &fields); err != nil {
return nil, err
}
return fields, nil
}
// ══════════════════════════════════════════════
// Panel field mappers
// ══════════════════════════════════════════════
// v1 stores timePreferance as `GLOBAL_TIME`, `LAST_5_MIN`, … (see
// frontend/src/container/NewWidget/RightContainer/timeItems.ts). v2 uses the
// lowercase form, so the translation is just downcase.
func mapV1TimePreference(s string) TimePreference {
if s == "" {
return TimePreferenceGlobalTime
}
candidate := TimePreference{valuer.NewString(strings.ToLower(s))}
for _, allowed := range candidate.Enum() {
if allowed == candidate {
return candidate
}
}
return TimePreferenceGlobalTime
}
// mapV1Precision is polymorphic (string|number), so it type-switches the raw
// value rather than reading through a typed accessor.
func mapV1Precision(raw any) PrecisionOption {
switch v := raw.(type) {
case string:
candidate := PrecisionOption{valuer.NewString(v)}
for _, allowed := range candidate.Enum() {
if allowed == candidate {
return candidate
}
}
case float64:
n := int(v)
if n >= 0 && n <= 4 {
return PrecisionOption{valuer.NewString(strconv.Itoa(n))}
}
}
return PrecisionOption2
}
// mapV1Enum picks the v1 string value if it matches one of the allowed v2
// values, otherwise returns the fallback. v1 frontend enums (lineInterpolation,
// lineStyle, fillMode, legendPosition) already use the v2 lowercase form.
func mapV1Enum[T interface{ StringValue() string }](s string, fallback T, allowed ...T) T {
if s == "" {
return fallback
}
for _, a := range allowed {
if a.StringValue() == s {
return a
}
}
return fallback
}
// v1 spanGaps is `boolean | number`. true → span every gap; false → never span;
// a number is interpreted (per frontend SeriesProps.spanGaps docs) as an
// X-axis threshold in seconds. Polymorphic, so it type-switches the raw value.
func mapV1SpanGaps(raw any) SpanGaps {
switch v := raw.(type) {
case bool:
if v {
return SpanGaps{FillOnlyBelow: false}
}
return SpanGaps{FillOnlyBelow: true}
case float64:
dur, err := valuer.ParseTextDuration(time.Duration(v * float64(time.Second)).String())
if err != nil {
return SpanGaps{FillOnlyBelow: false}
}
return SpanGaps{FillOnlyBelow: true, FillLessThan: dur}
}
return SpanGaps{FillOnlyBelow: false}
}
func (d *v1Decoder) mapV1ThresholdsWithLabel(w map[string]any) []ThresholdWithLabel {
rawSlice := d.readObjects(w, "thresholds")
if len(rawSlice) == 0 {
return nil
}
out := make([]ThresholdWithLabel, 0, len(rawSlice))
for _, t := range rawSlice {
color := d.readString(t, "thresholdColor")
label := d.readString(t, "thresholdLabel")
if color == "" || label == "" {
// v2 ThresholdWithLabel requires both; drop entries that wouldn't validate.
continue
}
value := d.readFloat(t, "thresholdValue")
out = append(out, ThresholdWithLabel{Value: &value, Unit: d.readString(t, "thresholdUnit"), Color: color, Label: label})
}
if len(out) == 0 {
return nil
}
return out
}
func (d *v1Decoder) mapV1ComparisonThresholds(w map[string]any) []ComparisonThreshold {
rawSlice := d.readObjects(w, "thresholds")
if len(rawSlice) == 0 {
return nil
}
out := make([]ComparisonThreshold, 0, len(rawSlice))
for _, t := range rawSlice {
color := d.readString(t, "thresholdColor")
if color == "" {
continue
}
value := d.readFloat(t, "thresholdValue")
out = append(out, ComparisonThreshold{
Value: &value,
Operator: d.mapV1ComparisonOperator(d.readString(t, "thresholdOperator")),
Unit: d.readString(t, "thresholdUnit"),
Color: color,
Format: mapV1ThresholdFormat(d.readString(t, "thresholdFormat")),
})
}
if len(out) == 0 {
return nil
}
return out
}
func (d *v1Decoder) mapV1TableThresholds(w map[string]any) []TableThreshold {
rawSlice := d.readObjects(w, "thresholds")
if len(rawSlice) == 0 {
return nil
}
out := make([]TableThreshold, 0, len(rawSlice))
for _, t := range rawSlice {
color := d.readString(t, "thresholdColor")
columnName := d.readString(t, "thresholdTableOptions")
if color == "" || columnName == "" {
continue
}
value := d.readFloat(t, "thresholdValue")
out = append(out, TableThreshold{
ComparisonThreshold: ComparisonThreshold{
Value: &value,
Operator: d.mapV1ComparisonOperator(d.readString(t, "thresholdOperator")),
Unit: d.readString(t, "thresholdUnit"),
Color: color,
Format: mapV1ThresholdFormat(d.readString(t, "thresholdFormat")),
},
ColumnName: columnName,
})
}
if len(out) == 0 {
return nil
}
return out
}
func (d *v1Decoder) mapV1ComparisonOperator(s string) ComparisonOperator {
switch s {
case ">":
return ComparisonOperatorAbove
case ">=":
return ComparisonOperatorAboveOrEqual
case "<":
return ComparisonOperatorBelow
case "<=":
return ComparisonOperatorBelowOrEqual
case "=":
return ComparisonOperatorEqual
case "!=":
return ComparisonOperatorNotEqual
default:
d.note("threshold has unknown comparison operator %q", s)
return ComparisonOperatorAbove
}
}
func mapV1ThresholdFormat(s string) ThresholdFormat {
switch strings.ToLower(s) {
case "background":
return ThresholdFormatBackground
case "text":
return ThresholdFormatText
}
return ThresholdFormatText
}

View File

@@ -0,0 +1,246 @@
package dashboardtypes
import (
"encoding/json"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// ══════════════════════════════════════════════
// Queries
// ══════════════════════════════════════════════
// convertV1WidgetQuery returns exactly one Query (per Spec.Validate). The kind
// chosen depends on the v1 widget query shape:
// - a single query (promql / clickhouse_sql / builder) → its native kind
// - multiple queries → signoz/CompositeQuery
//
// A single query is never wrapped in a CompositeQuery; in particular List
// panels accept only a bare signoz/BuilderQuery. Builder queries are routed
// through qb.WrapInV5Envelope (in collectV1QueryEnvelopes), which translates v4
// builder-field names (orderBy/selectColumns/dataSource) into their v5
// equivalents and adds the `signal` field required by BuilderQuerySpec's
// per-signal dispatch.
func (d *v1Decoder) convertV1WidgetQuery(widget map[string]any, panelKind PanelPluginKind) []Query {
envelopes, signal := d.collectV1QueryEnvelopes(widget)
if len(envelopes) == 0 {
return nil
}
requestType := requestTypeForPanel(panelKind)
// A single query keeps its native kind — never wrapped in a CompositeQuery.
if len(envelopes) == 1 {
if q := singleQueryFromEnvelope(envelopes[0], requestType, signal); q != nil {
return []Query{*q}
}
}
// Default: wrap in CompositeQuery.
composite, err := parseCompositeFromEnvelopes(envelopes)
if err != nil || composite == nil {
d.note("widget %q: could not build query from %d envelope(s): %v", d.readString(widget, "id"), len(envelopes), err)
return nil
}
return []Query{{
Kind: requestType,
Spec: QuerySpec{
Plugin: QueryPlugin{Kind: QueryKindComposite, Spec: composite},
},
}}
}
// requestTypeForPanel maps a v2 panel plugin kind to the request type (result
// shape) its queries produce. Mirrors the frontend's panelTypeToRequestType
// (buildQueryRangeRequest.ts): time series for line/bar/histogram (histogram
// bins client-side from raw time series, V1 parity), scalar for
// number/pie/table, raw rows for list.
func requestTypeForPanel(panelKind PanelPluginKind) qb.RequestType {
switch panelKind {
case PanelKindTimeSeries, PanelKindBarChart, PanelKindHistogram:
return qb.RequestTypeTimeSeries
case PanelKindNumber, PanelKindPieChart, PanelKindTable:
return qb.RequestTypeScalar
case PanelKindList:
return qb.RequestTypeRaw
}
return qb.RequestTypeTimeSeries
}
// collectV1QueryEnvelopes inspects widget.query.queryType and produces a
// flattened list of v5-shaped envelopes. The returned signal is the dominant
// builder signal (if any), used for typed builder-query dispatch.
func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any) ([]map[string]any, telemetrytypes.Signal) {
queryMap := d.readObject(widget, "query")
if queryMap == nil {
return nil, telemetrytypes.Signal{}
}
queryType := d.readString(queryMap, "queryType")
switch queryType {
case "promql":
var out []map[string]any
for _, q := range d.readObjects(queryMap, "promql") {
out = append(out, promQLEnvelope(q))
}
return out, telemetrytypes.Signal{}
case "clickhouse_sql":
var out []map[string]any
for _, q := range d.readObjects(queryMap, "clickhouse_sql") {
out = append(out, clickhouseEnvelope(q))
}
return out, telemetrytypes.Signal{}
case "builder":
builder := d.readObject(queryMap, "builder")
if builder == nil {
return nil, telemetrytypes.Signal{}
}
var out []map[string]any
var signal telemetrytypes.Signal
for _, q := range d.readObjects(builder, "queryData") {
name := d.readString(q, "queryName")
out = append(out, qb.WrapInV5Envelope(name, q, string(qb.QueryTypeBuilder.StringValue())))
if signal.IsZero() {
signal = signalFromDataSource(q["dataSource"])
}
}
for _, f := range d.readObjects(builder, "queryFormulas") {
name := d.readString(f, "queryName")
out = append(out, qb.WrapInV5Envelope(name, f, string(qb.QueryTypeFormula.StringValue())))
}
for _, op := range d.readObjects(builder, "queryTraceOperator") {
name := d.readString(op, "queryName")
out = append(out, qb.WrapInV5Envelope(name, op, string(qb.QueryTypeTraceOperator.StringValue())))
}
return out, signal
default:
d.note("widget %q has unknown queryType %q", d.readString(widget, "id"), queryType)
}
return nil, telemetrytypes.Signal{}
}
func promQLEnvelope(q map[string]any) map[string]any {
return map[string]any{
"type": qb.QueryTypePromQL.StringValue(),
"spec": map[string]any{
"name": q["name"],
"query": q["query"],
"disabled": q["disabled"],
"legend": q["legend"],
},
}
}
func clickhouseEnvelope(q map[string]any) map[string]any {
return map[string]any{
"type": qb.QueryTypeClickHouseSQL.StringValue(),
"spec": map[string]any{
"name": q["name"],
"query": q["query"],
"disabled": q["disabled"],
"legend": q["legend"],
},
}
}
// singleQueryFromEnvelope returns a typed Query for one envelope, using its
// native query kind (promql/clickhouse_sql/builder) rather than wrapping it in
// a CompositeQuery. A bare signoz/BuilderQuery is valid for every panel kind
// and is the only kind List panels accept.
func singleQueryFromEnvelope(envelope map[string]any, requestType qb.RequestType, signal telemetrytypes.Signal) *Query {
t, _ := envelope["type"].(string)
spec, _ := envelope["spec"].(map[string]any)
switch t {
case qb.QueryTypePromQL.StringValue():
prom, err := decodeMapInto[qb.PromQuery](spec)
if err != nil {
return nil
}
return &Query{
Kind: requestType,
Spec: QuerySpec{
Name: prom.Name,
Plugin: QueryPlugin{Kind: QueryKindPromQL, Spec: &prom},
},
}
case qb.QueryTypeClickHouseSQL.StringValue():
ch, err := decodeMapInto[qb.ClickHouseQuery](spec)
if err != nil {
return nil
}
return &Query{
Kind: requestType,
Spec: QuerySpec{
Name: ch.Name,
Plugin: QueryPlugin{Kind: QueryKindClickHouseSQL, Spec: &ch},
},
}
case qb.QueryTypeBuilder.StringValue():
builderSpec := parseBuilderQuerySpec(spec, signal)
if builderSpec == nil {
return nil
}
name, _ := spec["name"].(string)
return &Query{
Kind: requestType,
Spec: QuerySpec{
Name: name,
Plugin: QueryPlugin{Kind: QueryKindBuilder, Spec: &BuilderQuerySpec{Spec: builderSpec}},
},
}
}
return nil
}
func parseCompositeFromEnvelopes(envelopes []map[string]any) (*CompositeQuerySpec, error) {
bytes, err := json.Marshal(envelopes)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal v1 query envelopes")
}
var parsed []qb.QueryEnvelope
if err := json.Unmarshal(bytes, &parsed); err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidWidgetQuery, "decode v5 query envelopes")
}
return &CompositeQuerySpec{Queries: parsed}, nil
}
func parseBuilderQuerySpec(rawSpec any, signal telemetrytypes.Signal) any {
spec, ok := rawSpec.(map[string]any)
if !ok {
return nil
}
if !signal.IsZero() {
spec["signal"] = signal.StringValue()
}
bytes, err := json.Marshal(spec)
if err != nil {
return nil
}
parsed, err := qb.UnmarshalBuilderQueryBySignal(bytes)
if err != nil {
return nil
}
return parsed
}
// signalFromDataSource maps a v1 data-source string to a v5 signal. Casing
// varies by source: builder queries store lowercase ("traces"), while variable
// `dynamicVariablesSource` stores capitalized ("Traces"), so match
// case-insensitively. Unknown values (e.g. "All telemetry") map to the zero
// Signal.
func signalFromDataSource(raw any) telemetrytypes.Signal {
s, _ := raw.(string)
switch strings.ToLower(s) {
case "traces":
return telemetrytypes.SignalTraces
case "logs":
return telemetrytypes.SignalLogs
case "metrics":
return telemetrytypes.SignalMetrics
}
return telemetrytypes.Signal{}
}

View File

@@ -0,0 +1,122 @@
package dashboardtypes
import (
"fmt"
"regexp"
"strings"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// ══════════════════════════════════════════════
// Tags
// ══════════════════════════════════════════════
// v1 carries tags as a flat []string; v2 tags are (key, value) pairs. Each v1
// string is normalized into a pair (separator split, empty-side fallback,
// reserved-key prefix, `/` scrub). Tags that normalize to the same
// (lower(key), lower(value)) within a dashboard are collapsed, first occurrence
// winning the display casing.
//
// Characters still illegal after normalization (spaces, punctuation) are molded
// to fit the tag validators: disallowed runs collapse to "_" (see moldTagField).
// defaultV1TagKey is the key assigned when a v1 tag string has no usable
// separator (or one side of the split is empty).
const defaultV1TagKey = "tag"
func (d *v1Decoder) convertV1TagsForOrg(orgID valuer.UUID, raw any) []*tagtypes.Tag {
if raw == nil {
return nil
}
rawTagsList, ok := raw.([]any)
if !ok {
d.noteMalformedField("tags", raw)
return nil
}
seen := make(map[string]struct{}, len(rawTagsList))
tagsV2 := make([]*tagtypes.Tag, 0, len(rawTagsList))
for i, rawTag := range rawTagsList {
s, ok := rawTag.(string)
if !ok {
d.noteMalformedField(fmt.Sprintf("tags[%d]", i), rawTag)
continue
}
key, value, ok := normalizeV1Tag(s)
if !ok {
continue
}
dedupKey := strings.ToLower(key) + "\x00" + strings.ToLower(value)
if _, dup := seen[dedupKey]; dup {
continue
}
seen[dedupKey] = struct{}{}
tagsV2 = append(tagsV2, tagtypes.NewTag(orgID, coretypes.KindDashboard, key, value))
}
return tagsV2
}
// normalizeV1Tag derives a (key, value) pair from one v1 tag string. After
// splitting and molding both sides, a lone survivor becomes a value under the
// default key; ok is false if neither survives.
func normalizeV1Tag(s string) (string, string, bool) {
s = strings.TrimSpace(s)
if s == "" {
return "", "", false
}
var rawKey, rawValue string
switch {
case strings.Contains(s, ":"):
rawKey, rawValue, _ = strings.Cut(s, ":")
// Only the first ":" separates key from value; collapse the rest.
rawValue = strings.ReplaceAll(rawValue, ":", "_")
case strings.Contains(s, "/"):
rawKey, rawValue, _ = strings.Cut(s, "/")
default:
rawValue = s
}
rawKey = strings.TrimSpace(rawKey)
rawValue = strings.TrimSpace(rawValue)
// Reserved-key collision: prefix "_" so the list-query DSL stays unambiguous.
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(rawKey))]; rawKey != "" && reserved {
rawKey = "_" + rawKey
}
key := moldTagField(rawKey, tagKeyDisallowed, tagKeyNotLead, tagtypes.MAX_LEN_TAG_KEY)
value := moldTagField(rawValue, tagValueDisallowed, nil, tagtypes.MAX_LEN_TAG_VALUE)
switch {
case key == "" && value == "":
return "", "", false
case key == "":
return defaultV1TagKey, value, true
case value == "":
return defaultV1TagKey, key, true
default:
return key, value, true
}
}
// Inverse of tagKeyRegex/tagValueRegex ("/" always rejected); tagKeyNotLead
// matches a bad first char for a key. TestMoldedV1TagsPassValidation guards drift.
var (
tagKeyDisallowed = regexp.MustCompile(`[^a-zA-Z0-9$_@#{}:-]+`)
tagValueDisallowed = regexp.MustCompile(`[^a-zA-Z0-9$_@#{}:.+=-]+`)
tagKeyNotLead = regexp.MustCompile(`^[^a-zA-Z$_@{#]`)
)
// moldTagField collapses disallowed runs to "_", prefixes "_" if notLead hits
// the first char, and caps at max. Keeps a leading "_", trims a trailing one.
func moldTagField(s string, disallowed, notLead *regexp.Regexp, max int) string {
s = strings.TrimRight(disallowed.ReplaceAllString(s, "_"), "_")
if s != "" && notLead != nil && notLead.MatchString(s) {
s = "_" + s
}
if len(s) > max {
s = strings.TrimRight(s[:max], "_")
}
return s
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,169 @@
package dashboardtypes
import (
"sort"
"github.com/perses/spec/go/dashboard/variable"
)
// ══════════════════════════════════════════════
// Variables
// ══════════════════════════════════════════════
// convertV1Variables walks the v1 `variables` map (UUID-keyed) and produces an
// ordered []Variable. Variables sort by `order` first, then by id for stable
// output. v1 variable types map as follows:
//
// QUERY → ListVariable + signoz/QueryVariable
// CUSTOM → ListVariable + signoz/CustomVariable
// DYNAMIC → ListVariable + signoz/DynamicVariable
// TEXTBOX → TextVariable
func (d *v1Decoder) convertV1Variables(raw any) []Variable {
if raw == nil {
return nil
}
rawVariablesMap, ok := raw.(map[string]any)
if !ok {
d.noteMalformedField("variables", raw)
return nil
}
type ordered struct {
variableID string
variableContent map[string]any
order float64
}
entries := make([]ordered, 0, len(rawVariablesMap))
for variableID, variableContentRaw := range rawVariablesMap {
variableContent, ok := variableContentRaw.(map[string]any)
if !ok {
d.noteMalformedField("variables."+variableID, variableContentRaw)
continue
}
entries = append(entries, ordered{variableID: variableID, variableContent: variableContent, order: d.readFloat(variableContent, "order")})
}
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].order != entries[j].order {
return entries[i].order < entries[j].order
}
return entries[i].variableID < entries[j].variableID
})
variablesV2 := make([]Variable, 0, len(entries))
for _, e := range entries {
v, ok := d.convertV1Variable(e.variableContent)
if !ok {
continue
}
variablesV2 = append(variablesV2, v)
}
return variablesV2
}
func (d *v1Decoder) convertV1Variable(v map[string]any) (Variable, bool) {
name := d.readString(v, "name")
if name == "" {
return Variable{}, false
}
description := d.readString(v, "description")
kind := d.readString(v, "type")
switch kind {
case "TEXTBOX":
spec := &TextVariableSpec{
Display: Display{Name: name, Description: description},
Value: d.readString(v, "textboxValue"),
Name: name,
}
return Variable{Kind: variable.KindText, Spec: spec}, true
case "QUERY", "CUSTOM", "DYNAMIC":
listSpec := &ListVariableSpec{
Display: Display{Name: name, Description: description},
AllowAllValue: d.readBool(v, "showALLOption"),
AllowMultiple: d.readBool(v, "multiSelect"),
CustomAllValue: d.readString(v, "customAllValue"),
CapturingRegexp: d.readString(v, "capturingRegexp"),
Sort: mapV1Sort(d.readString(v, "sort")),
Plugin: d.variablePluginFor(kind, v),
Name: name,
}
if dv := mapV1VariableDefault(v); dv != nil {
listSpec.DefaultValue = dv
}
return Variable{Kind: variable.KindList, Spec: listSpec}, true
default:
d.note("variable %q has unknown type %q", name, kind)
return Variable{}, false
}
}
func (d *v1Decoder) variablePluginFor(kind string, v map[string]any) VariablePlugin {
switch kind {
case "QUERY":
return VariablePlugin{
Kind: VariableKindQuery,
Spec: &QueryVariableSpec{QueryValue: d.readString(v, "queryValue")},
}
case "CUSTOM":
return VariablePlugin{
Kind: VariableKindCustom,
Spec: &CustomVariableSpec{CustomValue: d.readString(v, "customValue")},
}
case "DYNAMIC":
spec := &DynamicVariableSpec{Name: d.readString(v, "dynamicVariablesAttribute")}
if signal := signalFromDataSource(v["dynamicVariablesSource"]); !signal.IsZero() {
spec.Signal = signal
}
return VariablePlugin{Kind: VariableKindDynamic, Spec: spec}
}
return VariablePlugin{}
}
// mapV1VariableDefault reads selectedValue/defaultValue, both polymorphic
// (string|array), so it indexes the raw value and lets defaultValueFromAny
// type-switch — no typed accessor, intentionally lenient.
func mapV1VariableDefault(v map[string]any) *VariableDefaultValue {
if raw, ok := v["selectedValue"]; ok {
return defaultValueFromAny(raw)
}
if raw, ok := v["defaultValue"]; ok {
return defaultValueFromAny(raw)
}
return nil
}
func defaultValueFromAny(raw any) *VariableDefaultValue {
switch v := raw.(type) {
case string:
if v == "" {
return nil
}
return &VariableDefaultValue{variable.DefaultValue{SingleValue: v}}
case []any:
if len(v) == 0 {
return nil
}
values := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
values = append(values, s)
}
}
if len(values) == 0 {
return nil
}
return &VariableDefaultValue{variable.DefaultValue{SliceValues: values}}
}
return nil
}
func mapV1Sort(s string) ListVariableSpecSort {
switch s {
case "ASC":
return SortAlphabeticalAsc
case "DESC":
return SortAlphabeticalDesc
}
return ListVariableSpecSort{} // zero (omitzero) — SortNone is the implicit default
}

View File

@@ -0,0 +1,127 @@
package querybuildertypesv5
// WrapInV5Envelope translates a single v4 builder query/formula map into a
// v5 query envelope ({"type": ..., "spec": ...}). It is a pure shape transform
// over untyped maps: v4 builder field names (groupBy/orderBy/selectColumns/
// dataSource) are rewritten to their v5 equivalents and a `signal` is derived
// from the data source. queryType selects the envelope type, except a formula
// (detected when name != queryMap["expression"]) is always emitted as
// "builder_formula".
//
// Migration code (pkg/transition) and the v1→v2 dashboard conversion both
// produce v5 envelopes, so this lives here with the v5 query types rather than
// in an infra-level package.
func WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}