Compare commits
19 Commits
feat/alert
...
email-aler
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
186f065902 | ||
|
|
7770d81a8d | ||
|
|
2ad17e16dd | ||
|
|
c9726c5fd6 | ||
|
|
e7ab03e47f | ||
|
|
110d5971a7 | ||
|
|
7cc728a9c8 | ||
|
|
bad3850117 | ||
|
|
5a8ca9573c | ||
|
|
e62bfb4a4c | ||
|
|
51d5d3ea35 | ||
|
|
7eb3f7df32 | ||
|
|
5eb3b5e3e0 | ||
|
|
b88ee12cd5 | ||
|
|
6f3dd0b7ad | ||
|
|
d32d93cd39 | ||
|
|
f928b02afb | ||
|
|
e9a931788c | ||
|
|
775c82a1bd |
2
.github/workflows/integrationci.yaml
vendored
@@ -39,6 +39,8 @@ jobs:
|
||||
matrix:
|
||||
suite:
|
||||
- alerts
|
||||
- alertmanager
|
||||
- alertmanagerrotation
|
||||
- basepath
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
|
||||
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,
|
||||
|
||||
@@ -45,4 +45,5 @@ export enum LOCALSTORAGE {
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
|
||||
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,18 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
mockUseAuthZDenyAll,
|
||||
mockUseAuthZGrantAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
// Admin gating on the write controls flows through useAuthZ. Mock it directly
|
||||
// (synchronous) so the functional tests stay synchronous; the read-only block
|
||||
// below flips it to deny-all.
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
import {
|
||||
GROUPS_ENDPOINT,
|
||||
makeGroupsResponse,
|
||||
@@ -102,6 +114,8 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
beforeEach(() => {
|
||||
// Reset URL state between tests — jsdom shares window.location across a file.
|
||||
window.history.pushState(null, '', '/');
|
||||
// Default to an admin; the read-only block overrides this.
|
||||
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -507,4 +521,55 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// The write APIs (create/update/delete group & mapper) are Admin-only on the
|
||||
// backend, so a non-admin gets a read-only view: the data renders, but every
|
||||
// write control is hidden.
|
||||
describe('read-only (non-admin)', () => {
|
||||
beforeEach(() => {
|
||||
mockedUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
|
||||
});
|
||||
|
||||
it('hides the "Add a new group" button', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
expect(screen.queryByTestId('add-group-row')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the group enable toggle and actions menu', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
expect(
|
||||
screen.queryByTestId('group-enabled-group-1'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('group-actions-group-1'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders mappers read-only: no "Add mapping" button or row actions', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model' })]);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
|
||||
// The mapper still renders...
|
||||
await screen.findByTestId('mapper-target-mapper-1');
|
||||
// ...but every write control is gone.
|
||||
expect(screen.queryByTestId('add-mapper-group-1')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('mapper-enabled-mapper-1'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Mapping actions' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
|
||||
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
|
||||
import GroupActionsMenu from '../GroupActionsMenu/GroupActionsMenu';
|
||||
import styles from './GroupHeaderActions.module.scss';
|
||||
|
||||
@@ -16,7 +17,11 @@ function GroupHeaderActions({
|
||||
onToggle,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: GroupHeaderActionsProps): JSX.Element {
|
||||
}: GroupHeaderActionsProps): JSX.Element | null {
|
||||
const canManage = useCanManageAttributeMapping();
|
||||
if (!canManage) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={styles.actions}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Mapper,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
|
||||
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
|
||||
import { COLUMN_COUNT } from '../constants';
|
||||
import MapperRow from '../MapperRow/MapperRow';
|
||||
import MapperRowSkeleton from '../MapperRow/MapperRowSkeleton';
|
||||
@@ -39,6 +40,7 @@ function GroupMappers({
|
||||
onEditMapper,
|
||||
}: GroupMappersProps): JSX.Element {
|
||||
const { hydrateGroupMappers, removeMapper, toggleMapper } = editor;
|
||||
const canManage = useCanManageAttributeMapping();
|
||||
|
||||
const hasServerId = group.serverId !== null;
|
||||
const { data, isLoading, isError } = useListSpanMappers(
|
||||
@@ -144,16 +146,20 @@ function GroupMappers({
|
||||
/>
|
||||
));
|
||||
|
||||
// The add-mapping row trails every non-error state (including loading/empty).
|
||||
// The add-mapping row trails every non-error state (including loading/empty),
|
||||
// but only for users who can manage mappings — non-admins get a read-only view.
|
||||
let rows: JSX.Element[];
|
||||
if (isErrorMappers) {
|
||||
rows = [errorRow];
|
||||
} else if (isLoadingMappers && mapperCount === 0) {
|
||||
rows = [...skeletonRows, addMapperRow];
|
||||
rows = [...skeletonRows];
|
||||
} else if (mapperCount === 0) {
|
||||
rows = [emptyRow, addMapperRow];
|
||||
rows = [emptyRow];
|
||||
} else {
|
||||
rows = [...mapperRows, addMapperRow];
|
||||
rows = [...mapperRows];
|
||||
}
|
||||
if (canManage && !isErrorMappers) {
|
||||
rows.push(addMapperRow);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ import cx from 'classnames';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import { DraftMapper } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
|
||||
import MapperActionsMenu from '../MapperActionsMenu/MapperActionsMenu';
|
||||
import styles from './MapperRow.module.scss';
|
||||
|
||||
@@ -30,6 +31,7 @@ function MapperRow({
|
||||
onRemove,
|
||||
onToggle,
|
||||
}: MapperRowProps): JSX.Element {
|
||||
const canManage = useCanManageAttributeMapping();
|
||||
const sources = mapper.sources ?? [];
|
||||
const visibleSources = sources.slice(0, MAX_VISIBLE_SOURCES);
|
||||
const remainingSources = sources.length - visibleSources.length;
|
||||
@@ -101,14 +103,16 @@ function MapperRow({
|
||||
)}
|
||||
</td>
|
||||
<td className={cx(styles.cell, styles.actionsCell)}>
|
||||
<div className={styles.rowActions}>
|
||||
<Switch
|
||||
value={mapper.enabled}
|
||||
onChange={(checked): void => onToggle(mapper.localId, checked)}
|
||||
testId={`mapper-enabled-${mapper.localId}`}
|
||||
/>
|
||||
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className={styles.rowActions}>
|
||||
<Switch
|
||||
value={mapper.enabled}
|
||||
onChange={(checked): void => onToggle(mapper.localId, checked)}
|
||||
testId={`mapper-enabled-${mapper.localId}`}
|
||||
/>
|
||||
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DraftMapper,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
|
||||
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
|
||||
import GroupHeader from './GroupHeader/GroupHeader';
|
||||
import GroupHeaderActions from './GroupHeaderActions/GroupHeaderActions';
|
||||
import GroupMappers from './GroupMappers/GroupMappers';
|
||||
@@ -33,6 +34,7 @@ function MappingsTable({
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||
const [targetGroupId, setTargetGroupId] = useState<string | null>(null);
|
||||
const drawer = useMapperFormDrawer();
|
||||
const canManage = useCanManageAttributeMapping();
|
||||
|
||||
const { upsertMapper, removeMapper } = editor;
|
||||
|
||||
@@ -120,18 +122,20 @@ function MappingsTable({
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrapper}>
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onAddGroup}
|
||||
testId="add-group-row"
|
||||
disabled={editor.isLoading}
|
||||
>
|
||||
Add a new group
|
||||
</Button>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onAddGroup}
|
||||
testId="add-group-row"
|
||||
disabled={editor.isLoading}
|
||||
>
|
||||
Add a new group
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty ? (
|
||||
<div className={styles.tableState} data-testid="mapper-groups-empty">
|
||||
|
||||
@@ -8,12 +8,18 @@ import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
|
||||
import DiscardChangesDialog from './components/DiscardChangesDialog/DiscardChangesDialog';
|
||||
import GroupFormDrawer from './components/GroupFormDrawer/GroupFormDrawer';
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import TestTab from './TestTab/TestTab';
|
||||
import { useAttributeMappingEditor } from './hooks/useAttributeMappingEditor';
|
||||
import { useGroupFormDrawer } from './components/GroupFormDrawer/hooks/useGroupFormDrawer';
|
||||
import { useTestSpanMapper } from './TestTab/useTestSpanMapper';
|
||||
|
||||
const MAPPINGS_TAB_KEY = 'attribute-mappings';
|
||||
const TEST_TAB_KEY = 'test';
|
||||
|
||||
function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
const editor = useAttributeMappingEditor();
|
||||
const groupDrawer = useGroupFormDrawer();
|
||||
const spanTest = useTestSpanMapper(editor.snapshot, editor.groups);
|
||||
|
||||
const { discard } = editor;
|
||||
// Discarding wipes the whole working copy, so gate it behind a confirm
|
||||
@@ -31,7 +37,7 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'attribute-mappings',
|
||||
key: MAPPINGS_TAB_KEY,
|
||||
label: 'Attribute Mappings',
|
||||
children: (
|
||||
<AttributeMappingsTab
|
||||
@@ -42,11 +48,9 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'test',
|
||||
key: TEST_TAB_KEY,
|
||||
label: 'Test',
|
||||
disabled: true,
|
||||
disabledReason: 'Coming soon',
|
||||
children: null,
|
||||
children: <TestTab spanTest={spanTest} />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -71,7 +75,7 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
|
||||
<Tabs
|
||||
testId="attribute-mapping-tabs"
|
||||
defaultValue="attribute-mappings"
|
||||
defaultValue={MAPPINGS_TAB_KEY}
|
||||
items={tabItems}
|
||||
/>
|
||||
{groupDrawer.isOpen && (
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
.skeleton {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.lineNumber {
|
||||
flex: 0 0 40px;
|
||||
padding-right: var(--padding-3);
|
||||
text-align: right;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
opacity: 0.6;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.lineContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 8px;
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--l2-border);
|
||||
animation: jsonSkeletonPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bar {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jsonSkeletonPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import styles from './JsonEditorSkeleton.module.scss';
|
||||
|
||||
interface SkeletonLine {
|
||||
indent: number;
|
||||
barWidths: number[];
|
||||
}
|
||||
|
||||
const SKELETON_LINES: SkeletonLine[] = [
|
||||
{ indent: 0, barWidths: [10] },
|
||||
{ indent: 1, barWidths: [90, 10] },
|
||||
{ indent: 2, barWidths: [155, 190] },
|
||||
{ indent: 2, barWidths: [135, 190] },
|
||||
{ indent: 2, barWidths: [150, 50] },
|
||||
{ indent: 2, barWidths: [180, 35] },
|
||||
{ indent: 2, barWidths: [185, 200] },
|
||||
{ indent: 1, barWidths: [14] },
|
||||
{ indent: 1, barWidths: [75, 10] },
|
||||
{ indent: 2, barWidths: [95, 90] },
|
||||
{ indent: 2, barWidths: [170, 85] },
|
||||
{ indent: 1, barWidths: [10] },
|
||||
{ indent: 0, barWidths: [10] },
|
||||
];
|
||||
|
||||
const INDENT_WIDTH_PX = 14;
|
||||
|
||||
function JsonEditorSkeleton(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.skeleton}
|
||||
data-testid="json-editor-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{SKELETON_LINES.map((line, lineIndex) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={lineIndex} className={styles.line}>
|
||||
<span className={styles.lineNumber}>{lineIndex + 1}</span>
|
||||
<span
|
||||
className={styles.lineContent}
|
||||
style={{ paddingLeft: line.indent * INDENT_WIDTH_PX }}
|
||||
>
|
||||
{line.barWidths.map((width, barIndex) => (
|
||||
<span
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={barIndex}
|
||||
className={styles.bar}
|
||||
style={{ width }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonEditorSkeleton;
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { SpantypesSpanMapperTestSpanDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
AttrChangeStatus,
|
||||
AttrDiffEntry,
|
||||
diffAttributeMaps,
|
||||
formatAttributeValue,
|
||||
} from './testPayload';
|
||||
import styles from './TestTab.module.scss';
|
||||
import { TestTabAttributes, TestTabResource } from './useTestSpanMapper';
|
||||
|
||||
interface TestResultProps {
|
||||
index: number;
|
||||
span: SpantypesSpanMapperTestSpanDTO;
|
||||
inputAttributes: TestTabAttributes;
|
||||
inputResource: TestTabResource;
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Partial<
|
||||
Record<
|
||||
AttrChangeStatus,
|
||||
{ color: 'success' | 'robin' | 'sienna'; label: string }
|
||||
>
|
||||
> = {
|
||||
added: { color: 'success', label: 'populated' },
|
||||
changed: { color: 'robin', label: 'remapped' },
|
||||
removed: { color: 'sienna', label: 'moved out' },
|
||||
};
|
||||
|
||||
const ROW_CLASS: Partial<Record<AttrChangeStatus, string>> = {
|
||||
added: styles.added,
|
||||
removed: styles.removed,
|
||||
};
|
||||
|
||||
interface ResultSection {
|
||||
key: string;
|
||||
title: string;
|
||||
entries: AttrDiffEntry[];
|
||||
}
|
||||
|
||||
function TestResult({
|
||||
index,
|
||||
span,
|
||||
inputAttributes,
|
||||
inputResource,
|
||||
}: TestResultProps): JSX.Element {
|
||||
const attributeEntries = useMemo(
|
||||
() => diffAttributeMaps(inputAttributes, span.attributes ?? {}),
|
||||
[inputAttributes, span.attributes],
|
||||
);
|
||||
const resourceEntries = useMemo(
|
||||
() => diffAttributeMaps(inputResource, span.resource ?? {}),
|
||||
[inputResource, span.resource],
|
||||
);
|
||||
|
||||
const sections: ResultSection[] = [
|
||||
{
|
||||
key: 'attributes',
|
||||
title: 'Resulting attributes',
|
||||
entries: attributeEntries,
|
||||
},
|
||||
];
|
||||
if (resourceEntries.length > 0) {
|
||||
sections.push({
|
||||
key: 'resource',
|
||||
title: 'Resulting resource',
|
||||
entries: resourceEntries,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.resultCard} data-testid={`test-result-${index}`}>
|
||||
{sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className={styles.resultSection}
|
||||
data-testid={`test-result-${index}-${section.key}`}
|
||||
>
|
||||
<div className={styles.resultTitle}>{section.title}</div>
|
||||
|
||||
{section.entries.length === 0 ? (
|
||||
<div className={styles.resultEmpty}>No keys in this map.</div>
|
||||
) : (
|
||||
<div className={styles.attrRows}>
|
||||
{section.entries.map((entry) => {
|
||||
const badge = STATUS_BADGE[entry.status];
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
className={`${styles.attrRow} ${ROW_CLASS[entry.status] ?? ''}`}
|
||||
>
|
||||
<span className={styles.attrKey} title={entry.key}>
|
||||
{entry.key}
|
||||
</span>
|
||||
<span
|
||||
className={styles.attrValue}
|
||||
title={formatAttributeValue(entry.value)}
|
||||
>
|
||||
{formatAttributeValue(entry.value)}
|
||||
</span>
|
||||
{badge ? (
|
||||
<Badge color={badge.color} variant="outline">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestResult;
|
||||
@@ -0,0 +1,177 @@
|
||||
.testTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
margin-top: var(--spacing-6);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.headerText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-6);
|
||||
height: calc(100vh - 320px);
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.validationCallout {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// No top padding so results align with the editor's first line, which sits
|
||||
// flush against the top of its own panel.
|
||||
.resultsPanel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: var(--padding-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
// Pre-run empty state that fills the blank right half.
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-2);
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.placeholderTitle {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.resultCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.resultSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.resultTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.resultEmpty {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.attrRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.attrRow {
|
||||
display: grid;
|
||||
grid-template-columns: clamp(200px, 40%, 340px) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--padding-3);
|
||||
border-radius: var(--radius-2);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
&.added {
|
||||
background: var(--callout-success-background);
|
||||
}
|
||||
|
||||
&.removed {
|
||||
opacity: 0.65;
|
||||
|
||||
.attrKey,
|
||||
.attrValue {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attrKey,
|
||||
.attrValue {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.attrKey {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.attrValue {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import MEditor from '@monaco-editor/react';
|
||||
import { Play, RotateCcw } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import styles from './TestTab.module.scss';
|
||||
import JsonEditorSkeleton from './JsonEditorSkeleton';
|
||||
import TestResult from './TestResult';
|
||||
import {
|
||||
defineSignozJsonTheme,
|
||||
SIGNOZ_JSON_THEME_DARK,
|
||||
SIGNOZ_JSON_THEME_LIGHT,
|
||||
} from './jsonEditorTheme';
|
||||
import { UseTestSpanMapper } from './useTestSpanMapper';
|
||||
|
||||
interface TestTabProps {
|
||||
spanTest: UseTestSpanMapper;
|
||||
}
|
||||
|
||||
function TestTab({ spanTest }: TestTabProps): JSX.Element {
|
||||
const {
|
||||
input,
|
||||
setInput,
|
||||
run,
|
||||
resetToTemplate,
|
||||
isTemplateInput,
|
||||
isRunning,
|
||||
result,
|
||||
testedAttributes,
|
||||
testedResource,
|
||||
error,
|
||||
validationError,
|
||||
} = spanTest;
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
function renderResults(): JSX.Element {
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.error} role="alert" data-testid="test-error">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (result) {
|
||||
if (result.length === 0) {
|
||||
return (
|
||||
<div className={styles.resultEmpty} data-testid="test-results-empty">
|
||||
No spans returned. The mappers produced no output for this input.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.results} data-testid="test-results">
|
||||
{result.map((span, index) => (
|
||||
<TestResult
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
index={index}
|
||||
span={span}
|
||||
inputAttributes={testedAttributes ?? {}}
|
||||
inputResource={testedResource ?? {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.placeholder} data-testid="test-results-placeholder">
|
||||
<span className={styles.placeholderTitle}>No results yet</span>
|
||||
<span>
|
||||
Run the test to see which target attributes your mappers populate.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.testTab} data-testid="test-tab">
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerText}>
|
||||
<h3 className={styles.heading}>Test with sample span</h3>
|
||||
<p className={styles.description}>
|
||||
Paste a JSON span object to see which target attributes get populated and
|
||||
which source key matched.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.headerActions}>
|
||||
<Button
|
||||
testId="reset-template-button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<RotateCcw size={14} />}
|
||||
onClick={resetToTemplate}
|
||||
disabled={isTemplateInput}
|
||||
>
|
||||
Reset to Default Span
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
testId="run-test-button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Play size={14} />}
|
||||
onClick={run}
|
||||
loading={isRunning}
|
||||
disabled={isRunning || validationError !== null}
|
||||
>
|
||||
Run Test
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div
|
||||
className={styles.editor}
|
||||
data-testid="test-span-input"
|
||||
aria-label="Sample span JSON"
|
||||
>
|
||||
<MEditor
|
||||
language="json"
|
||||
value={input}
|
||||
onChange={(value): void => setInput(value ?? '')}
|
||||
height="100%"
|
||||
loading={<JsonEditorSkeleton />}
|
||||
theme={isDarkMode ? SIGNOZ_JSON_THEME_DARK : SIGNOZ_JSON_THEME_LIGHT}
|
||||
beforeMount={defineSignozJsonTheme}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
formatOnPaste: true,
|
||||
tabSize: 2,
|
||||
automaticLayout: true,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.resultsPanel} data-testid="test-results-panel">
|
||||
{renderResults()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
title={validationError}
|
||||
testId="test-input-error"
|
||||
className={styles.validationCallout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestTab;
|
||||
@@ -0,0 +1,38 @@
|
||||
//TODO: This is a local theme for now, but ideally for the editor as well. Just like prettyViewer, we have a theme. We should have a theme for the editor as well.
|
||||
import { Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
export const SIGNOZ_JSON_THEME_DARK = 'signoz-attr-mapping-json-dark';
|
||||
export const SIGNOZ_JSON_THEME_LIGHT = 'signoz-attr-mapping-json-light';
|
||||
|
||||
const SHARED_THEME = {
|
||||
inherit: true,
|
||||
colors: {
|
||||
'editor.background': '#00000000', // transparent — inherit the panel bg
|
||||
},
|
||||
fontFamily: 'SF Mono, Geist Mono, Fira Code, monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: 'normal',
|
||||
lineHeight: 18,
|
||||
letterSpacing: -0.06,
|
||||
};
|
||||
|
||||
export function defineSignozJsonTheme(monaco: Monaco): void {
|
||||
monaco.editor.defineTheme(SIGNOZ_JSON_THEME_DARK, {
|
||||
...SHARED_THEME,
|
||||
base: 'vs-dark',
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_ROBIN_400 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_VANILLA_400 },
|
||||
],
|
||||
});
|
||||
|
||||
monaco.editor.defineTheme(SIGNOZ_JSON_THEME_LIGHT, {
|
||||
...SHARED_THEME,
|
||||
base: 'vs',
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_ROBIN_500 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_INK_400 },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import get from 'api/browser/localstorage/get';
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { parseSpanInput } from './testPayload';
|
||||
|
||||
export const SAMPLE_SPAN_JSON = `{
|
||||
"attributes": {
|
||||
"my_company.llm.input": "What is quantum computing?",
|
||||
"llm.input_messages": "What is quantum computing?",
|
||||
"gen_ai.request.model": "gpt-4",
|
||||
"gen_ai.usage.total_tokens": 1250,
|
||||
"gen_ai.content.completion": "Quantum computing leverages..."
|
||||
},
|
||||
"resource": {
|
||||
"service.name": "llm-gateway",
|
||||
"deployment.environment": "production"
|
||||
}
|
||||
}`;
|
||||
|
||||
function hasNoSpanData(input: string): boolean {
|
||||
let span;
|
||||
try {
|
||||
span = parseSpanInput(input);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
Object.keys(span.attributes ?? {}).length === 0 &&
|
||||
Object.keys(span.resource ?? {}).length === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function getStoredSpanInput(): string {
|
||||
const stored = get(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN);
|
||||
if (!stored?.trim() || hasNoSpanData(stored)) {
|
||||
return SAMPLE_SPAN_JSON;
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function setStoredSpanInput(value: string): void {
|
||||
if (!value.trim() || hasNoSpanData(value)) {
|
||||
remove(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN);
|
||||
return;
|
||||
}
|
||||
set(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN, value);
|
||||
}
|
||||
|
||||
export function clearStoredSpanInput(): void {
|
||||
remove(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesPostableSpanMapperTestGroupDTO,
|
||||
SpantypesSpanMapperTestSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import { DraftGroup } from '../types';
|
||||
import {
|
||||
buildPostableGroup,
|
||||
buildPostableMapper,
|
||||
groupDraftFromNode,
|
||||
mapperDraftFromNode,
|
||||
} from '../utils';
|
||||
|
||||
function shouldSendMappers(
|
||||
snap: DraftGroup | undefined,
|
||||
group: DraftGroup,
|
||||
): boolean {
|
||||
if (!snap) {
|
||||
return true;
|
||||
}
|
||||
return !isEqual(snap.mappers, group.mappers);
|
||||
}
|
||||
|
||||
export function buildTestGroups(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
): SpantypesPostableSpanMapperTestGroupDTO[] {
|
||||
const snapById = new Map(
|
||||
snapshot
|
||||
.filter((group) => group.serverId)
|
||||
.map((group) => [group.serverId as string, group]),
|
||||
);
|
||||
|
||||
return draft.map((group) => {
|
||||
const base = buildPostableGroup(groupDraftFromNode(group));
|
||||
const snap = group.serverId ? snapById.get(group.serverId) : undefined;
|
||||
return {
|
||||
...base,
|
||||
mappers: shouldSendMappers(snap, group)
|
||||
? group.mappers.map((mapper) =>
|
||||
buildPostableMapper(mapperDraftFromNode(mapper)),
|
||||
)
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isSpanEnvelope(parsed: Record<string, unknown>): boolean {
|
||||
const keys = Object.keys(parsed);
|
||||
return (
|
||||
keys.length > 0 &&
|
||||
keys.every((key) => key === 'attributes' || key === 'resource') &&
|
||||
(isPlainObject(parsed.attributes) || isPlainObject(parsed.resource))
|
||||
);
|
||||
}
|
||||
|
||||
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Paste a JSON span object to run the test.');
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Invalid JSON — check for trailing commas or missing quotes.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPlainObject(parsed)) {
|
||||
throw new Error('Span must be a JSON object of attribute key-value pairs.');
|
||||
}
|
||||
|
||||
if (isSpanEnvelope(parsed)) {
|
||||
return {
|
||||
attributes: isPlainObject(parsed.attributes) ? parsed.attributes : {},
|
||||
resource: isPlainObject(parsed.resource) ? parsed.resource : {},
|
||||
};
|
||||
}
|
||||
|
||||
return { attributes: parsed, resource: {} };
|
||||
}
|
||||
|
||||
export function buildTestRequest(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
input: string,
|
||||
): SpantypesPostableSpanMapperTestDTO {
|
||||
return {
|
||||
groups: buildTestGroups(snapshot, draft),
|
||||
spans: [parseSpanInput(input)],
|
||||
};
|
||||
}
|
||||
|
||||
export type AttrChangeStatus = 'added' | 'changed' | 'unchanged' | 'removed';
|
||||
|
||||
export interface AttrDiffEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
status: AttrChangeStatus;
|
||||
}
|
||||
|
||||
// Renders a diff value for display: strings as-is, everything else JSON-encoded.
|
||||
export function formatAttributeValue(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function diffAttributeMaps(
|
||||
inputAttributes: Record<string, unknown>,
|
||||
resultAttributes: Record<string, unknown>,
|
||||
): AttrDiffEntry[] {
|
||||
const added: AttrDiffEntry[] = [];
|
||||
const changed: AttrDiffEntry[] = [];
|
||||
const unchanged: AttrDiffEntry[] = [];
|
||||
const removed: AttrDiffEntry[] = [];
|
||||
|
||||
Object.entries(resultAttributes).forEach(([key, value]) => {
|
||||
if (!(key in inputAttributes)) {
|
||||
added.push({ key, value, status: 'added' });
|
||||
} else if (!isEqual(inputAttributes[key], value)) {
|
||||
changed.push({ key, value, status: 'changed' });
|
||||
} else {
|
||||
unchanged.push({ key, value, status: 'unchanged' });
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(inputAttributes).forEach(([key, value]) => {
|
||||
if (!(key in resultAttributes)) {
|
||||
removed.push({ key, value, status: 'removed' });
|
||||
}
|
||||
});
|
||||
|
||||
return [...added, ...changed, ...unchanged, ...removed];
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesSpanMapperTestSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useTestSpanMappers } from 'api/generated/services/spanmapper';
|
||||
import { AxiosError } from 'axios';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
||||
import { buildTestRequest, parseSpanInput } from './testPayload';
|
||||
import {
|
||||
clearStoredSpanInput,
|
||||
getStoredSpanInput,
|
||||
SAMPLE_SPAN_JSON,
|
||||
setStoredSpanInput,
|
||||
} from './spanInputStorage';
|
||||
import { DraftGroup } from '../types';
|
||||
|
||||
export type TestTabAttributes = Record<string, unknown>;
|
||||
export type TestTabResource = Record<string, unknown>;
|
||||
|
||||
const PERSIST_DEBOUNCE_MS = 500;
|
||||
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
const axiosError = error as AxiosError<RenderErrorResponseDTO>;
|
||||
return (
|
||||
axiosError?.response?.data?.error?.message ??
|
||||
(error instanceof Error ? error.message : 'Test failed. Please try again.')
|
||||
);
|
||||
}
|
||||
|
||||
export interface UseTestSpanMapper {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
run: () => void;
|
||||
reset: () => void;
|
||||
resetToTemplate: () => void;
|
||||
isTemplateInput: boolean;
|
||||
isRunning: boolean;
|
||||
validationError: string | null;
|
||||
result: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
testedAttributes: TestTabAttributes | null;
|
||||
testedResource: TestTabResource | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useTestSpanMapper(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
): UseTestSpanMapper {
|
||||
const [input, setInputValue] = useState<string>(getStoredSpanInput);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<SpantypesSpanMapperTestSpanDTO[] | null>(
|
||||
null,
|
||||
);
|
||||
const [testedAttributes, setTestedAttributes] =
|
||||
useState<TestTabAttributes | null>(null);
|
||||
const [testedResource, setTestedResource] = useState<TestTabResource | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { mutate, isLoading } = useTestSpanMappers();
|
||||
|
||||
const persistInput = useMemo(
|
||||
() => debounce(setStoredSpanInput, PERSIST_DEBOUNCE_MS),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
persistInput.flush();
|
||||
},
|
||||
[persistInput],
|
||||
);
|
||||
|
||||
const setInput = useCallback(
|
||||
(value: string): void => {
|
||||
setInputValue(value);
|
||||
persistInput(value);
|
||||
},
|
||||
[persistInput],
|
||||
);
|
||||
|
||||
const validationError = useMemo((): string | null => {
|
||||
try {
|
||||
parseSpanInput(input);
|
||||
return null;
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : 'Invalid span JSON.';
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setTestedAttributes(null);
|
||||
setTestedResource(null);
|
||||
}, []);
|
||||
|
||||
const resetToTemplate = useCallback((): void => {
|
||||
persistInput.cancel();
|
||||
clearStoredSpanInput();
|
||||
setInputValue(SAMPLE_SPAN_JSON);
|
||||
reset();
|
||||
}, [persistInput, reset]);
|
||||
|
||||
const isTemplateInput = input.trim() === SAMPLE_SPAN_JSON;
|
||||
|
||||
const run = useCallback((): void => {
|
||||
reset();
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = buildTestRequest(snapshot, draft, input);
|
||||
} catch (parseError) {
|
||||
setError(apiErrorMessage(parseError));
|
||||
return;
|
||||
}
|
||||
|
||||
const submittedSpan = body.spans?.[0];
|
||||
const submittedAttributes = (submittedSpan?.attributes ??
|
||||
{}) as TestTabAttributes;
|
||||
const submittedResource = (submittedSpan?.resource ?? {}) as TestTabResource;
|
||||
|
||||
mutate(
|
||||
{ data: body },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
setTestedAttributes(submittedAttributes);
|
||||
setTestedResource(submittedResource);
|
||||
setResult(response.data?.spans ?? []);
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setResult(null);
|
||||
setError(apiErrorMessage(mutationError));
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [snapshot, draft, input, mutate, reset]);
|
||||
|
||||
return {
|
||||
input,
|
||||
setInput,
|
||||
run,
|
||||
reset,
|
||||
resetToTemplate,
|
||||
isTemplateInput,
|
||||
isRunning: isLoading,
|
||||
validationError,
|
||||
result,
|
||||
testedAttributes,
|
||||
testedResource,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,32 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
// The header's Save/Discard and the group toggle are Admin-gated via useAuthZ;
|
||||
// render as an admin so those controls are present.
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
// Monaco can't run in jsdom — stand in a textarea so the span input is editable.
|
||||
jest.mock('@monaco-editor/react', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next?: string) => void;
|
||||
}): JSX.Element => (
|
||||
<textarea
|
||||
aria-label="span-json-editor"
|
||||
data-testid="span-json-editor"
|
||||
value={value}
|
||||
onChange={(event): void => onChange(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
|
||||
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
|
||||
|
||||
@@ -16,6 +42,7 @@ describe('LLMObservabilityAttributeMapping', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
setupGroups();
|
||||
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -86,6 +113,26 @@ describe('LLMObservabilityAttributeMapping', () => {
|
||||
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the sample span input when switching away from the test tab and back', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Test' }));
|
||||
const editor = await screen.findByTestId('span-json-editor');
|
||||
await user.clear(editor);
|
||||
await user.type(editor, '{{ "my.attr": 1 }');
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Attribute Mappings' }));
|
||||
await screen.findByTestId('attribute-mappings-tab');
|
||||
expect(screen.queryByTestId('span-json-editor')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Test' }));
|
||||
|
||||
await expect(screen.findByTestId('span-json-editor')).resolves.toHaveValue(
|
||||
'{ "my.attr": 1 }',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps staged changes when the discard prompt is dismissed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { useCanManageAttributeMapping } from '../../hooks/useCanManageAttributeMapping';
|
||||
import styles from './AttributeMappingHeader.module.scss';
|
||||
|
||||
interface AttributeMappingHeaderProps {
|
||||
@@ -16,12 +17,13 @@ function AttributeMappingHeader({
|
||||
onDiscard,
|
||||
onSave,
|
||||
}: AttributeMappingHeaderProps): JSX.Element {
|
||||
const canManage = useCanManageAttributeMapping();
|
||||
return (
|
||||
<header className={styles.pageHeader}>
|
||||
<Typography.Text as="p" size="base" color="muted">
|
||||
Configure source-to-target attribute remapping for LLM traces
|
||||
</Typography.Text>
|
||||
{isDirty && (
|
||||
{canManage && isDirty && (
|
||||
<div className={styles.pageHeaderActions}>
|
||||
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
|
||||
Unsaved changes
|
||||
|
||||
@@ -35,6 +35,7 @@ function clone(groups: DraftGroup[]): DraftGroup[] {
|
||||
|
||||
export interface AttributeMappingEditor {
|
||||
groups: DraftGroup[];
|
||||
snapshot: DraftGroup[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isDirty: boolean;
|
||||
@@ -304,6 +305,7 @@ export function useAttributeMappingEditor(): AttributeMappingEditor {
|
||||
|
||||
return {
|
||||
groups: draft ?? [],
|
||||
snapshot,
|
||||
isLoading: !ready || draft === null,
|
||||
isError: groupsQuery.isError,
|
||||
isDirty,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsAdminPermission } from 'lib/authz/hooks/useAuthZ/legacy';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
const ADMIN_PERMISSION = [IsAdminPermission];
|
||||
|
||||
export function useCanManageAttributeMapping(): boolean {
|
||||
const { allowed } = useAuthZ(ADMIN_PERMISSION);
|
||||
return allowed;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -756,10 +756,14 @@ function QueryBuilderSearchV2(
|
||||
|
||||
let operatorOptions;
|
||||
if (currentFilterItem?.key?.dataType) {
|
||||
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES[
|
||||
currentFilterItem.key
|
||||
.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
|
||||
].map((operator) => ({
|
||||
// Fallback to universal suggestions if no match found for currentFilter dataType
|
||||
const operatorsForDataType =
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES[
|
||||
currentFilterItem.key
|
||||
.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
|
||||
] ?? QUERY_BUILDER_OPERATORS_BY_TYPES.universal;
|
||||
|
||||
operatorOptions = operatorsForDataType.map((operator) => ({
|
||||
label: operator,
|
||||
value: operator,
|
||||
}));
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
RenderResult,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import { render, RenderResult, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValues,
|
||||
@@ -91,6 +86,9 @@ const renderWithContext = (props = {}): RenderResult => {
|
||||
);
|
||||
};
|
||||
|
||||
const getSearchCombobox = (): HTMLElement =>
|
||||
within(screen.getByTestId('qb-search-select')).getByRole('combobox');
|
||||
|
||||
// Constants to fix linter errors
|
||||
const TYPE_TAG = 'tag';
|
||||
const IS_COLUMN_FALSE = false;
|
||||
@@ -113,6 +111,14 @@ const mockAggregateKeysData = {
|
||||
isJSON: IS_JSON_FALSE,
|
||||
id: 'service.name--string--tag--false',
|
||||
},
|
||||
{
|
||||
key: 'unmapped.attribute',
|
||||
dataType: 'not-a-real-data-type' as unknown as DataTypes,
|
||||
type: TYPE_TAG,
|
||||
isColumn: IS_COLUMN_FALSE,
|
||||
isJSON: IS_JSON_FALSE,
|
||||
id: 'unmapped.attribute--String--tag--false',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -165,41 +171,28 @@ jest.mock('hooks/dashboard/useDashboardVariables', () => ({
|
||||
}));
|
||||
|
||||
describe('Suggestion Key -> Operator -> Value Flow', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('should complete full flow from key selection to value', async () => {
|
||||
const { container } = renderWithContext();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderWithContext();
|
||||
|
||||
// Get the combobox input specifically
|
||||
const combobox = container.querySelector(
|
||||
'.query-builder-search-v2 .ant-select-selection-search-input',
|
||||
) as HTMLInputElement;
|
||||
const combobox = getSearchCombobox();
|
||||
|
||||
// 1. Focus and type to trigger key suggestions
|
||||
await act(async () => {
|
||||
fireEvent.focus(combobox);
|
||||
fireEvent.change(combobox, { target: { value: 'http.' } });
|
||||
});
|
||||
await user.click(combobox);
|
||||
await user.type(combobox, 'http.');
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await screen.findByRole('listbox');
|
||||
|
||||
// 2. Select a key from suggestions
|
||||
const statusOption = await screen.findByText('http.status');
|
||||
await act(async () => {
|
||||
fireEvent.click(statusOption);
|
||||
});
|
||||
await user.click(await screen.findByText('http.status'));
|
||||
|
||||
// Should show operator suggestions
|
||||
expect(screen.getByText('=')).toBeInTheDocument();
|
||||
expect(screen.getByText('!=')).toBeInTheDocument();
|
||||
|
||||
// 3. Select an operator
|
||||
const equalsOption = screen.getByText('=');
|
||||
await act(async () => {
|
||||
fireEvent.click(equalsOption);
|
||||
});
|
||||
await user.click(screen.getByText('='));
|
||||
|
||||
// Should show value suggestions
|
||||
expect(screen.getByText('200')).toBeInTheDocument();
|
||||
@@ -207,10 +200,7 @@ describe('Suggestion Key -> Operator -> Value Flow', () => {
|
||||
expect(screen.getByText('500')).toBeInTheDocument();
|
||||
|
||||
// 4. Select a value
|
||||
const valueOption = screen.getByText('200');
|
||||
await act(async () => {
|
||||
fireEvent.click(valueOption);
|
||||
});
|
||||
await user.click(screen.getByText('200'));
|
||||
|
||||
// Verify final filter
|
||||
expect(mockOnChange).toHaveBeenCalledWith(
|
||||
@@ -227,39 +217,52 @@ describe('Suggestion Key -> Operator -> Value Flow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dynamic Variable Suggestions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
describe('Operator suggestions for data types missing from the operators map', () => {
|
||||
it('should fall back to universal operators for a non-canonical data type', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderWithContext();
|
||||
|
||||
const combobox = getSearchCombobox();
|
||||
|
||||
await user.click(combobox);
|
||||
await user.type(combobox, 'unmapped.');
|
||||
|
||||
await screen.findByRole('listbox');
|
||||
|
||||
await user.click(await screen.findByText('unmapped.attribute'));
|
||||
|
||||
expect(screen.getByText('=')).toBeInTheDocument();
|
||||
expect(screen.getByText('!=')).toBeInTheDocument();
|
||||
expect(screen.getByText('>')).toBeInTheDocument();
|
||||
expect(screen.getByText('<')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('REGEX')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('='));
|
||||
|
||||
expect(combobox).toHaveDisplayValue(/unmapped\.attribute =/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dynamic Variable Suggestions', () => {
|
||||
it('should suggest dynamic variable when key matches a variable attribute', async () => {
|
||||
const { container } = renderWithContext();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderWithContext();
|
||||
|
||||
// Get the combobox input
|
||||
const combobox = container.querySelector(
|
||||
'.query-builder-search-v2 .ant-select-selection-search-input',
|
||||
) as HTMLInputElement;
|
||||
const combobox = getSearchCombobox();
|
||||
|
||||
// Focus and type to trigger key suggestions for service.name
|
||||
await act(async () => {
|
||||
fireEvent.focus(combobox);
|
||||
fireEvent.change(combobox, { target: { value: 'service.' } });
|
||||
});
|
||||
await user.click(combobox);
|
||||
await user.type(combobox, 'service.');
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await screen.findByRole('listbox');
|
||||
|
||||
// Select service.name key from suggestions
|
||||
const serviceNameOption = await screen.findByText('service.name');
|
||||
await act(async () => {
|
||||
fireEvent.click(serviceNameOption);
|
||||
});
|
||||
await user.click(await screen.findByText('service.name'));
|
||||
|
||||
// Select equals operator
|
||||
await act(async () => {
|
||||
const equalsOption = screen.getByText('=');
|
||||
fireEvent.click(equalsOption);
|
||||
});
|
||||
await user.click(screen.getByText('='));
|
||||
|
||||
// Should show value suggestions including the dynamic variable
|
||||
// For 'service.name', we expect to see '$service' as the first suggestion
|
||||
@@ -271,9 +274,7 @@ describe('Dynamic Variable Suggestions', () => {
|
||||
expect(screen.getByText('404')).toBeInTheDocument();
|
||||
|
||||
// Select the variable suggestion
|
||||
await act(async () => {
|
||||
fireEvent.click(variableSuggestion);
|
||||
});
|
||||
await user.click(variableSuggestion);
|
||||
|
||||
// Verify the query was updated with the variable as value
|
||||
expect(mockOnChange).toHaveBeenCalledWith(
|
||||
|
||||
@@ -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,10 @@
|
||||
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';
|
||||
import { expandResourceCard, renderCreateRolePage } from './testUtils';
|
||||
|
||||
async function expandAllCards(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const expandButton = await screen.findByTestId('expand-all-button');
|
||||
await user.click(expandButton);
|
||||
}
|
||||
jest.setTimeout(15_000);
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
@@ -21,35 +14,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 +33,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 +44,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 +61,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 +73,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 +85,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 +99,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 +113,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,8 +124,8 @@ describe('PermissionEditor', () => {
|
||||
|
||||
describe('action toggles', () => {
|
||||
it('renders action toggles for each available action', async () => {
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
expect(
|
||||
@@ -169,8 +143,8 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
it('defaults all actions to None scope', async () => {
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -187,8 +161,8 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('changes scope to All when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -208,8 +182,8 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('updates granted count when scope changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -227,8 +201,8 @@ describe('PermissionEditor', () => {
|
||||
describe('Only Selected scope', () => {
|
||||
it('shows item input selector when Only Selected is chosen', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -239,13 +213,15 @@ 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 expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -254,7 +230,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,8 +240,8 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('adds item when Add button clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -272,10 +250,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,8 +265,8 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('adds multiple items separated by comma', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -293,7 +275,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,8 +287,8 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('adds multiple items separated by space', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -313,7 +297,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,8 +309,8 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('does not add duplicate items', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -333,7 +319,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,8 +331,8 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('removes item when X clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -353,21 +341,139 @@ 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 expandResourceCard('factor-api-key');
|
||||
|
||||
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 expandResourceCard('factor-api-key');
|
||||
|
||||
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 expandResourceCard('factor-api-key');
|
||||
|
||||
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 expandResourceCard('factor-api-key');
|
||||
|
||||
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 expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -376,7 +482,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,8 +492,8 @@ 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 expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -394,7 +502,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,8 +516,8 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('clears items when confirmed', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -416,7 +526,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,8 +545,8 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('keeps items when cancelled', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -443,7 +555,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,13 +569,15 @@ 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 expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -479,8 +595,8 @@ describe('PermissionEditor', () => {
|
||||
|
||||
describe('verbs without Only Selected option', () => {
|
||||
it('does not show Only Selected for list verb', async () => {
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const listToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -501,7 +617,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,21 +625,23 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
it('expands all cards when expand button clicked', async () => {
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true');
|
||||
await user.click(screen.getByTestId('expand-all-button'));
|
||||
|
||||
const headers = screen.getAllByTestId(/^resource-card-header-/);
|
||||
expect(headers.length).toBeGreaterThan(1);
|
||||
headers.forEach((header) => {
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 +671,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 +708,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,466 @@
|
||||
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 { expandResourceCard, renderCreateRolePage } from './testUtils';
|
||||
|
||||
jest.setTimeout(15_000);
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
async function selectOnlySelected(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
resource = 'logs',
|
||||
): Promise<void> {
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard(resource);
|
||||
|
||||
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 expandResourceCard('factor-api-key');
|
||||
|
||||
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(
|
||||
within(apiKeyCard).queryByTestId(
|
||||
'telemetry-wizard-trigger-factor-api-key-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,35 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render, screen, userEvent, within } 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 expandResourceCard(resourceId: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const card = await screen.findByTestId(`resource-card-${resourceId}`);
|
||||
await user.click(
|
||||
within(card).getByTestId(`resource-card-header-${resourceId}`),
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||