Compare commits

...

20 Commits

Author SHA1 Message Date
Nikhil Mantri
245ef858f9 Merge branch 'main' into infraM/google_chat_integration_backend 2026-07-30 16:18:06 +05:30
nikhilmantri0902
d95a2164d9 test(googlechat): cover default-body grouped alerts (single combined section) 2026-07-30 16:15:47 +05:30
nikhilmantri0902
de48f99455 docs(googlechat): clarify content.bodies default-vs-custom alignment 2026-07-30 16:05:05 +05:30
Swapnil Nakade
bad3850117 feat: adding cloud storage service for GCP integration (#12341)
* feat: adding gcp memorystore redis service

* refactor: updating dashboard title

* refactor: extending width of uptime gauge panel

* refactor: updating cpu utilization panel

* refactor: updating dashboard panel to use rate function instead of hack

* feat: adding compute engine service

* refactor: updating dashboard panels to use rate aggregation

* fix: correct typo and unit in compute engine dashboard

* refactor: migrating dashboard to v6

* feat: adding gcp gke service

* chore: generating openapi spec

* refactor: updating icon in dashboard JSON

* feat: adding gcp cloud storage service

* refactor: updating dashboard and integration config

* refactor: updating cloud storage icon
2026-07-30 10:09:06 +00:00
nikhilmantri0902
f9a770c50b feat(googlechat): render grouped alerts as per-alert card sections
Render each alert in its own card section with its own View Related
Logs/Traces buttons, and move Open in SigNoz to a shared footer button
(it's per-rule). Matches the slack/msteams per-alert layout; the default
template still produces one combined section.

Cap sections at 30 with a "+N more alerts" note: Google Chat allows 100
widgets per card and silently drops the section that crosses that count
and every section after it, so capping keeps the footer and trailing
alerts from vanishing. Cap is applied before the size guard so the trim
loop only touches rendered bodies.

Generalize the 32 KB size guard to trim the longest body, then the title.
2026-07-30 15:36:58 +05:30
Swapnil Nakade
5a8ca9573c feat: adding gcp gke service (#12319)
* feat: adding gcp memorystore redis service

* refactor: updating dashboard title

* refactor: extending width of uptime gauge panel

* refactor: updating cpu utilization panel

* refactor: updating dashboard panel to use rate function instead of hack

* feat: adding compute engine service

* refactor: updating dashboard panels to use rate aggregation

* fix: correct typo and unit in compute engine dashboard

* refactor: migrating dashboard to v6

* feat: adding gcp gke service

* chore: generating openapi spec

* refactor: updating icon in dashboard JSON
2026-07-30 08:48:34 +00:00
nikhilmantri0902
6136eb3258 fix(googlechat): guard nil HTTPConfig in New with a clean error 2026-07-30 13:47:26 +05:30
Vikrant Gupta
e62bfb4a4c feat(authz): add frontend support for telemetry resources (#12292)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
* feat(authz): expose telemetry resources in generated frontend permissions

* feat(authz): add support for telemetry resources on roles page (#12296)

* feat(create-edit-role): add support for telemetry resource

* test(create-edit-role): add tests for telemetry resource

* fix(pr): address comments

* fix(authz): app routing and sidebar fixes when no built-in role (#12307)

* fix(sidebar): render fallback when user preferences fails on sidenav

Otherwise, it fails and don't show any menu that can be pinned

* feat(app-routing): bypass unauthorized when authz is enabled

Those pages should render to allow us to handle authz
directly in each page/component

* fix(private): drop feature flag gate

* fix(authz): backfill managed role transaction groups for meter metrics

* feat(authz): resolve conflicts

* refactor(telemetry-selector): couple fixes after feedback on ux/ui (#12316)

* fix(pr): address more ux feedback

---------

Co-authored-by: Vinicius Lourenço <12551007+H4ad@users.noreply.github.com>
Co-authored-by: Vinícius Lourenço <vinicius@signoz.io>
2026-07-30 07:18:31 +00:00
Nikhil Mantri
dd64bdb5cc Merge branch 'main' into infraM/google_chat_integration_backend 2026-07-30 12:04:57 +05:30
nikhilmantri0902
589e33eb39 chore: truncation logic optimized for both body and title 2026-07-29 19:39:19 +05:30
nikhilmantri0902
91064d0b63 chore: cardsv2 scaffolding 2026-07-29 19:26:57 +05:30
Nikhil Mantri
0500d14054 Merge branch 'main' into infraM/google_chat_integration_backend 2026-07-29 15:38:09 +05:30
nikhilmantri0902
a9051c4e9e chore: error on empty title body with non-retryable error and give 429 retryable error due to limit 2026-07-29 01:34:03 +05:30
nikhilmantri0902
17f4b4af91 chore: added one more test 2026-07-28 18:02:14 +05:30
nikhilmantri0902
759d2f6db5 chore: comment correction 2026-07-28 17:38:22 +05:30
nikhilmantri0902
b9156ac4b0 chore: added utf 8 sanitization and capping the limit 2026-07-28 17:35:32 +05:30
nikhilmantri0902
1300dbc1c2 chore: adding processing logic for makrdown to google chat format 2026-07-28 17:09:17 +05:30
nikhilmantri0902
0f657b946e chore: added tests 2026-07-28 16:49:24 +05:30
nikhilmantri0902
376c1d9fcc chore: added content extraction and formatting for google chat content 2026-07-28 16:02:32 +05:30
nikhilmantri0902
e90783d3ed chore: added googlechat notify function 2026-07-28 15:14:27 +05:30
51 changed files with 5884 additions and 219 deletions

View File

@@ -93,9 +93,13 @@ 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.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,
}
allowedTypes := map[string]bool{}

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ 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';
@@ -178,6 +179,7 @@ function createMockAppContext(
isFetchingHosts: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
isFetchingUserPreferences: false,
userFetchError: null,
activeLicenseFetchError: null,
hostsFetchError: null,
@@ -198,24 +200,39 @@ 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 renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
const {
initialRoute = ROUTES.HOME,
appContext = {},
isCloudUser = true,
} = options;
function buildPrivateRouteTree(
options: RenderPrivateRouteOptions,
): ReactElement {
const { initialRoute = ROUTES.HOME, appContext = {} } = options;
mockIsCloudUser = isCloudUser;
const contextValue = createMockAppContext({
...appContext,
});
const contextValue = createMockAppContext(appContext);
render(
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialRoute]}>
<AppContext.Provider value={contextValue}>
@@ -229,10 +246,15 @@ function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
</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
@@ -1432,6 +1454,25 @@ 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', () => {
@@ -1477,6 +1518,174 @@ 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,6 +2816,8 @@ 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" 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>
<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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 958 B

View File

@@ -1 +1 @@
<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>
<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>

Before

Width:  |  Height:  |  Size: 988 B

After

Width:  |  Height:  |  Size: 941 B

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,462 @@
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

@@ -0,0 +1,33 @@
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,6 +7,7 @@ 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';
@@ -39,10 +40,13 @@ 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(() => {
@@ -121,11 +125,25 @@ function ActionToggle({
<Divider />
<ItemInputSelector
placeholder={getResourcePanel(resource).selectorPlaceholder}
placeholder={panel.selectorPlaceholder}
selectedIds={selectedIds}
onChange={onSelectedIdsChange}
docsAnchor={getResourcePanel(resource).docsAnchor}
testId={selectorTestId}
docsAnchor={panel.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,9 +4,10 @@
gap: var(--spacing-4);
background: var(--l1-background);
border: 1px solid var(--l1-border);
border-radius: 4px;
border-radius: var(--radius-2);
padding: var(--spacing-4);
--input-prefix-padding: var(--spacing-2);
--input-suffix-padding: var(--spacing-2);
}
@@ -41,6 +42,11 @@
overflow-y: auto;
}
.itemInputSelectorBadge {
--badge-border-radius: 4px;
--badge-hover-background: var(--l2-background) !important;
}
.itemInputSelectorInfoIcon {
flex-shrink: 0;
color: var(--l2-foreground);
@@ -51,55 +57,7 @@
}
}
.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,5 +1,6 @@
import { useCallback, useRef, useState } from 'react';
import { Info, Plus, X } from '@signozhq/icons';
import { Info, Plus } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
@@ -15,8 +16,10 @@ 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[] {
@@ -30,11 +33,13 @@ function ItemInputSelector({
placeholder,
selectedIds,
onChange,
testId,
docsAnchor = 'role',
hasError = false,
prefixElement,
}: ItemInputSelectorProps): JSX.Element {
const [inputValue, setInputValue] = useState('');
const footerRef = useRef<HTMLDivElement>(null);
const badgesRef = useRef<HTMLDivElement>(null);
const addValues = useCallback(
(input: string): void => {
@@ -87,26 +92,24 @@ function ItemInputSelector({
[selectedIds, onChange],
);
const handleBadgeKeyDown = useCallback(
(
e: React.KeyboardEvent<HTMLButtonElement>,
itemId: string,
index: number,
): void => {
if (e.key !== 'Enter' && e.key !== ' ') {
return;
}
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) {
return;
}
const targetIndex = index > 0 ? index - 1 : 0;
requestAnimationFrame(() => {
const buttons = footerRef.current?.querySelectorAll('button');
const targetButton = buttons?.[targetIndex] as
| HTMLButtonElement
| undefined;
targetButton?.focus();
const buttons = badgesRef.current?.querySelectorAll('button');
buttons?.[targetIndex]?.focus();
});
},
[handleRemove],
@@ -120,7 +123,7 @@ function ItemInputSelector({
styles.itemInputSelector,
showError ? styles.itemInputSelectorError : '',
)}
data-testid="item-input-selector"
data-testid={`item-input-selector-${testId}`}
>
<Input
placeholder={placeholder}
@@ -128,14 +131,15 @@ function ItemInputSelector({
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
data-testid="item-input-selector-input"
data-testid={`item-input-selector-input-${testId}`}
prefix={prefixElement}
suffix={
<Button
variant="solid"
size="sm"
onClick={handleAddClick}
disabled={!inputValue.trim()}
data-testid="item-input-selector-add-btn"
data-testid={`item-input-selector-add-btn-${testId}`}
>
<Plus size={14} />
Add
@@ -144,28 +148,22 @@ function ItemInputSelector({
/>
{selectedIds.length > 0 ? (
<div ref={footerRef} className={styles.itemInputSelectorFooter}>
<div className={styles.itemInputSelectorBadges}>
<div className={styles.itemInputSelectorFooter}>
<div ref={badgesRef} className={styles.itemInputSelectorBadges}>
{selectedIds.map((id, index) => (
<span key={id} className={styles.itemInputSelectorBadge} title={id}>
<Typography
as="span"
size="small"
truncate={1}
className={styles.itemInputSelectorBadgeLabel}
>
<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}>
{id}
</Typography>
<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>
</Badge>
))}
</div>
<TooltipSimple

View File

@@ -0,0 +1,50 @@
// 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

@@ -0,0 +1,52 @@
.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

@@ -0,0 +1,190 @@
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

@@ -0,0 +1,139 @@
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,9 +76,10 @@ function createGrantPermissionAsReadonly(
return {
label: 'readonly',
insertText: resources
.filter(
(r) => r.allowedVerbs.includes('read') || r.allowedVerbs.includes('list'),
)
.filter((r) => {
const verbs: readonly string[] = r.allowedVerbs;
return verbs.includes('read') || verbs.includes('list');
})
.flatMap((r) => {
const verbs = r.allowedVerbs.filter((v) => v === 'read' || v === 'list');
return verbs.map(
@@ -103,7 +104,12 @@ function buildResourceSnippets(): SnippetDef[] {
for (const resource of resources) {
const { kind, type, allowedVerbs } = resource;
snippets.push(createGrantAllPermissionSnippet(kind, allowedVerbs, type));
// 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));
}
for (const verb of allowedVerbs) {
snippets.push(createGrantPermissionToVerbAndKind(kind, verb, type));

View File

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

View File

@@ -1,4 +1,12 @@
import { Bot, Key, Shield } from '@signozhq/icons';
import {
Bot,
ChartLine,
DraftingCompass,
Gauge,
Key,
Logs,
Shield,
} from '@signozhq/icons';
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
import {
@@ -14,12 +22,15 @@ 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;
}
/**
@@ -50,6 +61,42 @@ 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,6 +108,7 @@ export function getAppContextMockState(
isFetchingHosts: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
isFetchingUserPreferences: false,
userFetchError: undefined,
activeLicenseFetchError: null,
hostsFetchError: undefined,

View File

@@ -144,6 +144,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
trialInfo,
isLoggedIn,
userPreferences,
isFetchingUserPreferences,
changelog,
toggleChangelogModal,
updateUserPreferenceInContext,
@@ -261,6 +262,11 @@ 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,
);
@@ -268,11 +274,6 @@ 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
@@ -282,9 +283,9 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
.filter((item): item is SidebarItem => item !== undefined);
}
// No preference, or empty array → use defaults
// No preference, or empty array, or error loading → use defaults
return defaultMoreMenuItems.filter((item) => item.isPinned);
}, [userPreferences]);
}, [isFetchingUserPreferences, userPreferences]);
const computedSecondaryMenuItems = useMemo(() => {
const shouldShowIntegrationsValue =
@@ -322,12 +323,16 @@ 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 && userPreferences !== null) {
if (!hasInitializedRef.current && isFetchingUserPreferences === false) {
setPinnedMenuItems(computedPinnedMenuItems);
setSecondaryMenuItems(computedSecondaryMenuItems);
hasInitializedRef.current = true;
}
}, [computedPinnedMenuItems, computedSecondaryMenuItems, userPreferences]);
}, [
computedPinnedMenuItems,
computedSecondaryMenuItems,
isFetchingUserPreferences,
]);
const isChatSupportEnabled = featureFlags?.find(
(flag) => flag.name === FeatureKeys.CHAT_SUPPORT,

View File

@@ -19,7 +19,7 @@ describe('PermissionDeniedCallout', () => {
it('renders multiple denied permissions', () => {
const deniedPermissions = [
buildPermission('read', buildObjectString('serviceaccount', '*')),
buildPermission('update', buildObjectString('role', 'admin')),
buildPermission('update', buildObjectString<'update'>('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('role', 'admin')),
buildPermission('update', buildObjectString<'update'>('role', 'admin')),
];
render(<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />);
expect(screen.getByText(/read:role:admin/)).toBeInTheDocument();

View File

@@ -35,6 +35,26 @@ 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'],
@@ -43,7 +63,7 @@ export default {
delete: ['metaresource', 'role', 'serviceaccount'],
detach: ['metaresource', 'role', 'serviceaccount'],
list: ['metaresource', 'role', 'serviceaccount'],
read: ['metaresource', 'role', 'serviceaccount'],
read: ['metaresource', 'role', 'serviceaccount', 'telemetryresource'],
update: ['metaresource', 'role', 'serviceaccount'],
},
},

View File

@@ -23,6 +23,40 @@ 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

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

View File

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

View File

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

View File

@@ -23,6 +23,11 @@ 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'],
@@ -46,11 +51,16 @@ 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', 'ANONYMOUS'],
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
SERVICE_MAP: ['ADMIN', 'EDITOR', 'VIEWER'],
ALL_CHANNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
INGESTION_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -75,13 +85,15 @@ 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', 'ANONYMOUS'],
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
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'],
UN_AUTHORIZED: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
// 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'],
USAGE_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
VERSION: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -95,12 +107,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', 'ANONYMOUS'],
ROLE_CREATE: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
ROLE_EDIT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLE_CREATE: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLE_EDIT: ['ADMIN', 'EDITOR', 'VIEWER'],
MEMBERS_SETTINGS: ['ADMIN'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -141,3 +153,34 @@ 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

@@ -0,0 +1,298 @@
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

@@ -0,0 +1,551 @@
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

@@ -0,0 +1,95 @@
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,6 +5,7 @@ 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"
@@ -24,6 +25,7 @@ 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) {
@@ -74,6 +76,11 @@ 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

@@ -0,0 +1,956 @@
{
"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": []
}
}

View File

@@ -0,0 +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>

After

Width:  |  Height:  |  Size: 958 B

View File

@@ -0,0 +1,70 @@
{
"id": "cloudstorage",
"title": "GCP Cloud Storage",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "storage.googleapis.com/storage/v2/total_bytes",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "storage.googleapis.com/storage/v2/total_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "storage.googleapis.com/storage/v2/deleted_bytes",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "storage.googleapis.com/api/request_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "storage.googleapis.com/network/received_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "storage.googleapis.com/network/sent_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "storage.googleapis.com/authn/authentication_count",
"unit": "Count",
"type": "Sum",
"description": ""
}
],
"logs": []
},
"telemetryCollectionStrategy": {
"gcp": {}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "GCP Cloud Storage Overview",
"description": "Overview of GCP Cloud Storage metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,3 @@
### Monitor GCP Cloud Storage with SigNoz
Collect key GCP Cloud Storage metrics and view them with an out of the box dashboard.

View File

@@ -0,0 +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>

After

Width:  |  Height:  |  Size: 941 B

View File

@@ -0,0 +1,124 @@
{
"id": "gke",
"title": "GCP Kubernetes Engine",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "kubernetes.io/container/cpu/limit_utilization",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/container/cpu/request_utilization",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/container/memory/limit_utilization",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/container/memory/request_utilization",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/container/restart_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "kubernetes.io/pod/latencies/pod_first_ready",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/pod/network/received_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "kubernetes.io/pod/network/sent_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "kubernetes.io/pod/volume/utilization",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/node/cpu/allocatable_utilization",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/node/memory/allocatable_utilization",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/node/ephemeral_storage/used_bytes",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/node/ephemeral_storage/allocatable_bytes",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/node/network/received_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "kubernetes.io/node/network/sent_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "kubernetes.io/node/status_condition",
"unit": "None",
"type": "Gauge",
"description": ""
}
],
"logs": []
},
"telemetryCollectionStrategy": {
"gcp": {}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "GCP Kubernetes Engine Overview",
"description": "Overview of GCP Kubernetes Engine metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,3 @@
### Monitor GCP Kubernetes Engine with SigNoz
Collect key GCP Kubernetes Engine metrics and view them with an out of the box dashboard.

View File

@@ -230,6 +230,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddTagRelationRankFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateDashboardsV1ToV2Factory(sqlstore, sqlschema, dashboardStore, tagModule),
sqlmigration.NewFillDashboardMeterSourceFactory(sqlstore, dashboardStore),
sqlmigration.NewUpdateRoleTransactionGroupsFactory(),
)
}

View File

@@ -0,0 +1,77 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type updateRoleTransactionGroups struct{}
func NewUpdateRoleTransactionGroupsFactory() factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("update_role_transaction_groups"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &updateRoleTransactionGroups{}, nil
},
)
}
func (migration *updateRoleTransactionGroups) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *updateRoleTransactionGroups) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
if err != nil {
return err
}
managedRoleGroups[roleName] = string(data)
}
for _, orgID := range orgIDs {
for roleName, data := range managedRoleGroups {
if _, err := tx.NewUpdate().
Model(new(roles)).
Set("transaction_groups = ?", data).
Where("org_id = ?", orgID).
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
Where("name = ?", roleName).
Exec(ctx); err != nil {
return err
}
}
}
return tx.Commit()
}
func (migration *updateRoleTransactionGroups) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -1,6 +1,10 @@
package alertmanagertypes
import (
"net/url"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/prometheus/alertmanager/config"
commoncfg "github.com/prometheus/common/config"
)
@@ -21,9 +25,9 @@ var DefaultGoogleChatReceiverConfig = GoogleChatReceiverConfig{
},
Title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
Text: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }}
*Summary:* {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }}
*Description:* {{ .Annotations.description }}{{ end }}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }}
**Summary:** {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }}
**Description:** {{ .Annotations.description }}{{ end }}
{{ end }}`,
}
@@ -32,3 +36,18 @@ func (c *GoogleChatReceiverConfig) UnmarshalYAML(unmarshal func(any) error) erro
type plain GoogleChatReceiverConfig
return unmarshal((*plain)(c))
}
// ValidateGoogleChatWebhookURL validates the Google Chat webhook URL format.
func ValidateGoogleChatWebhookURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid google chat webhook_url: %v", err)
}
if u.Scheme != "https" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url must use https")
}
if strings.ToLower(u.Hostname()) != "chat.googleapis.com" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url must use chat.googleapis.com")
}
return nil
}

View File

@@ -0,0 +1,44 @@
package alertmanagertypes
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestValidateGoogleChatWebhookURL(t *testing.T) {
cases := []struct {
name string
url string
wantErr bool
}{
{"valid", "https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t", false},
{"http scheme rejected", "http://chat.googleapis.com/v1/spaces/AAA/messages", true},
{"wrong host rejected", "https://example.com/v1/spaces/AAA/messages", true},
{"empty rejected", "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := ValidateGoogleChatWebhookURL(c.url)
if c.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestNewReceiverGoogleChatRejectsBadURL(t *testing.T) {
// http scheme
_, err := NewReceiver(`{"name":"gc","googlechat_configs":[{"webhook_url":"http://chat.googleapis.com/v1/spaces/x/messages"}]}`)
require.Error(t, err)
// wrong host
_, err = NewReceiver(`{"name":"gc","googlechat_configs":[{"webhook_url":"https://example.com/x"}]}`)
require.Error(t, err)
// missing webhook_url
_, err = NewReceiver(`{"name":"gc","googlechat_configs":[{"title":"x"}]}`)
require.Error(t, err)
}

View File

@@ -45,12 +45,23 @@ func NewReceiver(input string) (*Receiver, error) {
if err != nil {
return nil, err
}
if err := validateGoogleChatConfig(defaulted); err != nil {
return nil, err
}
receiver.GoogleChatConfigs[i] = defaulted
}
return receiver, nil
}
// validateGoogleChatConfig validates a Google Chat receiver configuration.
func validateGoogleChatConfig(cfg *GoogleChatReceiverConfig) error {
if cfg.WebhookURL == nil {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url is required")
}
return ValidateGoogleChatWebhookURL(cfg.WebhookURL.String())
}
func defaultedBaseReceiver(base *config.Receiver) (*config.Receiver, error) {
bytes, err := yaml.Marshal(base)
if err != nil {

View File

@@ -44,6 +44,8 @@ var (
GCPServiceCloudSQLPostgres = ServiceID{valuer.NewString("cloudsql_postgres")}
GCPServiceMemorystoreRedis = ServiceID{valuer.NewString("memorystore_redis")}
GCPServiceComputeEngine = ServiceID{valuer.NewString("computeengine")}
GCPServiceGKE = ServiceID{valuer.NewString("gke")}
GCPServiceCloudStorage = ServiceID{valuer.NewString("cloudstorage")}
)
func (ServiceID) Enum() []any {
@@ -78,6 +80,8 @@ func (ServiceID) Enum() []any {
GCPServiceCloudSQLPostgres,
GCPServiceMemorystoreRedis,
GCPServiceComputeEngine,
GCPServiceGKE,
GCPServiceCloudStorage,
}
}
@@ -118,6 +122,8 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
GCPServiceCloudSQLPostgres,
GCPServiceMemorystoreRedis,
GCPServiceComputeEngine,
GCPServiceGKE,
GCPServiceCloudStorage,
},
}