Compare commits

..

5 Commits

Author SHA1 Message Date
Vinícius Lourenço
52161f3abc refactor(serializer): avoid generic catch for invalid query 2026-07-30 10:15:19 -03:00
Vinícius Lourenço
68494e0083 test(useSafeNavigate): move helpers to add testing 2026-07-29 17:41:13 -03:00
Vinícius Lourenço
5a59dde101 feat(compositeQuery): drop query param in favor of serializer functions 2026-07-29 17:41:13 -03:00
Vinícius Lourenço
805b1ee416 feat(compositeQuery): add base structure for serializer with json as default 2026-07-29 17:40:39 -03:00
Vinícius Lourenço
1f54d7ba59 feat(compositeQuery): add contract for serialize/deserialize query 2026-07-29 17:40:38 -03:00
113 changed files with 1253 additions and 6313 deletions

View File

@@ -93,13 +93,9 @@ func runGenerateAuthz(_ context.Context) error {
registry := coretypes.NewRegistry()
allowedResources := map[string]bool{
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
}
allowedTypes := map[string]bool{}

View File

@@ -1497,8 +1497,6 @@ components:
- cloudsql_postgres
- memorystore_redis
- computeengine
- gke
- cloudstorage
type: string
CloudintegrationtypesServiceMetadata:
properties:

View File

@@ -14,10 +14,7 @@ import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
import { USER_ROLES } from 'types/roles';
import {
routePermission,
routeWithInitialAuthZSupport,
} from 'utils/permission';
import { routePermission } from 'utils/permission';
import routes, {
LIST_LICENSES,
@@ -45,6 +42,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const isAdmin = user.role === USER_ROLES.ADMIN;
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
const mapRoutes = useMemo(
() =>
new Map(
@@ -228,16 +226,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
if (isPrivate) {
if (isLoggedInState) {
const route = routePermission[key];
const hasInitialAuthZSupport = Object.hasOwn(
routeWithInitialAuthZSupport,
key,
);
if (
route &&
route.find((e) => e === user.role) === undefined &&
hasInitialAuthZSupport === false
) {
if (route && route.find((e) => e === user.role) === undefined) {
return <Redirect to={ROUTES.UN_AUTHORIZED} />;
}
} else {

View File

@@ -17,7 +17,6 @@ import {
} from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
import { ROLES, USER_ROLES } from 'types/roles';
import { routeWithInitialAuthZSupport } from 'utils/permission';
import PrivateRoute from '../Private';
@@ -179,7 +178,6 @@ function createMockAppContext(
isFetchingHosts: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
isFetchingUserPreferences: false,
userFetchError: null,
activeLicenseFetchError: null,
hostsFetchError: null,
@@ -200,39 +198,24 @@ function createMockAppContext(
};
}
// Roles that no authz-aware route grants through the legacy routePermission table
const DENIED_ROLES: ROLES[] = [
USER_ROLES.ANONYMOUS as ROLES,
USER_ROLES.AUTHOR as ROLES,
];
const PERMITTED_ROLES: ROLES[] = [
USER_ROLES.ADMIN as ROLES,
USER_ROLES.EDITOR as ROLES,
USER_ROLES.VIEWER as ROLES,
];
interface AuthzRouteCase {
path: string;
deniedRoles: ROLES[];
}
interface RenderPrivateRouteOptions {
initialRoute?: string;
appContext?: Partial<IAppContext>;
isCloudUser?: boolean;
}
function buildPrivateRouteTree(
options: RenderPrivateRouteOptions,
): ReactElement {
const { initialRoute = ROUTES.HOME, appContext = {} } = options;
function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
const {
initialRoute = ROUTES.HOME,
appContext = {},
isCloudUser = true,
} = options;
const contextValue = createMockAppContext({
...appContext,
});
mockIsCloudUser = isCloudUser;
return (
const contextValue = createMockAppContext(appContext);
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialRoute]}>
<AppContext.Provider value={contextValue}>
@@ -246,15 +229,10 @@ function buildPrivateRouteTree(
</PrivateRoute>
</AppContext.Provider>
</MemoryRouter>
</QueryClientProvider>
</QueryClientProvider>,
);
}
function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
mockIsCloudUser = options.isCloudUser ?? true;
render(buildPrivateRouteTree(options));
}
// Generic assertion helpers for navigation behavior
// Using location-based assertions since Private.tsx now uses Redirect component
@@ -1454,25 +1432,6 @@ describe('PrivateRoute', () => {
assertStaysOnRoute(ROUTES.GET_STARTED_WITH_CLOUD);
});
it.each([...PERMITTED_ROLES, ...DENIED_ROLES])(
'should render the unauthorized page for a %s instead of bouncing off it',
(role) => {
// routePermission.UN_AUTHORIZED must grant every role: a role denied here
// is redirected to /un-authorized and then redirected away from it again,
// which loops until React bails out with a blank screen.
renderPrivateRoute({
initialRoute: ROUTES.UN_AUTHORIZED,
appContext: {
isLoggedIn: true,
user: createMockUser({ role }),
},
});
assertStaysOnRoute(ROUTES.UN_AUTHORIZED);
assertRendersChildren();
},
);
});
describe('Edge Cases', () => {
@@ -1518,174 +1477,6 @@ describe('PrivateRoute', () => {
});
});
describe('AuthZ Support (routeWithInitialAuthZSupport)', () => {
// Routes in routeWithInitialAuthZSupport always bypass legacy role check
const AUTHZ_ROUTE_CASES: Record<
keyof typeof routeWithInitialAuthZSupport,
AuthzRouteCase
> = {
// Everything under /settings resolves to the non-exact SETTINGS route
SETTINGS: { path: ROUTES.SETTINGS, deniedRoles: DENIED_ROLES },
MY_SETTINGS: { path: ROUTES.MY_SETTINGS, deniedRoles: DENIED_ROLES },
ROLES_SETTINGS: { path: ROUTES.ROLES_SETTINGS, deniedRoles: DENIED_ROLES },
ROLE_CREATE: { path: ROUTES.ROLE_CREATE, deniedRoles: DENIED_ROLES },
ROLE_DETAILS: {
path: ROUTES.ROLE_DETAILS.replace(':roleId', 'role-id-1'),
deniedRoles: DENIED_ROLES,
},
ROLE_EDIT: {
path: ROUTES.ROLE_EDIT.replace(':roleId', 'role-id-1'),
deniedRoles: DENIED_ROLES,
},
SERVICE_ACCOUNTS_SETTINGS: {
path: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
deniedRoles: DENIED_ROLES,
},
TRACES_EXPLORER: { path: ROUTES.TRACES_EXPLORER, deniedRoles: DENIED_ROLES },
TRACE: { path: ROUTES.TRACE, deniedRoles: DENIED_ROLES },
TRACE_DETAIL: {
path: ROUTES.TRACE_DETAIL.replace(':id', 'trace-id-1'),
deniedRoles: DENIED_ROLES,
},
TRACE_DETAIL_OLD: {
path: ROUTES.TRACE_DETAIL_OLD.replace(':id', 'trace-id-1'),
deniedRoles: DENIED_ROLES,
},
// LOGS and LOGS_EXPLORER share a path - matchPath resolves it to whichever
// route definition comes last, and both keys are authz-aware either way.
LOGS: { path: ROUTES.LOGS, deniedRoles: DENIED_ROLES },
LOGS_EXPLORER: { path: ROUTES.LOGS_EXPLORER, deniedRoles: DENIED_ROLES },
LIVE_LOGS: { path: ROUTES.LIVE_LOGS, deniedRoles: DENIED_ROLES },
OLD_LOGS_EXPLORER: {
path: ROUTES.OLD_LOGS_EXPLORER,
deniedRoles: DENIED_ROLES,
},
METRICS_EXPLORER: {
path: ROUTES.METRICS_EXPLORER,
deniedRoles: DENIED_ROLES,
},
METRICS_EXPLORER_EXPLORER: {
path: ROUTES.METRICS_EXPLORER_EXPLORER,
deniedRoles: DENIED_ROLES,
},
METRICS_EXPLORER_VOLUME_CONTROL: {
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
deniedRoles: DENIED_ROLES,
},
METER: { path: ROUTES.METER, deniedRoles: DENIED_ROLES },
METER_EXPLORER: { path: ROUTES.METER_EXPLORER, deniedRoles: DENIED_ROLES },
// SUPPORT already grants ANONYMOUS in routePermission, so only AUTHOR
// exercises the redirect branch here.
SUPPORT: {
path: ROUTES.SUPPORT,
deniedRoles: [USER_ROLES.AUTHOR as ROLES],
},
};
const authzRouteRolePairs: [string, string, ROLES][] = Object.entries(
AUTHZ_ROUTE_CASES,
).flatMap(([name, { path, deniedRoles }]) =>
deniedRoles.map((role): [string, string, ROLES] => [name, path, role]),
);
// Routes with no authz check - legacy role check still applies
const NON_AUTHZ_ROUTE_CASES: [string, string, ROLES][] = [
['ALERTS_NEW', ROUTES.ALERTS_NEW, USER_ROLES.VIEWER as ROLES],
['LIST_LICENSES', ROUTES.LIST_LICENSES, USER_ROLES.EDITOR as ROLES],
['ONBOARDING', ROUTES.ONBOARDING, USER_ROLES.VIEWER as ROLES],
['APPLICATION', ROUTES.APPLICATION, USER_ROLES.ANONYMOUS as ROLES],
[
'METRICS_EXPLORER_VIEWS',
ROUTES.METRICS_EXPLORER_VIEWS,
USER_ROLES.ANONYMOUS as ROLES,
],
];
it.each(authzRouteRolePairs)(
'should not redirect %s (%s) for role %s - authz routes bypass legacy role check',
(_name, path, role) => {
// Routes in routeWithInitialAuthZSupport always bypass the legacy role check.
// Authorization is handled by downstream components via fine-grained authz.
renderPrivateRoute({
initialRoute: path,
appContext: {
isLoggedIn: true,
user: createMockUser({ role }),
},
});
assertStaysOnRoute(path);
assertRendersChildren();
},
);
it.each([...PERMITTED_ROLES, ...DENIED_ROLES])(
'should not redirect a %s from an authz-aware route',
(role) => {
// All roles pass through - authorization handled downstream
renderPrivateRoute({
initialRoute: ROUTES.LOGS_EXPLORER,
appContext: {
isLoggedIn: true,
user: createMockUser({ role }),
},
});
assertStaysOnRoute(ROUTES.LOGS_EXPLORER);
assertRendersChildren();
},
);
it.each(NON_AUTHZ_ROUTE_CASES)(
'should redirect %s (%s) for role %s - non-authz routes use legacy role check',
async (_name, path, role) => {
// Routes NOT in routeWithInitialAuthZSupport still use legacy role check
renderPrivateRoute({
initialRoute: path,
appContext: {
isLoggedIn: true,
user: createMockUser({ role }),
},
});
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
},
);
it.each(authzRouteRolePairs)(
'should still redirect unauthenticated users away from %s (%s) with role %s',
async (_name, path, role) => {
// The authz bypass only relaxes the role check, never the login check.
renderPrivateRoute({
initialRoute: path,
appContext: {
isLoggedIn: false,
user: createMockUser({ role }),
},
});
await assertRedirectsTo(ROUTES.LOGIN);
},
);
it('should still redirect to workspace locked from an authz-aware route', async () => {
// Workspace guards run before the role check and must not be bypassed.
renderPrivateRoute({
initialRoute: ROUTES.LOGS_EXPLORER,
appContext: {
isLoggedIn: true,
isFetchingActiveLicense: false,
activeLicense: createMockLicense({ platform: LicensePlatform.CLOUD }),
trialInfo: createMockTrialInfo({ workSpaceBlock: true }),
user: createMockUser({ role: USER_ROLES.VIEWER as ROLES }),
},
isCloudUser: true,
});
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
});
});
describe('Old channel route redirects', () => {
it.each([
['/settings/channels', '/alerts', 'tab=Channels'],

View File

@@ -2816,8 +2816,6 @@ export enum CloudintegrationtypesServiceIDDTO {
cloudsql_postgres = 'cloudsql_postgres',
memorystore_redis = 'memorystore_redis',
computeengine = 'computeengine',
gke = 'gke',
cloudstorage = 'cloudstorage',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}.cls-4{fill:#fff;}</style></defs><title>Icon_24px_CloudStorage_Color</title><g data-name="Product Icons"><rect class="cls-1" x="2" y="4" width="20" height="7"/><rect class="cls-2" x="20" y="4" width="2" height="7"/><polygon class="cls-3" points="22 4 20 4 20 11 22 4"/><rect class="cls-2" x="2" y="4" width="2" height="7"/><rect class="cls-4" x="6" y="7" width="6" height="1"/><rect class="cls-4" x="15" y="6" width="3" height="3" rx="1.5"/><rect class="cls-1" x="2" y="13" width="20" height="7"/><rect class="cls-2" x="20" y="13" width="2" height="7"/><polygon class="cls-3" points="22 13 20 13 20 20 22 13"/><rect class="cls-2" x="2" y="13" width="2" height="7"/><rect class="cls-4" x="6" y="16" width="6" height="1"/><rect class="cls-4" x="15" y="15" width="3" height="3" rx="1.5"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1200" height="800" viewBox="-19.2 -28.483 166.401 170.898"><g transform="translate(0 -7.034)"><linearGradient id="a" x1="64" x2="64" y1="7.034" y2="120.789" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4387fd"/><stop offset="1" stop-color="#4683ea"/></linearGradient><path fill="url(#a)" d="M27.79 115.217 1.54 69.749a11.5 11.5 0 0 1 0-11.499l26.25-45.467a11.5 11.5 0 0 1 9.96-5.75h52.5a11.5 11.5 0 0 1 9.959 5.75l26.25 45.467a11.5 11.5 0 0 1 0 11.5l-26.25 45.466a11.5 11.5 0 0 1-9.959 5.75h-52.5a11.5 11.5 0 0 1-9.96-5.75z"/></g><g transform="translate(0 -7.034)"><defs><path id="b" d="M27.791 115.217 1.541 69.749a11.5 11.5 0 0 1 0-11.499l26.25-45.467a11.5 11.5 0 0 1 9.959-5.75h52.5a11.5 11.5 0 0 1 9.96 5.75l26.25 45.467a11.5 11.5 0 0 1 0 11.5l-26.25 45.466a11.5 11.5 0 0 1-9.96 5.75h-52.5a11.5 11.5 0 0 1-9.959-5.75z"/></defs><clipPath id="c"><use xlink:href="#b" width="100%" height="100%" overflow="visible"/></clipPath><path d="m49.313 53.875-7.01 6.99 5.957 5.958-5.898 10.476 44.635 44.636 10.816.002L118.936 84 85.489 50.55z" clip-path="url(#c)" opacity=".07"/></g><path fill="#fff" d="M84.7 43.236H43.264c-.667 0-1.212.546-1.212 1.214v8.566c0 .666.546 1.212 1.212 1.212H84.7c.667 0 1.213-.546 1.213-1.212v-8.568c0-.666-.545-1.213-1.212-1.213m-6.416 7.976a2.484 2.484 0 0 1-2.477-2.48 2.475 2.475 0 0 1 2.477-2.477c1.37 0 2.48 1.103 2.48 2.477a2.48 2.48 0 0 1-2.48 2.48m6.415 8.491-41.436.002c-.667 0-1.212.546-1.212 1.214v8.565c0 .666.546 1.213 1.212 1.213H84.7c.667 0 1.213-.547 1.213-1.213v-8.567c0-.666-.545-1.214-1.212-1.214m-6.416 7.976a2.483 2.483 0 0 1-2.477-2.48 2.475 2.475 0 0 1 2.477-2.477 2.48 2.48 0 1 1 0 4.956"/></svg>

Before

Width:  |  Height:  |  Size: 958 B

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#4285f4;}.cls-1,.cls-2,.cls-4{fill-rule:evenodd;}.cls-2{fill:#669df6;}.cls-3,.cls-4{fill:#aecbfa;}</style></defs><title>Icon_24px_K8Engine_Color</title><g data-name="Product Icons"><g ><polygon class="cls-1" points="14.68 13.06 19.23 15.69 19.23 16.68 14.29 13.83 14.68 13.06"/><polygon class="cls-2" points="9.98 13.65 4.77 16.66 4.45 15.86 9.53 12.92 9.98 13.65"/><rect class="cls-3" x="11.55" y="3.29" width="0.86" height="5.78"/><path class="cls-4" d="M3.25,7V17L12,22l8.74-5V7L12,2Zm15.63,8.89L12,19.78,5.12,15.89V8.11L12,4.22l6.87,3.89v7.78Z"/><polygon class="cls-4" points="11.98 11.5 15.96 9.21 11.98 6.91 8.01 9.21 11.98 11.5"/><polygon class="cls-2" points="11.52 12.3 7.66 10.01 7.66 14.6 11.52 16.89 11.52 12.3"/><polygon class="cls-1" points="12.48 12.3 12.48 16.89 16.34 14.6 16.34 10.01 12.48 12.3"/></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path d="M19.73 6.56a1.73 1.73 0 0 0-1.68 1.68 1.83 1.83 0 0 0 .89 1.48v6.9l-5.16 3.06.8 1.28 5.55-3.25a.84.84 0 0 0 .4-.69v-7.3a1.64 1.64 0 0 0 .89-1.48 1.61 1.61 0 0 0-1.69-1.68" style="fill:#aecbfa"/><path d="m18 5.48-5.61-3.16a1.18 1.18 0 0 0-.79 0L5.25 6a1.72 1.72 0 0 0-2.68 1.35A1.73 1.73 0 0 0 4.26 9a1.73 1.73 0 0 0 1.68-1.65L12 3.9l5.15 3ZM11.2 18.5a1.57 1.57 0 0 0-.89.29l-5.16-3V9.92H3.56v6.31a.84.84 0 0 0 .4.69L9.52 20v.09a1.69 1.69 0 0 0 3.37 0 1.65 1.65 0 0 0-1.69-1.59" data-name="Path" style="fill:#aecbfa"/><path d="M16.96 8.63 12.1 5.78 7.13 8.63l4.97 2.77z" data-name="Path" style="fill:#669df6"/><path d="M12.1 12.38 6.84 9.32v2.47l5.26 2.96zM12.1 15.73l-5.26-3.05v2.07l5.26 3.06z" style="fill:#669df6"/><path d="M12.09 12.38v2.37l5.26-3.06V9.33Zm4.32-.94a.38.38 0 1 1 0-.76.38.38 0 0 1 0 .76M12.09 15.73v2.07l5.26-3.05v-2.07Zm4.32-1.07a.38.38 0 1 1 0-.76.38.38 0 0 1 0 .76" style="fill:#4285f4"/></svg>

Before

Width:  |  Height:  |  Size: 941 B

After

Width:  |  Height:  |  Size: 988 B

View File

@@ -6,6 +6,10 @@ 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';
@@ -124,15 +128,13 @@ export function useNavigateToExplorer(): (
});
}
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(preparedQuery));
applySerializedParams(serialize(preparedQuery), urlParams);
const basePath =
dataSource === DataSource.TRACES
? ROUTES.TRACES_EXPLORER
: ROUTES.LOGS_EXPLORER;
const newExplorerPath = `${basePath}?${urlParams.toString()}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
const newExplorerPath = `${basePath}?${urlParams.toString()}`;
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
},

View File

@@ -32,6 +32,7 @@ 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 {
@@ -252,7 +253,7 @@ function LogDetailInner({
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: minTime?.toString() || '',
[QueryParams.endTime]: maxTime?.toString() || '',
[QueryParams.compositeQuery]: JSON.stringify(
...serializeToParams(
updateAllQueriesOperators(
initialQueriesMap[DataSource.LOGS],
PANEL_TYPES.LIST,

View File

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

View File

@@ -1,5 +1,7 @@
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';
@@ -51,8 +53,8 @@ describe('getAutoContexts', () => {
it('includes the query in alert edit context', () => {
const ruleId = 'rule-edit';
const query = { queryType: 'builder', builder: { queryData: [] } };
const compositeQuery = encodeURIComponent(JSON.stringify(query));
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.compositeQuery}=${compositeQuery}`;
const serializedParams = serialize(query as unknown as Query);
const search = `?${QueryParams.ruleId}=${ruleId}&${serializedParams.toString()}`;
const contexts = getAutoContexts(ROUTES.EDIT_ALERTS, search);
@@ -72,8 +74,8 @@ describe('getAutoContexts', () => {
it('includes the query in alert new context (no ruleId)', () => {
const query = { queryType: 'builder', builder: { queryData: [] } };
const compositeQuery = encodeURIComponent(JSON.stringify(query));
const search = `?${QueryParams.compositeQuery}=${compositeQuery}`;
const serializedParams = serialize(query as unknown as Query);
const search = `?${serializedParams.toString()}`;
const contexts = getAutoContexts(ROUTES.ALERTS_NEW, search);
@@ -239,4 +241,24 @@ 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 { QueryParams } from 'constants/query';
import { serialize } from 'lib/compositeQuery/serializer';
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 encoded = encodeURIComponent(JSON.stringify(normalized));
deps.history.push(`${base}?${QueryParams.compositeQuery}=${encoded}`);
const params = serialize(normalized);
deps.history.push(`${base}?${params.toString()}`);
}
/** Picks the right rollback API call for a given action kind. */

View File

@@ -8,6 +8,7 @@ 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';
@@ -218,7 +219,9 @@ describe('buildExplorerNavigationUrl', () => {
);
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain(`${QueryParams.compositeQuery}=`);
const params = new URLSearchParams(new URL(url, 'http://x').search);
expect(deserialize(params)).not.toBeNull();
expect(url).toContain(`${QueryParams.viewKey}=`);
});
});

View File

@@ -2,6 +2,10 @@ 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';
@@ -75,10 +79,7 @@ export function buildExplorerNavigationUrl(
searchParams: Record<string, unknown>,
): string {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
applySerializedParams(serialize(query), params);
Object.entries(searchParams).forEach(([key, value]) => {
params.set(key, JSON.stringify(value));
});

View File

@@ -1,6 +1,7 @@
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 { NEW_PANEL_ID } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
import { matchPath } from 'react-router-dom';
@@ -345,15 +346,9 @@ function collectSharedMetadata(
out.timeRange = { start: startTime, end: endTime };
}
// 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.
}
const decodedQuery = deserialize(params);
if (decodedQuery) {
out.query = decodedQuery;
}
// Saved view selectors (logs / traces explorer) and dashboard variables.

View File

@@ -27,6 +27,7 @@ import {
} from 'hooks/useResourceAttribute/utils';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import useUrlQuery from 'hooks/useUrlQuery';
import { deserialize, serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { isUndefined } from 'lodash-es';
@@ -61,7 +62,6 @@ type QueryParams = {
pageSize: number;
exceptionType?: string;
serviceName?: string;
compositeQuery?: string;
};
function AllErrors(): JSX.Element {
@@ -78,7 +78,6 @@ function AllErrors(): JSX.Element {
getUpdatedPageSize,
getUpdatedExceptionType,
getUpdatedServiceName,
getUpdatedCompositeQuery,
} = useMemo(
() => ({
updatedOrder: getOrder(params.get(urlKey.order)),
@@ -87,7 +86,6 @@ function AllErrors(): JSX.Element {
getUpdatedPageSize: getUpdatePageSize(params.get(urlKey.pageSize)),
getUpdatedExceptionType: getFilterString(params.get(urlKey.exceptionType)),
getUpdatedServiceName: getFilterString(params.get(urlKey.serviceName)),
getUpdatedCompositeQuery: getFilterString(params.get(urlKey.compositeQuery)),
}),
[params],
);
@@ -213,7 +211,6 @@ function AllErrors(): JSX.Element {
offset: getUpdatedOffset,
orderParam: getUpdatedParams,
pageSize: getUpdatedPageSize,
compositeQuery: getUpdatedCompositeQuery,
};
if (exceptionFilterValue && exceptionFilterValue !== 'undefined') {
@@ -224,7 +221,13 @@ function AllErrors(): JSX.Element {
queryParams.serviceName = serviceFilterValue;
}
history.replace(`${pathname}?${createQueryParams(queryParams)}`);
// Carry the active query across the filter change so the trace context survives.
history.replace(
`${pathname}?${createQueryParams({
...queryParams,
...(compositeData ? serializeToParams(compositeData) : {}),
})}`,
);
confirm();
},
[
@@ -233,7 +236,7 @@ function AllErrors(): JSX.Element {
getUpdatedPageSize,
getUpdatedParams,
getUpdatedServiceName,
getUpdatedCompositeQuery,
compositeData,
pathname,
updatedOrder,
],
@@ -438,7 +441,9 @@ function AllErrors(): JSX.Element {
serviceName: getFilterString(params.get(urlKey.serviceName)),
exceptionType: getFilterString(params.get(urlKey.exceptionType)),
});
const compositeQuery = params.get(urlKey.compositeQuery) || '';
// Re-serialize from the live URL rather than forwarding the raw param, so
// every key the adapter owns is carried over.
const compositeQuery = deserialize(params);
history.replace(
`${pathname}?${createQueryParams({
order: updatedOrder,
@@ -447,7 +452,7 @@ function AllErrors(): JSX.Element {
pageSize,
exceptionType,
serviceName,
compositeQuery,
...(compositeQuery ? serializeToParams(compositeQuery) : {}),
})}`,
);
}

View File

@@ -46,32 +46,11 @@ export const MOCK_USE_QUERIES_DATA = [
},
];
// Deliberately double-encoded: this is the legacy shape older bookmarks/shared
// links still carry, so it also covers the serializer's legacy decode fallback.
export const INIT_URL_WITH_COMMON_QUERY =
'/exceptions?compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522dataSource%2522%253A%2522traces%2522%252C%2522queryName%2522%253A%2522A%2522%252C%2522aggregateOperator%2522%253A%2522noop%2522%252C%2522aggregateAttribute%2522%253A%257B%2522id%2522%253A%2522----resource--false%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522key%2522%253A%2522%2522%252C%2522isColumn%2522%253Afalse%252C%2522type%2522%253A%2522resource%2522%252C%2522isJSON%2522%253Afalse%257D%252C%2522timeAggregation%2522%253A%2522rate%2522%252C%2522spaceAggregation%2522%253A%2522sum%2522%252C%2522functions%2522%253A%255B%255D%252C%2522filters%2522%253A%257B%2522items%2522%253A%255B%257B%2522id%2522%253A%2522db118ac7-9313-4adb-963f-f31b5b32c496%2522%252C%2522op%2522%253A%2522in%2522%252C%2522key%2522%253A%257B%2522key%2522%253A%2522deployment.environment%2522%252C%2522dataType%2522%253A%2522string%2522%252C%2522type%2522%253A%2522resource%2522%252C%2522isColumn%2522%253Afalse%252C%2522isJSON%2522%253Afalse%257D%252C%2522value%2522%253A%2522mq-kafka%2522%257D%255D%252C%2522op%2522%253A%2522AND%2522%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522stepInterval%2522%253A60%252C%2522having%2522%253A%255B%255D%252C%2522limit%2522%253Anull%252C%2522orderBy%2522%253A%255B%255D%252C%2522groupBy%2522%253A%255B%255D%252C%2522legend%2522%253A%2522%2522%252C%2522reduceTo%2522%253A%2522avg%2522%257D%255D%252C%2522queryFormulas%2522%253A%255B%255D%257D%252C%2522promql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522query%2522%253A%2522%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%257D%255D%252C%2522clickhouse_sql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%252C%2522query%2522%253A%2522%2522%257D%255D%252C%2522id%2522%253A%2522dd576d04-0822-476d-b0c2-807a7af2e5e7%2522%257D';
export const extractCompositeQueryObject = (
url: string,
): Record<string, unknown> | null => {
try {
const urlObj = new URL(`http://dummy-base${url}`); // Add dummy base to parse relative URL
const encodedParam = urlObj.searchParams.get('compositeQuery');
if (!encodedParam) {
return null;
}
// Decode twice
const firstDecode = decodeURIComponent(encodedParam);
const secondDecode = decodeURIComponent(firstDecode);
// Parse JSON
return JSON.parse(secondDecode);
} catch (err) {
console.error('Failed to extract compositeQuery:', err);
return null;
}
};
export const TAG_FROM_QUERY = [
{
BoolValues: [],

View File

@@ -11,6 +11,8 @@ import {
export const isOrder = (order: string | null): order is Order =>
!!(order === 'ascending' || order === 'descending');
// The serialized query is deliberately absent: its param keys are owned by the
// compositeQuery serializer (see lib/compositeQuery), not spelled out here.
export const urlKey = {
order: 'order',
offset: 'offset',
@@ -18,7 +20,6 @@ export const urlKey = {
pageSize: 'pageSize',
exceptionType: 'exceptionType',
serviceName: 'serviceName',
compositeQuery: 'compositeQuery',
};
export const isOrderParams = (orderBy: string | null): orderBy is OrderBy =>

View File

@@ -104,7 +104,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
isFetchingFeatureFlags,
featureFlagsFetchError,
userPreferences,
isFetchingUserPreferences,
updateChangelog,
toggleChangelogModal,
showChangelogModal,
@@ -725,12 +724,12 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}
}, []);
// Set sidebar as loaded after user preferences fetch completes (success or error)
// Set sidebar as loaded after user preferences are fetched
useEffect(() => {
if (!isFetchingUserPreferences) {
if (userPreferences !== null) {
setIsSidebarLoaded(true);
}
}, [isFetchingUserPreferences]);
}, [userPreferences]);
// Use localStorage value as fallback until preferences are loaded
const isSideNavPinned = isSidebarLoaded

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,9 +28,7 @@ function PanelTypeSelectionModal(): JSX.Element {
const queryParams = {
graphType: name,
widgetId: id,
[QueryParams.compositeQuery]: JSON.stringify(
PANEL_TYPES_INITIAL_QUERY[name],
),
...serializeToParams(PANEL_TYPES_INITIAL_QUERY[name]),
};
history.push(

View File

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

View File

@@ -3,6 +3,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { MOCK_QUERY } from 'container/QueryTable/Drilldown/__tests__/mockTableData';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { serialize } from 'lib/compositeQuery/serializer';
import { rest, server } from 'mocks-server/server';
import {
defaultFeatureFlags,
@@ -380,9 +381,9 @@ describe('ExplorerOptionWrapper', () => {
await waitFor(() => {
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/dashboard/${TEST_DASHBOARD_ID}/new?graphType=${panelTypeParam}&widgetId=${widgetId}&compositeQuery=${encodeURIComponent(
JSON.stringify(query),
)}`,
`/dashboard/${TEST_DASHBOARD_ID}/new?graphType=${panelTypeParam}&widgetId=${widgetId}&${serialize(
query,
).toString()}`,
);
});

View File

@@ -34,6 +34,7 @@ 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';
@@ -384,7 +385,7 @@ function FormAlertRules({
const onCancelHandler = useCallback(
(e?: React.MouseEvent) => {
urlQuery.delete(QueryParams.compositeQuery);
clearSerializedParams(urlQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);
@@ -610,7 +611,7 @@ function FormAlertRules({
`${ruleId}`,
]);
urlQuery.delete(QueryParams.compositeQuery);
clearSerializedParams(urlQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);

View File

@@ -23,6 +23,10 @@ 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 {
@@ -213,9 +217,7 @@ function WidgetGraphComponent({
[QueryParams.graphType]: clonedWidget?.panelTypes,
[QueryParams.widgetId]: uuid,
...(clonedWidget?.query && {
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(clonedWidget.query),
),
...serializeToParams(clonedWidget.query),
}),
};
safeNavigate(`${pathname}/new?${createQueryParams(queryParams)}`);
@@ -256,7 +258,7 @@ function WidgetGraphComponent({
const onToggleModelHandler = (): void => {
const existingSearchParams = new URLSearchParams(search);
existingSearchParams.delete(QueryParams.expandedWidgetId);
existingSearchParams.delete(QueryParams.compositeQuery);
clearSerializedParams(existingSearchParams);
existingSearchParams.delete(QueryParams.graphType);
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
if (queryResponse.data?.payload) {

View File

@@ -29,6 +29,10 @@ 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';
@@ -86,10 +90,7 @@ function WidgetHeader({
const widgetId = widget.id;
urlQuery.set(QueryParams.widgetId, widgetId);
urlQuery.set(QueryParams.graphType, widget.panelTypes);
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(widget.query)),
);
applySerializedParams(serialize(widget.query), urlQuery);
const generatedUrl = buildAbsolutePath({
relativePath: 'new',
urlQueryString: urlQuery.toString(),

View File

@@ -7,6 +7,10 @@ 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';
@@ -134,10 +138,7 @@ export default function AlertRules({
const compositeQuery = mapQueryDataFromApi(
toCompositeMetricQuery(record.condition.compositeQuery),
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
applySerializedParams(serialize(compositeQuery), params);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {

View File

@@ -28,6 +28,10 @@ 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 {
@@ -403,7 +407,7 @@ export default function K8sBaseDetails<T>({
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), urlQuery);
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
@@ -428,7 +432,7 @@ export default function K8sBaseDetails<T>({
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), urlQuery);
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}

View File

@@ -18,7 +18,12 @@ import {
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { parseAsString, useQueryState } from 'nuqs';
import { EQueryType } from 'types/common/dashboard';
import {
LogsAggregatorOperator,
TracesAggregatorOperator,
@@ -162,7 +167,7 @@ export default function K8sBaseDetailsContent<T>({
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
queryType: EQueryType.QUERY_BUILDER,
builder: {
...initialQueryState.builder,
queryData: [
@@ -176,7 +181,7 @@ export default function K8sBaseDetailsContent<T>({
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery), urlQuery);
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
@@ -187,7 +192,7 @@ export default function K8sBaseDetailsContent<T>({
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
queryType: EQueryType.QUERY_BUILDER,
builder: {
...initialQueryState.builder,
queryData: [
@@ -201,7 +206,7 @@ export default function K8sBaseDetailsContent<T>({
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery), urlQuery);
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}

View File

@@ -10,9 +10,12 @@ import TanStackTable, {
TableColumnDef,
TanStackTableStateProvider,
} from 'components/TanStackTableView';
import { QueryParams } from 'constants/query';
import { CornerDownRight } from '@signozhq/icons';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { v4 as uuid } from 'uuid';
@@ -214,10 +217,7 @@ export function K8sExpandedRow<T, TItemKey = string>({
};
const newUrlQuery = new URLSearchParams(urlQuery.toString());
newUrlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
applySerializedParams(serialize(updatedQuery), newUrlQuery);
safeNavigate(`${location.pathname}?${newUrlQuery.toString()}`);
};

View File

@@ -11,11 +11,14 @@ import {
import { TableColumnDef } from 'components/TanStackTableView';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { SlidersHorizontal } from '@signozhq/icons';
import { v4 as uuid } from 'uuid';
@@ -107,10 +110,7 @@ function K8sHeader<TData>({
// Use window.location.search to get fresh URL params (avoids stale hook state)
const newUrlQuery = new URLSearchParams(window.location.search);
newUrlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
applySerializedParams(serialize(updatedQuery), newUrlQuery);
safeNavigate(`${location.pathname}?${newUrlQuery.toString()}`);
void invalidateQueries();

View File

@@ -5,6 +5,10 @@ import { Compass } from '@signozhq/icons';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { Link } from 'react-router-dom';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
@@ -54,10 +58,7 @@ export function EntityCountsSection<T>({
const urlParams = new URLSearchParams();
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
urlParams.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
applySerializedParams(serialize(compositeQuery), urlParams);
const currentSearchParams = new URLSearchParams(window.location.search);
const detailRelativeTime = currentSearchParams.get(

View File

@@ -55,6 +55,7 @@ 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,
@@ -79,6 +80,7 @@ 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,8 +900,6 @@ 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;
@@ -909,20 +909,18 @@ 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);
// Declare the metering prefill explicitly: "in total" + the meter window.
const URL = `${ROUTES.ALERTS_NEW}?${
QueryParams.compositeQuery
}=${encodeURIComponent(stringifiedQuery)}&${
QueryParams.thresholds
}=${encodeURIComponent(JSON.stringify(thresholds))}&${
QueryParams.ruleName
}=${encodeURIComponent(ruleName)}&${
QueryParams.yAxisUnit
}=${encodeURIComponent(yAxisUnit)}&${QueryParams.matchType}=${
AlertThresholdMatchType.IN_TOTAL
}&${QueryParams.evaluationWindowPreset}=${EvaluationWindowPreset.METER}`;
params.set(QueryParams.matchType, AlertThresholdMatchType.IN_TOTAL);
params.set(
QueryParams.evaluationWindowPreset,
EvaluationWindowPreset.METER,
);
history.push(URL);
history.push(`${ROUTES.ALERTS_NEW}?${params.toString()}`);
};
const columns: AntDTableProps<GatewaytypesIngestionKeyDTO>['columns'] = [

View File

@@ -1,5 +1,6 @@
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,
@@ -132,17 +133,19 @@ describe('MultiIngestionSettings Page', () => {
expect(thresholds[0].thresholdValue).toBe(1000);
expect(thresholds[0].unit).toBe('{count}');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.unit).toBe('{count}');
expect(compositeQuery.builder.queryData).toBeDefined();
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
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'",
);
expect(firstQueryData.aggregations[0].metricName).toBe(
const firstAggregation = firstQueryData?.aggregations?.[0] as {
metricName: string;
};
expect(firstAggregation.metricName).toBe(
'signoz.meter.metric.datapoint.count',
);
@@ -213,18 +216,18 @@ describe('MultiIngestionSettings Page', () => {
expect(thresholds[0].thresholdValue).toBe(400);
expect(thresholds[0].unit).toBe('GiBy');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.unit).toBe('bytes');
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
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'",
);
expect(firstQueryData.aggregations[0].metricName).toBe(
'signoz.meter.log.size',
);
const firstAggregation = firstQueryData?.aggregations?.[0] as {
metricName: string;
};
expect(firstAggregation.metricName).toBe('signoz.meter.log.size');
expect(urlParams.get(QueryParams.yAxisUnit)).toBe('bytes');
expect(urlParams.get(QueryParams.ruleName)).toContain('logs');

View File

@@ -6,6 +6,10 @@ 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';
@@ -31,10 +35,7 @@ export function useAlertRulesHandlers(
mapQueryDataFromApi(toCompositeMetricQuery(rule.condition.compositeQuery)),
rule.alertType,
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
applySerializedParams(serialize(compositeQuery), params);
const panelType = rule.condition.compositeQuery.panelType;
if (panelType) {

View File

@@ -14,6 +14,10 @@ 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';
@@ -111,10 +115,7 @@ function ContextLogRenderer({
(logId: string): void => {
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
applySerializedParams(serialize(query), urlQuery);
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
window.open(withBasePath(link), '_blank', 'noopener,noreferrer');

View File

@@ -247,16 +247,12 @@ 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,
JSONCompositeQuery,
apmToTraceQuery,
queryString,
);

View File

@@ -8,6 +8,10 @@ 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';
@@ -60,16 +64,18 @@ export function generateExplorerPath(
urlParams: URLSearchParams,
servicename: string | undefined,
selectedTraceTags: string,
JSONCompositeQuery: string,
apmToTraceQuery: Query,
queryString: string[],
): string {
const basePath = isViewLogsClicked
? ROUTES.LOGS_EXPLORER
: ROUTES.TRACES_EXPLORER;
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}&${queryString.join('&')}`;
applySerializedParams(serialize(apmToTraceQuery), urlParams);
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${queryString.join(
'&',
)}`;
}
// TODO(@rahul-signoz): update the name of this function once we have view logs button in every panel
@@ -105,16 +111,12 @@ 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,
JSONCompositeQuery,
apmToTraceQuery,
queryString,
);

View File

@@ -1,5 +1,6 @@
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';
@@ -29,13 +30,11 @@ 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}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${serialize(
apmToTraceQuery,
).toString()}`;
if (openInNewTab) {
window.open(withBasePath(newTraceExplorerPath), '_blank');

View File

@@ -33,6 +33,7 @@ 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';
@@ -791,9 +792,7 @@ function NewWidget({
const queryParams = {
[QueryParams.expandedWidgetId]: widgetId,
[QueryParams.graphType]: graphType,
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(currentQuery),
),
...serializeToParams(currentQuery),
[QueryParams.variables]: variables,
};

View File

@@ -5,6 +5,7 @@ import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { Button } from 'antd';
import ROUTES from 'constants/routes';
import { deserialize } from 'lib/compositeQuery/serializer';
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import store from 'store';
@@ -189,12 +190,7 @@ describe('TableDrilldown', () => {
// Parse the URL to check query parameters
const urlObj = new URL(url, 'http://localhost');
// Check that compositeQuery parameter exists and contains the query with filters
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
const compositeQuery = JSON.parse(
decodeURIComponent(urlObj.searchParams.get('compositeQuery') || '{}'),
);
const compositeQuery = deserialize(urlObj.searchParams) as Query;
// Verify the query structure includes the filters from clicked data
expect(compositeQuery.builder).toBeDefined();
@@ -265,12 +261,7 @@ describe('TableDrilldown', () => {
// Parse the URL to check query parameters
const urlObj = new URL(url, 'http://localhost');
// Check that compositeQuery parameter exists and contains the query with filters
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
const compositeQuery = JSON.parse(
decodeURIComponent(urlObj.searchParams.get('compositeQuery') || '{}'),
);
const compositeQuery = deserialize(urlObj.searchParams) as Query;
// Verify the query structure includes the filters from clicked data
expect(compositeQuery.builder).toBeDefined();
expect(compositeQuery.builder.queryData).toBeDefined();
@@ -278,7 +269,7 @@ describe('TableDrilldown', () => {
// Check that the query contains the correct filter expression
// The filter should include the clicked data filters (service.name = 'adservice', trace_id = 'df2cfb0e57bb8736207689851478cd50')
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toStrictEqual(
expect(firstQueryData.filter?.expression).toStrictEqual(
MOCK_QUERY_WITH_FILTER,
);

View File

@@ -2,6 +2,7 @@ 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';
@@ -49,7 +50,7 @@ const useBaseDrilldownNavigate = ({
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...serializeToParams(viewQuery),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
@@ -94,7 +95,7 @@ export function buildDrilldownUrl(
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...serializeToParams(viewQuery),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),

View File

@@ -151,9 +151,7 @@ describe('JsonEditor', () => {
);
await user.click(await within(readToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'test-key-123{enter}');
await switchToJsonMode();

View File

@@ -1,8 +1,17 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { expandAllCards, renderCreateRolePage } from './testUtils';
import CreateEditRolePage from '../CreateEditRolePage';
async function expandAllCards(): Promise<void> {
const user = userEvent.setup();
const expandButton = await screen.findByTestId('expand-all-button');
await user.click(expandButton);
}
beforeEach(() => {
server.use(setupAuthzAdmin());
@@ -12,16 +21,35 @@ afterEach(() => {
server.resetHandlers();
});
async function renderPage(): Promise<ReturnType<typeof render>> {
const result = render(
<TooltipProvider>
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
</Route>
<Route path={ROUTES.ROLE_CREATE}>
<CreateEditRolePage />
</Route>
</Switch>
</TooltipProvider>,
undefined,
{ initialRoute: '/settings/roles/new' },
);
await screen.findByTestId('permission-editor');
return result;
}
describe('PermissionEditor', () => {
describe('mode toggle', () => {
it('renders permission editor with testId', async () => {
await renderCreateRolePage();
await renderPage();
expect(screen.getByTestId('permission-editor')).toBeInTheDocument();
});
it('defaults to interactive mode', async () => {
await renderCreateRolePage();
await renderPage();
const interactiveRadio = screen.getByTestId(
'permission-editor-mode-interactive',
@@ -31,7 +59,7 @@ describe('PermissionEditor', () => {
it('switches to JSON mode when clicked', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
@@ -42,7 +70,7 @@ describe('PermissionEditor', () => {
it('switches back to interactive mode', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
@@ -59,7 +87,7 @@ describe('PermissionEditor', () => {
describe('resource cards', () => {
it('renders all resource cards', async () => {
await renderCreateRolePage();
await renderPage();
expect(
screen.getByTestId('resource-card-factor-api-key'),
@@ -71,7 +99,7 @@ describe('PermissionEditor', () => {
});
it('resource cards are collapsed by default', async () => {
await renderCreateRolePage();
await renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
@@ -83,7 +111,7 @@ describe('PermissionEditor', () => {
it('expands resource card when header clicked', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
@@ -97,7 +125,7 @@ describe('PermissionEditor', () => {
it('collapses expanded resource card when header clicked again', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
@@ -111,7 +139,7 @@ describe('PermissionEditor', () => {
});
it('shows granted count in resource card header', async () => {
await renderCreateRolePage();
await renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
await expect(
@@ -122,7 +150,7 @@ describe('PermissionEditor', () => {
describe('action toggles', () => {
it('renders action toggles for each available action', async () => {
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -141,7 +169,7 @@ describe('PermissionEditor', () => {
});
it('defaults all actions to None scope', async () => {
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -159,7 +187,7 @@ describe('PermissionEditor', () => {
it('changes scope to All when clicked', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -180,7 +208,7 @@ describe('PermissionEditor', () => {
it('updates granted count when scope changed', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -199,7 +227,7 @@ describe('PermissionEditor', () => {
describe('Only Selected scope', () => {
it('shows item input selector when Only Selected is chosen', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -211,14 +239,12 @@ describe('PermissionEditor', () => {
await within(createToggle).findByText('Only selected');
await user.click(onlySelectedBtn);
expect(
screen.getByTestId('item-input-selector-factor-api-key-read'),
).toBeInTheDocument();
expect(screen.getByTestId('item-input-selector')).toBeInTheDocument();
});
it('adds item when typed and Enter pressed', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -228,9 +254,7 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'api-key-001{enter}');
await expect(screen.findByText('api-key-001')).resolves.toBeInTheDocument();
@@ -238,7 +262,7 @@ describe('PermissionEditor', () => {
it('adds item when Add button clicked', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -248,14 +272,10 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'api-key-002');
const addBtn = screen.getByTestId(
'item-input-selector-add-btn-factor-api-key-read',
);
const addBtn = screen.getByTestId('item-input-selector-add-btn');
await user.click(addBtn);
await expect(screen.findByText('api-key-002')).resolves.toBeInTheDocument();
@@ -263,7 +283,7 @@ describe('PermissionEditor', () => {
it('adds multiple items separated by comma', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -273,9 +293,7 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'key-a, key-b, key-c{enter}');
await expect(screen.findByText('key-a')).resolves.toBeInTheDocument();
@@ -285,7 +303,7 @@ describe('PermissionEditor', () => {
it('adds multiple items separated by space', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -295,9 +313,7 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'key-x key-y key-z{enter}');
await expect(screen.findByText('key-x')).resolves.toBeInTheDocument();
@@ -307,7 +323,7 @@ describe('PermissionEditor', () => {
it('does not add duplicate items', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -317,9 +333,7 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'same-key{enter}');
await user.type(input, 'same-key{enter}');
@@ -329,7 +343,7 @@ describe('PermissionEditor', () => {
it('removes item when X clicked', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -339,138 +353,20 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'removable-key{enter}');
const badge = await screen.findByTestId('item-badge-factor-api-key-read-0');
const removeBtn = within(badge).getByRole('button', {
name: 'Remove removable-key',
const removeBtn = screen.getByRole('button', {
name: /remove removable-key/i,
});
await user.click(removeBtn);
expect(screen.queryByText('removable-key')).not.toBeInTheDocument();
});
it('names each badge close button after the item it removes', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(readToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'key-one key-two{enter}');
const firstBadge = await screen.findByTestId(
'item-badge-factor-api-key-read-0',
);
const secondBadge = screen.getByTestId('item-badge-factor-api-key-read-1');
expect(
within(firstBadge).getByRole('button', { name: 'Remove key-one' }),
).toBeInTheDocument();
expect(
within(secondBadge).getByRole('button', { name: 'Remove key-two' }),
).toBeInTheDocument();
});
it('exposes the full item value as a title so truncated badges stay readable', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(readToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'a-very-long-api-key-identifier-000001{enter}');
const badge = await screen.findByTestId('item-badge-factor-api-key-read-0');
expect(
within(badge).getByTitle('a-very-long-api-key-identifier-000001'),
).toBeInTheDocument();
});
it('moves focus to the previous badge when closed with the keyboard', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(readToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'key-one key-two{enter}');
const secondBadge = await screen.findByTestId(
'item-badge-factor-api-key-read-1',
);
within(secondBadge).getByRole('button', { name: 'Remove key-two' }).focus();
await user.keyboard('{Enter}');
await waitFor(() => {
const firstBadge = screen.getByTestId('item-badge-factor-api-key-read-0');
expect(
within(firstBadge).getByRole('button', { name: 'Remove key-one' }),
).toHaveFocus();
});
});
it('does not steal focus when a badge is closed with the mouse', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(readToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'key-one key-two{enter}');
const secondBadge = await screen.findByTestId(
'item-badge-factor-api-key-read-1',
);
await user.click(
within(secondBadge).getByRole('button', { name: 'Remove key-two' }),
);
await waitFor(() => {
expect(screen.queryByText('key-two')).not.toBeInTheDocument();
});
const firstBadge = screen.getByTestId('item-badge-factor-api-key-read-0');
expect(
within(firstBadge).getByRole('button', { name: 'Remove key-one' }),
).not.toHaveFocus();
});
it('shows Add button disabled when input is empty', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -480,9 +376,7 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const addBtn = screen.getByTestId(
'item-input-selector-add-btn-factor-api-key-read',
);
const addBtn = screen.getByTestId('item-input-selector-add-btn');
expect(addBtn).toBeDisabled();
});
});
@@ -490,7 +384,7 @@ describe('PermissionEditor', () => {
describe('scope change confirmation dialog', () => {
it('shows confirm dialog when leaving Only Selected with items', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -500,9 +394,7 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'will-be-cleared{enter}');
await user.click(await within(createToggle).findByText('All'));
@@ -514,7 +406,7 @@ describe('PermissionEditor', () => {
it('clears items when confirmed', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -524,9 +416,7 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'to-be-cleared{enter}');
await user.click(await within(createToggle).findByText('All'));
@@ -543,7 +433,7 @@ describe('PermissionEditor', () => {
it('keeps items when cancelled', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -553,9 +443,7 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
const input = screen.getByTestId('item-input-selector-input');
await user.type(input, 'preserved-key{enter}');
await user.click(await within(createToggle).findByText('None'));
@@ -567,14 +455,12 @@ describe('PermissionEditor', () => {
screen.findByText('preserved-key'),
).resolves.toBeInTheDocument();
expect(
screen.getByTestId('item-input-selector-factor-api-key-read'),
).toBeInTheDocument();
expect(screen.getByTestId('item-input-selector')).toBeInTheDocument();
});
it('does not show dialog when leaving Only Selected with no items', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -593,7 +479,7 @@ describe('PermissionEditor', () => {
describe('verbs without Only Selected option', () => {
it('does not show Only Selected for list verb', async () => {
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -615,7 +501,7 @@ describe('PermissionEditor', () => {
describe('collapse/expand all resources', () => {
it('shows expand/collapse toggle group', async () => {
await renderCreateRolePage();
await renderPage();
expect(screen.getByTestId('toggle-all-group')).toBeInTheDocument();
expect(screen.getByTestId('expand-all-button')).toBeInTheDocument();
@@ -623,7 +509,7 @@ describe('PermissionEditor', () => {
});
it('expands all cards when expand button clicked', async () => {
await renderCreateRolePage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -637,7 +523,7 @@ describe('PermissionEditor', () => {
describe('resource card error states', () => {
it('shows error border on collapsed card with validation error', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');
@@ -667,7 +553,7 @@ describe('PermissionEditor', () => {
it('hides error border when card is expanded', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');
@@ -704,7 +590,7 @@ describe('PermissionEditor', () => {
it('clears validation error when permission is changed', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await renderPage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');

View File

@@ -1,462 +0,0 @@
import { server } from 'mocks-server/server';
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { expandAllCards, renderCreateRolePage } from './testUtils';
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
server.resetHandlers();
});
async function selectOnlySelected(
user: ReturnType<typeof userEvent.setup>,
resource = 'logs',
): Promise<void> {
await renderCreateRolePage();
await expandAllCards();
const card = screen.getByTestId(`resource-card-${resource}`);
const readToggle = within(card).getByTestId(`action-toggle-${resource}-read`);
await user.click(await within(readToggle).findByText('Only selected'));
}
async function selectLogsOnlySelected(
user: ReturnType<typeof userEvent.setup>,
): Promise<void> {
await selectOnlySelected(user, 'logs');
}
async function openWizard(
user: ReturnType<typeof userEvent.setup>,
resource = 'logs',
): Promise<void> {
await selectOnlySelected(user, resource);
await user.click(
screen.getByTestId(`telemetry-wizard-trigger-${resource}-read`),
);
await screen.findByTestId(`telemetry-wizard-dialog-${resource}-read`);
}
async function openLogsWizard(
user: ReturnType<typeof userEvent.setup>,
): Promise<void> {
await openWizard(user, 'logs');
}
describe('PermissionEditor - TelemetrySelectorWizard', () => {
it('shows wizard button for telemetry resources', async () => {
const user = userEvent.setup();
await selectLogsOnlySelected(user);
expect(
screen.getByTestId('telemetry-wizard-trigger-logs-read'),
).toBeInTheDocument();
});
it('does not show wizard button for non-telemetry resources', async () => {
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
const user = userEvent.setup();
await user.click(await within(readToggle).findByText('Only selected'));
expect(
screen.queryByTestId('telemetry-wizard-trigger-logs-read'),
).not.toBeInTheDocument();
});
it('opens wizard dialog when trigger clicked', async () => {
const user = userEvent.setup();
await selectLogsOnlySelected(user);
await user.click(screen.getByTestId('telemetry-wizard-trigger-logs-read'));
await expect(
screen.findByTestId('telemetry-wizard-dialog-logs-read'),
).resolves.toBeInTheDocument();
});
it('adds a query-type wildcard when the value is left empty', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await expect(
screen.findByText('builder_query/*'),
).resolves.toBeInTheDocument();
});
it('does not offer builder sub query', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
expect(screen.queryByText('Builder Sub Query')).not.toBeInTheDocument();
});
it('does not show PromQL for logs', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
expect(
screen.queryByTestId('wizard-query-type-option-promql-logs-read'),
).not.toBeInTheDocument();
});
it('does not show PromQL for traces', async () => {
const user = userEvent.setup();
await openWizard(user, 'traces');
await user.click(screen.getByTestId('wizard-query-type-select-traces-read'));
expect(
screen.queryByTestId('wizard-query-type-option-promql-traces-read'),
).not.toBeInTheDocument();
});
it('allows PromQL for metrics', async () => {
const user = userEvent.setup();
await openWizard(user, 'metrics');
await user.click(screen.getByTestId('wizard-query-type-select-metrics-read'));
await user.click(
await screen.findByTestId('wizard-query-type-option-promql-metrics-read'),
);
await user.click(screen.getByTestId('wizard-add-btn-metrics-read'));
await expect(screen.findByText('promql/*')).resolves.toBeInTheDocument();
});
it('hardcodes the key and does not let it be edited', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const keyInput = screen.getByTestId('wizard-key-input-logs-read');
expect(keyInput).toHaveValue('signoz.workspace.key.id');
expect(keyInput).toBeDisabled();
});
it('hides Key field for query types that do not support key scoping', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
await user.click(await screen.findByText('ClickHouse SQL'));
expect(
screen.queryByTestId('wizard-key-input-logs-read'),
).not.toBeInTheDocument();
});
it('adds a key-scoped selector when the value is filled', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
'builder_query/signoz.workspace.key.id/123',
);
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await expect(
screen.findByText('builder_query/signoz.workspace.key.id/123'),
).resolves.toBeInTheDocument();
});
it('replaces the value with a wildcard when any resource is checked', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByLabelText('Any value'));
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('*');
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await expect(
screen.findByText('builder_query/signoz.workspace.key.id/*'),
).resolves.toBeInTheDocument();
});
it('clears the value when any resource is unchecked', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const anyResource = screen.getByLabelText('Any value');
await user.click(anyResource);
expect(anyResource).toBeChecked();
await user.click(anyResource);
expect(anyResource).not.toBeChecked();
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('');
});
it('checks any resource when the value is typed as a wildcard', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '*');
expect(screen.getByLabelText('Any value')).toBeChecked();
});
it('disables value scoping for query types that do not support it', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
await user.click(await screen.findByText('ClickHouse SQL'));
expect(screen.getByTestId('wizard-value-input-logs-read')).toBeDisabled();
expect(screen.getByLabelText('Any value')).toBeDisabled();
});
it('clears the value when switching to a query type without key scoping', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
await user.click(await screen.findByText('ClickHouse SQL'));
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('');
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
'clickhouse_sql/*',
);
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await expect(
screen.findByText('clickhouse_sql/*'),
).resolves.toBeInTheDocument();
});
it('follows a hand-edited selector on the query type and value inputs', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(
selectorInput,
'clickhouse_sql/signoz.workspace.key.id/checkout',
);
const selectTrigger = screen.getByTestId(
'wizard-query-type-select-logs-read',
);
expect(within(selectTrigger).getByText('ClickHouse SQL')).toBeInTheDocument();
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue(
'checkout',
);
});
it('checks any resource when the selector is edited to a wildcard value', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/signoz.workspace.key.id/*');
expect(screen.getByLabelText('Any value')).toBeChecked();
});
it('keeps the key input hardcoded when the selector uses another key', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/service.name/frontend');
expect(screen.getByTestId('wizard-key-input-logs-read')).toHaveValue(
'signoz.workspace.key.id',
);
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent('Allow service.name=frontend for Builder Query queries.');
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toBeDisabled();
});
it('restores the hardcoded key in the selector once the value changes', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/service.name/frontend');
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '2');
expect(selectorInput).toHaveValue(
'builder_query/signoz.workspace.key.id/frontend2',
);
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toBeDisabled();
});
it('blocks adding when the selector has an unknown query type', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'sql_query/*');
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent('"sql_query" is not a supported query type.');
expect(screen.getByTestId('wizard-add-btn-logs-read')).toBeDisabled();
});
it('blocks adding when the selector is emptied', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.clear(screen.getByTestId('wizard-selector-input-logs-read'));
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent('Enter a selector.');
expect(screen.getByTestId('wizard-add-btn-logs-read')).toBeDisabled();
});
it('adds the hand-edited selector verbatim', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/signoz.workspace.key.id/a/b');
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await expect(
screen.findByText('builder_query/signoz.workspace.key.id/a/b'),
).resolves.toBeInTheDocument();
});
it('closes dialog after adding selector', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await waitFor(() => {
expect(
screen.queryByTestId('telemetry-wizard-dialog-logs-read'),
).not.toBeInTheDocument();
});
});
it('does not add duplicate selectors', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await user.click(screen.getByTestId('telemetry-wizard-trigger-logs-read'));
await screen.findByTestId('telemetry-wizard-dialog-logs-read');
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
const badges = screen.getAllByText('builder_query/*');
expect(badges).toHaveLength(1);
});
it('resets wizard state when dialog is closed and reopened', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
await user.click(await screen.findByText('ClickHouse SQL'));
await user.click(screen.getByRole('button', { name: /cancel/i }));
await waitFor(() => {
expect(
screen.queryByTestId('telemetry-wizard-dialog-logs-read'),
).not.toBeInTheDocument();
});
await user.click(screen.getByTestId('telemetry-wizard-trigger-logs-read'));
const selectTrigger = await screen.findByTestId(
'wizard-query-type-select-logs-read',
);
expect(within(selectTrigger).getByText('Builder Query')).toBeInTheDocument();
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('');
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
'builder_query/*',
);
});
it('previews the query-type wildcard while the value is empty', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
'builder_query/*',
);
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent('Allow every "Builder Query" query.');
});
it('describes a key-scoped selector', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent(
'Allow signoz.workspace.key.id=123 for Builder Query queries.',
);
});
it('describes an any-resource selector', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByLabelText('Any value'));
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent(
'Allow every signoz.workspace.key.id for Builder Query queries.',
);
});
it('does not show query type descriptions', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
expect(
screen.queryByText(
'Visual query builder for selecting data sources, filters, and aggregations',
),
).not.toBeInTheDocument();
});
});

View File

@@ -1,33 +0,0 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { render, screen, userEvent } from 'tests/test-utils';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import CreateEditRolePage from '../CreateEditRolePage';
export async function renderCreateRolePage(): Promise<
ReturnType<typeof render>
> {
const result = render(
<TooltipProvider>
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
</Route>
<Route path={ROUTES.ROLE_CREATE}>
<CreateEditRolePage />
</Route>
</Switch>
</TooltipProvider>,
undefined,
{ initialRoute: '/settings/roles/new' },
);
await screen.findByTestId('permission-editor');
return result;
}
export async function expandAllCards(): Promise<void> {
const user = userEvent.setup();
const expandButton = await screen.findByTestId('expand-all-button');
await user.click(expandButton);
}

View File

@@ -7,7 +7,6 @@ import { Typography } from '@signozhq/ui/typography';
import { PermissionScope } from '../../types';
import { getResourcePanel } from '../../permissions.config';
import ItemInputSelector from './ItemInputSelector';
import TelemetrySelectorWizard from './TelemetrySelectorWizard';
import styles from './ActionToggle.module.scss';
import { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
@@ -40,13 +39,10 @@ function ActionToggle({
onSelectedIdsChange,
hasError = false,
}: ActionToggleProps): JSX.Element {
const panel = getResourcePanel(resource);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [pendingScope, setPendingScope] = useState<PermissionScope | null>(null);
const displayLabel = getActionLabel(action);
const selectorTestId = `${resource}-${action}`;
const scopeItems: Array<{ value: PermissionScope; label: string }> =
useMemo(() => {
@@ -125,25 +121,11 @@ function ActionToggle({
<Divider />
<ItemInputSelector
placeholder={panel.selectorPlaceholder}
placeholder={getResourcePanel(resource).selectorPlaceholder}
selectedIds={selectedIds}
onChange={onSelectedIdsChange}
testId={selectorTestId}
docsAnchor={panel.docsAnchor}
docsAnchor={getResourcePanel(resource).docsAnchor}
hasError={hasError}
prefixElement={
panel.selectorType === 'telemetryBuilder' ? (
<TelemetrySelectorWizard
resource={resource}
testId={selectorTestId}
onAdd={(selector): void => {
if (!selectedIds.includes(selector)) {
onSelectedIdsChange([...selectedIds, selector]);
}
}}
/>
) : null
}
/>
</div>
)}

View File

@@ -4,10 +4,9 @@
gap: var(--spacing-4);
background: var(--l1-background);
border: 1px solid var(--l1-border);
border-radius: var(--radius-2);
border-radius: 4px;
padding: var(--spacing-4);
--input-prefix-padding: var(--spacing-2);
--input-suffix-padding: var(--spacing-2);
}
@@ -42,11 +41,6 @@
overflow-y: auto;
}
.itemInputSelectorBadge {
--badge-border-radius: 4px;
--badge-hover-background: var(--l2-background) !important;
}
.itemInputSelectorInfoIcon {
flex-shrink: 0;
color: var(--l2-foreground);
@@ -57,7 +51,55 @@
}
}
.itemInputSelectorBadge {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 140px;
padding: 2px 4px 2px 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
}
.itemInputSelectorBadgeLabel {
flex: 1;
min-width: 0;
}
.itemInputSelectorBadgeRemove {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
background: transparent;
border: none;
border-radius: 2px;
color: var(--l2-foreground);
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
&:hover {
background: var(--l1-background);
color: var(--l1-foreground);
}
}
.itemInputSelectorHint {
margin: 0;
color: var(--l2-foreground);
a {
color: var(--primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -1,6 +1,5 @@
import { useCallback, useRef, useState } from 'react';
import { Info, Plus } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Info, Plus, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
@@ -16,10 +15,8 @@ export interface ItemInputSelectorProps {
placeholder: string;
selectedIds: string[];
onChange: (ids: string[]) => void;
testId: string;
docsAnchor?: string;
hasError?: boolean;
prefixElement?: React.ReactNode;
}
function parseInputValues(input: string): string[] {
@@ -33,13 +30,11 @@ function ItemInputSelector({
placeholder,
selectedIds,
onChange,
testId,
docsAnchor = 'role',
hasError = false,
prefixElement,
}: ItemInputSelectorProps): JSX.Element {
const [inputValue, setInputValue] = useState('');
const badgesRef = useRef<HTMLDivElement>(null);
const footerRef = useRef<HTMLDivElement>(null);
const addValues = useCallback(
(input: string): void => {
@@ -92,24 +87,26 @@ function ItemInputSelector({
[selectedIds, onChange],
);
const handleBadgeClose = useCallback(
(e: React.MouseEvent, itemId: string, index: number): void => {
e.preventDefault();
handleRemove(itemId);
// Activating a button via Enter/Space reports detail 0;
// a real click reports 1 or more
// Only trigger focus when using keyboard
const isKeyboardActivation = e.detail === 0;
if (!isKeyboardActivation) {
const handleBadgeKeyDown = useCallback(
(
e: React.KeyboardEvent<HTMLButtonElement>,
itemId: string,
index: number,
): void => {
if (e.key !== 'Enter' && e.key !== ' ') {
return;
}
e.preventDefault();
handleRemove(itemId);
const targetIndex = index > 0 ? index - 1 : 0;
requestAnimationFrame(() => {
const buttons = badgesRef.current?.querySelectorAll('button');
buttons?.[targetIndex]?.focus();
const buttons = footerRef.current?.querySelectorAll('button');
const targetButton = buttons?.[targetIndex] as
| HTMLButtonElement
| undefined;
targetButton?.focus();
});
},
[handleRemove],
@@ -123,7 +120,7 @@ function ItemInputSelector({
styles.itemInputSelector,
showError ? styles.itemInputSelectorError : '',
)}
data-testid={`item-input-selector-${testId}`}
data-testid="item-input-selector"
>
<Input
placeholder={placeholder}
@@ -131,15 +128,14 @@ function ItemInputSelector({
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
data-testid={`item-input-selector-input-${testId}`}
prefix={prefixElement}
data-testid="item-input-selector-input"
suffix={
<Button
variant="solid"
size="sm"
onClick={handleAddClick}
disabled={!inputValue.trim()}
data-testid={`item-input-selector-add-btn-${testId}`}
data-testid="item-input-selector-add-btn"
>
<Plus size={14} />
Add
@@ -148,22 +144,28 @@ function ItemInputSelector({
/>
{selectedIds.length > 0 ? (
<div className={styles.itemInputSelectorFooter}>
<div ref={badgesRef} className={styles.itemInputSelectorBadges}>
<div ref={footerRef} className={styles.itemInputSelectorFooter}>
<div className={styles.itemInputSelectorBadges}>
{selectedIds.map((id, index) => (
<Badge
key={id}
color="secondary"
className={styles.itemInputSelectorBadge}
testId={`item-badge-${testId}-${index}`}
closable
closeAriaLabel={`Remove ${id}`}
onClose={(e): void => handleBadgeClose(e, id, index)}
>
<Typography as="span" size="small" truncate={1} title={id}>
<span key={id} className={styles.itemInputSelectorBadge} title={id}>
<Typography
as="span"
size="small"
truncate={1}
className={styles.itemInputSelectorBadgeLabel}
>
{id}
</Typography>
</Badge>
<button
type="button"
className={styles.itemInputSelectorBadgeRemove}
onClick={(): void => handleRemove(id)}
onKeyDown={(e): void => handleBadgeKeyDown(e, id, index)}
aria-label={`Remove ${id}`}
>
<X size={10} />
</button>
</span>
))}
</div>
<TooltipSimple

View File

@@ -1,50 +0,0 @@
// intentionally omitting few query types
// from pkg/types/querybuildertypes/querybuildertypesv5/query_type.go
export type QueryTypeId = 'builder_query' | 'promql' | 'clickhouse_sql';
export interface QueryTypeOption {
id: QueryTypeId;
label: string;
supportsKeyScoping: boolean;
metricsOnly?: boolean;
}
export const QUERY_TYPES: readonly QueryTypeOption[] = [
{
id: 'builder_query',
label: 'Builder Query',
supportsKeyScoping: true,
},
{
id: 'promql',
label: 'PromQL',
supportsKeyScoping: false,
metricsOnly: true,
},
{
id: 'clickhouse_sql',
label: 'ClickHouse SQL',
supportsKeyScoping: false,
},
] as const;
export const DEFAULT_QUERY_TYPE: QueryTypeId = 'builder_query';
export const SUPPORTED_GRANT_KEY = 'signoz.workspace.key.id';
export const ANY_RESOURCE_VALUE = '*';
export interface SelectorDraft {
queryType: QueryTypeId;
value: string;
}
export interface ParsedSelector {
queryType?: QueryTypeId;
value: string;
}
export interface SelectorValidation {
message: string;
isError: boolean;
}

View File

@@ -1,52 +0,0 @@
.wizardDialog {
border-color: var(--l1-border);
--select-trigger-background-color: var(--l3-background);
--select-trigger-border-color: var(--l3-background);
--select-content-background: var(--l3-background);
--select-item-highlight-background: var(--l3-background-hover);
--select-trigger-outline-width: 1px;
--select-trigger-outline-offset: 0px;
--input-background: var(--l3-background);
--input-hover-background: var(--l3-background);
--input-focus-background: var(--l3-background);
--input-disabled-background: var(--l3-background);
--input-border-color: var(--l3-border);
--input-hover-border-color: var(--l3-border);
--input-focus-border-color: var(--l3-border);
outline: none;
& > div {
background-color: var(--l2-background);
}
}
.wizardBody {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.wizardField {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.wizardValueRow {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.wizardValueInput {
flex: 1;
min-width: 0;
}
.selectContent {
z-index: 10;
background-color: var(--l3-background);
}

View File

@@ -1,190 +0,0 @@
import { Wand } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import {
ANY_RESOURCE_VALUE,
QUERY_TYPES,
SUPPORTED_GRANT_KEY,
} from './TelemetrySelectorWizard.constants';
import { isQueryTypeAvailable } from './TelemetrySelectorWizard.utils';
import useTelemetrySelectorWizard from './useTelemetrySelectorWizard';
import styles from './TelemetrySelectorWizard.module.scss';
import { AuthZResource } from 'lib/authz/hooks/useAuthZ/types';
interface TelemetrySelectorWizardProps {
onAdd: (selector: string) => void;
resource: AuthZResource;
testId: string;
}
function TelemetrySelectorWizard({
onAdd,
resource,
testId,
}: TelemetrySelectorWizardProps): JSX.Element {
const {
open,
queryType,
selectedQueryType,
value,
selector,
isAnyResource,
supportsKeyScoping,
validation,
canAdd,
handleOpenChange,
handleQueryTypeChange,
handleValueChange,
handleAnyResourceChange,
handleSelectorChange,
handleAdd,
handleInputKeyDown,
} = useTelemetrySelectorWizard({ onAdd });
const trigger = (
<Button
variant="solid"
size="sm"
data-testid={`telemetry-wizard-trigger-${testId}`}
>
<Wand size={14} />
Wizard
</Button>
);
const footer = (
<>
<Button
variant="ghost"
color="secondary"
onClick={(): void => handleOpenChange(false)}
>
Cancel
</Button>
<Button
variant="solid"
onClick={handleAdd}
disabled={!canAdd}
data-testid={`wizard-add-btn-${testId}`}
>
Add Selector
</Button>
</>
);
return (
<DialogWrapper
open={open}
onOpenChange={handleOpenChange}
title="Selector Wizard"
width="wide"
testId={`telemetry-wizard-dialog-${testId}`}
trigger={trigger}
footer={footer}
className={styles.wizardDialog}
>
<div className={styles.wizardBody}>
<div className={styles.wizardField}>
<Typography as="label" weight="medium">
Query Type
</Typography>
<Select value={queryType} onChange={handleQueryTypeChange}>
<SelectTrigger data-testid={`wizard-query-type-select-${testId}`}>
<SelectValue>{selectedQueryType?.label}</SelectValue>
</SelectTrigger>
<SelectContent withPortal={false} className={styles.selectContent}>
{QUERY_TYPES.filter((queryTypeOption) =>
isQueryTypeAvailable(queryTypeOption, resource),
).map((queryTypeOption) => (
<SelectItem
key={queryTypeOption.id}
value={queryTypeOption.id}
testId={`wizard-query-type-option-${queryTypeOption.id}-${testId}`}
>
{queryTypeOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{supportsKeyScoping && (
<div className={styles.wizardField}>
<Typography as="label" weight="medium">
Key
</Typography>
<Input
value={SUPPORTED_GRANT_KEY}
readOnly
disabled
testId={`wizard-key-input-${testId}`}
/>
</div>
)}
<div className={styles.wizardField}>
<Typography as="label" weight="medium">
Value
</Typography>
<div className={styles.wizardValueRow}>
<Input
className={styles.wizardValueInput}
placeholder={
supportsKeyScoping
? 'Value or leave empty to allow every query'
: ANY_RESOURCE_VALUE
}
value={value}
disabled={!supportsKeyScoping}
onChange={handleValueChange}
onKeyDown={handleInputKeyDown}
testId={`wizard-value-input-${testId}`}
/>
<Checkbox
id={`wizard-any-resource-${testId}`}
value={isAnyResource}
disabled={!supportsKeyScoping}
onChange={(checked): void => handleAnyResourceChange(checked === true)}
testId={`wizard-any-resource-checkbox-${testId}`}
>
Any value
</Checkbox>
</div>
</div>
<div className={styles.wizardField}>
<Typography as="label" weight="medium">
Selector
</Typography>
<Input
value={selector}
onChange={handleSelectorChange}
onKeyDown={handleInputKeyDown}
testId={`wizard-selector-input-${testId}`}
/>
<Typography.Text
size="small"
color={validation.isError ? 'danger' : 'muted'}
testId={`wizard-selector-hint-${testId}`}
>
{validation.message}
</Typography.Text>
</div>
</div>
</DialogWrapper>
);
}
export default TelemetrySelectorWizard;

View File

@@ -1,139 +0,0 @@
import { AuthZResource } from 'lib/authz/hooks/useAuthZ/types';
import {
ANY_RESOURCE_VALUE,
ParsedSelector,
QUERY_TYPES,
QueryTypeId,
QueryTypeOption,
SelectorDraft,
SelectorValidation,
SUPPORTED_GRANT_KEY,
} from './TelemetrySelectorWizard.constants';
const METRIC_RESOURCES: ReadonlySet<AuthZResource> = new Set<AuthZResource>([
'metrics',
'meter-metrics',
]);
export function getQueryTypeOption(
queryType: string,
): QueryTypeOption | undefined {
return QUERY_TYPES.find((option) => option.id === queryType);
}
export function isQueryTypeAvailable(
option: QueryTypeOption,
resource: AuthZResource,
): boolean {
return !option.metricsOnly || METRIC_RESOURCES.has(resource);
}
export function supportsKeyScoping(queryType: string): boolean {
return getQueryTypeOption(queryType)?.supportsKeyScoping ?? false;
}
export function isAnyResourceValue(value: string): boolean {
return value.trim() === ANY_RESOURCE_VALUE;
}
function splitSelector(selector: string): string[] {
const parts = selector.split('/');
if (parts.length <= 3) {
return parts;
}
return [parts[0], parts[1], parts.slice(2).join('/')];
}
export function buildSelector({ queryType, value }: SelectorDraft): string {
const trimmedValue = value.trim();
if (!supportsKeyScoping(queryType) || !trimmedValue) {
return `${queryType}/${ANY_RESOURCE_VALUE}`;
}
return `${queryType}/${SUPPORTED_GRANT_KEY}/${trimmedValue}`;
}
export function parseSelector(selector: string): ParsedSelector {
const parts = splitSelector(selector.trim());
return {
queryType: getQueryTypeOption(parts[0])?.id,
value: parts.length >= 3 ? parts[2] : '',
};
}
/**
* This does a basic validation, intentionally omitting deep validations since this is to be made at backend,
* so this will allow to produce invalid selectors, and the validation will be done after the user try to save
*/
export function validateSelector(selector: string): SelectorValidation {
const trimmed = selector.trim();
if (!trimmed) {
return { message: 'Enter a selector.', isError: true };
}
if (trimmed === ANY_RESOURCE_VALUE) {
return { message: 'Allow every query of every type.', isError: false };
}
const parts = splitSelector(trimmed);
const option = getQueryTypeOption(parts[0]);
if (!option) {
return {
message: `"${parts[0]}" is not a supported query type.`,
isError: true,
};
}
if (parts.length < 3) {
if (parts.length === 2 && parts[1] !== ANY_RESOURCE_VALUE) {
if (!option.supportsKeyScoping) {
return {
message: `This query type does not support key scoping. Use ${option.id}/*`,
isError: false, // intentionally not an error
};
}
return {
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/${SUPPORTED_GRANT_KEY}/<value>.`,
isError: false, // intentionally not an error
};
}
return {
message: `Allow every "${option.label}" query.`,
isError: false,
};
}
const [, key, value] = parts;
if (value === ANY_RESOURCE_VALUE) {
return {
message: `Allow every ${key} for ${option.label} queries.`,
isError: false,
};
}
if (!option.supportsKeyScoping) {
return {
message: `This query type does not support key scoping. Use ${option.id}/*`,
isError: false, // intentionally not an error
};
}
return {
message: `Allow ${key}=${value} for ${option.label} queries.`,
isError: false,
};
}
export function getDefaultSelector(queryType: QueryTypeId): string {
return buildSelector({ queryType, value: '' });
}

View File

@@ -76,10 +76,9 @@ function createGrantPermissionAsReadonly(
return {
label: 'readonly',
insertText: resources
.filter((r) => {
const verbs: readonly string[] = r.allowedVerbs;
return verbs.includes('read') || verbs.includes('list');
})
.filter(
(r) => r.allowedVerbs.includes('read') || r.allowedVerbs.includes('list'),
)
.flatMap((r) => {
const verbs = r.allowedVerbs.filter((v) => v === 'read' || v === 'list');
return verbs.map(
@@ -104,12 +103,7 @@ function buildResourceSnippets(): SnippetDef[] {
for (const resource of resources) {
const { kind, type, allowedVerbs } = resource;
// If only one verb is supported, no point on add grant all
// that will only add one permission at time, just leave the verb snippet
// and it will look cleaner
if (allowedVerbs.length > 1) {
snippets.push(createGrantAllPermissionSnippet(kind, allowedVerbs, type));
}
snippets.push(createGrantAllPermissionSnippet(kind, allowedVerbs, type));
for (const verb of allowedVerbs) {
snippets.push(createGrantPermissionToVerbAndKind(kind, verb, type));

View File

@@ -1,157 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import {
ANY_RESOURCE_VALUE,
DEFAULT_QUERY_TYPE,
QueryTypeId,
QueryTypeOption,
SelectorValidation,
} from './TelemetrySelectorWizard.constants';
import {
buildSelector,
getDefaultSelector,
getQueryTypeOption,
isAnyResourceValue,
parseSelector,
validateSelector,
} from './TelemetrySelectorWizard.utils';
interface UseTelemetrySelectorWizardParams {
onAdd: (selector: string) => void;
}
interface UseTelemetrySelectorWizardResult {
open: boolean;
queryType: QueryTypeId;
selectedQueryType: QueryTypeOption | undefined;
value: string;
selector: string;
isAnyResource: boolean;
supportsKeyScoping: boolean;
validation: SelectorValidation;
canAdd: boolean;
handleOpenChange: (nextOpen: boolean) => void;
handleQueryTypeChange: (value: string | string[]) => void;
handleValueChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
handleAnyResourceChange: (checked: boolean) => void;
handleSelectorChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
handleAdd: () => void;
handleInputKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
}
function useTelemetrySelectorWizard({
onAdd,
}: UseTelemetrySelectorWizardParams): UseTelemetrySelectorWizardResult {
const [open, setOpen] = useState(false);
const [queryType, setQueryType] = useState<QueryTypeId>(DEFAULT_QUERY_TYPE);
const [value, setValue] = useState('');
const [selector, setSelector] = useState(() =>
getDefaultSelector(DEFAULT_QUERY_TYPE),
);
const selectedQueryType = useMemo(
() => getQueryTypeOption(queryType),
[queryType],
);
const supportsKeyScoping = selectedQueryType?.supportsKeyScoping ?? false;
const validation = useMemo(() => validateSelector(selector), [selector]);
const applyDraft = useCallback(
(nextQueryType: QueryTypeId, nextValue: string): void => {
setQueryType(nextQueryType);
setValue(nextValue);
setSelector(buildSelector({ queryType: nextQueryType, value: nextValue }));
},
[],
);
const handleQueryTypeChange = useCallback(
(next: string | string[]): void => {
const selected = (Array.isArray(next) ? next[0] : next) as QueryTypeId;
const keepsValue = getQueryTypeOption(selected)?.supportsKeyScoping ?? false;
applyDraft(selected, keepsValue ? value : '');
},
[applyDraft, value],
);
const handleValueChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
applyDraft(queryType, event.target.value);
},
[applyDraft, queryType],
);
const handleAnyResourceChange = useCallback(
(checked: boolean): void => {
applyDraft(queryType, checked ? ANY_RESOURCE_VALUE : '');
},
[applyDraft, queryType],
);
const handleSelectorChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
const nextSelector = event.target.value;
setSelector(nextSelector);
const parsed = parseSelector(nextSelector);
if (parsed.queryType) {
setQueryType(parsed.queryType);
}
setValue(parsed.value);
},
[],
);
const handleOpenChange = useCallback((nextOpen: boolean): void => {
setOpen(nextOpen);
if (!nextOpen) {
setQueryType(DEFAULT_QUERY_TYPE);
setValue('');
setSelector(getDefaultSelector(DEFAULT_QUERY_TYPE));
}
}, []);
const handleAdd = useCallback((): void => {
const trimmed = selector.trim();
if (validateSelector(trimmed).isError) {
return;
}
onAdd(trimmed);
handleOpenChange(false);
}, [selector, onAdd, handleOpenChange]);
const handleInputKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
handleAdd();
}
},
[handleAdd],
);
return {
open,
queryType,
selectedQueryType,
value,
selector,
isAnyResource: isAnyResourceValue(value),
supportsKeyScoping,
validation,
canAdd: !validation.isError,
handleOpenChange,
handleQueryTypeChange,
handleValueChange,
handleAnyResourceChange,
handleSelectorChange,
handleAdd,
handleInputKeyDown,
};
}
export default useTelemetrySelectorWizard;

View File

@@ -329,15 +329,11 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
expect(result).toHaveLength(7);
expect(result).toHaveLength(3);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'role',
'serviceaccount',
'logs',
'traces',
'metrics',
'meter-metrics',
]);
});
@@ -418,15 +414,11 @@ describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(7);
expect(result).toHaveLength(3);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'role',
'serviceaccount',
'logs',
'traces',
'metrics',
'meter-metrics',
]);
});

View File

@@ -1,12 +1,4 @@
import {
Bot,
ChartLine,
DraftingCompass,
Gauge,
Key,
Logs,
Shield,
} from '@signozhq/icons';
import { Bot, Key, Shield } from '@signozhq/icons';
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
import {
@@ -22,15 +14,12 @@ type IconComponent = typeof Shield;
const OBJECT_SCOPED_VERB_SET = new Set<string>(OBJECT_SCOPED_VERBS);
export type SelectorType = 'input' | 'telemetryBuilder';
export interface ResourcePanelConfig {
label: string;
description: string;
icon: IconComponent;
selectorPlaceholder: string;
docsAnchor: string;
selectorType?: SelectorType;
}
/**
@@ -61,42 +50,6 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
'Type service account ID, separate multiple with comma or space',
docsAnchor: 'service-account',
},
logs: {
label: 'Logs',
description: 'Log data collected across the workspace.',
icon: Logs,
selectorPlaceholder:
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
docsAnchor: 'logs',
selectorType: 'telemetryBuilder',
},
traces: {
label: 'Traces',
description: 'Distributed tracing data collected across the workspace.',
icon: DraftingCompass,
selectorPlaceholder:
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
docsAnchor: 'traces',
selectorType: 'telemetryBuilder',
},
metrics: {
label: 'Metrics',
description: 'Metric data collected across the workspace.',
icon: ChartLine,
selectorPlaceholder:
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
docsAnchor: 'metrics',
selectorType: 'telemetryBuilder',
},
'meter-metrics': {
label: 'Meter Metrics',
description: 'Usage metering data for the workspace.',
icon: Gauge,
selectorPlaceholder:
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
docsAnchor: 'meter-metrics',
selectorType: 'telemetryBuilder',
},
};
export const RESOURCE_ORDER = Object.keys(RESOURCE_PANELS) as AuthZResource[];

View File

@@ -108,7 +108,6 @@ export function getAppContextMockState(
isFetchingHosts: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
isFetchingUserPreferences: false,
userFetchError: undefined,
activeLicenseFetchError: null,
hostsFetchError: undefined,
@@ -144,10 +143,7 @@ export function mockQueryParams(
}
});
return Object.create(URLSearchParams.prototype, {
toString: { value: (): string => realUrlQuery.toString() },
get: { value: (key: string): string | null => realUrlQuery.get(key) },
});
return realUrlQuery;
}
export function convertRoutingPolicyToApiResponse(

View File

@@ -144,7 +144,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
trialInfo,
isLoggedIn,
userPreferences,
isFetchingUserPreferences,
changelog,
toggleChangelogModal,
updateUserPreferenceInContext,
@@ -262,11 +261,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
// Compute initial pinned items and secondary menu items synchronously to avoid flash
const computedPinnedMenuItems = useMemo(() => {
// While loading, return empty to avoid flash
if (isFetchingUserPreferences) {
return [];
}
const navShortcutsPreference = userPreferences?.find(
(preference) => preference.name === USER_PREFERENCES.NAV_SHORTCUTS,
);
@@ -274,6 +268,11 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
| string[]
| undefined;
// If userPreferences not loaded yet, return empty to avoid showing defaults before preferences load
if (userPreferences === null) {
return [];
}
// If preference exists with non-empty array, use stored shortcuts
if (isArray(navShortcuts) && navShortcuts.length > 0) {
return navShortcuts
@@ -283,9 +282,9 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
.filter((item): item is SidebarItem => item !== undefined);
}
// No preference, or empty array, or error loading → use defaults
// No preference, or empty array → use defaults
return defaultMoreMenuItems.filter((item) => item.isPinned);
}, [isFetchingUserPreferences, userPreferences]);
}, [userPreferences]);
const computedSecondaryMenuItems = useMemo(() => {
const shouldShowIntegrationsValue =
@@ -323,16 +322,12 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
// Sync state only on initial load when userPreferences first becomes available
useEffect(() => {
// Only sync once: when userPreferences loads for the first time
if (!hasInitializedRef.current && isFetchingUserPreferences === false) {
if (!hasInitializedRef.current && userPreferences !== null) {
setPinnedMenuItems(computedPinnedMenuItems);
setSecondaryMenuItems(computedSecondaryMenuItems);
hasInitializedRef.current = true;
}
}, [
computedPinnedMenuItems,
computedSecondaryMenuItems,
isFetchingUserPreferences,
]);
}, [computedPinnedMenuItems, computedSecondaryMenuItems, userPreferences]);
const isChatSupportEnabled = featureFlags?.find(
(flag) => flag.name === FeatureKeys.CHAT_SUPPORT,

View File

@@ -35,6 +35,11 @@ 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';
@@ -278,7 +283,7 @@ function DateTimeSelection({
return `Refreshed ${secondsDiff} sec ago`;
}, [maxTime, minTime, selectedTime]);
const getUpdatedCompositeQuery = useCallback((): string => {
const getUpdatedCompositeQuery = useCallback((): URLSearchParams => {
let updatedCompositeQuery = cloneDeep(currentQuery);
updatedCompositeQuery.id = uuid();
// Remove the filters
@@ -299,7 +304,7 @@ function DateTimeSelection({
})),
},
};
return encodeURIComponent(JSON.stringify(updatedCompositeQuery));
return serialize(updatedCompositeQuery);
}, [currentQuery]);
const onSelectHandler = useCallback(
@@ -334,9 +339,9 @@ function DateTimeSelection({
// Remove Hidden Filters from URL query parameters on time change
urlQuery.delete(QueryParams.activeLogId);
if (urlQuery.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
const staledQuery = deserialize(urlQuery);
if (staledQuery) {
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
@@ -424,9 +429,9 @@ function DateTimeSelection({
urlQuery.set(QueryParams.endTime, endTime?.toDate().getTime().toString());
urlQuery.delete(QueryParams.relativeTime);
if (urlQuery.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
const staledQuery = deserialize(urlQuery);
if (staledQuery) {
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;

View File

@@ -0,0 +1,170 @@
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,7 +5,6 @@ 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';
@@ -13,6 +12,7 @@ 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,9 +58,14 @@ export const useActiveLog = (): UseActiveLog => {
const [activeLog, setActiveLog] = useState<ILog | null>(null);
// Close drawer/clear active log when query in URL changes
// 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.
const urlQuery = useUrlQuery();
const compositeQuery = urlQuery.get(QueryParams.compositeQuery) ?? '';
const compositeQuery = useMemo(() => {
const decoded = deserialize(urlQuery);
return decoded ? JSON.stringify(decoded) : '';
}, [urlQuery]);
const prevQueryRef = useRef<string | null>(null);
useEffect(() => {
if (

View File

@@ -2,15 +2,17 @@ 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 { QueryParams } from 'constants/query';
import { deserialize } from 'lib/compositeQuery/serializer';
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';
jest.mock('react-query', () => ({
useMutation: jest.fn(),
QueryClient: jest.fn().mockImplementation(() => ({})),
}));
jest.mock('react-redux', () => ({
@@ -79,14 +81,14 @@ const buildWidget = (queryType: EQueryType | undefined): Widgets =>
},
}) as unknown as Widgets;
const getCompositeQueryFromLastOpen = (): Record<string, unknown> => {
const getCompositeQueryFromLastOpen = (): Query => {
const [url] = (window.open as jest.Mock).mock.calls[0];
const query = new URLSearchParams((url as string).split('?')[1]);
const raw = query.get(QueryParams.compositeQuery);
if (!raw) {
const composite = deserialize(query);
if (!composite) {
throw new Error('compositeQuery not found in URL');
}
return JSON.parse(decodeURIComponent(raw));
return composite;
};
describe('useCreateAlerts', () => {

View File

@@ -0,0 +1,26 @@
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,6 +14,10 @@ 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';
@@ -91,10 +95,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
}
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
applySerializedParams(serialize(updatedQuery), params);
params.set(QueryParams.panelTypes, widget.panelTypes);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);

View File

@@ -1,72 +1,10 @@
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 { deserialize } from 'lib/compositeQuery/serializer';
import { useMemo } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const useGetCompositeQueryParam = (): Query | null => {
const urlQuery = useUrlQuery();
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]);
return useMemo(() => deserialize(urlQuery), [urlQuery]);
};

View File

@@ -1,6 +1,9 @@
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 {
@@ -18,77 +21,6 @@ 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

@@ -0,0 +1,103 @@
import { deserialize } from 'lib/compositeQuery/serializer';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/constants';
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

@@ -19,7 +19,7 @@ describe('PermissionDeniedCallout', () => {
it('renders multiple denied permissions', () => {
const deniedPermissions = [
buildPermission('read', buildObjectString('serviceaccount', '*')),
buildPermission('update', buildObjectString<'update'>('role', 'admin')),
buildPermission('update', buildObjectString('role', 'admin')),
];
render(<PermissionDeniedCallout deniedPermissions={deniedPermissions} />);

View File

@@ -20,7 +20,7 @@ describe('PermissionDeniedFullPage', () => {
it('renders with multiple denied permissions', () => {
const deniedPermissions = [
buildPermission('read', buildObjectString('role', 'admin')),
buildPermission('update', buildObjectString<'update'>('role', 'admin')),
buildPermission('update', buildObjectString('role', 'admin')),
];
render(<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />);
expect(screen.getByText(/read:role:admin/)).toBeInTheDocument();

View File

@@ -35,26 +35,6 @@ export default {
'update',
],
},
{
kind: 'logs',
type: 'telemetryresource',
allowedVerbs: ['read'],
},
{
kind: 'meter-metrics',
type: 'telemetryresource',
allowedVerbs: ['read'],
},
{
kind: 'metrics',
type: 'telemetryresource',
allowedVerbs: ['read'],
},
{
kind: 'traces',
type: 'telemetryresource',
allowedVerbs: ['read'],
},
],
relations: {
assignee: ['role'],
@@ -63,7 +43,7 @@ export default {
delete: ['metaresource', 'role', 'serviceaccount'],
detach: ['metaresource', 'role', 'serviceaccount'],
list: ['metaresource', 'role', 'serviceaccount'],
read: ['metaresource', 'role', 'serviceaccount', 'telemetryresource'],
read: ['metaresource', 'role', 'serviceaccount'],
update: ['metaresource', 'role', 'serviceaccount'],
},
},

View File

@@ -23,40 +23,6 @@ export function buildPermission<R extends AuthZRelation>(
return `${relation}${PermissionSeparator}${object}` as BrandedPermission;
}
/**
* Builds an object string for use with `buildPermission`.
*
* ## Type Inference Behavior
*
* TypeScript infers `R` from `resource`. If a resource belongs to multiple relations,
* the return type becomes a union of all matching `AuthZObject` types.
*
* Example: 'role' is valid for 'read', 'update', 'delete', 'assignee'.
* Without explicit generic, return type = `AuthZObject<'read' | 'update' | 'delete' | 'assignee'>`.
*
* ## When to specify explicit generic
*
* **Needed** when resource belongs to multiple relations AND you pass result to `buildPermission`
* with a relation that has FEWER valid resources than others:
*
* ```ts
* // ERROR: 'read' includes telemetry resources, 'update' does not
* buildPermission('update', buildObjectString('role', 'admin'))
*
* // OK: explicit generic narrows return type
* buildPermission('update', buildObjectString<'update'>('role', 'admin'))
* ```
*
* **Not needed** when:
* - Resource only belongs to one relation in the constraint
* - Using with 'read' relation (widest type, accepts all)
* - Storing in a variable typed as `BrandedPermission` (already opaque)
*
* ```ts
* // OK: 'read' is widest, accepts union return type
* buildPermission('read', buildObjectString('role', 'admin'))
* ```
*/
export function buildObjectString<
R extends 'delete' | 'read' | 'update' | 'assignee',
>(resource: ResourcesForRelation<R>, objectId: string): AuthZObject<R> {

View File

@@ -0,0 +1,51 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/constants';
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

@@ -0,0 +1,74 @@
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/constants';
import type { CompositeQueryAdapter } from 'lib/compositeQuery/types';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { 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);
if (!raw) {
return null;
}
try {
let parsed: Query;
try {
parsed = JSON.parse(raw);
} catch {
parsed = JSON.parse(decodeURIComponent(raw.replace(/\+/g, ' ')));
}
return migrateLegacyFormat(parsed);
} catch {
return null;
}
},
};

View File

@@ -0,0 +1,216 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/constants';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { jsonAdapter } from './index';
const roundTrip = (query: Query): Query => {
const decoded = jsonAdapter.decode(jsonAdapter.encode(query));
if (!decoded) {
throw new Error('roundTrip: decode returned null');
}
return decoded;
};
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

@@ -0,0 +1 @@
export const COMPOSITE_QUERY_KEY = 'compositeQuery';

View File

@@ -0,0 +1,78 @@
import { jsonAdapter } from 'lib/compositeQuery/adapters/json';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/constants';
import type { CompositeQueryAdapter } from 'lib/compositeQuery/types';
import type { 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 = params.toString().length > 0;
if (!hasParams) {
return null;
}
return adapterFor(params).decode(params);
}
/**
* 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 {
const decoded = adapter.decode(target);
if (!decoded) {
target.delete(COMPOSITE_QUERY_KEY);
return;
}
adapter.encode(decoded).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

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

View File

@@ -1,4 +1,5 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { deserialize } from 'lib/compositeQuery/serializer';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
@@ -49,13 +50,9 @@ describe('newPanelRoute', () => {
return { path, params: new URLSearchParams(search) };
};
// Mirrors useGetCompositeQueryParam: it decodes the param twice.
const readCompositeQuery = (params: URLSearchParams): unknown =>
JSON.parse(
decodeURIComponent(
(params.get('compositeQuery') as string).replace(/\+/g, ' '),
),
);
// The real reader — the same entry point `useGetCompositeQueryParam` uses.
const readCompositeQuery = (params: URLSearchParams): Query | null =>
deserialize(params);
it.each([
[PANEL_TYPES.TIME_SERIES, 'signoz/TimeSeriesPanel'],
@@ -85,15 +82,14 @@ describe('newPanelRoute', () => {
expect(readCompositeQuery(params)).toStrictEqual(query);
});
// Regression: a bare `%`/`+` must survive the reader's double-decode.
// Regression: a bare `%`/`+` must survive URL encode/decode intact.
it('round-trips a filter expression containing % and + literals', () => {
const expression = "severity_text ILIKE 'Inf%' AND path = 'a+b'";
const queryWithLiterals = {
id: 'q1',
queryType: 'builder',
builder: {
queryData: [
{ filter: { expression: "severity_text ILIKE 'Inf%' AND path = 'a+b'" } },
],
queryData: [{ filter: { expression } }],
},
} as unknown as Query;
const link = buildExportPanelLink({
@@ -102,7 +98,9 @@ describe('newPanelRoute', () => {
query: queryWithLiterals,
});
const { params } = parseLink(link);
expect(readCompositeQuery(params)).toStrictEqual(queryWithLiterals);
expect(
readCompositeQuery(params)?.builder.queryData[0].filter?.expression,
).toBe(expression);
});
it('returns null for a panel type with no V2 kind', () => {

View File

@@ -4,8 +4,8 @@ import type {
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { serialize } from 'lib/compositeQuery/serializer';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { AllTheProviders } from 'tests/test-utils';
@@ -50,11 +50,7 @@ function makePanel(queries: DashboardtypesQueryDTO[]): DashboardtypesPanelDTO {
function makeUrlWrapper(
query: Query,
): ({ children }: { children: React.ReactNode }) => JSX.Element {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
const params = serialize(query);
return function UrlWrapper({
children,
}: {

View File

@@ -1,6 +1,7 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { deserialize } from 'lib/compositeQuery/serializer';
import {
clearViewPanelHandoff,
readViewPanelHandoff,
@@ -56,11 +57,7 @@ describe('useSwitchToViewMode', () => {
expect(target.pathname).toBe('/dashboard/dash-1');
expect(target.searchParams.get('expandedWidgetId')).toBe('panel-1');
expect(target.searchParams.get('graphType')).toBe(PANEL_TYPES.TIME_SERIES);
expect(
JSON.parse(
decodeURIComponent(target.searchParams.get('compositeQuery') || ''),
),
).toStrictEqual(query);
expect(deserialize(target.searchParams)).toStrictEqual(query);
});
it('stashes the live draft spec in the sessionStorage handoff, not the URL', () => {

View File

@@ -6,6 +6,10 @@ import type { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { writeViewPanelHandoff } from '../../PanelsAndSectionsLayout/Panel/ViewPanelModal/viewPanelHandoffStore';
@@ -44,10 +48,7 @@ export function useSwitchToViewMode({
}
params.set(QueryParams.expandedWidgetId, panelId);
params.set(QueryParams.graphType, panelType);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
applySerializedParams(serialize(query), params);
safeNavigate(
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}?${params.toString()}`,
);

View File

@@ -1,7 +1,7 @@
import { generatePath } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
@@ -45,11 +45,8 @@ export function parseNewPanelKind(
/**
* New-panel editor link that exports an explorer query into a V2 dashboard. Carries the
* raw `Query` as `compositeQuery` (conversion happens in the editor). `null` when the panel
* type has no V2 kind, so the caller skips the export instead of landing on an unrelated kind.
*
* Double-encoded on purpose: `useGetCompositeQueryParam` decodes twice, so a single encode
* would let a bare `%`/`+` (e.g. `ILIKE 'Inf%'`) break its second decode and drop the query.
* raw `Query` (conversion happens in the editor). `null` when the panel type has no V2
* kind, so the caller skips the export instead of landing on an unrelated kind.
*/
export function buildExportPanelLink({
dashboardId,
@@ -68,9 +65,7 @@ export function buildExportPanelLink({
dashboardId,
panelId: NEW_PANEL_ID,
});
return `${path}${newPanelSearch(kind)}&${
QueryParams.compositeQuery
}=${encodeURIComponent(encodeURIComponent(JSON.stringify(query)))}`;
return `${path}${newPanelSearch(kind)}&${serialize(query).toString()}`;
}
/** Target section index for a new panel, or undefined when unset/invalid. */

View File

@@ -5,6 +5,11 @@ import { QueryParams } from 'constants/query';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
clearSerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -47,7 +52,7 @@ export function useViewPanel(): UseViewPanelApi {
next.set(QueryParams.expandedWidgetId, panelId);
// Drop leftover in-modal query/kind + the editor's handoff so a plain View opens
// on the saved panel, not stale state the modal would otherwise hydrate from.
next.delete(QueryParams.compositeQuery);
clearSerializedParams(next);
next.delete(QueryParams.graphType);
clearViewPanelHandoff();
void logEvent(DashboardDetailEvents.PanelViewed, { panelId });
@@ -63,12 +68,7 @@ export function useViewPanel(): UseViewPanelApi {
next.set(QueryParams.graphType, panelType);
// A grid drilldown opens on the saved panel, never a stale editor handoff.
clearViewPanelHandoff();
// Same encoding the query builder uses (see `useGetCompositeQueryParam`): the URL
// value is `encodeURIComponent(JSON.stringify(query))`, decoded once on read.
next.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
applySerializedParams(serialize(query), next);
safeNavigate(`${pathname}?${next.toString()}`);
},
[pathname, safeNavigate, urlQuery],
@@ -79,7 +79,7 @@ export function useViewPanel(): UseViewPanelApi {
next.delete(QueryParams.expandedWidgetId);
// Drop the drilldown editor's URL state so it doesn't leak to the dashboard
// (the in-modal query builder writes compositeQuery, V1 parity).
next.delete(QueryParams.compositeQuery);
clearSerializedParams(next);
next.delete(QueryParams.graphType);
clearViewPanelHandoff();
const search = next.toString();

View File

@@ -3,6 +3,7 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { deserialize } from 'lib/compositeQuery/serializer';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
@@ -73,32 +74,25 @@ describe('buildCreateAlertUrl', () => {
expect(params.get(QueryParams.source)).toBe('dashboards');
});
it('encodes the translated query as the compositeQuery param', () => {
it('serializes the translated query into the URL', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
const raw = params.get(QueryParams.compositeQuery);
expect(raw).toBeTruthy();
const decoded = JSON.parse(decodeURIComponent(raw as string));
expect(decoded.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(decoded.id).toBe('q1');
const decoded = deserialize(params);
expect(decoded).not.toBeNull();
expect(decoded?.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(decoded?.id).toBe('q1');
});
it('carries the panel formatting unit onto the alert query when set', () => {
const params = parse(buildCreateAlertUrl(makePanel({ unit: 'bytes' })));
const decoded = JSON.parse(
decodeURIComponent(params.get(QueryParams.compositeQuery) as string),
);
expect(decoded.unit).toBe('bytes');
expect(deserialize(params)?.unit).toBe('bytes');
});
it('leaves the query unit unset when the panel has no formatting unit', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
const decoded = JSON.parse(
decodeURIComponent(params.get(QueryParams.compositeQuery) as string),
);
expect(decoded.unit).toBeUndefined();
expect(deserialize(params)?.unit).toBeUndefined();
});
it('omits the alert-condition params when the panel has no reduceTo or thresholds', () => {

View File

@@ -7,6 +7,10 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -44,10 +48,7 @@ export function buildAlertUrl(
}
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
applySerializedParams(serialize(query), params);
params.set(QueryParams.panelTypes, panelType);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);

View File

@@ -15,6 +15,7 @@ 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,
@@ -49,7 +50,7 @@ function EditRules(): JSX.Element {
const { notifications } = useNotifications();
const clickHandler = (): void => {
params.delete(QueryParams.compositeQuery);
clearSerializedParams(params);
params.delete(QueryParams.panelTypes);
params.delete(QueryParams.ruleId);
params.delete(QueryParams.relativeTime);

View File

@@ -20,6 +20,10 @@ 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,
@@ -240,7 +244,7 @@ function SpanDetailsContent({
};
const searchParams = new URLSearchParams();
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), searchParams);
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());

View File

@@ -18,6 +18,7 @@ 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';
@@ -138,7 +139,7 @@ function SpanLogs({
[QueryParams.activeLogId]: `"${log.id}"`,
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
[QueryParams.compositeQuery]: JSON.stringify(updatedQuery),
...serializeToParams(updatedQuery),
};
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;

View File

@@ -434,7 +434,6 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
isFetchingHosts,
isFetchingFeatureFlags,
isFetchingOrgPreferences,
isFetchingUserPreferences,
userFetchError,
activeLicenseFetchError,
hostsFetchError,
@@ -465,7 +464,6 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
isFetchingHosts,
isFetchingFeatureFlags,
isFetchingOrgPreferences,
isFetchingUserPreferences,
isFetchingUser,
isLoggedIn,
hostsData,

View File

@@ -27,7 +27,6 @@ export interface IAppContext {
isFetchingHosts: boolean;
isFetchingFeatureFlags: boolean;
isFetchingOrgPreferences: boolean;
isFetchingUserPreferences: boolean;
userFetchError: unknown;
activeLicenseFetchError: APIError | null;
hostsFetchError: unknown;

View File

@@ -38,6 +38,10 @@ 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';
@@ -990,10 +994,7 @@ export function QueryBuilderProvider({
);
}
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(currentGeneratedQuery)),
);
applySerializedParams(serialize(currentGeneratedQuery), urlQuery);
if (searchParams) {
Object.keys(searchParams).forEach((param) =>

View File

@@ -242,7 +242,6 @@ export function getAppContextMock(
userPreferences: [],
updateUserPreferenceInContext: jest.fn(),
isFetchingOrgPreferences: false,
isFetchingUserPreferences: false,
orgPreferencesFetchError: null,
isLoggedIn: true,
isPreflightLoading: false,

View File

@@ -2,6 +2,7 @@ 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 = {
@@ -21,6 +22,4 @@ export const generateExportToDashboardLink = ({
dashboardId,
})}/new?${QueryParams.graphType}=${panelType}&${
QueryParams.widgetId
}=${widgetId}&${QueryParams.compositeQuery}=${encodeURIComponent(
JSON.stringify(query),
)}`;
}=${widgetId}&${serialize(query).toString()}`;

View File

@@ -1,6 +1,10 @@
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -67,10 +71,7 @@ export const getMetricsExplorerUrl = ({
endTimeMs,
}: MetricsExplorerUrlParams): string => {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
applySerializedParams(serialize(query), params);
if (relativeTime) {
params.set(QueryParams.relativeTime, relativeTime);

View File

@@ -23,11 +23,6 @@ export type ComponentTypes =
| 'add_panel_locked_dashboard'
| 'manage_llm_pricing';
/**
* @deprecated Before adding a new value here, check if what you want to add permission is supported by authz.
* If so, read AuthZ Guidelines on how to add permission check via AuthZ.
* If not, you can keep adding to this record.
*/
export const componentPermission: Record<ComponentTypes, ROLES[]> = {
current_org_settings: ['ADMIN'],
invite_members: ['ADMIN'],
@@ -51,16 +46,11 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
manage_llm_pricing: ['ADMIN'],
};
/**
* @deprecated You can still add new permissions/routes here but be aware if this page/module supports authz.
* If so, also implement the correct authz checks in the page itself, and here you can add ADMIN/EDITOR/VIEWER,
* and also update/include the route at {@link routeWithInitialAuthZSupport}
*/
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
HOME: ['ADMIN', 'EDITOR', 'VIEWER'],
ALERTS_NEW: ['ADMIN', 'EDITOR'],
ORG_SETTINGS: ['ADMIN'],
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SERVICE_MAP: ['ADMIN', 'EDITOR', 'VIEWER'],
ALL_CHANNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
INGESTION_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -85,15 +75,13 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR', 'ANONYMOUS'],
PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'],
SERVICE_METRICS: ['ADMIN', 'EDITOR', 'VIEWER'],
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SIGN_UP: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACES_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACE: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACE_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACE_DETAIL_OLD: ['ADMIN', 'EDITOR', 'VIEWER'],
// Every role must be able to land here - a role missing from this list is
// redirected to /un-authorized and then redirected off it again, looping.
UN_AUTHORIZED: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS', 'AUTHOR'],
UN_AUTHORIZED: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
USAGE_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
VERSION: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -107,12 +95,12 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
GET_STARTED_WITH_CLOUD: ['ADMIN', 'EDITOR'],
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLE_CREATE: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLE_EDIT: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
ROLE_CREATE: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
ROLE_EDIT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
MEMBERS_SETTINGS: ['ADMIN'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -153,34 +141,3 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
AI_OBSERVABILITY_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_OBSERVABILITY_CONFIGURATION: ['ADMIN', 'EDITOR', 'VIEWER'],
};
/**
* Any route that will start be supported under AuthZ should be added here.
* This will help correctly identify when fallback to show the page
* or just return unauthorized.
*
* This prevents us from adding `ANONYMOUS` on the `routePermission`
*/
export const routeWithInitialAuthZSupport = {
MY_SETTINGS: true,
SETTINGS: true,
TRACES_EXPLORER: true,
TRACE: true,
TRACE_DETAIL: true,
TRACE_DETAIL_OLD: true,
LOGS: true,
LOGS_EXPLORER: true,
LIVE_LOGS: true,
ROLES_SETTINGS: true,
ROLE_CREATE: true,
ROLE_DETAILS: true,
ROLE_EDIT: true,
SERVICE_ACCOUNTS_SETTINGS: true,
SUPPORT: true,
OLD_LOGS_EXPLORER: true,
METRICS_EXPLORER: true,
METRICS_EXPLORER_EXPLORER: true,
METRICS_EXPLORER_VOLUME_CONTROL: true,
METER_EXPLORER: true,
METER: true,
} as const satisfies Partial<Record<keyof typeof ROUTES, true>>;

View File

@@ -1,298 +0,0 @@
package googlechat
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
)
func New(conf *alertmanagertypes.GoogleChatReceiverConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater) (*Notifier, error) {
if conf.HTTPConfig == nil {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat http_config is nil")
}
client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration)
if err != nil {
return nil, err
}
return &Notifier{
conf: conf,
tmpl: t,
logger: l,
client: client,
retrier: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
templater: templater,
}, nil
}
func (n *Notifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) {
if n.conf.WebhookURL == nil {
return false, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url is empty")
}
key, err := notify.ExtractGroupKey(ctx)
if err != nil {
return false, err
}
n.logger.DebugContext(ctx, "sending google chat notification", slog.Any("group_key", key))
c, err := n.prepareContent(ctx, alerts)
if err != nil {
return false, err
}
// Empty title and every body empty means a misconfigured template, so fail
// loudly and non-retryably instead of sending a card with no content.
if c.title == "" && !isAnyNonEmpty(c.bodies) {
return false, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat message rendered empty; check the channel title/text templates")
}
status := statusLine(alerts)
// Cap per-alert sections well under Google Chat's 100-widget limit and keep
// the card readable; the overflow count drives a "+N more" note. Capping
// before the size guard keeps the trim loop working only on rendered bodies.
capAlerts, capBodies, remaining := capAlertSections(alerts, c.bodies)
// Serialized-size guard: keep the payload within Google Chat's byte limit by
// trimming the longest body first, then the title (which appears in both the
// summary and the card header). Banners/buttons are small and bounded, so
// trimming the text fields always brings the payload under the limit.
title := c.title
bodies := append([]string(nil), capBodies...)
buf, err := encodeMessage(buildMessage(title, status, capAlerts, bodies, remaining))
if err != nil {
return false, err
}
for buf.Len() > maxMessageBytes {
over := buf.Len() - maxMessageBytes
if i := longestBodyIndex(bodies); i >= 0 {
bodies[i] = truncateToByteLimit(bodies[i], max(len(bodies[i])-over, 0))
} else if title != "" {
title = truncateToByteLimit(title, max(len(title)-over, 0))
} else {
break
}
if buf, err = encodeMessage(buildMessage(title, status, capAlerts, bodies, remaining)); err != nil {
return false, err
}
}
// Thread same-rule alerts together: threadKey is a stable hash of the
// alert group key. Changing a rule's grouping starts a new thread.
u, err := url.Parse(n.conf.WebhookURL.String())
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse google chat webhook url")
}
q := u.Query()
q.Set("threadKey", key.Hash())
q.Set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD")
u.RawQuery = q.Encode()
resp, err := notify.PostJSON(ctx, n.client, u.String(), buf) //nolint:bodyclose
if err != nil {
return true, notify.RedactURL(err)
}
defer notify.Drain(resp)
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
if err != nil {
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
}
return shouldRetry, err
}
// prepareContent expands the title and body templates. Custom templates (from
// alert annotations) override the channel defaults. The title is used as a plain
// summary + card header; the body is converted to HTML for the card text widget.
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (content, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Title,
DefaultBodyTemplate: n.conf.Text,
}, alerts)
if err != nil {
return content{}, err
}
// Each body goes into a per-alert card textParagraph, which renders HTML.
// Default and custom templates are both standard markdown, so convert each
// one. Bold/links/lists/code render well; tables flatten (rare in alerts).
bodies := make([]string, len(result.Body))
for i, body := range result.Body {
if body == "" {
continue
}
html, err := markdownrenderer.RenderHTML(body)
if err != nil {
return content{}, err
}
bodies[i] = html
}
return content{title: result.Title, bodies: bodies}, nil
}
// statusLine returns a colored firing/resolved banner for the card body.
func statusLine(alerts []*types.Alert) string {
if types.Alerts(alerts...).Status() == model.AlertResolved {
return `<font color="#33a853"><b>🟢 RESOLVED</b></font>`
}
return `<font color="#d32f2f"><b>🔴 FIRING</b></font>`
}
// relatedButtons builds the per-alert "View Related Logs/Traces" buttons from
// the annotations the ruler attaches to each alert. Empty links are skipped.
func relatedButtons(alert *types.Alert) []button {
var buttons []button
add := func(text, u string) {
if u != "" {
buttons = append(buttons, button{Text: text, OnClick: onClick{OpenLink: openLink{URL: u}}})
}
}
add("View Related Logs", string(alert.Annotations[ruletypes.AnnotationRelatedLogs]))
add("View Related Traces", string(alert.Annotations[ruletypes.AnnotationRelatedTraces]))
return buttons
}
// sigNozButton builds the shared "Open in SigNoz" button from the ruleSource
// label, which is per-rule (identical for every alert in the group).
func sigNozButton(alert *types.Alert) *button {
if u := string(alert.Labels[ruletypes.LabelRuleSource]); u != "" {
return &button{Text: "Open in SigNoz", OnClick: onClick{OpenLink: openLink{URL: u}}}
}
return nil
}
// capAlertSections keeps the first maxAlertSections non-empty bodies (with their
// aligned alerts) and returns the count of non-empty bodies dropped beyond the
// cap. Capping the render set up front keeps the card within Google Chat's
// widget limit and bounds the size-guard trim loop.
func capAlertSections(alerts []*types.Alert, bodies []string) ([]*types.Alert, []string, int) {
capAlerts := make([]*types.Alert, 0, maxAlertSections)
capBodies := make([]string, 0, maxAlertSections)
remaining := 0
for i, body := range bodies {
if body == "" {
continue
}
if len(capBodies) >= maxAlertSections {
remaining++
continue
}
var alert *types.Alert
if i < len(alerts) {
alert = alerts[i]
}
capAlerts = append(capAlerts, alert)
capBodies = append(capBodies, body)
}
return capAlerts, capBodies, remaining
}
// buildMessage assembles the text+card payload: a plain text summary plus a card
// with a status banner, one section per alert (its body + related-link buttons),
// an optional "+N more" note, and a shared "Open in SigNoz" footer button. The
// alerts and bodies slices are the already-capped, aligned render set.
func buildMessage(title, statusHTML string, alerts []*types.Alert, bodies []string, remaining int) Message {
sections := []cardSection{{Widgets: []widget{{TextParagraph: &textParagraph{Text: statusHTML}}}}}
for i, body := range bodies {
if body == "" {
continue
}
widgets := []widget{{TextParagraph: &textParagraph{Text: body}}}
if i < len(alerts) && alerts[i] != nil {
if btns := relatedButtons(alerts[i]); len(btns) > 0 {
widgets = append(widgets, widget{ButtonList: &buttonList{Buttons: btns}})
}
}
sections = append(sections, cardSection{Widgets: widgets})
}
if remaining > 0 {
note := fmt.Sprintf("<i>…and %d more alerts. Open in SigNoz for the full list.</i>", remaining)
sections = append(sections, cardSection{Widgets: []widget{{TextParagraph: &textParagraph{Text: note}}}})
}
if len(alerts) > 0 && alerts[0] != nil {
if btn := sigNozButton(alerts[0]); btn != nil {
sections = append(sections, cardSection{Widgets: []widget{{ButtonList: &buttonList{Buttons: []button{*btn}}}}})
}
}
return Message{
Text: title,
CardsV2: []cardWithID{{
CardID: "signoz-alert",
Card: card{Header: &cardHeader{Title: title}, Sections: sections},
}},
}
}
// isAnyNonEmpty reports whether any string in ss is non-empty.
func isAnyNonEmpty(ss []string) bool {
for _, s := range ss {
if s != "" {
return true
}
}
return false
}
// longestBodyIndex returns the index of the longest non-empty body, or -1 when
// all bodies are empty.
func longestBodyIndex(bodies []string) int {
idx, best := -1, 0
for i, b := range bodies {
if len(b) > best {
idx, best = i, len(b)
}
}
return idx
}
func encodeMessage(msg Message) (*bytes.Buffer, error) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
return nil, err
}
return &buf, nil
}
// truncateToByteLimit trims s to at most maxBytes bytes on a rune boundary,
// appending an ellipsis when it truncates.
func truncateToByteLimit(s string, maxBytes int) string {
if len(s) <= maxBytes {
return s
}
const ellipsis = "..."
target := maxBytes - len(ellipsis)
if target <= 0 {
return ellipsis[:maxBytes]
}
truncated := s
for len(truncated) > target {
_, size := utf8.DecodeLastRuneInString(truncated)
if size == 0 {
break
}
truncated = truncated[:len(truncated)-size]
}
return truncated + ellipsis
}

View File

@@ -1,551 +0,0 @@
package googlechat
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/notify/test"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
func secretURLFromString(t *testing.T, rawURL string) *config.SecretURL {
t.Helper()
parsed, err := url.Parse(rawURL)
require.NoError(t, err)
return &config.SecretURL{URL: parsed}
}
func newTestNotifier(t *testing.T, webhookURL, title, text string) *Notifier {
t.Helper()
tmpl := test.CreateTmpl(t)
n, err := New(&alertmanagertypes.GoogleChatReceiverConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
WebhookURL: secretURLFromString(t, webhookURL),
Title: title,
Text: text,
}, tmpl, slog.New(slog.DiscardHandler), newTestTemplater(tmpl))
require.NoError(t, err)
return n
}
func newTestAlerts(alertname string) []*types.Alert {
return []*types.Alert{{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": model.LabelValue(alertname)},
Annotations: model.LabelSet{"summary": model.LabelValue("summary for " + alertname)},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Minute),
},
}}
}
func newTestContext() context.Context {
return notify.WithGroupKey(context.Background(), "test-receiver")
}
// captureServer starts an httptest server that decodes the posted Google Chat
// Message into got and replies 200.
func captureServer(t *testing.T, got *Message) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got != nil {
_ = json.NewDecoder(r.Body).Decode(got)
}
w.WriteHeader(http.StatusOK)
}))
}
func TestGoogleChatSend(t *testing.T) {
var got Message
server := captureServer(t, &got)
defer server.Close()
n := newTestNotifier(t, server.URL, `[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}`, "")
retry, err := n.Notify(newTestContext(), newTestAlerts("TestAlert")...)
require.NoError(t, err)
require.False(t, retry)
require.Contains(t, got.Text, "FIRING")
require.Contains(t, got.Text, "TestAlert")
}
func TestGoogleChatTitleAndBody(t *testing.T) {
var got Message
server := captureServer(t, &got)
defer server.Close()
n := newTestNotifier(t, server.URL, "TITLE", "BODY")
retry, err := n.Notify(newTestContext(), newTestAlerts("TestAlert")...)
require.NoError(t, err)
require.False(t, retry)
// Title is the plain summary + card header; body lives in a card widget.
require.Equal(t, "TITLE", got.Text)
require.Equal(t, "TITLE", got.CardsV2[0].Card.Header.Title)
require.Contains(t, cardBody(t, got), "BODY")
}
// cardBody concatenates the text of all textParagraph widgets in the message.
func cardBody(t *testing.T, m Message) string {
t.Helper()
require.NotEmpty(t, m.CardsV2)
var b strings.Builder
for _, s := range m.CardsV2[0].Card.Sections {
for _, w := range s.Widgets {
if w.TextParagraph != nil {
b.WriteString(w.TextParagraph.Text)
b.WriteByte('\n')
}
}
}
return b.String()
}
// cardButtons returns all buttons across every buttonList widget in the card.
func cardButtons(t *testing.T, m Message) []button {
t.Helper()
require.NotEmpty(t, m.CardsV2)
var buttons []button
for _, s := range m.CardsV2[0].Card.Sections {
for _, w := range s.Widgets {
if w.ButtonList != nil {
buttons = append(buttons, w.ButtonList.Buttons...)
}
}
}
return buttons
}
// cardSectionCount returns the number of sections in the card.
func cardSectionCount(t *testing.T, m Message) int {
t.Helper()
require.NotEmpty(t, m.CardsV2)
return len(m.CardsV2[0].Card.Sections)
}
func TestGoogleChatNilHTTPConfig(t *testing.T) {
// A nil HTTPConfig must fail cleanly, not panic on the *conf.HTTPConfig deref.
tmpl := test.CreateTmpl(t)
n, err := New(&alertmanagertypes.GoogleChatReceiverConfig{
HTTPConfig: nil,
WebhookURL: secretURLFromString(t, "https://chat.googleapis.com/v1/spaces/test/messages"),
}, tmpl, slog.New(slog.DiscardHandler), newTestTemplater(tmpl))
require.Error(t, err)
require.Nil(t, n)
}
func TestGoogleChatRetryCodes(t *testing.T) {
tmpl := test.CreateTmpl(t)
n, err := New(&alertmanagertypes.GoogleChatReceiverConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
WebhookURL: secretURLFromString(t, "https://chat.googleapis.com/v1/spaces/test/messages"),
}, tmpl, slog.New(slog.DiscardHandler), newTestTemplater(tmpl))
require.NoError(t, err)
cases := []struct {
code int
retry bool
}{
{http.StatusOK, false},
{http.StatusBadRequest, false}, // 400: malformed payload, permanent
{http.StatusTooManyRequests, true}, // 429: rate limited, retry (our RetryCodes)
{http.StatusInternalServerError, true}, // 5xx: retry
{http.StatusServiceUnavailable, true},
}
for _, c := range cases {
t.Run(http.StatusText(c.code), func(t *testing.T) {
actual, _ := n.retrier.Check(c.code, nil)
require.Equal(t, c.retry, actual, "retry mismatch on status %d", c.code)
})
}
}
func TestGoogleChatRedactedURL(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
ctx = notify.WithGroupKey(ctx, "test-receiver")
tmpl := test.CreateTmpl(t)
n, err := New(&alertmanagertypes.GoogleChatReceiverConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
WebhookURL: &config.SecretURL{URL: u},
Title: "alert", // non-empty so it reaches the POST (not the empty-text guard)
}, tmpl, slog.New(slog.DiscardHandler), newTestTemplater(tmpl))
require.NoError(t, err)
test.AssertNotifyLeaksNoSecret(ctx, t, n, u.String())
}
func TestTruncateToByteLimit(t *testing.T) {
cases := []struct {
name string
in string
max int
wantLen int // upper bound on byte length of result
wantHas string // substring the result must contain (or "")
}{
{"under limit passthrough", "hello", 100, 5, "hello"},
{"over limit trims with ellipsis", strings.Repeat("a", 50), 10, 10, "..."},
{"exact limit passthrough", "hello", 5, 5, "hello"},
{"tiny limit", "hello", 2, 2, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := truncateToByteLimit(c.in, c.max)
require.LessOrEqual(t, len(got), c.wantLen)
if c.wantHas != "" {
require.Contains(t, got, c.wantHas)
}
})
}
}
func TestGoogleChatMessageSizeLimit(t *testing.T) {
var bodyLen int
var valid bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
bodyLen = len(raw)
valid = json.Valid(raw)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Huge plain-ASCII title so one-time truncation lands deterministically under the limit.
n := newTestNotifier(t, server.URL, strings.Repeat("A", 40000), "")
retry, err := n.Notify(newTestContext(), newTestAlerts("Big")...)
require.NoError(t, err)
require.False(t, retry)
require.True(t, valid, "posted body must be valid JSON")
require.LessOrEqual(t, bodyLen, maxMessageBytes, "posted body must be within the size limit")
}
func TestGoogleChatThreading(t *testing.T) {
var query url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query = r.URL.Query()
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
cases := []struct{ name, groupKey string }{
{"rule a", "{ruleId=\"aaa\"}"},
{"rule b", "{ruleId=\"bbb\"}"},
}
seen := map[string]string{}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n := newTestNotifier(t, server.URL, "T", "")
ctx := notify.WithGroupKey(context.Background(), c.groupKey)
_, err := n.Notify(ctx, newTestAlerts("X")...)
require.NoError(t, err)
require.Equal(t, "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD", query.Get("messageReplyOption"))
threadKey := query.Get("threadKey")
require.Equal(t, notify.Key(c.groupKey).Hash(), threadKey, "threadKey must be the group key hash")
seen[c.name] = threadKey
})
}
require.NotEqual(t, seen["rule a"], seen["rule b"], "distinct group keys must yield distinct threadKeys")
}
func TestGoogleChatCustomTemplateMarkdown(t *testing.T) {
var got Message
server := captureServer(t, &got)
defer server.Close()
// Custom body template (standard markdown) supplied via annotation → the
// !IsDefaultBody path must convert it to Google Chat dialect.
alerts := []*types.Alert{{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "X"},
Annotations: model.LabelSet{ruletypes.AnnotationBodyTemplate: "**bold** and [link](https://x)"},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Minute),
},
}}
n := newTestNotifier(t, server.URL, "Alert", "default body")
_, err := n.Notify(newTestContext(), alerts...)
require.NoError(t, err)
// Custom body is standard markdown → converted to card HTML.
body := cardBody(t, got)
require.Contains(t, body, "<strong>bold</strong>", "** should convert to HTML bold")
require.Contains(t, body, `<a href="https://x">link</a>`, "[t](u) should convert to an HTML link")
}
func TestGoogleChatSerializedSizeUnderLimit(t *testing.T) {
var bodyLen int
var valid bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
bodyLen = len(raw)
valid = json.Valid(raw)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Escaping-dense payload: <, >, & and newlines each expand under JSON
// encoding, so this is the shape that would push the serialized body over
// the limit if we measured the text bytes instead of the marshaled buffer.
dense := strings.Repeat("<https://example.com/a?x=1&y=2|link>\n", 2000)
n := newTestNotifier(t, server.URL, dense, "")
_, err := n.Notify(newTestContext(), newTestAlerts("Dense")...)
require.NoError(t, err)
require.True(t, valid, "posted body must be valid JSON")
require.LessOrEqual(t, bodyLen, maxMessageBytes, "serialized body must be within the limit")
}
func TestGoogleChatEmptyText(t *testing.T) {
server := captureServer(t, nil)
defer server.Close()
// Empty title and body templates → must not POST; fail non-retryably.
n := newTestNotifier(t, server.URL, "", "")
retry, err := n.Notify(newTestContext(), newTestAlerts("X")...)
require.Error(t, err)
require.False(t, retry)
}
func TestGoogleChatLinkButtons(t *testing.T) {
cases := []struct {
name string
labels model.LabelSet
annotations model.LabelSet
wantButtons map[string]string
}{
{
name: "all links present",
labels: model.LabelSet{
"alertname": "X",
ruletypes.LabelRuleSource: "https://signoz.example/alerts/1",
},
annotations: model.LabelSet{
ruletypes.AnnotationRelatedLogs: "https://signoz.example/logs",
ruletypes.AnnotationRelatedTraces: "https://signoz.example/traces",
},
wantButtons: map[string]string{
"Open in SigNoz": "https://signoz.example/alerts/1",
"View Related Logs": "https://signoz.example/logs",
"View Related Traces": "https://signoz.example/traces",
},
},
{
name: "no links → no buttons",
labels: model.LabelSet{"alertname": "X"},
annotations: model.LabelSet{"summary": "s"},
wantButtons: map[string]string{},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var got Message
server := captureServer(t, &got)
defer server.Close()
alerts := []*types.Alert{{Alert: model.Alert{
Labels: c.labels,
Annotations: c.annotations,
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Minute),
}}}
// Non-empty body so the alert section (which carries related buttons) exists.
n := newTestNotifier(t, server.URL, "T", "an alert")
_, err := n.Notify(newTestContext(), alerts...)
require.NoError(t, err)
buttons := cardButtons(t, got)
require.Len(t, buttons, len(c.wantButtons))
for _, b := range buttons {
require.Equal(t, c.wantButtons[b.Text], b.OnClick.OpenLink.URL, "button %q", b.Text)
}
})
}
}
func TestGoogleChatMultiAlertSections(t *testing.T) {
var got Message
server := captureServer(t, &got)
defer server.Close()
// A per-alert custom body template yields one card section per alert, each
// with that alert's own related-link buttons, plus one shared SigNoz button.
mkAlert := func(pod, logs string) *types.Alert {
return &types.Alert{Alert: model.Alert{
Labels: model.LabelSet{
"alertname": "X",
"pod": model.LabelValue(pod),
ruletypes.LabelRuleSource: "https://signoz.example/alerts/1",
},
Annotations: model.LabelSet{
ruletypes.AnnotationBodyTemplate: "an alert fired",
ruletypes.AnnotationRelatedLogs: model.LabelValue(logs),
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Minute),
}}
}
alerts := []*types.Alert{
mkAlert("pod-1", "https://signoz.example/logs?pod=pod-1"),
mkAlert("pod-2", "https://signoz.example/logs?pod=pod-2"),
}
n := newTestNotifier(t, server.URL, "T", "default body")
_, err := n.Notify(newTestContext(), alerts...)
require.NoError(t, err)
// banner + one section per alert + shared SigNoz footer.
require.Equal(t, 4, cardSectionCount(t, got))
sigNoz := 0
logsURLs := map[string]bool{}
for _, b := range cardButtons(t, got) {
switch b.Text {
case "Open in SigNoz":
sigNoz++
case "View Related Logs":
logsURLs[b.OnClick.OpenLink.URL] = true
}
}
require.Equal(t, 1, sigNoz, "SigNoz button must appear once (shared, per-rule)")
require.True(t, logsURLs["https://signoz.example/logs?pod=pod-1"], "pod-1's logs button")
require.True(t, logsURLs["https://signoz.example/logs?pod=pod-2"], "pod-2's logs button")
}
func TestGoogleChatDefaultBodyGrouped(t *testing.T) {
var got Message
server := captureServer(t, &got)
defer server.Close()
// Default body template (no per-alert annotation): the templater combines all
// grouped alerts into ONE section, carrying only the first alert's buttons.
mkAlert := func(pod string) *types.Alert {
return &types.Alert{Alert: model.Alert{
Labels: model.LabelSet{
"alertname": "X",
"pod": model.LabelValue(pod),
ruletypes.LabelRuleSource: "https://signoz.example/alerts/1",
},
Annotations: model.LabelSet{
ruletypes.AnnotationRelatedLogs: model.LabelValue("https://signoz.example/logs?pod=" + pod),
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Minute),
}}
}
alerts := []*types.Alert{mkAlert("pod-1"), mkAlert("pod-2")}
n := newTestNotifier(t, server.URL, "T", "{{ range .Alerts }}pod {{ .Labels.pod }} {{ end }}")
_, err := n.Notify(newTestContext(), alerts...)
require.NoError(t, err)
// banner + one combined alert section + shared SigNoz footer = 3 sections.
require.Equal(t, 3, cardSectionCount(t, got))
body := cardBody(t, got)
require.Contains(t, body, "pod-1")
require.Contains(t, body, "pod-2", "default template combines all alerts into one section")
logs := 0
for _, b := range cardButtons(t, got) {
if b.Text == "View Related Logs" {
logs++
}
}
require.Equal(t, 1, logs, "default path surfaces only the first alert's related buttons")
}
func TestGoogleChatSectionCap(t *testing.T) {
var got Message
server := captureServer(t, &got)
defer server.Close()
// 35 grouped alerts (custom body → per-alert bodies) exceed the 30 cap.
const total = 35
alerts := make([]*types.Alert, 0, total)
for range total {
alerts = append(alerts, &types.Alert{Alert: model.Alert{
Labels: model.LabelSet{
"alertname": "X",
ruletypes.LabelRuleSource: "https://signoz.example/alerts/1",
},
Annotations: model.LabelSet{ruletypes.AnnotationBodyTemplate: "an alert fired"},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Minute),
}})
}
n := newTestNotifier(t, server.URL, "T", "default body")
_, err := n.Notify(newTestContext(), alerts...)
require.NoError(t, err)
// banner + 30 alert sections + "+N more" note + shared SigNoz footer.
require.Equal(t, 1+maxAlertSections+1+1, cardSectionCount(t, got))
body := cardBody(t, got)
require.Contains(t, body, "5 more alerts", "overflow note must state the dropped count")
// The SigNoz footer must survive after the note (last section).
sigNoz := 0
for _, b := range cardButtons(t, got) {
if b.Text == "Open in SigNoz" {
sigNoz++
}
}
require.Equal(t, 1, sigNoz, "SigNoz footer must be present despite the cap")
}
func TestGoogleChatStatusLine(t *testing.T) {
cases := []struct {
name string
resolved bool
want string
}{
{"firing", false, "🔴 FIRING"},
{"resolved", true, "🟢 RESOLVED"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var got Message
server := captureServer(t, &got)
defer server.Close()
endsAt := time.Now().Add(time.Minute)
if c.resolved {
endsAt = time.Now().Add(-time.Minute) // EndsAt in the past → resolved
}
alerts := []*types.Alert{{Alert: model.Alert{
Labels: model.LabelSet{"alertname": "X"},
StartsAt: time.Now().Add(-2 * time.Minute),
EndsAt: endsAt,
}}}
n := newTestNotifier(t, server.URL, "T", "")
_, err := n.Notify(newTestContext(), alerts...)
require.NoError(t, err)
require.Contains(t, cardBody(t, got), c.want)
})
}
}

View File

@@ -1,95 +0,0 @@
package googlechat
import (
"log/slog"
"net/http"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
)
const (
Integration = "googlechat"
// maxMessageBytes is the Google Chat message payload limit.
// https://developers.google.com/chat/api/guides/message-formats/basic#maximum_size
maxMessageBytes = 32000
// maxAlertSections caps per-alert sections in a grouped card. Google Chat
// allows 100 widgets per card and silently drops the section that crosses
// that count and every section after it, so we cap well under the limit
// (~63 widgets worst case) and add a "+N more" note for the overflow.
// https://developers.google.com/workspace/chat/api/reference/rpc/google.apps.card.v1#card
maxAlertSections = 30
)
// Notifier implements notify.Notifier for Google Chat.
type Notifier struct {
conf *alertmanagertypes.GoogleChatReceiverConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
}
// Message is the Google Chat webhook payload. A message carries a short text
// summary (space list / push preview) and a rich card.
type Message struct {
Text string `json:"text,omitempty"`
CardsV2 []cardWithID `json:"cardsV2,omitempty"`
}
type cardWithID struct {
CardID string `json:"cardId"`
Card card `json:"card"`
}
type card struct {
Header *cardHeader `json:"header,omitempty"`
Sections []cardSection `json:"sections"`
}
type cardHeader struct {
Title string `json:"title,omitempty"`
Subtitle string `json:"subtitle,omitempty"`
}
type cardSection struct {
Header string `json:"header,omitempty"`
Widgets []widget `json:"widgets"`
}
// widget is a one-of: exactly one field is set per instance.
type widget struct {
TextParagraph *textParagraph `json:"textParagraph,omitempty"`
ButtonList *buttonList `json:"buttonList,omitempty"`
}
type textParagraph struct {
Text string `json:"text"`
}
type buttonList struct {
Buttons []button `json:"buttons"`
}
type button struct {
Text string `json:"text"`
OnClick onClick `json:"onClick"`
}
type onClick struct {
OpenLink openLink `json:"openLink"`
}
type openLink struct {
URL string `json:"url"`
}
// content holds the rendered title and the HTML bodies for a Google Chat message.
// bodies is per-alert (positionally aligned with the alerts slice) for a custom
// body template, but a single combined entry for the default template.
type content struct {
title string
bodies []string
}

View File

@@ -5,7 +5,6 @@ import (
"slices"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/email"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/googlechat"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/msteamsv2"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty"
@@ -25,7 +24,6 @@ var customNotifierIntegrations = []string{
opsgenie.Integration,
slack.Integration,
msteamsv2.Integration,
googlechat.Integration,
}
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
@@ -76,11 +74,6 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l, templater)
})
}
for i, c := range nc.GoogleChatConfigs {
add(googlechat.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
return googlechat.New(c, tmpl, l, templater)
})
}
if errs.Len() > 0 {
return nil, &errs

View File

@@ -1,956 +0,0 @@
{
"schemaVersion": "v6",
"image": "/assets/Logos/gcp-cloud-storage",
"name": "",
"generateName": true,
"tags": [
{
"key": "tag",
"value": "observability"
}
],
"spec": {
"display": {
"name": "GCP Cloud Storage Overview",
"description": "Dashboard for GCP Cloud Storage overview"
},
"variables": [
{
"kind": "ListVariable",
"spec": {
"display": {
"name": "project_id",
"description": "GCP Project ID"
},
"allowAllValue": false,
"allowMultiple": false,
"customAllValue": "",
"capturingRegexp": "",
"sort": "none",
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {
"name": "project_id",
"signal": "metrics"
}
},
"name": "project_id"
}
},
{
"kind": "ListVariable",
"spec": {
"display": {
"name": "bucket_name",
"description": "GCS bucket name"
},
"allowAllValue": true,
"allowMultiple": true,
"customAllValue": "",
"capturingRegexp": "",
"sort": "none",
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {
"name": "bucket_name",
"signal": "metrics"
}
},
"name": "bucket_name"
}
}
],
"panels": {
"1f0a4d5b-6c2e-4a71-8b3d-9e5c07a41d20": {
"kind": "Panel",
"spec": {
"display": {
"name": "Bucket size",
"description": "Total size of all objects in the bucket, grouped by storage class."
},
"plugin": {
"kind": "signoz/TablePanel",
"spec": {
"visualization": {
"timePreference": "global_time"
},
"formatting": {
"columnUnits": {
"A": "By"
},
"decimalPrecision": "2"
},
"thresholds": null
}
},
"queries": [
{
"kind": "scalar",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/storage/v2/total_bytes",
"temporality": "",
"timeAggregation": "latest",
"spaceAggregation": "sum",
"reduceTo": "last"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "storage_class",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "Size"
}
}
}
}
],
"links": []
}
},
"29ed9824-21db-44c3-9537-12be458f5c20": {
"kind": "Panel",
"spec": {
"display": {
"name": "Total object bytes",
"description": "Total size of all objects in the bucket, grouped by storage class and type."
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "By",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "right",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/storage/v2/total_bytes",
"temporality": "",
"timeAggregation": "latest",
"spaceAggregation": "sum",
"reduceTo": "avg"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "storage_class",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "type",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{bucket_name}}/{{storage_class}}/{{type}}"
}
}
}
}
],
"links": []
}
},
"cbc53174-5527-4bce-b32d-317403849e48": {
"kind": "Panel",
"spec": {
"display": {
"name": "Total object count",
"description": "Total number of objects and multipart-uploads per bucket, grouped by storage class and type, where type can be live-object, noncurrent-object, soft-deleted-object or multipart-upload."
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "{count}",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "right",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/storage/v2/total_count",
"temporality": "",
"timeAggregation": "latest",
"spaceAggregation": "sum",
"reduceTo": "avg"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "storage_class",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "type",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{bucket_name}}/{{storage_class}}/{{type}}"
}
}
}
}
],
"links": []
}
},
"76e70ce5-47e4-4b5b-9715-49fa21c58b0c": {
"kind": "Panel",
"spec": {
"display": {
"name": "Deleted bytes",
"description": "Rate of deleted bytes per bucket, grouped by storage class."
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "By/s",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "right",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/storage/v2/deleted_bytes",
"temporality": "",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "storage_class",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{bucket_name}}/{{storage_class}}"
}
}
}
}
],
"links": []
}
},
"c5af2813-34ec-41a9-8d53-c9acd67f569f": {
"kind": "Panel",
"spec": {
"display": {
"name": "Request count",
"description": "Rate of API calls, grouped by the API method name and response code."
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "{count}/s",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "right",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/api/request_count",
"temporality": "",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "method",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "response_code",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{bucket_name}}/{{method}} - {{response_code}}"
}
}
}
}
],
"links": []
}
},
"70333210-68c3-4387-a6b7-afa2459d27dd": {
"kind": "Panel",
"spec": {
"display": {
"name": "Authentication count",
"description": "Rate of HMAC/RSA signed requests, grouped by authentication method, API method name and response code."
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "{count}/s",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "right",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/authn/authentication_count",
"temporality": "",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "authentication_method",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "method",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "response_code",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{bucket_name}}/{{authentication_method}}/{{method}} - {{response_code}}"
}
}
}
}
],
"links": []
}
},
"7a98084c-2cd3-4146-a831-f5382162ffe5": {
"kind": "Panel",
"spec": {
"display": {
"name": "Received bytes",
"description": "Rate of bytes received over the network, grouped by the API method name and response code."
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "By/s",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "right",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/network/received_bytes_count",
"temporality": "",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "method",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "response_code",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{bucket_name}}/{{method}} - {{response_code}}"
}
}
}
}
],
"links": []
}
},
"7138bf98-b41a-46d8-90ef-8e7470819cdf": {
"kind": "Panel",
"spec": {
"display": {
"name": "Sent bytes",
"description": "Rate of bytes sent over the network, grouped by the API method name and response code."
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "By/s",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "right",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/network/sent_bytes_count",
"temporality": "",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "method",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "response_code",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{bucket_name}}/{{method}} - {{response_code}}"
}
}
}
}
],
"links": []
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/1f0a4d5b-6c2e-4a71-8b3d-9e5c07a41d20"
}
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": {
"title": "Storage",
"collapse": {
"open": true
}
},
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/29ed9824-21db-44c3-9537-12be458f5c20"
}
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/cbc53174-5527-4bce-b32d-317403849e48"
}
},
{
"x": 0,
"y": 6,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/76e70ce5-47e4-4b5b-9715-49fa21c58b0c"
}
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": {
"title": "API & Authentication",
"collapse": {
"open": true
}
},
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/c5af2813-34ec-41a9-8d53-c9acd67f569f"
}
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/70333210-68c3-4387-a6b7-afa2459d27dd"
}
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": {
"title": "Network",
"collapse": {
"open": true
}
},
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/7a98084c-2cd3-4146-a831-f5382162ffe5"
}
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/7138bf98-b41a-46d8-90ef-8e7470819cdf"
}
}
]
}
}
],
"duration": "",
"refreshInterval": "",
"links": []
}
}

Some files were not shown because too many files have changed in this diff Show More