Compare commits

...

9 Commits

Author SHA1 Message Date
Vikrant Gupta
e62bfb4a4c feat(authz): add frontend support for telemetry resources (#12292)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat(authz): expose telemetry resources in generated frontend permissions

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

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

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

* fix(pr): address comments

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

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

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

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

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

* fix(private): drop feature flag gate

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

* feat(authz): resolve conflicts

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

* fix(pr): address more ux feedback

---------

Co-authored-by: Vinicius Lourenço <12551007+H4ad@users.noreply.github.com>
Co-authored-by: Vinícius Lourenço <vinicius@signoz.io>
2026-07-30 07:18:31 +00:00
Aditya Singh
51d5d3ea35 fix: remove navigator clipboard use while using copy btn (#12333)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(copy-button): use focus-independent clipboard copy

navigator.clipboard.writeText requires document focus and rejects with
"Document is not focused" in remote-desktop/VDI/multi-monitor setups.
Copy through react-use's useCopyToClipboard (execCommand) via a colocated
useCopyButton hook that also owns the transient "copied" state.

Fixes SIGNOZ-UI-5J4

* refactor(legend): reuse CopyButton for the copy action

Replace the duplicated inline copy button with the shared CopyButton.
Each button now owns its own copied state, so the per-item id tracking
is no longer needed.

* chore(hooks): remove unused useCopyToClipboard hook

No longer imported after CopyButton and the legend moved to
useCopyButton.
2026-07-29 18:51:19 +00:00
Gaurav Tewari
7eb3f7df32 fix(logs): open log details in table (Column) format when activeId is set (#12269)
* fix(logs): open log details in table (Column) format when activeId is set

Opening a log link (?activeLogId=...) and switching to Column/table format
left the highlighted row un-openable — clicking it did nothing.

Root cause: the shared TanStackTable decided open vs. close internally from
`isRowActive` + `onRowDeactivate`. LogsExplorerList had overloaded `isRowActive`
with `|| log.id === activeLogId`, so a URL-highlighted-but-closed row looked
"active" and the first click ran the deactivate (close) path instead of opening.

Fix (PR 1 of 2): move click routing to the consumer.
- Remove `onRowDeactivate` from TanStackTable; it took no args and only fired
  when re-clicking the same active row (clicking A→B never deactivated A).
- `onRowClick` now receives a third `{ isActive }` context arg (additive; the
  ~14 existing consumers are unaffected).
- LogsExplorerList and LiveLogsList own the toggle:
  `isActive ? handleCloseLogDetail() : handleSetActiveLog(log)`, and
  `isRowActive` tracks only `activeLog?.id`.

The linked-row visual highlight (`isHighlighted`) is deferred to PR 2 (blocked
on a design decision for the highlight color); see
frontend/docs/tdd-tanstack-row-click-routing.md.

* feat(logs): highlight the URL-linked (activeLogId) row in table format (#12271)

Restores the linked-row highlight that the TanStack migration (#10946)
dropped from the table view, without coupling it to click routing.

Uses the table's existing `getRowClassName` hook (styling-only) — no new
shared-table prop:
- LogsExplorerList / LiveLogsList set getRowClassName to 'logs-linked-row'
  when `log.id === activeLogId`.
- A global, lowest-specificity rule paints `.logs-linked-row td` using the
  per-row `--row-active-bg` var (already set via getRowStyle). The table's own
  :hover / .tableRowActive cell rules are higher-specificity !important, so
  active/hover correctly win when a linked row is also open or hovered.

`isRowActive` stays `activeLog?.id` only, so the highlighted row still opens on
first click (relies on the routing fix in the base PR).

Color reuses --row-active-bg as a placeholder; a distinct "linked" token is a
follow-up pending design.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>

* chore: remove comments

* chore: remove extra comments

* feat: add e2e for flows

* chore: add test id

* chore: chore: remove comment

* chore: remove comment

* chore: remove comment

* chore: add comment

* chore: remove comment

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-29 18:24:30 +00:00
Aditya Singh
5eb3b5e3e0 test(query-builder): make suggestion-fetch debounce overridable to deflake CodeMirror spec (#12336) 2026-07-29 18:22:27 +00:00
Vinicius Lourenço
b88ee12cd5 chore(docs): add authz guide (#12317)
* chore(docs): add authz guide

* chore(assets): clarify what withAuthZContent means
2026-07-29 17:18:15 +00:00
Pandey
6f3dd0b7ad fix(querybuilder): give each clickhouse sql refusal its own error code (#12339)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(querybuilder): give each clickhouse sql refusal its own error code

Every refusal used CodeInvalidInput, so the only way to tell why a statement was
refused was to match on the message. Counting the failures on a live deployment
meant regexing the parser's text out of the log, and grouping by cause meant
knowing which of five different messages belonged to the same underlying gap.

One code per reason instead, which arrives as exception.code on the warning that
LogIfStatementIsNotValid writes, so the failures can be grouped and alerted on
directly.

Pin the expected code on each failing case, so the mapping is checked rather
than assumed.

* chore(querybuilder): drop the comment above the error codes

The names say it.

* fix(querybuilder): separate the parser panic code from the parse failure

A panic is a parser defect worth alerting on; a parse failure is a grammar gap
that shows up in ordinary traffic. Sharing one code lumps the two together.
2026-07-29 15:36:10 +00:00
Pandey
d32d93cd39 chore(deps): bump clickhouse-sql-parser to v0.5.3 (#12331)
* chore(deps): bump clickhouse-sql-parser to v0.5.3

v0.5.3 stops lexing a signed number after a closing bracket as one literal, so
`(now()-1)` and `arr[1]-1` parse where they used to fail. That was the third of
the three gaps the validator trips over on real dashboard SQL, and the only one
where the statement had to be rewritten to be accepted.

Move the two queries it covers out of the failing set, which is what that test
exists to prompt. On the production corpus the rejection rate goes from 6.1% to
5.6% of v5 query shapes; the remaining two gaps, `interval` used as a column name
and the standard trim(BOTH x FROM y) syntax, are still open upstream.

* chore(querybuilder): link the upstream issue on the signed literal cases
2026-07-29 15:33:00 +00:00
Tushar Vats
e9a931788c chore(statementbuilder): log key adjustment actions at debug level (#12337)
These fired at info on every query build across the audit, logs and traces
builders. The behavior is settled, so drop them to debug and remove the
TODOs that asked for exactly this.
2026-07-29 14:37:09 +00:00
Ashwin Bhatkal
e51c464417 fix(frontend): alias lodash to lodash-es to survive a page-level AMD loader (#12332)
@grafana/data's ESM build imports bare CJS `lodash` in ~28 modules.
lodash's UMD footer checks for an AMD loader before assigning
module.exports, so when a third-party script has already defined
window.define.amd, lodash takes that branch and never exports. rolldown
can't statically detect lodash's dynamic named exports, so those imports
compile to runtime namespace reads against an empty object and every
method is undefined.

lodash-es is the same version, real ESM, and tree-shakes.
2026-07-29 14:03:32 +00:00
69 changed files with 2496 additions and 499 deletions

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 139 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 49 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 86 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

View 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
![Visual rules for structuring a list page](./assets/list-example.svg)
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
![Visual rules for structuring an edit page](./assets/edit-example.svg)
Without `read`:
- Block the content with `withAuthZContent`, not the whole route.
- Keep delete visible (rule 3).
- Keep update blocked (rule 2).
### Drawer
![Visual rules for structuring a drawer](./assets/drawer-example.svg)
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
![Visual rules for structuring a page with quick filters](./assets/quick-filters-example.svg)
## 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.

View File

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

View File

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

View File

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

View File

@@ -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,
@@ -353,7 +357,7 @@ function QuerySearch({
);
const debouncedFetchKeySuggestions = useMemo(
() => debounce(fetchKeySuggestions, 300),
() => debounce(fetchKeySuggestions, SUGGESTION_FETCH_DEBOUNCE_MS),
[fetchKeySuggestions],
);
@@ -584,7 +588,7 @@ function QuerySearch({
);
const debouncedFetchValueSuggestions = useMemo(
() => debounce(fetchValueSuggestions, 300),
() => debounce(fetchValueSuggestions, SUGGESTION_FETCH_DEBOUNCE_MS),
[fetchValueSuggestions],
);

View File

@@ -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',

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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 &&

View File

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

View File

@@ -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', () => {

View File

@@ -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 () => {

View File

@@ -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>}

View File

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

View File

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

View File

@@ -60,3 +60,7 @@
}
}
}
.logs-linked-row td {
background-color: var(--row-active-bg) !important;
}

View File

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

View File

@@ -323,3 +323,7 @@
}
}
}
.logs-linked-row td {
background-color: var(--row-active-bg) !important;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -108,6 +108,7 @@ export function getAppContextMockState(
isFetchingHosts: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
isFetchingUserPreferences: false,
userFetchError: undefined,
activeLicenseFetchError: null,
hostsFetchError: undefined,

View File

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

View File

@@ -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,
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

@@ -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();
});
});
});

View File

@@ -1,10 +1,10 @@
import { CSSProperties, useCallback } from 'react';
import { CSSProperties, type MouseEvent, useCallback } from 'react';
import { Check, Copy } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import cx from 'classnames';
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
import styles from './CopyButton.module.scss';
import { useCopyButton } from './useCopyButton';
export interface CopyButtonProps {
/** Text written to the clipboard on click. */
@@ -30,11 +30,15 @@ function CopyButton({
className,
testId,
}: CopyButtonProps): JSX.Element {
const { copyToClipboard, isCopied } = useCopyToClipboard();
const { copyToClipboard, isCopied } = useCopyButton();
const handleClick = useCallback((): void => {
copyToClipboard(value);
}, [copyToClipboard, value]);
const handleClick = useCallback(
(e: MouseEvent<HTMLButtonElement>): void => {
e.stopPropagation();
copyToClipboard(value);
},
[copyToClipboard, value],
);
const stackStyle: CSSProperties = { width: size, height: size };

View File

@@ -0,0 +1,65 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CopyButton from '../CopyButton';
const mockCopy = jest.fn();
// Exercise the real useCopyButton — stub only react-use's underlying copy so the
// click doesn't hit copy-to-clipboard's jsdom fallback (window.prompt).
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
}));
describe('CopyButton', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
jest.useFakeTimers();
user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
mockCopy.mockClear();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('copies its value on click', async () => {
render(<CopyButton value="hello" testId="copy" />);
await user.click(screen.getByTestId('copy'));
expect(mockCopy).toHaveBeenCalledWith('hello');
});
it('does not trigger parent click handlers (stops propagation)', async () => {
const onParentClick = jest.fn();
render(
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
<div onClick={onParentClick}>
<CopyButton value="x" testId="copy" />
</div>,
);
await user.click(screen.getByTestId('copy'));
expect(onParentClick).not.toHaveBeenCalled();
});
it('shows the copied state after clicking and reverts after the reset window', async () => {
const { container } = render(<CopyButton value="hello" testId="copy" />);
const iconStack = container.querySelector('[data-copied]') as HTMLElement;
expect(iconStack).toHaveAttribute('data-copied', 'false');
await user.click(screen.getByTestId('copy'));
expect(iconStack).toHaveAttribute('data-copied', 'true');
act(() => {
jest.runOnlyPendingTimers();
});
expect(iconStack).toHaveAttribute('data-copied', 'false');
});
});

View File

@@ -0,0 +1,2 @@
/** How long (ms) CopyButton stays in the "copied" state before reverting. */
export const COPIED_RESET_MS = 1000;

View File

@@ -0,0 +1,44 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { COPIED_RESET_MS } from './constants';
export interface UseCopyButtonReturn {
copyToClipboard: (text: string) => void;
isCopied: boolean;
}
export function useCopyButton(): UseCopyButtonReturn {
const [, copy] = useCopyToClipboard();
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return (): void => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
const copyToClipboard = useCallback(
(text: string): void => {
copy(text);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setIsCopied(true);
timeoutRef.current = setTimeout(() => {
setIsCopied(false);
timeoutRef.current = null;
}, COPIED_RESET_MS);
},
[copy],
);
return {
copyToClipboard,
isCopied,
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -143,6 +143,12 @@ export default defineConfig(({ mode }): UserConfig => {
plugins,
resolve: {
alias: {
// @grafana/data imports bare CJS `lodash`, whose UMD footer checks for an
// AMD loader before assigning module.exports. Any third-party script that
// defines window.define.amd first leaves the bundled namespace empty, so
// every lodash method reached through it is undefined at runtime. lodash-es
// is the same version, real ESM, and tree-shakes.
lodash: 'lodash-es',
'@': resolve(__dirname, './src'),
utils: resolve(__dirname, './src/utils'),
types: resolve(__dirname, './src/types'),

2
go.mod
View File

@@ -4,7 +4,7 @@ go 1.25.7
require (
dario.cat/mergo v1.0.2
github.com/AfterShip/clickhouse-sql-parser v0.5.2
github.com/AfterShip/clickhouse-sql-parser v0.5.3
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/SigNoz/clickhouse-go-mock v0.14.0

4
go.sum
View File

@@ -66,8 +66,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/AfterShip/clickhouse-sql-parser v0.5.2 h1:KCxdmvuVawVF4yl0ZS33q7olgmLZwvZG5BjWHp6o414=
github.com/AfterShip/clickhouse-sql-parser v0.5.2/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
github.com/AfterShip/clickhouse-sql-parser v0.5.3 h1:6iap8XGjuSjD3w7r1UNrg66ljBugcv2P39s4eo/ZLRw=
github.com/AfterShip/clickhouse-sql-parser v0.5.3/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=

View File

@@ -9,6 +9,16 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
)
var (
CodeClickHouseSQLParserPanic = errors.MustNewCode("clickhouse_sql_parser_panic")
CodeClickHouseSQLUnparseable = errors.MustNewCode("clickhouse_sql_unparseable")
CodeClickHouseSQLNotSingleStatement = errors.MustNewCode("clickhouse_sql_not_single_statement")
CodeClickHouseSQLNotSelect = errors.MustNewCode("clickhouse_sql_not_select")
CodeClickHouseSQLTableFunction = errors.MustNewCode("clickhouse_sql_table_function")
CodeClickHouseSQLInternalDatabase = errors.MustNewCode("clickhouse_sql_internal_database")
CodeClickHouseSQLReadonlyOverride = errors.MustNewCode("clickhouse_sql_readonly_override")
)
// internalDatabases hold server metadata, credentials and grants rather than telemetry.
var internalDatabases = map[string]struct{}{
"system": {},
@@ -20,30 +30,30 @@ func ErrIfStatementIsNotValid(query string) (err error) {
defer func() {
// The parser has a history of panicking on malformed input rather than returning an error.
if recovered := recover(); recovered != nil {
err = errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid ClickHouse SQL (recovered): %v", recovered)
err = errors.NewInvalidInputf(CodeClickHouseSQLParserPanic, "invalid ClickHouse SQL (recovered): %v", recovered)
}
}()
stmts, parseErr := chparser.NewParser(query).ParseStmts()
if parseErr != nil {
// Wrapped rather than formatted in, so that callers can recover the parser's *ParseError and read the position off it.
return errors.WrapInvalidInputf(parseErr, errors.CodeInvalidInput, "invalid ClickHouse SQL: %s", parseErr.Error())
return errors.WrapInvalidInputf(parseErr, CodeClickHouseSQLUnparseable, "invalid ClickHouse SQL: %s", parseErr.Error())
}
if len(stmts) != 1 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "ClickHouse SQL must contain exactly one statement, found %d statements", len(stmts))
return errors.NewInvalidInputf(CodeClickHouseSQLNotSingleStatement, "ClickHouse SQL must contain exactly one statement, found %d statements", len(stmts))
}
selectQuery, ok := stmts[0].(*chparser.SelectQuery)
if !ok {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "only SELECT statements are allowed in ClickHouse SQL queries")
return errors.NewInvalidInputf(CodeClickHouseSQLNotSelect, "only SELECT statements are allowed in ClickHouse SQL queries")
}
visitor := &chparser.DefaultASTVisitor{Visit: func(node chparser.Expr) error {
switch expr := node.(type) {
case *chparser.TableFunctionExpr:
// Source table functions remain usable in ClickHouse read-only mode.
return errors.NewInvalidInputf(errors.CodeInvalidInput, "ClickHouse table functions are not allowed in SQL queries: %s", chparser.Format(expr.Name))
return errors.NewInvalidInputf(CodeClickHouseSQLTableFunction, "ClickHouse table functions are not allowed in SQL queries: %s", chparser.Format(expr.Name))
case *chparser.TableIdentifier:
// Reading these is unaffected by ClickHouse read-only mode.
@@ -52,13 +62,13 @@ func ErrIfStatementIsNotValid(query string) (err error) {
}
if _, ok := internalDatabases[strings.ToLower(expr.Database.Name)]; ok {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "the ClickHouse %s database is not allowed in SQL queries", expr.Database.Name)
return errors.NewInvalidInputf(CodeClickHouseSQLInternalDatabase, "the ClickHouse %s database is not allowed in SQL queries", expr.Database.Name)
}
case *chparser.SettingExpr:
// A query-level setting takes precedence over the context setting.
if strings.EqualFold(expr.Name.Name, "readonly") {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "the ClickHouse readonly setting cannot be overridden")
return errors.NewInvalidInputf(CodeClickHouseSQLReadonlyOverride, "the ClickHouse readonly setting cannot be overridden")
}
}

View File

@@ -3,6 +3,8 @@ package querybuilder
import (
"testing"
"github.com/SigNoz/signoz/pkg/errors"
chparser "github.com/AfterShip/clickhouse-sql-parser/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -30,10 +32,15 @@ func TestErrIfStatementIsNotValid_Pass(t *testing.T) {
{"TrailingUnterminatedBlockComment", "SELECT count() FROM t /* unterminated"},
// The rule keys on the database, not on the table name.
{"TableNamedSystemInTelemetryDatabase", "SELECT * FROM signoz_logs.system"},
{"SignedLiteralAfterClosingParen", "SELECT (toUnixTimestamp(now()) - 3600)*1000000000"},
{"SignedLiteralAfterClosingParenSpaced", "SELECT (toUnixTimestamp(now()) - 3600)*1000000000"},
// order by interval
{"OrderByInterval", "SELECT toStartOfInterval(timestamp, INTERVAL 1 MINUTE) AS interval ORDER BY interval"},
{"OrderByIntervalAndDirection", "SELECT toStartOfInterval(timestamp, INTERVAL 1 MINUTE) AS `interval` ORDER BY `interval` ASC"},
// Unspaced, so rejected until the parser stopped lexing a signed literal after a
// closing bracket. The spaced form above no longer needs to be spaced.
// https://github.com/AfterShip/clickhouse-sql-parser/issues/286
{"SignedLiteralAfterClosingParenUnspaced", "SELECT now() AS ts, toFloat64(count()) AS value FROM ( SELECT attributes_string['TableName'] AS T, attributes_string['MissingId'] AS M, max(fromUnixTimestamp64Nano(timestamp)) AS last_seen, dateDiff('minute', min(fromUnixTimestamp64Nano(timestamp)), max(fromUnixTimestamp64Nano(timestamp))) AS age_min FROM signoz_logs.distributed_logs_v2 WHERE body='missing_map_record' AND timestamp >= (toUnixTimestamp(now())-3600)*1000000000 GROUP BY T, M ) WHERE age_min >= 20 AND last_seen >= now() - toIntervalMinute(8)"},
{"SignedLiteralAfterClosingParenMinimal", "SELECT (1)-1"},
{"TrimFunction", "SELECT trimBoth('/api/endpoint/', '/');"},
}
@@ -47,49 +54,52 @@ func TestErrIfStatementIsNotValid_Pass(t *testing.T) {
func TestErrIfStatementIsNotValid_Fail(t *testing.T) {
testCases := []struct {
name string
query string
name string
query string
expectedCode errors.Code
}{
// Not a single statement, or not a statement at all.
{"Empty", ""},
{"UnterminatedBlockCommentOnly", "/* x"},
{"Unparseable", "SELECT FROM WHERE"},
{"MultipleStatements", "SELECT 1; DROP TABLE signoz_logs.logs_v2"},
{"Empty", "", CodeClickHouseSQLNotSingleStatement},
{"UnterminatedBlockCommentOnly", "/* x", CodeClickHouseSQLUnparseable},
{"Unparseable", "SELECT FROM WHERE", CodeClickHouseSQLUnparseable},
{"MultipleStatements", "SELECT 1; DROP TABLE signoz_logs.logs_v2", CodeClickHouseSQLNotSingleStatement},
// Parses, but is not a SELECT.
{"Drop", "DROP TABLE signoz_logs.logs_v2"},
{"Insert", "INSERT INTO signoz_logs.logs_v2 SELECT * FROM signoz_logs.logs_v2"},
{"AlterDelete", "ALTER TABLE signoz_logs.logs_v2 DELETE WHERE 1 = 1"},
{"CreateTable", "CREATE TABLE evil (a Int) ENGINE = Memory"},
{"Grant", "GRANT ALL ON *.* TO admin"},
{"Set", "SET readonly = 0"},
{"Drop", "DROP TABLE signoz_logs.logs_v2", CodeClickHouseSQLNotSelect},
{"Insert", "INSERT INTO signoz_logs.logs_v2 SELECT * FROM signoz_logs.logs_v2", CodeClickHouseSQLNotSelect},
{"AlterDelete", "ALTER TABLE signoz_logs.logs_v2 DELETE WHERE 1 = 1", CodeClickHouseSQLNotSelect},
{"CreateTable", "CREATE TABLE evil (a Int) ENGINE = Memory", CodeClickHouseSQLNotSelect},
{"Grant", "GRANT ALL ON *.* TO admin", CodeClickHouseSQLNotSelect},
{"Set", "SET readonly = 0", CodeClickHouseSQLNotSelect},
// These the parser rejects outright rather than classifying.
{"ShowGrants", "SHOW GRANTS"},
{"IntoOutfile", "SELECT * FROM t INTO OUTFILE '/tmp/x.csv'"},
{"ShowGrants", "SHOW GRANTS", CodeClickHouseSQLUnparseable},
{"IntoOutfile", "SELECT * FROM t INTO OUTFILE '/tmp/x.csv'", CodeClickHouseSQLUnparseable},
// Table functions, which read through something other than a telemetry table.
{"UrlTableFunction", "SELECT * FROM url('http://attacker.example/x', CSV, 'a String')"},
{"FileTableFunction", "SELECT * FROM file('/etc/passwd', CSV, 'a String')"},
{"ExecutableTableFunction", "SELECT * FROM executable('script.sh', CSV, 'a String')"},
{"TableFunctionInJoin", "SELECT * FROM t1 JOIN url('http://x', CSV, 'a String') u ON 1 = 1"},
{"TableFunctionInCommonTableExpression", "WITH c AS (SELECT * FROM url('http://x', CSV, 'a String')) SELECT * FROM c"},
{"TableFunctionInWhereSubquery", "SELECT * FROM t WHERE a IN (SELECT * FROM file('/etc/passwd', CSV, 'a String'))"},
{"TableFunctionInUnion", "SELECT * FROM t UNION ALL SELECT * FROM url('http://x', CSV, 'a String')"},
{"UrlTableFunction", "SELECT * FROM url('http://attacker.example/x', CSV, 'a String')", CodeClickHouseSQLTableFunction},
{"FileTableFunction", "SELECT * FROM file('/etc/passwd', CSV, 'a String')", CodeClickHouseSQLTableFunction},
{"ExecutableTableFunction", "SELECT * FROM executable('script.sh', CSV, 'a String')", CodeClickHouseSQLTableFunction},
{"TableFunctionInJoin", "SELECT * FROM t1 JOIN url('http://x', CSV, 'a String') u ON 1 = 1", CodeClickHouseSQLTableFunction},
{"TableFunctionInCommonTableExpression", "WITH c AS (SELECT * FROM url('http://x', CSV, 'a String')) SELECT * FROM c", CodeClickHouseSQLTableFunction},
{"TableFunctionInWhereSubquery", "SELECT * FROM t WHERE a IN (SELECT * FROM file('/etc/passwd', CSV, 'a String'))", CodeClickHouseSQLTableFunction},
{"TableFunctionInUnion", "SELECT * FROM t UNION ALL SELECT * FROM url('http://x', CSV, 'a String')", CodeClickHouseSQLTableFunction},
// Internal databases, which hold grants and server metadata rather than telemetry.
{"SystemUsers", "SELECT * FROM system.users"},
{"SystemUppercase", "SELECT * FROM SYSTEM.USERS"},
{"SystemQuoted", "SELECT count() FROM `system`.`tables`"},
{"SystemInSubquery", "SELECT * FROM (SELECT name FROM system.parts)"},
{"SystemInJoin", "SELECT * FROM signoz_logs.distributed_logs_v2 AS l JOIN system.users AS u ON 1 = 1"},
{"SystemInIntersect", "SELECT * FROM t INTERSECT SELECT * FROM system.users"},
{"InformationSchema", "SELECT * FROM information_schema.tables"},
{"SystemUsers", "SELECT * FROM system.users", CodeClickHouseSQLInternalDatabase},
{"SystemUppercase", "SELECT * FROM SYSTEM.USERS", CodeClickHouseSQLInternalDatabase},
{"SystemQuoted", "SELECT count() FROM `system`.`tables`", CodeClickHouseSQLInternalDatabase},
{"SystemInSubquery", "SELECT * FROM (SELECT name FROM system.parts)", CodeClickHouseSQLInternalDatabase},
{"SystemInJoin", "SELECT * FROM signoz_logs.distributed_logs_v2 AS l JOIN system.users AS u ON 1 = 1", CodeClickHouseSQLInternalDatabase},
{"SystemInIntersect", "SELECT * FROM t INTERSECT SELECT * FROM system.users", CodeClickHouseSQLInternalDatabase},
{"InformationSchema", "SELECT * FROM information_schema.tables", CodeClickHouseSQLInternalDatabase},
// A query-level setting takes precedence over the one the caller applies.
{"ReadonlySettingOverride", "SELECT * FROM t SETTINGS readonly = 0"},
{"ReadonlySettingOverrideAmongOthers", "SELECT * FROM t SETTINGS max_threads = 4, readonly = 0"},
{"ReadonlySettingOverride", "SELECT * FROM t SETTINGS readonly = 0", CodeClickHouseSQLReadonlyOverride},
{"ReadonlySettingOverrideAmongOthers", "SELECT * FROM t SETTINGS max_threads = 4, readonly = 0", CodeClickHouseSQLReadonlyOverride},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
err := ErrIfStatementIsNotValid(testCase.query)
assert.Error(t, err)
assert.True(t, errors.Asc(err, testCase.expectedCode), "expected code %s, got %v", testCase.expectedCode, err)
})
}
}
@@ -122,18 +132,6 @@ func TestErrIfStatementIsNotValid_ShouldPassButFails(t *testing.T) {
expectedStopsAfter: "trim(BOTH '",
fix: "SELECT trimBoth(replaceOne( JSONExtractString(body, 'Action'), 'health_status: ', '' ), ' ')",
},
{
name: "SignedLiteralAfterClosingParen",
query: "SELECT now() AS ts, toFloat64(count()) AS value FROM ( SELECT attributes_string['TableName'] AS T, attributes_string['MissingId'] AS M, max(fromUnixTimestamp64Nano(timestamp)) AS last_seen, dateDiff('minute', min(fromUnixTimestamp64Nano(timestamp)), max(fromUnixTimestamp64Nano(timestamp))) AS age_min FROM signoz_logs.distributed_logs_v2 WHERE body='missing_map_record' AND timestamp >= (toUnixTimestamp(now())-3600)*1000000000 GROUP BY T, M ) WHERE age_min >= 20 AND last_seen >= now() - toIntervalMinute(8)",
expectedStopsAfter: "(toUnixTimestamp(now())",
fix: "SELECT (toUnixTimestamp(now()) - 3600) * 1000000000",
},
{
name: "SignedLiteralAfterClosingParenMinimal",
query: "SELECT (1)-1",
expectedStopsAfter: "(1)",
fix: "SELECT (1) - 1",
},
}
for _, testCase := range testCases {

View File

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

View File

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

View File

@@ -205,7 +205,7 @@ func (b *auditQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[st
}
for _, action := range actions {
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
b.logger.DebugContext(ctx, "key adjustment action", slog.String("action", action))
}
return query

View File

@@ -274,8 +274,7 @@ func (b *logQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[stri
}
for _, action := range actions {
// TODO: change to debug level once we are confident about the behavior
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
b.logger.DebugContext(ctx, "key adjustment action", slog.String("action", action))
}
return query

View File

@@ -127,8 +127,7 @@ func (b *traceQueryStatementBuilder) Build(
}
for _, action := range adjustTraceKeys(keys, &query, requestType) {
// TODO: change to debug level once we are confident about the behavior
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
b.logger.DebugContext(ctx, "key adjustment action", slog.String("action", action))
}
// Create SQL builder
q := sqlbuilder.NewSelectBuilder()

View File

@@ -223,8 +223,7 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
// The CTE only selects spans matching the filter. Aggregations, group by
// and order by run later in buildFinalQuery, so RequestTypeRaw is fine here.
for _, action := range adjustTraceKeys(keys, query, qbtypes.RequestTypeRaw) {
// TODO: change to debug level once we are confident about the behavior
b.stmtBuilder.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
b.stmtBuilder.logger.DebugContext(ctx, "key adjustment action", slog.String("action", action))
}
// Build resource filter CTE for this specific query
@@ -576,7 +575,7 @@ func (b *traceOperatorCTEBuilder) adjustOperatorKeys(ctx context.Context, keys m
b.operator.Order = tmp.Order
for _, action := range actions {
b.stmtBuilder.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
b.stmtBuilder.logger.DebugContext(ctx, "key adjustment action", slog.String("action", action))
}
}

View File

@@ -0,0 +1,112 @@
import { expect, type Locator, type Page } from '@playwright/test';
import { dismissQuickFiltersAnnouncement } from './quick-filters';
const LOGS_EXPLORER_PATH = '/logs/logs-explorer';
const RELATIVE_TIME = '6h';
const FORMAT_OPTIONS_TEST_ID = 'periscope-btn-format-options';
const ROW_TEST_ID_PREFIX = 'logs-table-row-';
const LOG_DETAIL_DRAWER_TEST_ID = 'log-detail-drawer';
export const LINKED_ROW_CLASS = 'logs-linked-row';
type LogsFormat = 'Raw' | 'Default' | 'Column';
const FORMAT_MODES: Record<LogsFormat, string> = {
Raw: 'raw',
Default: 'list',
Column: 'table',
};
export async function gotoLogsExplorer(page: Page): Promise<void> {
await dismissQuickFiltersAnnouncement(page);
await page.goto(`${LOGS_EXPLORER_PATH}?relativeTime=${RELATIVE_TIME}`);
await expect(page.getByTestId(FORMAT_OPTIONS_TEST_ID)).toBeVisible();
}
export async function setLogsFormat(
page: Page,
format: LogsFormat,
): Promise<void> {
const trigger = page.getByTestId(FORMAT_OPTIONS_TEST_ID);
const popover = page.locator('.format-options-popover');
const appliedMode = new RegExp(`%22format%22%3A%22${FORMAT_MODES[format]}%22`);
await expect(async () => {
if (!(await popover.isVisible())) {
await trigger.click();
await expect(popover).toBeVisible();
}
await popover
.locator('.menu-items .item')
.filter({ hasText: format })
.click();
await expect(page).toHaveURL(appliedMode, { timeout: 3_000 });
}).toPass({ timeout: 20_000 });
await trigger.click();
await expect(popover).toBeHidden();
}
export function logsTableRows(page: Page): Locator {
return page.locator(`[data-testid^="${ROW_TEST_ID_PREFIX}"]`);
}
export function logsTableRow(page: Page, logId: string): Locator {
return page.getByTestId(`${ROW_TEST_ID_PREFIX}${logId}`);
}
export function highlightedLogsTableRows(page: Page): Locator {
return logsTableRows(page).and(page.locator(`.${LINKED_ROW_CLASS}`));
}
export function unhighlightedLogsTableRows(page: Page): Locator {
return page.locator(
`[data-testid^="${ROW_TEST_ID_PREFIX}"]:not(.${LINKED_ROW_CLASS})`,
);
}
export function activeLogIdFromUrl(page: Page): string {
const value = new URL(page.url()).searchParams.get('activeLogId');
if (!value) {
throw new Error(`No activeLogId in URL: ${page.url()}`);
}
return value.replace(/"/g, '');
}
export function logDetailsDrawer(page: Page): Locator {
return page.getByTestId(LOG_DETAIL_DRAWER_TEST_ID);
}
export async function clickRowFirstCell(row: Locator): Promise<void> {
await row.locator('td').first().click();
}
export async function openLogDetailsFromRow(row: Locator): Promise<void> {
await clickRowFirstCell(row);
await expect(logDetailsDrawer(row.page())).toBeVisible();
}
export async function openContextView(page: Page): Promise<void> {
await page
.locator('.views-tabs')
.getByText('Context', { exact: true })
.click();
await expect(contextLogItems(page).first()).toBeVisible();
}
function contextLogItems(page: Page): Locator {
return page.locator('.context-log-renderer__item');
}
export async function openFirstContextLogInNewTab(page: Page): Promise<Page> {
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
contextLogItems(page).first().click(),
]);
await newPage.waitForLoadState();
return newPage;
}

View File

@@ -0,0 +1,49 @@
import { expect, test } from '../../fixtures/auth';
import {
activeLogIdFromUrl,
clickRowFirstCell,
gotoLogsExplorer,
highlightedLogsTableRows,
LINKED_ROW_CLASS,
logDetailsDrawer,
logsTableRow,
logsTableRows,
openFirstContextLogInNewTab,
openContextView,
openLogDetailsFromRow,
setLogsFormat,
unhighlightedLogsTableRows,
} from '../../helpers/logs-explorer';
test.describe('Logs Explorer — linked row in Column format', () => {
test('TC-01 the linked row in a newly opened tab is highlighted and toggles the details drawer on click', async ({
authedPage: page,
}) => {
await gotoLogsExplorer(page);
await setLogsFormat(page, 'Column');
const rows = logsTableRows(page);
await expect(rows.first()).toBeVisible();
await expect(highlightedLogsTableRows(page)).toHaveCount(0);
await openLogDetailsFromRow(rows.first());
await openContextView(page);
const linkedTab = await openFirstContextLogInNewTab(page);
await expect(linkedTab).toHaveURL(/activeLogId=/);
const linkedLogId = activeLogIdFromUrl(linkedTab);
const linkedRow = logsTableRow(linkedTab, linkedLogId);
await expect(linkedRow).toBeVisible();
await expect(linkedRow).toHaveClass(new RegExp(LINKED_ROW_CLASS));
await expect(highlightedLogsTableRows(linkedTab)).toHaveCount(1);
await expect(unhighlightedLogsTableRows(linkedTab).first()).toBeVisible();
await expect(logDetailsDrawer(linkedTab)).toBeHidden();
await openLogDetailsFromRow(linkedRow);
await clickRowFirstCell(linkedRow);
await expect(logDetailsDrawer(linkedTab)).toBeHidden();
});
});