Compare commits
27 Commits
feat/alert
...
infraM/goo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
245ef858f9 | ||
|
|
d95a2164d9 | ||
|
|
de48f99455 | ||
|
|
bad3850117 | ||
|
|
f9a770c50b | ||
|
|
5a8ca9573c | ||
|
|
6136eb3258 | ||
|
|
e62bfb4a4c | ||
|
|
dd64bdb5cc | ||
|
|
51d5d3ea35 | ||
|
|
7eb3f7df32 | ||
|
|
5eb3b5e3e0 | ||
|
|
b88ee12cd5 | ||
|
|
6f3dd0b7ad | ||
|
|
d32d93cd39 | ||
|
|
e9a931788c | ||
|
|
589e33eb39 | ||
|
|
91064d0b63 | ||
|
|
0500d14054 | ||
|
|
a9051c4e9e | ||
|
|
17f4b4af91 | ||
|
|
759d2f6db5 | ||
|
|
b9156ac4b0 | ||
|
|
1300dbc1c2 | ||
|
|
0f657b946e | ||
|
|
376c1d9fcc | ||
|
|
e90783d3ed |
10
cmd/authz.go
@@ -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{}
|
||||
|
||||
@@ -1497,6 +1497,8 @@ components:
|
||||
- cloudsql_postgres
|
||||
- memorystore_redis
|
||||
- computeengine
|
||||
- gke
|
||||
- cloudstorage
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
|
||||
2
frontend/docs/assets/drawer-example.svg
Normal file
|
After Width: | Height: | Size: 139 KiB |
2
frontend/docs/assets/edit-example.svg
Normal file
|
After Width: | Height: | Size: 49 KiB |
2
frontend/docs/assets/list-example.svg
Normal file
|
After Width: | Height: | Size: 86 KiB |
2
frontend/docs/assets/quick-filters-example.svg
Normal file
|
After Width: | Height: | Size: 64 KiB |
136
frontend/docs/authz-guide.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# AuthZ Guide
|
||||
|
||||
How to structure a page so it works with the permission system.
|
||||
|
||||
We are migrating from the `ADMIN | EDITOR | VIEWER` roles to per-action checks. Instead of granting `VIEWER` and
|
||||
exposing everything, a user can now be granted access to a single resource, eg: `Logs` only.
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Core rules](#core-rules)
|
||||
- [Page patterns](#page-patterns)
|
||||
- [What "blocked" means](#what-blocked-means)
|
||||
- [Migration checklist](#migration-checklist)
|
||||
- [Testing](#testing)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Check whether the resource your page represents is supported: see
|
||||
[permissions.type.ts](../src/lib/authz/hooks/useAuthZ/permissions.type.ts) for the current resources and their allowed
|
||||
verbs.
|
||||
|
||||
**If the resource is not listed there, skip authz for now**. The backend does not enforce it yet, so any frontend
|
||||
check would be decorative. Revisit once the resource is generated into that file (it is auto-generated from the
|
||||
backend).
|
||||
|
||||
## Core rules
|
||||
|
||||
These hold for every page. The per-pattern sections below only add to them.
|
||||
|
||||
1. **A page is always reachable, regardless of permission.** The intended action must be known before permission can
|
||||
be checked, so the route renders first and individual pieces are gated.
|
||||
2. **`update` requires both `read` and `update`.** A user who can write but cannot read the current value must not get
|
||||
an edit affordance.
|
||||
3. **`delete` is independent of `read`.** Delete controls stay visible for a user who holds `delete` but not `read`.
|
||||
4. **Never gate per row.** If a user can `list`, render every row. Check `read` only when the row is opened (drawer or
|
||||
detail route).
|
||||
5. **Gate the narrowest thing that works**, a button over a section, a section over a page.
|
||||
6. **A resource may be gated while a sub-resource is not.** A user without `read` on Service Accounts can still hold
|
||||
`create` on API Keys, so blocking the outer container would hide work they are allowed to do.
|
||||
7. **Verbs not covered here** (`attach`, `detach`, `assignee`) behave like `delete`: gate the control that triggers
|
||||
them, not the surrounding content.
|
||||
|
||||
## Page patterns
|
||||
|
||||
### List page
|
||||
|
||||

|
||||
|
||||
Without `list`, but with any of `read` / `create` / `update`, only the table is blocked:
|
||||
|
||||
- Title, description, search filters and action buttons stay visible.
|
||||
- Filters and any control that drives the table are non-interactive.
|
||||
- The create button stays enabled if the user holds `create`.
|
||||
|
||||
### Edit page
|
||||
|
||||

|
||||
|
||||
Without `read`:
|
||||
|
||||
- Block the content with `withAuthZContent`, not the whole route.
|
||||
- Keep delete visible (rule 3).
|
||||
- Keep update blocked (rule 2).
|
||||
|
||||
### Drawer
|
||||
|
||||

|
||||
|
||||
Without `read`:
|
||||
|
||||
- Block the drawer body, not the drawer itself.
|
||||
- Prefer routing that carries the resource ID in the URL, so delete stays reachable without `read`.
|
||||
- Keep update blocked (rule 2).
|
||||
- Watch for sub-resources the user may still be allowed to act on (rule 6).
|
||||
|
||||
### Create
|
||||
|
||||
Without `create`:
|
||||
|
||||
| Entry point | Gate with |
|
||||
| --------------------- | ------------------------------------------------------------- |
|
||||
| Button | `AuthZButton` |
|
||||
| Dedicated create page | `withAuthZPage` |
|
||||
| Create drawer opened directly (deep link) | `withAuthZContent` on the body + `AuthZButton` on footer actions |
|
||||
|
||||
### Delete
|
||||
|
||||
Gate the control with `AuthZButton`, or `AuthZTooltip` for a non-button trigger such as an icon or menu item.
|
||||
|
||||
### Quick filters
|
||||
|
||||

|
||||
|
||||
## What "blocked" means
|
||||
|
||||
Blocking is always a visible denial, never a silent removal. Use the components in
|
||||
[`lib/authz/components`](../src/lib/authz/components/README.md) rather than hand-rolling a check, they carry the
|
||||
denial message and the loading state.
|
||||
|
||||
| Scope | Component | Denied state |
|
||||
| --------------- | ----------------------------------------- | ------------------------------------------- |
|
||||
| Button | `AuthZButton` | Disabled + tooltip |
|
||||
| Any element | `AuthZTooltip` | Disabled child + tooltip |
|
||||
| Section | `withAuthZContent` / `AuthZGuardContent` | Inline `PermissionDeniedCallout` |
|
||||
| Page / route | `withAuthZPage` / `AuthZGuardPage` | `PermissionDeniedFullPage` |
|
||||
| Custom fallback | `withAuthZ` / `AuthZGuard` | Whatever you pass as `fallback` |
|
||||
|
||||
Prefer the HOC (`withAuthZ*`); reach for the JSX guard (`AuthZGuard*`) when the gate depends on conditional rendering
|
||||
and an HOC cannot express it. The components README has the full decision tree and how to build the `checks` array.
|
||||
|
||||
## Migration checklist
|
||||
|
||||
Use the existing components. Create a new one only if none fit.
|
||||
|
||||
- [ ] Can I apply a `withAuthZ*` variant directly, or must I extract the content into a component first?
|
||||
- If extraction is needed, declare the new content component in the same file to keep the diff small, then move it
|
||||
to its own file in a follow-up commit or PR.
|
||||
- [ ] Is the layout structured to respect the [core rules](#core-rules)?
|
||||
- If not, raise it in the frontend Slack channel before reshaping the page.
|
||||
- [ ] Am I gating a shared component rather than a page or section?
|
||||
- If so, it must stay functional with no permission. Example: the Query Builder works without field suggestions when
|
||||
the user cannot read them.
|
||||
|
||||
## Testing
|
||||
|
||||
### Devtool
|
||||
|
||||
Press `Cmd + K` (`Ctrl + K` on Windows/Linux) to open the shortcuts, search for `AuthZ`, and pick the first
|
||||
result. The devtool simulates granted and denied permissions across the UI.
|
||||
|
||||
Available only in local development, it is stripped from production builds.
|
||||
|
||||
### Unit tests
|
||||
|
||||
[`lib/authz/utils/README.md`](../src/lib/authz/utils/README.md) covers the `*.authz.test.tsx` naming convention, the
|
||||
MSW handlers (`setupAuthzAdmin`, `setupAuthzDenyAll`, `setupAuthzDeny`, `setupAuthzAllow`,
|
||||
`setupAuthzGrantByPrefix`), and how to test the loading state.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
28
frontend/src/api/alerts/ruleStats.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleStatsPayload } from 'types/api/alerts/def';
|
||||
import { RuleStatsProps } from 'types/api/alerts/ruleStats';
|
||||
|
||||
const ruleStats = async (
|
||||
props: RuleStatsProps,
|
||||
): Promise<SuccessResponse<AlertRuleStatsPayload> | ErrorResponse> => {
|
||||
try {
|
||||
const response = await axios.post(`/rules/${props.id}/history/stats`, {
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default ruleStats;
|
||||
33
frontend/src/api/alerts/timelineGraph.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleTimelineGraphResponsePayload } from 'types/api/alerts/def';
|
||||
import { GetTimelineGraphRequestProps } from 'types/api/alerts/timelineGraph';
|
||||
|
||||
const timelineGraph = async (
|
||||
props: GetTimelineGraphRequestProps,
|
||||
): Promise<
|
||||
SuccessResponse<AlertRuleTimelineGraphResponsePayload> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/rules/${props.id}/history/overall_status`,
|
||||
{
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default timelineGraph;
|
||||
36
frontend/src/api/alerts/timelineTable.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleTimelineTableResponsePayload } from 'types/api/alerts/def';
|
||||
import { GetTimelineTableRequestProps } from 'types/api/alerts/timelineTable';
|
||||
|
||||
const timelineTable = async (
|
||||
props: GetTimelineTableRequestProps,
|
||||
): Promise<
|
||||
SuccessResponse<AlertRuleTimelineTableResponsePayload> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.post(`/rules/${props.id}/history/timeline`, {
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
offset: props.offset,
|
||||
limit: props.limit,
|
||||
order: props.order,
|
||||
state: props.state,
|
||||
// TODO(shaheer): implement filters
|
||||
filters: props.filters,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default timelineTable;
|
||||
33
frontend/src/api/alerts/topContributors.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleTopContributorsPayload } from 'types/api/alerts/def';
|
||||
import { TopContributorsProps } from 'types/api/alerts/topContributors';
|
||||
|
||||
const topContributors = async (
|
||||
props: TopContributorsProps,
|
||||
): Promise<
|
||||
SuccessResponse<AlertRuleTopContributorsPayload> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/rules/${props.id}/history/top_contributors`,
|
||||
{
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default topContributors;
|
||||
@@ -2816,6 +2816,8 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
cloudsql_postgres = 'cloudsql_postgres',
|
||||
memorystore_redis = 'memorystore_redis',
|
||||
computeengine = 'computeengine',
|
||||
gke = 'gke',
|
||||
cloudstorage = 'cloudstorage',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -438,7 +438,11 @@ function LogDetailInner({
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
|
||||
>
|
||||
<div className="log-detail-drawer__content" data-log-detail-ignore="true">
|
||||
<div
|
||||
className="log-detail-drawer__content"
|
||||
data-log-detail-ignore="true"
|
||||
data-testid="log-detail-drawer"
|
||||
>
|
||||
<div className="log-detail-drawer__log">
|
||||
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
|
||||
<Tooltip
|
||||
|
||||
@@ -49,7 +49,11 @@ import { unquote } from 'utils/stringUtils';
|
||||
import { getRecentQueries } from 'lib/recentQueries/getRecentQueries';
|
||||
import type { SignalType } from 'types/api/v5/queryRange';
|
||||
|
||||
import { queryExamples, SUGGESTIONS_SECTION } from './constants';
|
||||
import {
|
||||
queryExamples,
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS,
|
||||
SUGGESTIONS_SECTION,
|
||||
} from './constants';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
dedupeOptionsByLabel,
|
||||
@@ -98,15 +102,6 @@ interface QuerySearchProps {
|
||||
showFilterSuggestionsWithoutMetric?: boolean;
|
||||
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
|
||||
initialExpression?: string;
|
||||
/** When set, replaces the generic value-suggestion API with a custom fetcher. */
|
||||
valueSuggestionsOverride?: (
|
||||
key: string,
|
||||
searchText: string,
|
||||
) => Promise<{
|
||||
stringValues: string[];
|
||||
numberValues: number[];
|
||||
complete: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
function QuerySearch({
|
||||
@@ -120,7 +115,6 @@ function QuerySearch({
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
initialExpression,
|
||||
metricNamespace,
|
||||
valueSuggestionsOverride,
|
||||
}: QuerySearchProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
|
||||
@@ -363,7 +357,7 @@ function QuerySearch({
|
||||
);
|
||||
|
||||
const debouncedFetchKeySuggestions = useMemo(
|
||||
() => debounce(fetchKeySuggestions, 300),
|
||||
() => debounce(fetchKeySuggestions, SUGGESTION_FETCH_DEBOUNCE_MS),
|
||||
[fetchKeySuggestions],
|
||||
);
|
||||
|
||||
@@ -490,24 +484,13 @@ function QuerySearch({
|
||||
const sanitizedSearchText = searchText ? searchText?.trim() : '';
|
||||
|
||||
try {
|
||||
const values = valueSuggestionsOverride
|
||||
? await valueSuggestionsOverride(key, sanitizedSearchText)
|
||||
: await getValueSuggestions({
|
||||
key,
|
||||
searchText: sanitizedSearchText,
|
||||
signal: dataSource,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
}).then((response) => {
|
||||
const responseData = response.data as any;
|
||||
const data = responseData.data || {};
|
||||
const values = data.values || {};
|
||||
return {
|
||||
stringValues: values.stringValues || [],
|
||||
numberValues: values.numberValues || [],
|
||||
complete: data.complete ?? false,
|
||||
};
|
||||
});
|
||||
const response = await getValueSuggestions({
|
||||
key,
|
||||
searchText: sanitizedSearchText,
|
||||
signal: dataSource,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
});
|
||||
|
||||
// Skip updates if component unmounted or key changed
|
||||
if (
|
||||
@@ -519,6 +502,8 @@ function QuerySearch({
|
||||
}
|
||||
|
||||
// Process the response data
|
||||
const responseData = response.data as any;
|
||||
const values = responseData.data?.values || {};
|
||||
const stringValues = values.stringValues || [];
|
||||
const numberValues = values.numberValues || [];
|
||||
|
||||
@@ -599,12 +584,11 @@ function QuerySearch({
|
||||
debouncedMetricName,
|
||||
signalSource,
|
||||
toggleSuggestions,
|
||||
valueSuggestionsOverride,
|
||||
],
|
||||
);
|
||||
|
||||
const debouncedFetchValueSuggestions = useMemo(
|
||||
() => debounce(fetchValueSuggestions, 300),
|
||||
() => debounce(fetchValueSuggestions, SUGGESTION_FETCH_DEBOUNCE_MS),
|
||||
[fetchValueSuggestions],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const RECENTS_SECTION = { name: 'Recent searches', rank: 1 } as const;
|
||||
export const SUGGESTIONS_SECTION = { name: 'Suggestions', rank: 2 } as const;
|
||||
|
||||
export const SUGGESTION_FETCH_DEBOUNCE_MS = 300;
|
||||
|
||||
// TODO: move to using TelemetrytypesFieldContextDTO when we migrate getKeySuggestions API (Part of https://github.com/SigNoz/engineering-pod/issues/5289)
|
||||
export const FIELD_CONTEXTS = [
|
||||
'attribute',
|
||||
|
||||
@@ -23,6 +23,13 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Shrink the suggestion-fetch debounce (300ms in prod) so these integration
|
||||
// tests aren't paced by it; coalescing semantics stay intact.
|
||||
jest.mock('../QuerySearch/constants', () => ({
|
||||
...jest.requireActual('../QuerySearch/constants'),
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS: 30,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
|
||||
const handleRunQuery = jest.fn();
|
||||
return {
|
||||
|
||||
@@ -88,6 +88,7 @@ function TanStackCustomTableRow<TData, TItemKey = string>({
|
||||
{...props}
|
||||
className={rowClassName}
|
||||
style={rowStyle}
|
||||
data-testid={context?.getRowTestId?.(rowData)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={clearHovered}
|
||||
>
|
||||
@@ -154,6 +155,12 @@ function areTableRowPropsEqual<TData, TItemKey = string>(
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevTestId = prev.context?.getRowTestId?.(prevData) ?? '';
|
||||
const nextTestId = next.context?.getRowTestId?.(nextData) ?? '';
|
||||
if (prevTestId !== nextTestId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevStyle = prev.context?.getRowStyle?.(prevData);
|
||||
const nextStyle = next.context?.getRowStyle?.(nextData);
|
||||
if (prevStyle !== nextStyle) {
|
||||
|
||||
@@ -33,7 +33,6 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
|
||||
// Stable references via destructuring, keep them as is
|
||||
const onRowClick = context?.onRowClick;
|
||||
const onRowClickNewTab = context?.onRowClickNewTab;
|
||||
const onRowDeactivate = context?.onRowDeactivate;
|
||||
const isRowActive = context?.isRowActive;
|
||||
const getRowKeyData = context?.getRowKeyData;
|
||||
const rowIndex = row.index;
|
||||
@@ -52,21 +51,9 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
|
||||
}
|
||||
|
||||
const isActive = isRowActive?.(rowData) ?? false;
|
||||
if (isActive && onRowDeactivate) {
|
||||
onRowDeactivate();
|
||||
} else {
|
||||
onRowClick?.(rowData, itemKey);
|
||||
}
|
||||
onRowClick?.(rowData, itemKey, { isActive });
|
||||
},
|
||||
[
|
||||
isRowActive,
|
||||
onRowDeactivate,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
rowData,
|
||||
getRowKeyData,
|
||||
rowIndex,
|
||||
],
|
||||
[isRowActive, onRowClick, onRowClickNewTab, rowData, getRowKeyData, rowIndex],
|
||||
);
|
||||
|
||||
if (itemKind === 'expansion') {
|
||||
@@ -120,7 +107,6 @@ function areRowCellsPropsEqual<TData>(
|
||||
prev.columnVisibilityKey === next.columnVisibilityKey &&
|
||||
prev.context?.onRowClick === next.context?.onRowClick &&
|
||||
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
|
||||
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
|
||||
prev.context?.isRowActive === next.context?.isRowActive &&
|
||||
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
|
||||
prev.context?.renderRowActions === next.context?.renderRowActions &&
|
||||
|
||||
@@ -92,11 +92,11 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
getGroupKey,
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
getRowTestId,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
onSort,
|
||||
activeRowIndex,
|
||||
renderExpandedRow,
|
||||
@@ -378,11 +378,11 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
() => ({
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
getRowTestId,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
colCount: visibleColumnsCount,
|
||||
@@ -396,11 +396,11 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
[
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
getRowTestId,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
visibleColumnsCount,
|
||||
|
||||
@@ -85,7 +85,9 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('fires onRowClick with empty itemKey when getRowKeyData is not provided', async () => {
|
||||
@@ -117,17 +119,19 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '', {
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
|
||||
it('calls onRowClick with isActive: true when the row is active', async () => {
|
||||
// The table no longer owns open/close — it reports the active state and the
|
||||
// consumer routes the click. An active row must still fire onRowClick.
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const onRowDeactivate = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: () => true,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
@@ -152,8 +156,44 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowDeactivate).toHaveBeenCalled();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onRowClick with isActive: false when the row is not active', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
isRowActive: () => false,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render renderRowActions before hover', () => {
|
||||
|
||||
@@ -611,6 +611,7 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
'1',
|
||||
{ isActive: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -639,6 +640,7 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ isActive: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -659,15 +661,15 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(row).toHaveClass('tableRowActive');
|
||||
});
|
||||
|
||||
it('calls onRowDeactivate when clicking active row', async () => {
|
||||
it('calls onRowClick with isActive: true when clicking the active row', async () => {
|
||||
// The consumer owns open/close routing — the table just reports the
|
||||
// active state via the click context.
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const onRowDeactivate = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: (row) => row.id === '1',
|
||||
},
|
||||
});
|
||||
@@ -678,8 +680,11 @@ describe('TanStackTableView Integration', () => {
|
||||
|
||||
await user.click(screen.getByText('Item 1'));
|
||||
|
||||
expect(onRowDeactivate).toHaveBeenCalled();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1' }),
|
||||
expect.anything(),
|
||||
{ isActive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('opens in new tab on ctrl+click', async () => {
|
||||
|
||||
@@ -116,9 +116,11 @@ export * from './useTableParams';
|
||||
* getItemKey={(row) => row.id}
|
||||
* isRowActive={(row) => row.id === selectedId}
|
||||
* activeRowIndex={selectedIndex}
|
||||
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
|
||||
* // The table reports the click + the row's active state; the consumer owns open/close.
|
||||
* onRowClick={(row, itemKey, { isActive }) =>
|
||||
* setSelectedId(isActive ? undefined : itemKey)
|
||||
* }
|
||||
* onRowClickNewTab={(row, itemKey) => openInNewTab(itemKey)}
|
||||
* onRowDeactivate={() => setSelectedId(undefined)}
|
||||
* getRowClassName={(row) => (row.severity === 'error' ? 'row-error' : '')}
|
||||
* getRowStyle={(row) => (row.dimmed ? { opacity: 0.5 } : {})}
|
||||
* renderRowActions={(row) => <Button size="small">Open</Button>}
|
||||
|
||||
@@ -81,15 +81,19 @@ export type FlatItem<TData> =
|
||||
| { kind: 'row'; row: TanStackRowType<TData> }
|
||||
| { kind: 'expansion'; row: TanStackRowType<TData> };
|
||||
|
||||
export type RowClickContext = {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type TableRowContext<TData, TItemKey = string> = {
|
||||
getRowStyle?: (row: TData) => CSSProperties;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
getRowTestId?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
rowKey: string,
|
||||
@@ -178,12 +182,12 @@ export type TanStackTableProps<TData, TItemKey = string> = {
|
||||
getGroupKey?: (row: TData) => Record<string, string>;
|
||||
getRowStyle?: (row: TData) => CSSProperties;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
getRowTestId?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
activeRowIndex?: number;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
|
||||
@@ -20,6 +20,10 @@ export const REACT_QUERY_KEY = {
|
||||
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
|
||||
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',
|
||||
ALERT_RULE_DETAILS: 'ALERT_RULE_DETAILS',
|
||||
ALERT_RULE_STATS: 'ALERT_RULE_STATS',
|
||||
ALERT_RULE_TOP_CONTRIBUTORS: 'ALERT_RULE_TOP_CONTRIBUTORS',
|
||||
ALERT_RULE_TIMELINE_TABLE: 'ALERT_RULE_TIMELINE_TABLE',
|
||||
ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH',
|
||||
GET_CONSUMER_LAG_DETAILS: 'GET_CONSUMER_LAG_DETAILS',
|
||||
TOGGLE_ALERT_STATE: 'TOGGLE_ALERT_STATE',
|
||||
GET_ALL_ALERTS: 'GET_ALL_ALERTS',
|
||||
|
||||
@@ -29,7 +29,6 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-logs"
|
||||
>
|
||||
<div className="icon">
|
||||
<LogsIcon />
|
||||
@@ -41,7 +40,6 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.TRACES_EXPLORER}?${relatedTracesLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-traces"
|
||||
>
|
||||
<div className="icon">
|
||||
<DraftingCompass
|
||||
|
||||
@@ -26,10 +26,7 @@ function ChangePercentage({
|
||||
}: ChangePercentageProps): JSX.Element {
|
||||
if (direction > 0) {
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--success"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--success">
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
@@ -41,10 +38,7 @@ function ChangePercentage({
|
||||
}
|
||||
if (direction < 0) {
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--error"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--error">
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
|
||||
</div>
|
||||
@@ -56,10 +50,7 @@ function ChangePercentage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--no-previous-data"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--no-previous-data">
|
||||
<div className="change-percentage__label">no previous data</div>
|
||||
</div>
|
||||
);
|
||||
@@ -112,12 +103,7 @@ function StatsCard({
|
||||
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}
|
||||
data-testid="stats-card"
|
||||
data-stats-title={title}
|
||||
data-empty={isEmpty ? 'true' : 'false'}
|
||||
>
|
||||
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
|
||||
<div className="stats-card__title-wrapper">
|
||||
<div className="title">{title}</div>
|
||||
<div className="duration-indicator">
|
||||
@@ -137,7 +123,7 @@ function StatsCard({
|
||||
</div>
|
||||
|
||||
<div className="stats-card__stats">
|
||||
<div className="count-label" data-testid="stats-card-value">
|
||||
<div className="count-label">
|
||||
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -81,11 +81,7 @@ function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
ref={graphRef}
|
||||
data-testid="stats-card-sparkline"
|
||||
>
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot data={[xData, yData]} options={options} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks';
|
||||
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
|
||||
import { StatsTimeSeriesItem } from 'types/api/alerts/def';
|
||||
|
||||
import AverageResolutionCard from '../AverageResolutionCard/AverageResolutionCard';
|
||||
import StatsCard from '../StatsCard/StatsCard';
|
||||
@@ -26,59 +25,24 @@ type StatsCardsRendererProps = {
|
||||
};
|
||||
|
||||
// TODO(shaheer): render the DataStateRenderer inside the TotalTriggeredCard/AverageResolutionCard, it should display the title
|
||||
type AdaptedStatsData = {
|
||||
totalCurrentTriggers: number;
|
||||
totalPastTriggers: number;
|
||||
currentAvgResolutionTime: string;
|
||||
pastAvgResolutionTime: string;
|
||||
currentTriggersSeries: StatsTimeSeriesItem[];
|
||||
currentAvgResolutionTimeSeries: StatsTimeSeriesItem[];
|
||||
};
|
||||
|
||||
function StatsCardsRenderer({
|
||||
setTotalCurrentTriggers,
|
||||
}: StatsCardsRendererProps): JSX.Element {
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsStats();
|
||||
|
||||
const adaptedData = useMemo((): AdaptedStatsData | null => {
|
||||
if (!data?.data) {
|
||||
return null;
|
||||
}
|
||||
const statsData = data.data;
|
||||
|
||||
const adaptTimeSeries = (
|
||||
series: typeof statsData.currentTriggersSeries,
|
||||
): StatsTimeSeriesItem[] =>
|
||||
series?.values?.map((item) => ({
|
||||
timestamp: item.timestamp ?? 0,
|
||||
value: String(item.value ?? 0),
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
totalCurrentTriggers: statsData.totalCurrentTriggers,
|
||||
totalPastTriggers: statsData.totalPastTriggers,
|
||||
currentAvgResolutionTime: String(statsData.currentAvgResolutionTime),
|
||||
pastAvgResolutionTime: String(statsData.pastAvgResolutionTime),
|
||||
currentTriggersSeries: adaptTimeSeries(statsData.currentTriggersSeries),
|
||||
currentAvgResolutionTimeSeries: adaptTimeSeries(
|
||||
statsData.currentAvgResolutionTimeSeries,
|
||||
),
|
||||
};
|
||||
}, [data?.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (adaptedData?.totalCurrentTriggers !== undefined) {
|
||||
setTotalCurrentTriggers(adaptedData.totalCurrentTriggers);
|
||||
if (data?.payload?.data?.totalCurrentTriggers !== undefined) {
|
||||
setTotalCurrentTriggers(data.payload.data.totalCurrentTriggers);
|
||||
}
|
||||
}, [adaptedData, setTotalCurrentTriggers]);
|
||||
}, [data, setTotalCurrentTriggers]);
|
||||
|
||||
return (
|
||||
<DataStateRenderer
|
||||
isLoading={isLoading}
|
||||
isRefetching={isRefetching}
|
||||
isError={isError || !isValidRuleId || !ruleId}
|
||||
data={adaptedData}
|
||||
data={data?.payload?.data || null}
|
||||
>
|
||||
{(data): JSX.Element => {
|
||||
const {
|
||||
@@ -96,7 +60,7 @@ function StatsCardsRenderer({
|
||||
<TotalTriggeredCard
|
||||
totalCurrentTriggers={totalCurrentTriggers}
|
||||
totalPastTriggers={totalPastTriggers}
|
||||
timeSeries={currentTriggersSeries}
|
||||
timeSeries={currentTriggersSeries?.values}
|
||||
/>
|
||||
) : (
|
||||
<StatsCard
|
||||
@@ -113,7 +77,7 @@ function StatsCardsRenderer({
|
||||
<AverageResolutionCard
|
||||
currentAvgResolutionTime={currentAvgResolutionTime}
|
||||
pastAvgResolutionTime={pastAvgResolutionTime}
|
||||
timeSeries={currentAvgResolutionTimeSeries}
|
||||
timeSeries={currentAvgResolutionTimeSeries?.values}
|
||||
/>
|
||||
) : (
|
||||
<StatsCard
|
||||
|
||||
@@ -48,16 +48,11 @@ function TopContributorsCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="top-contributors-card" data-testid="top-contributors-card">
|
||||
<div className="top-contributors-card">
|
||||
<div className="top-contributors-card__header">
|
||||
<div className="title">top contributors</div>
|
||||
{topContributorsData.length > 3 && (
|
||||
<Button
|
||||
type="text"
|
||||
className="view-all"
|
||||
onClick={toggleViewAllDrawer}
|
||||
data-testid="top-contributors-view-all"
|
||||
>
|
||||
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
|
||||
<div className="label">View all</div>
|
||||
<div className="icon">
|
||||
<ArrowRight
|
||||
|
||||
@@ -68,10 +68,7 @@ function TopContributorsRows({
|
||||
relatedTracesLink={record.relatedTracesLink}
|
||||
relatedLogsLink={record.relatedLogsLink}
|
||||
>
|
||||
<div
|
||||
className="total-contribution"
|
||||
data-testid="top-contributors-row-count"
|
||||
>
|
||||
<div className="total-contribution">
|
||||
{count}/{totalCurrentTriggers}
|
||||
</div>
|
||||
</ConditionalAlertPopover>
|
||||
@@ -81,10 +78,7 @@ function TopContributorsRows({
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTopContributors,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'top-contributors-row',
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
onClick: (): void => {
|
||||
logEvent('Alert history: Top contributors row: Clicked', {
|
||||
labels: record.labels,
|
||||
|
||||
@@ -31,10 +31,7 @@ function ViewAllDrawer({
|
||||
}}
|
||||
title="Viewing All Contributors"
|
||||
>
|
||||
<div
|
||||
className="top-contributors-card--view-all"
|
||||
data-testid="top-contributors-drawer"
|
||||
>
|
||||
<div className="top-contributors-card--view-all">
|
||||
<div className="top-contributors-card__content">
|
||||
<TopContributorsRows
|
||||
topContributors={topContributorsData}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetAlertRuleDetailsTopContributors } from 'pages/AlertDetails/hooks';
|
||||
import { labelsArrayToObject } from 'container/AlertHistory/utils/labelAdapters';
|
||||
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
|
||||
import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def';
|
||||
import { AlertRuleStats } from 'types/api/alerts/def';
|
||||
|
||||
import TopContributorsCard from '../TopContributorsCard/TopContributorsCard';
|
||||
|
||||
@@ -15,27 +13,15 @@ function TopContributorsRenderer({
|
||||
}: TopContributorsRendererProps): JSX.Element {
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsTopContributors();
|
||||
const response = data?.payload?.data;
|
||||
|
||||
// TODO(shaheer): render the DataStateRenderer inside the TopContributorsCard, it should display the title and view all
|
||||
const adaptedData = useMemo((): AlertRuleTopContributors[] | null => {
|
||||
if (!data?.data) {
|
||||
return null;
|
||||
}
|
||||
return data.data.map((contributor) => ({
|
||||
fingerprint: contributor.fingerprint,
|
||||
count: contributor.count,
|
||||
labels: labelsArrayToObject(contributor.labels),
|
||||
relatedLogsLink: contributor.relatedLogsLink ?? '',
|
||||
relatedTracesLink: contributor.relatedTracesLink ?? '',
|
||||
}));
|
||||
}, [data?.data]);
|
||||
|
||||
return (
|
||||
<DataStateRenderer
|
||||
isLoading={isLoading}
|
||||
isRefetching={isRefetching}
|
||||
isError={isError || !isValidRuleId || !ruleId}
|
||||
data={adaptedData}
|
||||
data={response || null}
|
||||
>
|
||||
{(topContributorsData): JSX.Element => (
|
||||
<TopContributorsCard
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const ALERT_STATUS: Record<RuletypesAlertStateDTO, number> & {
|
||||
[key: string]: number;
|
||||
} = {
|
||||
[RuletypesAlertStateDTO.firing]: 0,
|
||||
[RuletypesAlertStateDTO.inactive]: 1,
|
||||
export const ALERT_STATUS: { [key: string]: number } = {
|
||||
firing: 0,
|
||||
inactive: 1,
|
||||
normal: 1,
|
||||
[RuletypesAlertStateDTO.pending]: 2,
|
||||
[RuletypesAlertStateDTO.recovering]: 2,
|
||||
'no-data': 3,
|
||||
[RuletypesAlertStateDTO.nodata]: 3,
|
||||
[RuletypesAlertStateDTO.disabled]: 4,
|
||||
muted: 5,
|
||||
'no-data': 2,
|
||||
disabled: 3,
|
||||
muted: 4,
|
||||
};
|
||||
|
||||
export const STATE_VS_COLOR: {
|
||||
@@ -22,10 +16,9 @@ export const STATE_VS_COLOR: {
|
||||
{
|
||||
0: { stroke: Color.BG_CHERRY_500, fill: Color.BG_CHERRY_500 },
|
||||
1: { stroke: Color.BG_FOREST_500, fill: Color.BG_FOREST_500 },
|
||||
2: { stroke: Color.BG_AMBER_500, fill: Color.BG_AMBER_500 },
|
||||
3: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 },
|
||||
4: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 },
|
||||
5: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 },
|
||||
2: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 },
|
||||
3: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 },
|
||||
4: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 },
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
|
||||
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
|
||||
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
|
||||
|
||||
import Graph from '../Graph/Graph';
|
||||
|
||||
@@ -20,20 +18,30 @@ function GraphWrapper({
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsTimelineGraphData();
|
||||
|
||||
const adaptedData = useMemo((): AlertRuleTimelineGraphResponse[] | null => {
|
||||
if (!data?.data) {
|
||||
return null;
|
||||
}
|
||||
return data.data.map((item) => ({
|
||||
start: item.start,
|
||||
end: item.end,
|
||||
state: item.state as AlertRuleTimelineGraphResponse['state'],
|
||||
}));
|
||||
}, [data?.data]);
|
||||
// TODO(shaheer): uncomment when the API is ready for
|
||||
// const { startTime } = useAlertHistoryQueryParams();
|
||||
|
||||
// const [isVerticalGraph, setIsVerticalGraph] = useState(false);
|
||||
|
||||
// useEffect(() => {
|
||||
// const checkVerticalGraph = (): void => {
|
||||
// if (startTime) {
|
||||
// const startTimeDate = dayjs(Number(startTime));
|
||||
// const twentyFourHoursAgo = dayjs().subtract(
|
||||
// HORIZONTAL_GRAPH_HOURS_THRESHOLD,
|
||||
// DAYJS_MANIPULATE_TYPES.HOUR,
|
||||
// );
|
||||
|
||||
// setIsVerticalGraph(startTimeDate.isBefore(twentyFourHoursAgo));
|
||||
// }
|
||||
// };
|
||||
|
||||
// checkVerticalGraph();
|
||||
// }, [startTime]);
|
||||
|
||||
return (
|
||||
<div className="timeline-graph" data-testid="timeline-graph">
|
||||
<div className="timeline-graph__title" data-testid="timeline-graph-title">
|
||||
<div className="timeline-graph">
|
||||
<div className="timeline-graph__title">
|
||||
{totalCurrentTriggers} triggers in {relativeTime}
|
||||
</div>
|
||||
<div className="timeline-graph__chart">
|
||||
@@ -41,7 +49,7 @@ function GraphWrapper({
|
||||
isLoading={isLoading}
|
||||
isError={isError || !isValidRuleId || !ruleId}
|
||||
isRefetching={isRefetching}
|
||||
data={adaptedData}
|
||||
data={data?.payload?.data || null}
|
||||
>
|
||||
{(data): JSX.Element => <Graph type="horizontal" data={data} />}
|
||||
</DataStateRenderer>
|
||||
|
||||
@@ -1,35 +1,11 @@
|
||||
.timeline-table {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
min-height: 600px;
|
||||
|
||||
&__filter {
|
||||
padding: 12px 16px;
|
||||
background: var(--l1-background);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&__filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__filter-search {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__filter--loading,
|
||||
&__filter--loading-skeleton {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
background: var(--l1-background);
|
||||
&-placeholder {
|
||||
background: var(--l1-background) !important;
|
||||
}
|
||||
&-cell {
|
||||
padding: 12px 16px !important;
|
||||
vertical-align: baseline;
|
||||
@@ -47,9 +23,6 @@
|
||||
&-tbody > tr > td {
|
||||
border: none;
|
||||
}
|
||||
&-footer {
|
||||
background-color: var(--l1-background);
|
||||
}
|
||||
}
|
||||
|
||||
.label-filter {
|
||||
@@ -113,38 +86,4 @@
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
display: flex;
|
||||
align-self: flex-start;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
background: var(--l1-background);
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,129 +1,52 @@
|
||||
import { HTMLAttributes, useCallback, useMemo } from 'react';
|
||||
import { Button, Skeleton, Table } from 'antd';
|
||||
import { ChevronLeft, ChevronRight } from '@signozhq/icons';
|
||||
import { HTMLAttributes, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Table } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import {
|
||||
QuerySearchV2Provider,
|
||||
useExpression,
|
||||
useInputExpression,
|
||||
useQuerySearchOnChange,
|
||||
useQuerySearchOnRun,
|
||||
} from 'components/QueryBuilderV2';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { initialFilters } from 'constants/queryBuilder';
|
||||
import {
|
||||
useGetAlertRuleDetailsTimelineTable,
|
||||
useTimelineTable,
|
||||
} from 'pages/AlertDetails/hooks';
|
||||
import { labelsArrayToObject } from 'container/AlertHistory/utils/labelAdapters';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useAlertHistoryFilterSuggestions } from './useAlertHistoryFilterSuggestions';
|
||||
import { timelineTableColumns } from './useTimelineTable';
|
||||
|
||||
import './Table.styles.scss';
|
||||
|
||||
export const ALERT_HISTORY_EXPRESSION_KEY = 'alertHistoryExpression';
|
||||
function TimelineTable(): JSX.Element {
|
||||
const [filters, setFilters] = useState<TagFilter>(initialFilters);
|
||||
|
||||
function TimelineTableContent(): JSX.Element {
|
||||
const expression = useExpression();
|
||||
const inputExpression = useInputExpression();
|
||||
const querySearchOnChange = useQuerySearchOnChange();
|
||||
const querySearchOnRun = useQuerySearchOnRun();
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
isRefetching,
|
||||
isError,
|
||||
data,
|
||||
error,
|
||||
ruleId,
|
||||
refetch,
|
||||
cancel,
|
||||
} = useGetAlertRuleDetailsTimelineTable({ filterExpression: expression });
|
||||
|
||||
const apiError = useMemo(() => convertToApiError(error), [error]);
|
||||
|
||||
const { hardcodedAttributeKeys, valueSuggestionsOverride, isLoadingKeys } =
|
||||
useAlertHistoryFilterSuggestions(ruleId ?? null);
|
||||
|
||||
const { timelineData, totalItems, nextCursor } = useMemo(() => {
|
||||
const response = data?.data;
|
||||
const items: AlertRuleTimelineTableResponse[] | undefined =
|
||||
response?.items?.map((item) => {
|
||||
return {
|
||||
ruleID: item.ruleId,
|
||||
ruleName: item.ruleName,
|
||||
overallState: item.overallState as string,
|
||||
overallStateChanged: item.overallStateChanged,
|
||||
state: item.state as string,
|
||||
stateChanged: item.stateChanged,
|
||||
unixMilli: item.unixMilli,
|
||||
fingerprint: item.fingerprint,
|
||||
value: item.value,
|
||||
labels: labelsArrayToObject(item.labels),
|
||||
relatedLogsLink: item.relatedLogsLink,
|
||||
relatedTracesLink: item.relatedTracesLink,
|
||||
};
|
||||
});
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsTimelineTable({ filters });
|
||||
|
||||
const { timelineData, totalItems, labels } = useMemo(() => {
|
||||
const response = data?.payload?.data;
|
||||
return {
|
||||
timelineData: items,
|
||||
totalItems: response?.total ?? 0,
|
||||
nextCursor: response?.nextCursor,
|
||||
timelineData: response?.items,
|
||||
totalItems: response?.total,
|
||||
labels: response?.labels,
|
||||
};
|
||||
}, [data?.data]);
|
||||
}, [data?.payload?.data]);
|
||||
|
||||
const {
|
||||
paginationConfig,
|
||||
onChangeHandler,
|
||||
handleNextPage,
|
||||
handlePrevPage,
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
} = useTimelineTable({
|
||||
const { paginationConfig, onChangeHandler } = useTimelineTable({
|
||||
totalItems: totalItems ?? 0,
|
||||
nextCursor,
|
||||
});
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const handleRunQuery = useCallback(
|
||||
(updatedExpression?: string): void => {
|
||||
const nextExpression = updatedExpression ?? inputExpression;
|
||||
querySearchOnRun(nextExpression);
|
||||
|
||||
if (nextExpression === expression) {
|
||||
refetch();
|
||||
}
|
||||
},
|
||||
[querySearchOnRun, refetch, inputExpression, expression],
|
||||
);
|
||||
|
||||
const queryData = useMemo(
|
||||
() => ({
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.LOGS,
|
||||
filter: { expression },
|
||||
expression,
|
||||
}),
|
||||
[expression],
|
||||
);
|
||||
if (isError || !isValidRuleId || !ruleId) {
|
||||
return <div>{t('something_went_wrong')}</div>;
|
||||
}
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTimelineTableResponse,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'timeline-row',
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
onClick: (): void => {
|
||||
void logEvent('Alert history: Timeline table row: Clicked', {
|
||||
logEvent('Alert history: Timeline table row: Clicked', {
|
||||
ruleId: record.ruleID,
|
||||
labels: record.labels,
|
||||
});
|
||||
@@ -131,114 +54,24 @@ function TimelineTableContent(): JSX.Element {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="timeline-table" data-testid="timeline-table">
|
||||
{/* If we don't wait to have the keys, the QuerySearch will not render them at first usage */}
|
||||
{!isLoadingKeys && hardcodedAttributeKeys ? (
|
||||
<div className="timeline-table__filter">
|
||||
<div className="timeline-table__filter-row">
|
||||
<div
|
||||
className="timeline-table__filter-search"
|
||||
data-testid="timeline-filter-search"
|
||||
>
|
||||
<QuerySearch
|
||||
onChange={querySearchOnChange}
|
||||
queryData={queryData}
|
||||
dataSource={DataSource.LOGS}
|
||||
onRun={handleRunQuery}
|
||||
hardcodedAttributeKeys={hardcodedAttributeKeys}
|
||||
valueSuggestionsOverride={valueSuggestionsOverride}
|
||||
/>
|
||||
</div>
|
||||
<RunQueryBtn
|
||||
isLoadingQueries={isLoading || isRefetching}
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
handleCancelQuery={cancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="timeline-table__filter timeline-table__filter--loading">
|
||||
<Skeleton.Input
|
||||
className="timeline-table__filter--loading-skeleton"
|
||||
active
|
||||
data-testid="timeline-filter-skeleton"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="timeline-table">
|
||||
<Table
|
||||
rowKey={(row): string => `${row.fingerprint}-${row.value}-${row.unixMilli}`}
|
||||
columns={timelineTableColumns({
|
||||
filters,
|
||||
labels: labels ?? {},
|
||||
setFilters,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
})}
|
||||
onRow={handleRowClick}
|
||||
dataSource={timelineData}
|
||||
pagination={false}
|
||||
pagination={paginationConfig}
|
||||
size="middle"
|
||||
onChange={onChangeHandler}
|
||||
loading={isLoading || isRefetching}
|
||||
locale={{
|
||||
emptyText:
|
||||
isError && apiError ? (
|
||||
<div className="timeline-table__error" data-testid="timeline-error">
|
||||
<ErrorContent error={apiError} />
|
||||
</div>
|
||||
) : undefined,
|
||||
}}
|
||||
footer={(): JSX.Element => (
|
||||
<div className="timeline-table__pagination">
|
||||
<div
|
||||
className="timeline-table__pagination-info"
|
||||
data-testid="timeline-footer-range"
|
||||
>
|
||||
{paginationConfig.showTotal?.(totalItems, [
|
||||
totalItems === 0
|
||||
? 0
|
||||
: ((paginationConfig.current ?? 1) - 1) *
|
||||
(paginationConfig.pageSize ?? 10) +
|
||||
1,
|
||||
Math.min(
|
||||
(paginationConfig.current ?? 1) * (paginationConfig.pageSize ?? 10),
|
||||
totalItems,
|
||||
),
|
||||
])}
|
||||
</div>
|
||||
<div className="pagination-controls">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
disabled={!hasPrevPage}
|
||||
onClick={handlePrevPage}
|
||||
data-testid="timeline-prev-page"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
disabled={!hasNextPage}
|
||||
onClick={handleNextPage}
|
||||
data-testid="timeline-next-page"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineTable(): JSX.Element {
|
||||
return (
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={ALERT_HISTORY_EXPRESSION_KEY}
|
||||
initialExpression=""
|
||||
persistOnUnmount
|
||||
>
|
||||
<TimelineTableContent />
|
||||
</QuerySearchV2Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineTable;
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
getRuleHistoryFilterValues,
|
||||
useGetRuleHistoryFilterKeys,
|
||||
} from 'api/generated/services/rules';
|
||||
import { useAlertHistoryQueryParams } from 'pages/AlertDetails/hooks';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { fieldContextToSuggestionContext } from 'container/AlertHistory/Timeline/Table/utils';
|
||||
|
||||
export interface AlertHistoryFilterSuggestions {
|
||||
hardcodedAttributeKeys: QueryKeyDataSuggestionsProps[];
|
||||
valueSuggestionsOverride: (
|
||||
key: string,
|
||||
searchText: string,
|
||||
) => Promise<{
|
||||
stringValues: string[];
|
||||
numberValues: number[];
|
||||
complete: boolean;
|
||||
}>;
|
||||
isLoadingKeys: boolean;
|
||||
}
|
||||
|
||||
export function useAlertHistoryFilterSuggestions(
|
||||
ruleId: string | null,
|
||||
): AlertHistoryFilterSuggestions {
|
||||
const { startTime, endTime } = useAlertHistoryQueryParams();
|
||||
|
||||
const { data: filterKeysData, isLoading: isLoadingKeys } =
|
||||
useGetRuleHistoryFilterKeys(
|
||||
{ id: ruleId ?? '' },
|
||||
{ startUnixMilli: startTime, endUnixMilli: endTime },
|
||||
{
|
||||
query: {
|
||||
enabled: !!ruleId,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const hardcodedAttributeKeys = useMemo((): QueryKeyDataSuggestionsProps[] => {
|
||||
const keys = filterKeysData?.data?.keys;
|
||||
if (!keys) {
|
||||
// by default, when QuerySearch keys fails, we don't render fallback keys
|
||||
// we just return empty to let user write whatever they want with no
|
||||
// key suggestion
|
||||
return [];
|
||||
}
|
||||
return Object.values(keys).flatMap((items) =>
|
||||
items.map(
|
||||
(item) =>
|
||||
({
|
||||
label: item.name,
|
||||
name: item.name,
|
||||
type: item.fieldDataType || 'string',
|
||||
signal: 'logs' as const,
|
||||
fieldDataType: item.fieldDataType,
|
||||
fieldContext: fieldContextToSuggestionContext(item.fieldContext),
|
||||
}) satisfies QueryKeyDataSuggestionsProps,
|
||||
),
|
||||
);
|
||||
}, [filterKeysData]);
|
||||
|
||||
const valueSuggestionsOverride = useCallback(
|
||||
async (
|
||||
key: string,
|
||||
searchText: string,
|
||||
): Promise<{
|
||||
stringValues: string[];
|
||||
numberValues: number[];
|
||||
complete: boolean;
|
||||
}> => {
|
||||
if (!ruleId) {
|
||||
return {
|
||||
stringValues: [],
|
||||
numberValues: [],
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
const response = await getRuleHistoryFilterValues(
|
||||
{ id: ruleId },
|
||||
{
|
||||
name: key,
|
||||
searchText,
|
||||
startUnixMilli: startTime,
|
||||
endUnixMilli: endTime,
|
||||
},
|
||||
);
|
||||
const values = response.data?.values;
|
||||
return {
|
||||
stringValues: values?.stringValues ?? [],
|
||||
numberValues: values?.numberValues ?? [],
|
||||
complete: response.data?.complete ?? false,
|
||||
};
|
||||
},
|
||||
[ruleId, startTime, endTime],
|
||||
);
|
||||
|
||||
return { hardcodedAttributeKeys, valueSuggestionsOverride, isLoadingKeys };
|
||||
}
|
||||
@@ -1,15 +1,83 @@
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Button, TableColumnsType as ColumnsType, Tooltip } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
import { Ellipsis, Search } from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, TableColumnsType as ColumnsType } from 'antd';
|
||||
import ClientSideQBSearch, {
|
||||
AttributeKey,
|
||||
} from 'components/ClientSideQBSearch/ClientSideQBSearch';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
|
||||
import { transformKeyValuesToAttributeValuesMap } from 'container/QueryBuilder/filters/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
|
||||
import AlertLabels, {
|
||||
AlertLabelsProps,
|
||||
} from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
|
||||
import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState';
|
||||
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
const transformLabelsToQbKeys = (
|
||||
labels: AlertRuleTimelineTableResponse['labels'],
|
||||
): AttributeKey[] => Object.keys(labels).flatMap((key) => [{ key }]);
|
||||
|
||||
function LabelFilter({
|
||||
filters,
|
||||
setFilters,
|
||||
labels,
|
||||
}: {
|
||||
setFilters: (filters: TagFilter) => void;
|
||||
filters: TagFilter;
|
||||
labels: AlertLabelsProps['labels'];
|
||||
}): JSX.Element | null {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { transformedKeys, attributesMap } = useMemo(
|
||||
() => ({
|
||||
transformedKeys: transformLabelsToQbKeys(labels || {}),
|
||||
attributesMap: transformKeyValuesToAttributeValuesMap(labels),
|
||||
}),
|
||||
[labels],
|
||||
);
|
||||
|
||||
const handleSearch = (tagFilters: TagFilter): void => {
|
||||
const tagFiltersLength = tagFilters.items.length;
|
||||
|
||||
if (
|
||||
(!tagFiltersLength && (!filters || !filters.items.length)) ||
|
||||
tagFiltersLength === filters?.items.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setFilters(tagFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<ClientSideQBSearch
|
||||
onChange={handleSearch}
|
||||
filters={filters}
|
||||
className="alert-history-label-search"
|
||||
attributeKeys={transformedKeys}
|
||||
attributeValuesMap={attributesMap}
|
||||
suffixIcon={
|
||||
<Search
|
||||
size={14}
|
||||
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const timelineTableColumns = ({
|
||||
filters,
|
||||
labels,
|
||||
setFilters,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
}: {
|
||||
filters: TagFilter;
|
||||
labels: AlertLabelsProps['labels'];
|
||||
setFilters: (filters: TagFilter) => void;
|
||||
formatTimezoneAdjustedTimestamp: (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
@@ -21,16 +89,18 @@ export const timelineTableColumns = ({
|
||||
sorter: true,
|
||||
width: 140,
|
||||
render: (value): JSX.Element => (
|
||||
<div className="alert-rule-state" data-testid="timeline-row-state">
|
||||
<div className="alert-rule-state">
|
||||
<AlertState state={value} showLabel />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'LABELS',
|
||||
title: (
|
||||
<LabelFilter setFilters={setFilters} filters={filters} labels={labels} />
|
||||
),
|
||||
dataIndex: 'labels',
|
||||
render: (labels): JSX.Element => (
|
||||
<div className="alert-rule-labels" data-testid="timeline-row-labels">
|
||||
<div className="alert-rule-labels">
|
||||
<AlertLabels labels={labels} />
|
||||
</div>
|
||||
),
|
||||
@@ -40,10 +110,7 @@ export const timelineTableColumns = ({
|
||||
dataIndex: 'unixMilli',
|
||||
width: 200,
|
||||
render: (value): JSX.Element => (
|
||||
<div
|
||||
className="alert-rule__created-at"
|
||||
data-testid="timeline-row-created-at"
|
||||
>
|
||||
<div className="alert-rule__created-at">
|
||||
{formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.DASH_DATETIME)}
|
||||
</div>
|
||||
),
|
||||
@@ -52,27 +119,15 @@ export const timelineTableColumns = ({
|
||||
title: 'ACTIONS',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
render: (_, record): JSX.Element => {
|
||||
if (!record.relatedTracesLink && !record.relatedLogsLink) {
|
||||
return (
|
||||
<Tooltip title="No links available for this item">
|
||||
<Button type="text" ghost disabled data-testid="timeline-row-actions">
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConditionalAlertPopover
|
||||
relatedTracesLink={record.relatedTracesLink ?? ''}
|
||||
relatedLogsLink={record.relatedLogsLink ?? ''}
|
||||
>
|
||||
<Button type="text" ghost data-testid="timeline-row-actions">
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
);
|
||||
},
|
||||
render: (record): JSX.Element => (
|
||||
<ConditionalAlertPopover
|
||||
relatedTracesLink={record.relatedTracesLink}
|
||||
relatedLogsLink={record.relatedLogsLink}
|
||||
>
|
||||
<Button type="text" ghost>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
Options,
|
||||
parseAsInteger,
|
||||
parseAsStringLiteral,
|
||||
useQueryState,
|
||||
UseQueryStateReturn,
|
||||
} from 'nuqs';
|
||||
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
|
||||
|
||||
const defaultNuqsOptions: Options = {
|
||||
history: 'push',
|
||||
};
|
||||
|
||||
export const TIMELINE_TABLE_PARAMS = {
|
||||
PAGE: 'page',
|
||||
ORDER: 'order',
|
||||
} as const;
|
||||
|
||||
const ORDER_VALUES = ['asc', 'desc'] as const;
|
||||
export type OrderDirection = (typeof ORDER_VALUES)[number];
|
||||
|
||||
export const useTimelineTablePage = (): UseQueryStateReturn<number, number> =>
|
||||
useQueryState(
|
||||
TIMELINE_TABLE_PARAMS.PAGE,
|
||||
parseAsInteger.withDefault(1).withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export const useTimelineTableOrder = (): UseQueryStateReturn<
|
||||
OrderDirection,
|
||||
OrderDirection
|
||||
> =>
|
||||
useQueryState(
|
||||
TIMELINE_TABLE_PARAMS.ORDER,
|
||||
parseAsStringLiteral(ORDER_VALUES)
|
||||
.withDefault('asc')
|
||||
.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export function encodeCursor(page: number, limit: number): string | undefined {
|
||||
if (page <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
const offset = (page - 1) * limit;
|
||||
// Backend uses base64.RawURLEncoding (URL-safe, no padding)
|
||||
return btoa(JSON.stringify({ offset, limit }))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function computeCursorForPage(page: number): string | undefined {
|
||||
return encodeCursor(page, TIMELINE_TABLE_PAGE_SIZE);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
|
||||
const fieldContextToSuggestionMap: Record<
|
||||
TelemetrytypesFieldContextDTO,
|
||||
QueryKeyDataSuggestionsProps['fieldContext']
|
||||
> = {
|
||||
[TelemetrytypesFieldContextDTO.resource]: 'resource',
|
||||
[TelemetrytypesFieldContextDTO.span]: 'span',
|
||||
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
|
||||
// no maps for the following values on suggestion context
|
||||
[TelemetrytypesFieldContextDTO.body]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.metric]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.log]: undefined,
|
||||
[TelemetrytypesFieldContextDTO['']]: undefined,
|
||||
};
|
||||
|
||||
export function fieldContextToSuggestionContext(
|
||||
fc: TelemetrytypesFieldContextDTO | undefined,
|
||||
): QueryKeyDataSuggestionsProps['fieldContext'] {
|
||||
if (fc === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return fieldContextToSuggestionMap[fc];
|
||||
}
|
||||
@@ -23,7 +23,6 @@ function TimelineTabs(): JSX.Element {
|
||||
{
|
||||
value: TimelineTab.OVERALL_STATUS,
|
||||
label: 'Overall Status',
|
||||
testId: 'timeline-tab-overall-status',
|
||||
},
|
||||
{
|
||||
value: TimelineTab.TOP_5_CONTRIBUTORS,
|
||||
@@ -34,7 +33,6 @@ function TimelineTabs(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
testId: 'timeline-tab-top-contributors',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -59,17 +57,14 @@ function TimelineFilters(): JSX.Element {
|
||||
{
|
||||
value: TimelineFilter.ALL,
|
||||
label: 'All',
|
||||
testId: 'timeline-filter-all',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.FIRED,
|
||||
label: 'Fired',
|
||||
testId: 'timeline-filter-fired',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.RESOLVED,
|
||||
label: 'Resolved',
|
||||
testId: 'timeline-filter-resolved',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Querybuildertypesv5LabelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { Labels } from 'types/api/alerts/def';
|
||||
|
||||
export function labelsArrayToObject(
|
||||
labels: Querybuildertypesv5LabelDTO[] | null | undefined,
|
||||
): Labels {
|
||||
if (!labels) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return labels.reduce<Labels>((acc, label) => {
|
||||
const key = label.key?.name ?? '';
|
||||
const value = String(label.value ?? '');
|
||||
if (key) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -60,3 +60,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-linked-row td {
|
||||
background-color: var(--row-active-bg) !important;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,9 @@ function LiveLogsList({
|
||||
data={formattedLogs}
|
||||
isLoading={false}
|
||||
isRowActive={(log): boolean => log.id === activeLog?.id}
|
||||
getRowClassName={(log): string =>
|
||||
log.id === activeLogId ? 'logs-linked-row' : ''
|
||||
}
|
||||
getRowStyle={(log): CSSProperties =>
|
||||
({
|
||||
'--row-active-bg': getRowBackgroundColor(
|
||||
@@ -216,10 +219,13 @@ function LiveLogsList({
|
||||
),
|
||||
}) as CSSProperties
|
||||
}
|
||||
onRowClick={(log): void => {
|
||||
handleSetActiveLog(log);
|
||||
onRowClick={(log, _itemKey, { isActive }): void => {
|
||||
if (isActive) {
|
||||
handleCloseLogDetail();
|
||||
} else {
|
||||
handleSetActiveLog(log);
|
||||
}
|
||||
}}
|
||||
onRowDeactivate={handleCloseLogDetail}
|
||||
activeRowIndex={activeLogIndex}
|
||||
renderRowActions={(log): ReactNode => (
|
||||
<LogLinesActionButtons
|
||||
|
||||
@@ -323,3 +323,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-linked-row td {
|
||||
background-color: var(--row-active-bg) !important;
|
||||
}
|
||||
|
||||
@@ -211,9 +211,11 @@ function LogsExplorerList({
|
||||
data={logs}
|
||||
isLoading={isLoading || isFetching}
|
||||
onEndReached={onEndReached}
|
||||
isRowActive={(log): boolean =>
|
||||
log.id === activeLog?.id || log.id === activeLogId
|
||||
isRowActive={(log): boolean => log.id === activeLog?.id}
|
||||
getRowClassName={(log): string =>
|
||||
log.id === activeLogId ? 'logs-linked-row' : ''
|
||||
}
|
||||
getRowTestId={(log): string => `logs-table-row-${log.id}`}
|
||||
getRowStyle={(log): CSSProperties =>
|
||||
({
|
||||
'--row-active-bg': getRowBackgroundColor(
|
||||
@@ -226,10 +228,13 @@ function LogsExplorerList({
|
||||
),
|
||||
}) as CSSProperties
|
||||
}
|
||||
onRowClick={(log): void => {
|
||||
handleSetActiveLog(log);
|
||||
onRowClick={(log, _itemKey, { isActive }): void => {
|
||||
if (isActive) {
|
||||
handleCloseLogDetail();
|
||||
} else {
|
||||
handleSetActiveLog(log);
|
||||
}
|
||||
}}
|
||||
onRowDeactivate={handleCloseLogDetail}
|
||||
activeRowIndex={activeLogIndex}
|
||||
renderRowActions={(log): ReactNode => (
|
||||
<LogLinesActionButtons
|
||||
@@ -282,6 +287,7 @@ function LogsExplorerList({
|
||||
options.maxLines,
|
||||
options.fontSize,
|
||||
activeLogIndex,
|
||||
activeLogId,
|
||||
logs,
|
||||
onEndReached,
|
||||
getItemContent,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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: '' });
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -108,6 +108,7 @@ export function getAppContextMockState(
|
||||
isFetchingHosts: false,
|
||||
isFetchingFeatureFlags: false,
|
||||
isFetchingOrgPreferences: false,
|
||||
isFetchingUserPreferences: false,
|
||||
userFetchError: undefined,
|
||||
activeLicenseFetchError: null,
|
||||
hostsFetchError: undefined,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const DEFAULT_COPIED_RESET_MS = 2000;
|
||||
|
||||
export interface UseCopyToClipboardOptions {
|
||||
/** How long (ms) to keep "copied" state before resetting. Default 2000. */
|
||||
copiedResetMs?: number;
|
||||
}
|
||||
|
||||
export type ID = number | string | null;
|
||||
|
||||
export interface UseCopyToClipboardReturn {
|
||||
/** Copy text to clipboard. Pass an optional id to track which item was copied (e.g. seriesIndex). */
|
||||
copyToClipboard: (text: string, id?: ID) => void;
|
||||
/** True when something was just copied and still within the reset threshold. */
|
||||
isCopied: boolean;
|
||||
/** The id passed to the last successful copy, or null after reset. Use to show "copied" state for a specific item (e.g. copiedId === item.seriesIndex). */
|
||||
id: ID;
|
||||
}
|
||||
|
||||
export function useCopyToClipboard(
|
||||
options: UseCopyToClipboardOptions = {},
|
||||
): UseCopyToClipboardReturn {
|
||||
const { copiedResetMs = DEFAULT_COPIED_RESET_MS } = options;
|
||||
const [state, setState] = useState<{ isCopied: boolean; id: ID }>({
|
||||
isCopied: false,
|
||||
id: null,
|
||||
});
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = useCallback(
|
||||
(text: string, id?: ID): void => {
|
||||
// oxlint-disable-next-line signoz/no-navigator-clipboard
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setState({ isCopied: true, id: id ?? null });
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setState({ isCopied: false, id: null });
|
||||
timeoutRef.current = null;
|
||||
}, copiedResetMs);
|
||||
});
|
||||
},
|
||||
[copiedResetMs],
|
||||
);
|
||||
|
||||
return {
|
||||
copyToClipboard,
|
||||
isCopied: state.isCopied,
|
||||
id: state.id,
|
||||
};
|
||||
}
|
||||
@@ -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} />);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { Input } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
|
||||
import { LegendPosition, LegendProps } from '../types';
|
||||
|
||||
@@ -33,7 +31,6 @@ export default function Legend({
|
||||
}: LegendProps): JSX.Element {
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [legendSearchQuery, setLegendSearchQuery] = useState('');
|
||||
const { copyToClipboard, id: copiedId } = useCopyToClipboard();
|
||||
|
||||
// Search is intrinsic to the right-positioned legend.
|
||||
const searchEnabled = position === LegendPosition.RIGHT;
|
||||
@@ -57,17 +54,8 @@ export default function Legend({
|
||||
return items.filter((item) => item.label?.toLowerCase().includes(query));
|
||||
}, [searchEnabled, legendSearchQuery, items]);
|
||||
|
||||
const handleCopyLegendItem = useCallback(
|
||||
(e: React.MouseEvent, seriesIndex: number, label: string): void => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(label, seriesIndex);
|
||||
},
|
||||
[copyToClipboard],
|
||||
);
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(item: LegendItem): JSX.Element => {
|
||||
const isCopied = copiedId === item.seriesIndex;
|
||||
// `color` is uPlot's stroke union (string | fn | gradient); only a string
|
||||
// is a usable CSS colour for the marker.
|
||||
const markerColor = typeof item.color === 'string' ? item.color : undefined;
|
||||
@@ -91,36 +79,18 @@ export default function Legend({
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
{showCopy && (
|
||||
<TooltipSimple
|
||||
title={isCopied ? 'Copied' : 'Copy'}
|
||||
arrow
|
||||
side="top"
|
||||
disableHoverableContent
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="legend-copy-button"
|
||||
onClick={(e): void =>
|
||||
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
|
||||
}
|
||||
aria-label={`Copy ${item.label}`}
|
||||
// data-testid (not testId): TooltipSimple's trigger injects
|
||||
// data-testid:undefined via Radix Slot, and Button spreads
|
||||
// incoming props after its own testId — so set it as a prop
|
||||
// that wins the Slot merge and survives the spread.
|
||||
data-testid="legend-copy"
|
||||
>
|
||||
{isCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
<CopyButton
|
||||
value={item.label ?? ''}
|
||||
size={12}
|
||||
className="legend-copy-button"
|
||||
ariaLabel={`Copy ${item.label}`}
|
||||
testId="legend-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[copiedId, focusedSeriesIndex, handleCopyLegendItem, position, showCopy],
|
||||
[focusedSeriesIndex, position, showCopy],
|
||||
);
|
||||
|
||||
const isEmptyState = useMemo(() => {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
RenderResult,
|
||||
screen,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
@@ -15,9 +9,6 @@ import { useLegendActions } from '../../hooks/useLegendActions';
|
||||
import UPlotLegend from '../Legend/UPlotLegend';
|
||||
import { LegendPosition } from '../types';
|
||||
|
||||
const mockWriteText = jest.fn().mockResolvedValue(undefined);
|
||||
let clipboardSpy: jest.SpyInstance | undefined;
|
||||
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
@@ -49,15 +40,6 @@ const mockUseLegendActions = useLegendActions as jest.MockedFunction<
|
||||
>;
|
||||
|
||||
describe('UPlotLegend', () => {
|
||||
beforeAll(() => {
|
||||
// JSDOM does not define navigator.clipboard; add it so we can spy on writeText
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: () => Promise.resolve() },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
const baseLegendItemsMap = {
|
||||
0: {
|
||||
seriesIndex: 0,
|
||||
@@ -89,11 +71,6 @@ describe('UPlotLegend', () => {
|
||||
onLegendMouseMove = jest.fn();
|
||||
onLegendMouseLeave = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
mockWriteText.mockClear();
|
||||
|
||||
clipboardSpy = jest
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockImplementation(mockWriteText);
|
||||
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: baseLegendItemsMap,
|
||||
@@ -110,7 +87,6 @@ describe('UPlotLegend', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clipboardSpy?.mockRestore();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -237,47 +213,4 @@ describe('UPlotLegend', () => {
|
||||
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy action', () => {
|
||||
it('copies the legend label to clipboard when copy button is clicked', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const firstLegendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteText).toHaveBeenCalledWith('A');
|
||||
});
|
||||
|
||||
it('copies the correct label when copy is clicked on a different legend item', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const thirdLegendItem = document.querySelector(
|
||||
'[data-legend-item-id="2"]',
|
||||
) as HTMLElement;
|
||||
const copyButton = within(thirdLegendItem).getByTestId('legend-copy');
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteText).toHaveBeenCalledWith('C');
|
||||
});
|
||||
|
||||
it('does not call onLegendClick when copy button is clicked', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const firstLegendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(onLegendClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,8 +94,6 @@ function AlertDetails(): JSX.Element {
|
||||
>
|
||||
<div
|
||||
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
|
||||
data-testid="alert-details-root"
|
||||
data-schema-version={isV2Alert ? NEW_ALERT_SCHEMA_VERSION : 'v1'}
|
||||
>
|
||||
<AlertBreadcrumb
|
||||
className="alert-details__breadcrumb"
|
||||
|
||||
@@ -117,11 +117,7 @@ function AlertActionButtons({
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch
|
||||
onChange={toggleAlertRule}
|
||||
value={!isAlertRuleDisabled}
|
||||
testId="alert-actions-toggle"
|
||||
/>
|
||||
<Switch onChange={toggleAlertRule} value={!isAlertRuleDisabled} />
|
||||
)}
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
@@ -133,7 +129,6 @@ function AlertActionButtons({
|
||||
<Tooltip title="More options">
|
||||
<Button
|
||||
type="text"
|
||||
data-testid="alert-actions-menu"
|
||||
icon={
|
||||
<Ellipsis
|
||||
size={16}
|
||||
|
||||
@@ -47,29 +47,21 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
<div className="alert-info__info-wrapper">
|
||||
<div className="top-section">
|
||||
<div className="alert-title-wrapper">
|
||||
<div data-testid="alert-header-state">
|
||||
<AlertState state={alertRuleState ?? state ?? ''} />
|
||||
</div>
|
||||
<div className="alert-title" data-testid="alert-header-title">
|
||||
<AlertState state={alertRuleState ?? state ?? ''} />
|
||||
<div className="alert-title">
|
||||
<LineClampedText text={displayName || ''} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bottom-section">
|
||||
{labels?.severity && (
|
||||
<div data-testid="alert-header-severity">
|
||||
<AlertSeverity severity={labels.severity} />
|
||||
</div>
|
||||
)}
|
||||
{labels?.severity && <AlertSeverity severity={labels.severity} />}
|
||||
|
||||
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
|
||||
{/* <AlertStatus
|
||||
status="firing"
|
||||
timestamp={dayjs().subtract(1, 'd').valueOf()}
|
||||
/> */}
|
||||
<div data-testid="alert-header-labels">
|
||||
<AlertLabels labels={labelsWithoutSeverity} />
|
||||
</div>
|
||||
<AlertLabels labels={labelsWithoutSeverity} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { BellOff, CircleCheck, CircleOff, Flame } from '@signozhq/icons';
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import './AlertState.styles.scss';
|
||||
|
||||
type AlertStateProps = {
|
||||
state: RuletypesAlertStateDTO | string;
|
||||
state: string;
|
||||
showLabel?: boolean;
|
||||
};
|
||||
|
||||
@@ -18,7 +17,7 @@ export default function AlertState({
|
||||
let label;
|
||||
const isDarkMode = useIsDarkMode();
|
||||
switch (state) {
|
||||
case RuletypesAlertStateDTO.nodata:
|
||||
case 'nodata':
|
||||
icon = (
|
||||
<CircleOff
|
||||
size={18}
|
||||
@@ -29,7 +28,7 @@ export default function AlertState({
|
||||
label = <span style={{ color: Color.BG_SIENNA_400 }}>No Data</span>;
|
||||
break;
|
||||
|
||||
case RuletypesAlertStateDTO.disabled:
|
||||
case 'disabled':
|
||||
icon = (
|
||||
<BellOff
|
||||
size={18}
|
||||
@@ -39,16 +38,15 @@ export default function AlertState({
|
||||
);
|
||||
label = <span style={{ color: Color.BG_VANILLA_400 }}>Muted</span>;
|
||||
break;
|
||||
|
||||
case RuletypesAlertStateDTO.firing:
|
||||
case 'firing':
|
||||
icon = (
|
||||
<Flame size={18} fill={Color.BG_CHERRY_500} color={Color.BG_CHERRY_500} />
|
||||
);
|
||||
label = <span style={{ color: Color.BG_CHERRY_500 }}>Firing</span>;
|
||||
break;
|
||||
|
||||
case RuletypesAlertStateDTO.inactive:
|
||||
case 'normal': // legacy
|
||||
case 'normal':
|
||||
case 'inactive':
|
||||
icon = (
|
||||
<CircleCheck
|
||||
size={18}
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useMutation, useQueryClient, useQuery } from 'react-query';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from 'react-query';
|
||||
import { generatePath, useLocation } from 'react-router-dom';
|
||||
import { TablePaginationConfig, TableProps } from 'antd';
|
||||
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||
import { patchRulePartial } from 'api/alerts/patchRulePartial';
|
||||
import ruleStats from 'api/alerts/ruleStats';
|
||||
import timelineGraph from 'api/alerts/timelineGraph';
|
||||
import timelineTable from 'api/alerts/timelineTable';
|
||||
import topContributors from 'api/alerts/topContributors';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
createRule,
|
||||
deleteRuleByID,
|
||||
getGetRuleByIDQueryKey,
|
||||
getGetRuleHistoryTimelineQueryOptions,
|
||||
invalidateGetRuleByID,
|
||||
invalidateListRules,
|
||||
updateRuleByID,
|
||||
useGetRuleByID,
|
||||
useGetRuleHistoryOverallStatus,
|
||||
useGetRuleHistoryStats,
|
||||
useGetRuleHistoryTopContributors,
|
||||
useListRules,
|
||||
} from 'api/generated/services/rules';
|
||||
import {
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
RuletypesAlertStateDTO,
|
||||
type GetRuleByID200,
|
||||
type GetRuleHistoryOverallStatus200,
|
||||
type GetRuleHistoryStats200,
|
||||
type GetRuleHistoryTimeline200,
|
||||
type GetRuleHistoryTopContributors200,
|
||||
type RenderErrorResponseDTO,
|
||||
type RuletypesPostableRuleDTO,
|
||||
import type {
|
||||
GetRuleByID200,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesPostableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
@@ -37,27 +31,35 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AlertHistory from 'container/AlertHistory';
|
||||
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
|
||||
import {
|
||||
computeCursorForPage,
|
||||
useTimelineTableOrder,
|
||||
useTimelineTablePage,
|
||||
} from 'container/AlertHistory/Timeline/Table/useTimelineTableCursor';
|
||||
import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
|
||||
import { urlKey } from 'container/AllError/utils';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import history from 'lib/history';
|
||||
import { History, Table } from '@signozhq/icons';
|
||||
import EditRules from 'pages/EditRules';
|
||||
import { OrderPreferenceItems } from 'pages/Logs/config';
|
||||
import BetaTag from 'periscope/components/BetaTag/BetaTag';
|
||||
import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
|
||||
import { AlertDef, AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import {
|
||||
AlertDef,
|
||||
AlertRuleStatsPayload,
|
||||
AlertRuleTimelineGraphResponsePayload,
|
||||
AlertRuleTimelineTableResponse,
|
||||
AlertRuleTimelineTableResponsePayload,
|
||||
AlertRuleTopContributorsPayload,
|
||||
} from 'types/api/alerts/def';
|
||||
import APIError from 'types/api/error';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { nanoToMilli } from 'utils/timeUtils';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
export const useAlertHistoryQueryParams = (): {
|
||||
ruleId: string | null;
|
||||
@@ -127,7 +129,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: EditRules,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-overview">
|
||||
<div className="tab-item">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
@@ -138,7 +140,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: AlertHistory,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-history">
|
||||
<div className="tab-item">
|
||||
<History size={14} />
|
||||
History
|
||||
<BetaTag />
|
||||
@@ -199,7 +201,10 @@ type GetAlertRuleDetailsApiProps = {
|
||||
};
|
||||
|
||||
type GetAlertRuleDetailsStatsProps = GetAlertRuleDetailsApiProps & {
|
||||
data: GetRuleHistoryStats200 | undefined;
|
||||
data:
|
||||
| SuccessResponse<AlertRuleStatsPayload, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export const useGetAlertRuleDetailsStats =
|
||||
@@ -208,15 +213,18 @@ export const useGetAlertRuleDetailsStats =
|
||||
|
||||
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
|
||||
|
||||
const { isLoading, isRefetching, isError, data } = useGetRuleHistoryStats(
|
||||
{ id: ruleId || '' },
|
||||
{ start: startTime, end: endTime },
|
||||
const { isLoading, isRefetching, isError, data } = useQuery(
|
||||
[REACT_QUERY_KEY.ALERT_RULE_STATS, ruleId, startTime, endTime],
|
||||
{
|
||||
query: {
|
||||
enabled: isValidRuleId && !!startTime && !!endTime,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
queryFn: () =>
|
||||
ruleStats({
|
||||
id: ruleId || '',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
}),
|
||||
enabled: isValidRuleId && !!startTime && !!endTime,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -224,7 +232,10 @@ export const useGetAlertRuleDetailsStats =
|
||||
};
|
||||
|
||||
type GetAlertRuleDetailsTopContributorsProps = GetAlertRuleDetailsApiProps & {
|
||||
data: GetRuleHistoryTopContributors200 | undefined;
|
||||
data:
|
||||
| SuccessResponse<AlertRuleTopContributorsPayload, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export const useGetAlertRuleDetailsTopContributors =
|
||||
@@ -233,128 +244,90 @@ export const useGetAlertRuleDetailsTopContributors =
|
||||
|
||||
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
|
||||
|
||||
const { isLoading, isRefetching, isError, data } =
|
||||
useGetRuleHistoryTopContributors(
|
||||
{ id: ruleId || '' },
|
||||
{ start: startTime, end: endTime },
|
||||
{
|
||||
query: {
|
||||
enabled: isValidRuleId && !!startTime && !!endTime,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
const { isLoading, isRefetching, isError, data } = useQuery(
|
||||
[REACT_QUERY_KEY.ALERT_RULE_TOP_CONTRIBUTORS, ruleId, startTime, endTime],
|
||||
{
|
||||
queryFn: () =>
|
||||
topContributors({
|
||||
id: ruleId || '',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
}),
|
||||
enabled: isValidRuleId,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
|
||||
};
|
||||
|
||||
type GetAlertRuleDetailsTimelineTableProps = GetAlertRuleDetailsApiProps & {
|
||||
data: GetRuleHistoryTimeline200 | undefined;
|
||||
error: AxiosError<RenderErrorResponseDTO> | null;
|
||||
refetch: () => void;
|
||||
cancel: () => void;
|
||||
data:
|
||||
| SuccessResponse<AlertRuleTimelineTableResponsePayload, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export const useGetAlertRuleDetailsTimelineTable = ({
|
||||
filterExpression,
|
||||
filters,
|
||||
}: {
|
||||
filterExpression: string;
|
||||
filters: TagFilter;
|
||||
}): GetAlertRuleDetailsTimelineTableProps => {
|
||||
const queryClient = useQueryClient();
|
||||
const { ruleId, startTime, endTime, params } = useAlertHistoryQueryParams();
|
||||
const [page, setPage] = useTimelineTablePage();
|
||||
const [order] = useTimelineTableOrder();
|
||||
|
||||
const updatedOrder = useMemo(
|
||||
() =>
|
||||
order === 'asc'
|
||||
? Querybuildertypesv5OrderDirectionDTO.asc
|
||||
: Querybuildertypesv5OrderDirectionDTO.desc,
|
||||
[order],
|
||||
const { updatedOrder, offset } = useMemo(
|
||||
() => ({
|
||||
updatedOrder: params.get(urlKey.order) ?? OrderPreferenceItems.ASC,
|
||||
offset: parseInt(params.get(urlKey.offset) ?? '0', 10),
|
||||
}),
|
||||
[params],
|
||||
);
|
||||
|
||||
const timelineFilter = params.get('timelineFilter');
|
||||
|
||||
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
|
||||
const hasStartAndEnd = startTime !== null && endTime !== null;
|
||||
|
||||
const stateFilter = useMemo(() => {
|
||||
if (!timelineFilter || timelineFilter === TimelineFilter.ALL) {
|
||||
return undefined;
|
||||
}
|
||||
return timelineFilter === TimelineFilter.FIRED
|
||||
? RuletypesAlertStateDTO.firing
|
||||
: RuletypesAlertStateDTO.inactive;
|
||||
}, [timelineFilter]);
|
||||
|
||||
const filtersKey = `${filterExpression}|${stateFilter ?? ''}|${startTime}|${endTime}`;
|
||||
const prevFiltersKeyRef = useRef(filtersKey);
|
||||
const filtersChanged = prevFiltersKeyRef.current !== filtersKey;
|
||||
const cursor = computeCursorForPage(filtersChanged ? 1 : page);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevFiltersKeyRef.current !== filtersKey) {
|
||||
prevFiltersKeyRef.current = filtersKey;
|
||||
if (page > 1) {
|
||||
void setPage(1);
|
||||
}
|
||||
}
|
||||
}, [filtersKey, page, setPage]);
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
limit: TIMELINE_TABLE_PAGE_SIZE,
|
||||
order: updatedOrder,
|
||||
cursor,
|
||||
filterExpression: filterExpression || undefined,
|
||||
state: stateFilter,
|
||||
}),
|
||||
[startTime, endTime, updatedOrder, cursor, filterExpression, stateFilter],
|
||||
);
|
||||
|
||||
const queryOptions = getGetRuleHistoryTimelineQueryOptions(
|
||||
{ id: ruleId || '' },
|
||||
queryParams,
|
||||
const { isLoading, isRefetching, isError, data } = useQuery(
|
||||
[
|
||||
REACT_QUERY_KEY.ALERT_RULE_TIMELINE_TABLE,
|
||||
ruleId,
|
||||
startTime,
|
||||
endTime,
|
||||
timelineFilter,
|
||||
updatedOrder,
|
||||
offset,
|
||||
JSON.stringify(filters.items),
|
||||
],
|
||||
{
|
||||
query: {
|
||||
enabled: isValidRuleId,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
queryFn: () =>
|
||||
timelineTable({
|
||||
id: ruleId || '',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
limit: TIMELINE_TABLE_PAGE_SIZE,
|
||||
order: updatedOrder,
|
||||
offset,
|
||||
filters,
|
||||
...(timelineFilter && timelineFilter !== TimelineFilter.ALL
|
||||
? {
|
||||
state: timelineFilter === TimelineFilter.FIRED ? 'firing' : 'normal',
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
enabled: isValidRuleId && hasStartAndEnd,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const { isLoading, isRefetching, isError, data, error, refetch } =
|
||||
useQuery(queryOptions);
|
||||
|
||||
const queryKeyRef = useRef(queryOptions.queryKey);
|
||||
queryKeyRef.current = queryOptions.queryKey;
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
void queryClient.cancelQueries({ queryKey: queryKeyRef.current });
|
||||
}, [queryClient]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isRefetching,
|
||||
isError,
|
||||
data,
|
||||
error: error as AxiosError<RenderErrorResponseDTO> | null,
|
||||
isValidRuleId,
|
||||
ruleId,
|
||||
refetch,
|
||||
cancel,
|
||||
};
|
||||
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
|
||||
};
|
||||
|
||||
export const useTimelineTable = ({
|
||||
totalItems,
|
||||
nextCursor,
|
||||
}: {
|
||||
totalItems: number;
|
||||
nextCursor?: string;
|
||||
}): {
|
||||
paginationConfig: TablePaginationConfig;
|
||||
onChangeHandler: (
|
||||
@@ -363,13 +336,16 @@ export const useTimelineTable = ({
|
||||
filters: any,
|
||||
extra: any,
|
||||
) => void;
|
||||
handleNextPage: () => void;
|
||||
handlePrevPage: () => void;
|
||||
hasNextPage: boolean;
|
||||
hasPrevPage: boolean;
|
||||
} => {
|
||||
const [page, setPage] = useTimelineTablePage();
|
||||
const [, setOrder] = useTimelineTableOrder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const { search } = useLocation();
|
||||
|
||||
const params = useMemo(() => new URLSearchParams(search), [search]);
|
||||
|
||||
const offset = params.get('offset') ?? '0';
|
||||
|
||||
const onChangeHandler: TableProps<AlertRuleTimelineTableResponse>['onChange'] =
|
||||
useCallback(
|
||||
@@ -381,52 +357,38 @@ export const useTimelineTable = ({
|
||||
| SorterResult<AlertRuleTimelineTableResponse>,
|
||||
) => {
|
||||
if (!Array.isArray(sorter)) {
|
||||
const { pageSize = 0, current = 0 } = pagination;
|
||||
const { order } = sorter;
|
||||
const updatedOrder = order === 'ascend' ? 'asc' : 'desc';
|
||||
void Promise.all([setOrder(updatedOrder), setPage(1)]);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
safeNavigate(
|
||||
`${pathname}?${createQueryParams({
|
||||
...Object.fromEntries(params),
|
||||
order: updatedOrder,
|
||||
offset: current * TIMELINE_TABLE_PAGE_SIZE - TIMELINE_TABLE_PAGE_SIZE,
|
||||
pageSize,
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[setOrder, setPage],
|
||||
[pathname, safeNavigate],
|
||||
);
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
if (!nextCursor) {
|
||||
return;
|
||||
}
|
||||
void setPage(page + 1);
|
||||
}, [nextCursor, page, setPage]);
|
||||
|
||||
const handlePrevPage = useCallback(() => {
|
||||
if (page <= 1) {
|
||||
return;
|
||||
}
|
||||
void setPage(page - 1);
|
||||
}, [page, setPage]);
|
||||
const offsetInt = parseInt(offset, 10);
|
||||
const pageSize = params.get('pageSize') ?? String(TIMELINE_TABLE_PAGE_SIZE);
|
||||
const pageSizeInt = parseInt(pageSize, 10);
|
||||
|
||||
const paginationConfig: TablePaginationConfig = {
|
||||
pageSize: TIMELINE_TABLE_PAGE_SIZE,
|
||||
showTotal: (total, [start, end]) => (
|
||||
<span>
|
||||
<Typography.Text size="small">
|
||||
{start} — {end}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="small"> of {total}</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
current: page,
|
||||
pageSize: pageSizeInt,
|
||||
showTotal: PaginationInfoText,
|
||||
current: offsetInt / TIMELINE_TABLE_PAGE_SIZE + 1,
|
||||
showSizeChanger: false,
|
||||
hideOnSinglePage: true,
|
||||
total: totalItems,
|
||||
};
|
||||
|
||||
return {
|
||||
paginationConfig,
|
||||
onChangeHandler,
|
||||
handleNextPage,
|
||||
handlePrevPage,
|
||||
hasNextPage: !!nextCursor,
|
||||
hasPrevPage: page > 1,
|
||||
};
|
||||
return { paginationConfig, onChangeHandler };
|
||||
};
|
||||
|
||||
export const useAlertRuleStatusToggle = ({
|
||||
@@ -619,7 +581,10 @@ export const useAlertRuleDelete = ({
|
||||
};
|
||||
|
||||
type GetAlertRuleDetailsTimelineGraphProps = GetAlertRuleDetailsApiProps & {
|
||||
data: GetRuleHistoryOverallStatus200 | undefined;
|
||||
data:
|
||||
| SuccessResponse<AlertRuleTimelineGraphResponsePayload, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export const useGetAlertRuleDetailsTimelineGraphData =
|
||||
@@ -629,18 +594,20 @@ export const useGetAlertRuleDetailsTimelineGraphData =
|
||||
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
|
||||
const hasStartAndEnd = startTime !== null && endTime !== null;
|
||||
|
||||
const { isLoading, isRefetching, isError, data } =
|
||||
useGetRuleHistoryOverallStatus(
|
||||
{ id: ruleId || '' },
|
||||
{ start: startTime, end: endTime },
|
||||
{
|
||||
query: {
|
||||
enabled: isValidRuleId && hasStartAndEnd,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
const { isLoading, isRefetching, isError, data } = useQuery(
|
||||
[REACT_QUERY_KEY.ALERT_RULE_TIMELINE_GRAPH, ruleId, startTime, endTime],
|
||||
{
|
||||
queryFn: () =>
|
||||
timelineGraph({
|
||||
id: ruleId || '',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
}),
|
||||
enabled: isValidRuleId && hasStartAndEnd,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { CSSProperties, useCallback } from 'react';
|
||||
import { CSSProperties, type MouseEvent, useCallback } from 'react';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
|
||||
|
||||
import styles from './CopyButton.module.scss';
|
||||
import { useCopyButton } from './useCopyButton';
|
||||
|
||||
export interface CopyButtonProps {
|
||||
/** Text written to the clipboard on click. */
|
||||
@@ -30,11 +30,15 @@ function CopyButton({
|
||||
className,
|
||||
testId,
|
||||
}: CopyButtonProps): JSX.Element {
|
||||
const { copyToClipboard, isCopied } = useCopyToClipboard();
|
||||
const { copyToClipboard, isCopied } = useCopyButton();
|
||||
|
||||
const handleClick = useCallback((): void => {
|
||||
copyToClipboard(value);
|
||||
}, [copyToClipboard, value]);
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>): void => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(value);
|
||||
},
|
||||
[copyToClipboard, value],
|
||||
);
|
||||
|
||||
const stackStyle: CSSProperties = { width: size, height: size };
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import CopyButton from '../CopyButton';
|
||||
|
||||
const mockCopy = jest.fn();
|
||||
|
||||
// Exercise the real useCopyButton — stub only react-use's underlying copy so the
|
||||
// click doesn't hit copy-to-clipboard's jsdom fallback (window.prompt).
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
|
||||
}));
|
||||
|
||||
describe('CopyButton', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
mockCopy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('copies its value on click', async () => {
|
||||
render(<CopyButton value="hello" testId="copy" />);
|
||||
|
||||
await user.click(screen.getByTestId('copy'));
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('hello');
|
||||
});
|
||||
|
||||
it('does not trigger parent click handlers (stops propagation)', async () => {
|
||||
const onParentClick = jest.fn();
|
||||
render(
|
||||
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
<div onClick={onParentClick}>
|
||||
<CopyButton value="x" testId="copy" />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('copy'));
|
||||
|
||||
expect(onParentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the copied state after clicking and reverts after the reset window', async () => {
|
||||
const { container } = render(<CopyButton value="hello" testId="copy" />);
|
||||
const iconStack = container.querySelector('[data-copied]') as HTMLElement;
|
||||
|
||||
expect(iconStack).toHaveAttribute('data-copied', 'false');
|
||||
|
||||
await user.click(screen.getByTestId('copy'));
|
||||
expect(iconStack).toHaveAttribute('data-copied', 'true');
|
||||
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
expect(iconStack).toHaveAttribute('data-copied', 'false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
/** How long (ms) CopyButton stays in the "copied" state before reverting. */
|
||||
export const COPIED_RESET_MS = 1000;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import { COPIED_RESET_MS } from './constants';
|
||||
|
||||
export interface UseCopyButtonReturn {
|
||||
copyToClipboard: (text: string) => void;
|
||||
isCopied: boolean;
|
||||
}
|
||||
|
||||
export function useCopyButton(): UseCopyButtonReturn {
|
||||
const [, copy] = useCopyToClipboard();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = useCallback(
|
||||
(text: string): void => {
|
||||
copy(text);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setIsCopied(true);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
timeoutRef.current = null;
|
||||
}, COPIED_RESET_MS);
|
||||
},
|
||||
[copy],
|
||||
);
|
||||
|
||||
return {
|
||||
copyToClipboard,
|
||||
isCopied,
|
||||
};
|
||||
}
|
||||
@@ -13,8 +13,6 @@ interface Tab {
|
||||
disabled?: boolean;
|
||||
icon?: string | JSX.Element;
|
||||
isBeta?: boolean;
|
||||
/** Optional `data-testid` for the tab button. */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
interface TimelineTabsProps {
|
||||
@@ -65,7 +63,6 @@ function Tabs2({
|
||||
disabled={tab.disabled}
|
||||
icon={tab.icon}
|
||||
style={{ minWidth: buttonMinWidth }}
|
||||
data-testid={tab.testId}
|
||||
>
|
||||
{tab.label}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface IAppContext {
|
||||
isFetchingHosts: boolean;
|
||||
isFetchingFeatureFlags: boolean;
|
||||
isFetchingOrgPreferences: boolean;
|
||||
isFetchingUserPreferences: boolean;
|
||||
userFetchError: unknown;
|
||||
activeLicenseFetchError: APIError | null;
|
||||
hostsFetchError: unknown;
|
||||
|
||||
@@ -242,6 +242,7 @@ export function getAppContextMock(
|
||||
userPreferences: [],
|
||||
updateUserPreferenceInContext: jest.fn(),
|
||||
isFetchingOrgPreferences: false,
|
||||
isFetchingUserPreferences: false,
|
||||
orgPreferencesFetchError: null,
|
||||
isLoggedIn: true,
|
||||
isPreflightLoading: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AlertLabelsProps } from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
|
||||
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||
|
||||
// default match type for threshold
|
||||
@@ -73,6 +73,10 @@ export interface StatsTimeSeriesItem {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type AlertRuleStatsPayload = {
|
||||
data: AlertRuleStats;
|
||||
};
|
||||
|
||||
export interface AlertRuleTopContributors {
|
||||
fingerprint: number;
|
||||
labels: Labels;
|
||||
@@ -80,6 +84,9 @@ export interface AlertRuleTopContributors {
|
||||
relatedLogsLink: string;
|
||||
relatedTracesLink: string;
|
||||
}
|
||||
export type AlertRuleTopContributorsPayload = {
|
||||
data: AlertRuleTopContributors[];
|
||||
};
|
||||
|
||||
export interface AlertRuleTimelineTableResponse {
|
||||
ruleID: string;
|
||||
@@ -92,12 +99,24 @@ export interface AlertRuleTimelineTableResponse {
|
||||
labels: Labels;
|
||||
fingerprint: number;
|
||||
value: number;
|
||||
relatedLogsLink?: string;
|
||||
relatedTracesLink?: string;
|
||||
relatedTracesLink: string;
|
||||
relatedLogsLink: string;
|
||||
}
|
||||
export type AlertRuleTimelineTableResponsePayload = {
|
||||
data: {
|
||||
items: AlertRuleTimelineTableResponse[];
|
||||
total: number;
|
||||
labels: AlertLabelsProps['labels'];
|
||||
};
|
||||
};
|
||||
|
||||
type AlertState = 'firing' | 'normal' | 'nodata' | 'muted';
|
||||
|
||||
export interface AlertRuleTimelineGraphResponse {
|
||||
start: number;
|
||||
end: number;
|
||||
state: RuletypesAlertStateDTO;
|
||||
state: AlertState;
|
||||
}
|
||||
export type AlertRuleTimelineGraphResponsePayload = {
|
||||
data: AlertRuleTimelineGraphResponse[];
|
||||
};
|
||||
|
||||
7
frontend/src/types/api/alerts/ruleStats.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { AlertDef } from './def';
|
||||
|
||||
export interface RuleStatsProps {
|
||||
id: AlertDef['id'];
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
7
frontend/src/types/api/alerts/timelineGraph.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { AlertDef } from './def';
|
||||
|
||||
export interface GetTimelineGraphRequestProps {
|
||||
id: AlertDef['id'];
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
13
frontend/src/types/api/alerts/timelineTable.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { TagFilter } from '../queryBuilder/queryBuilderData';
|
||||
import { AlertDef } from './def';
|
||||
|
||||
export interface GetTimelineTableRequestProps {
|
||||
id: AlertDef['id'];
|
||||
start: number;
|
||||
end: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
order: string;
|
||||
filters?: TagFilter;
|
||||
state?: string;
|
||||
}
|
||||
7
frontend/src/types/api/alerts/topContributors.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { AlertDef } from './def';
|
||||
|
||||
export interface TopContributorsProps {
|
||||
id: AlertDef['id'];
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
@@ -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>>;
|
||||
|
||||
2
go.mod
@@ -4,7 +4,7 @@ go 1.25.7
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.2
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.3
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/SigNoz/clickhouse-go-mock v0.14.0
|
||||
|
||||
4
go.sum
@@ -66,8 +66,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.2 h1:KCxdmvuVawVF4yl0ZS33q7olgmLZwvZG5BjWHp6o414=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.2/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.3 h1:6iap8XGjuSjD3w7r1UNrg66ljBugcv2P39s4eo/ZLRw=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.3/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
|
||||
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
|
||||
|
||||
298
pkg/alertmanager/alertmanagernotify/googlechat/googlechat.go
Normal 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: ¬ify.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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
95
pkg/alertmanager/alertmanagernotify/googlechat/types.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||