Compare commits

...

7 Commits

Author SHA1 Message Date
Vinícius Lourenço
8cc685bfeb feat(storybook): add story for home page 2026-08-20 19:34:21 -03:00
Vinícius Lourenço
9857737c1e feat(storybook): add skill to create stories for pages 2026-08-20 19:34:20 -03:00
Vinícius Lourenço
4a41b0db33 feat(storybook): add initial support for storybook 2026-08-20 19:34:20 -03:00
Gaurav Tewari
4d69e3f9e5 chore: remove last min trim logic in uplotScaleBuilder (#12627)
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
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- `UPlotScaleBuilder` was overriding the x-axis max with `endTime - 1
minute`, rounded down to the minute — behaviour carried over from the
legacy `getXAxisScale`.
- On short time windows the trimmed max lands at or before the min, so
the scale range is empty/inverted and the chart draws no data.
- Removes the trim so the requested `min`/`max` pass through as-is and
the scale always matches the selected time range.
- Updates the scale builder tests, including a case for a sub-minute
window.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes -
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=231774376&issue=SigNoz%7Cengineering-pod%7C5902

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

Before - 



https://github.com/user-attachments/assets/11ca2fa4-9a07-42eb-9d8d-3a42daf4cfe1


Now - 




https://github.com/user-attachments/assets/0114fccd-a6ef-4717-8d1c-aa3faf820da7





#### Additional Information

- Only the uPlotV2 path changes

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-20 11:41:49 +00:00
Vikrant Gupta
dca6aa497d chore(serviceaccount): remove deprecated nested role endpoints (#12591)
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
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Removes the deprecated `POST /api/v1/service_accounts/{id}/roles` and
`DELETE /api/v1/service_accounts/{id}/roles/{rid}` routes, their HTTP
handlers, and the `DeprecatedPostableServiceAccountRole` type, now that
all consumers use `/api/v1/service_account_roles`.
- Keeps the `GET /api/v1/service_accounts/{id}/roles` listing endpoint.
- Regenerates `docs/api/openapi.yml` and the frontend client.

#### Issues closed by this PR

Closes SigNoz/platform-pod#2919

#### Additional Information

- Final step of the migration; the frontend (#12589) and
integration-test (#12590) consumer moves are already merged.
2026-08-19 20:03:16 +00:00
Vikrant Gupta
bb47550c01 feat(authz): enable FGA for auth domains (#12588)
#### Description

- Auth domain routes (`/api/v2/auth_domains`) now use `CheckResources` +
`ResourceDef` instead of the coarse `AdminAccess` gate — per-resource
FGA checks on enterprise, admin role gate on community.
- Create and update also check `attach` on the roles the request's
`roleMapping` will grant at SSO login (mapped roles + default role,
`signoz-viewer` when unset, `role:*` when `useRoleAttribute` is on);
update additionally checks `detach` on the roles the stored mapping was
granting, since a `PUT` replaces the mapping.
- Migration `117_add_auth_domain_tuples` backfills the admin
`auth-domain` tuples for existing organizations and re-syncs the stored
managed-role transaction groups; new organizations get both from the
registry at bootstrap.
- Regenerated OpenAPI spec: the auth-domain operations advertise
`auth-domain:*` and `role:attach`/`role:detach` scopes instead of
`ADMIN`.
- Added `callbackauthn/05_authz.py` covering managed-role gating,
custom-role wildcard/instance grants, and the role-mapping attach/detach
checks.

#### Issues closed by this PR

Closes SigNoz/platform-pod#2649
2026-08-19 19:16:14 +00:00
Vinicius Lourenço
ecf510ed67 refactor(query-builder): drop ClientSideQBSearch & QueryBuilderSearch (#12427)
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
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Pull Request

---

### 📄 Summary
> Why does this change exist?  
> What problem does it solve, and why is this the right approach?

These are pending code that was supposed to be deleted after
Infrastructure Monitoring & Alert History adopt the QBv5.

#### Issues closed by this PR
> Reference issues using `Closes #issue-number` to enable automatic
closure on merge.

Closes https://github.com/SigNoz/engineering-pod/issues/5117

Closes https://github.com/SigNoz/engineering-pod/issues/5116

---

###  Change Type
_Select all that apply_

- [ ]  Feature
- [ ] 🐛 Bug fix
- [x] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [ ] 🧪 Test-only

---

### 🧪 Testing Strategy
> How was this change validated?

- Tests added/updated: Yes
- Manual verification: -
- Edge cases covered: -

---

### ⚠️ Risk & Impact Assessment
> What could break? How do we recover?

- Blast radius: Query Builder
- Potential regressions: Deleting more code than needed
- Rollback plan: Revert the deletion.

---

### 📝 Changelog
> Fill only if this affects users, APIs, UI, or documented behavior  
> Use **N/A** for internal or non-user-facing changes

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Maintenance |
| Description | N/A |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-19 18:00:15 +00:00
124 changed files with 6329 additions and 3361 deletions

View File

@@ -0,0 +1,78 @@
---
name: signoz-page-story
description: Explore a SigNoz page, map the endpoints, states and query params it has, then write its Storybook page story with control-driven msw mocks that reach every state. Use when asked to create, extend or review a Storybook story for a page under frontend/src/pages, to add controls to an existing page story, or to write defineStoryMocks handlers and mock data for a page.
---
# SigNoz page stories
A page story renders the real page inside the real app shell against msw, and its
controls panel can reach every state the page has. The panel is the deliverable,
not the story list.
`frontend/src/storybook/README.md` is the API surface (providers, parameters,
control builders, module mocks, navigation). Read it first; this skill is the
process on top of it.
## Workflow
1. **Map the page**: [references/discovery.md](references/discovery.md). Produce
the inventory (endpoints, states, params, permissions, caps) before writing
code. No inventory, no story.
2. **Skeleton first**: story + empty `defineStoryMocks`, then run it. The console
names the endpoints step 1 missed.
3. **Inventory to controls**: [references/controls.md](references/controls.md).
4. **Mock data and handlers**: builders in `__mockdata__`, handlers in the page's
mocks module.
5. **Verify in the browser**: [references/verify.md](references/verify.md). Never
report the story as done without it.
## Rules
- **Default is the loaded page.** `export const Default: Story = {}` with no args,
every widget carrying data. Empty, loading and failed are variants or control
values, never the default.
- **A control is a knob on a response**, resolved through `handlers`, `config` or
`effect`. Never a component prop, never a module mock added for one story.
- **Every branch in the inventory is reachable from the panel.** A state that
needs a code edit to see is a missing control.
- **Never re-declare what every story already has**: banner, side nav, data state
(loaded/loading/error), access preset, permissions, check state.
- **A variant earns a story only when it is worth linking to**: an empty
workspace, a viewer, a page mid-load. Everything else stays a control.
- **Endpoints the page owns go through `response.json`**, so the Data control
covers loaded, loading and failed in one declaration. Endpoints the page cannot
render without (ingestion detection, preferences, feature payloads) take a
plain resolver so the shell survives the loading and error states.
- **Query-param state starts from `route`** (`/logs?tab=explorer`). In-page param
navigation works inside a story; a different pathname is blocked and reported
by the overlay. A control for a param is worth it only when the param is a page
mode someone would want to flip.
- **File layout**: `src/pages/<Page>/<Page>.stories.tsx`,
`src/pages/<Page>/<Page>.stories.mocks.tsx`, payload builders in
`src/pages/<Page>/__mockdata__/<page>.ts`. Nothing page-specific in
`src/storybook/controls/`.
- **Reuse fixtures** from `src/mocks-server/` and `src/tests/fixtures/` where they
exist. An endpoint jest needs too belongs in `src/mocks-server/handlers.ts`.
- **Shared response builders live in `src/storybook/msw/__mockdata__/`**: typed
helpers like `queryRangeV5ScalarResponse` that multiple pages need. Before
writing a response shape inline, check if a builder exists; if not and the
shape will repeat, add it there. Page-specific builders stay in the page's
`__mockdata__/`.
- **No comment is the default.** Write one only for what the code cannot show:
a shape the backend dictates, an app bug the mock reproduces, an ordering or
cap the page depends on, a workaround and the reason for it. Never restate a
name, a type, or what a builder plainly builds; if the sentence reads as the
signature in prose, delete it. Nothing addressed to a reviewer. The one
comment a story always gets is its own doc comment: what it shows, in the
page's own terms.
## Done means
- [ ] `Default` shows the page with data, checked in dark and light
- [ ] every control flipped once, its effect seen on screen
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled
request, no React warning
- [ ] no navigation overlay on mount
- [ ] `pnpm tsgo --noEmit`, `pnpm exec oxlint <files>`,
`pnpm exec oxfmt --check <files>` all clean. The repo has no
prettier: `pnpm exec prettier` prints a pass while exiting 254

View File

@@ -0,0 +1,163 @@
# Turning the inventory into controls
Every row of the inventory becomes a control, a global control that already
exists, or a documented reason it cannot be one.
## Imports
Paths written as `src/storybook/...` in prose are repo paths, not import
specifiers. Stories import through the `@/` alias (`@/*``./src/*`); modules
inside `src/storybook/` import each other relatively.
| Import | From |
| --- | --- |
| `toggleControl`, `countControl`, `choiceControl`, `multiChoiceControl` | `../controls/controls` |
| `defineStoryMocks`, `storyMocks` | `../controls/defineStoryMocks` |
| `PageStoryArgs` | `../controls/resolveStoryMocks` |
| `MockRequest`, `MockResponse` | `../controls/types` |
| `withAppLayout` | `@/storybook/decorators/withAppLayout` |
| the page's mocks, from the story | `./<Page>.stories.mocks` |
| `queryRangeV5ScalarResponse`, `queryRangeV5RawResponse`, etc. | `@/storybook/msw/__mockdata__/queryRange` |
## Which builder
`src/storybook/controls/controls.ts`:
| The state is | Builder |
| --- | --- |
| on or off (a signal ingesting, a feature present) | `toggleControl` |
| how many rows a list has | `countControl` |
| one of several modes (tab, visibility, plan, severity filter) | `choiceControl` |
| a subset (steps skipped, columns shown, signals selected) | `multiChoiceControl` |
Rules that come with them:
- `countControl` `max` goes past what the page renders, so a story can show the
cap being hit. `0` is the empty state, which is why an empty list rarely needs
its own story. When the cap is in the *request* (`?limit=5`) rather than the
renderer, stop `max` at the limit: a longer response is a body the backend
cannot send.
- `choiceControl` options come from a `const` array typed with
`(typeof X)[number]`, not from string literals scattered in the handlers.
- Defaults describe the fully-populated page. The panel starts where `Default`
starts.
- `group` is `'<Page> · <facet>'`, such as `'Services · lists'` or
`'Alerts · rules'`. Keep a page's knobs in two or three groups, not one per
control.
- `description` only when the name does not carry the effect (what dismissing
does, what the cap is, which widget it feeds).
## Which hook
`defineStoryMocks` takes three, all optional:
- `handlers(values, response)`: the page's endpoints. Everything the page owns
goes through `response.json`, so the global Data control turns the whole page
into loading or failed without a second declaration. An endpoint the page
cannot render at all without (ingestion detection, preferences, license
payloads) takes a plain `rest.get(...)` resolver instead, so the shell stays
visible while the rest hangs or fails.
- `config(values)`: `SignozStoryConfig` for knobs no endpoint covers: `route`,
`appContext`, `reduxState`, `queryBuilder`, `theme`.
- `effect(values)`: module-level state no provider exposes.
One endpoint feeding several widgets stays one handler that reads the request.
`response.json` hands the request to the builder and awaits it, so reading a
query param, or a POST body, does not cost the Data control:
```ts
rest.get(
'http://localhost/api/v1/explorer/views',
response.json((req) =>
savedViews(values.savedViews, req.url.searchParams.get('sourcePage') ?? 'logs'),
),
),
```
```ts
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) => {
const body = (await req.json()) as QueryRangeRequestV5;
const signal = body.compositeQuery?.queries?.[0]?.spec?.signal;
return countResponse(values[`${signal}Ingestion`] ? 4213 : 0);
}),
),
```
Reach for a plain `rest.post(url, async (req, res, ctx) => …)` only when the
endpoint has to keep answering while the Data control is on `loading` or
`error`: detection calls the page cannot render without.
## Mutations
A control drives the response, so a write the page makes against state a control
owns does not stick: the refetch answers with the control's value and the button
appears to do nothing. Two honest options: leave it declarative and say so in
the PR, or move the state into `effect` so the handler can read what the page
wrote. Never fake the write by mutating a builder's module state without saying
where the state lives.
## Wiring it up
```ts
// src/pages/Services/Services.stories.mocks.tsx
export const servicesMocks = defineStoryMocks({
controls: {
services: countControl('Services', { group: LISTS, value: 8, max: 12 }),
apdex: choiceControl<ApdexState>('Apdex', {
group: HEALTH,
options: APDEX_STATES,
value: 'mixed',
}),
},
handlers: (values, response) => [
rest.post(
'http://localhost/api/v2/services',
response.json(() => buildServices(values.services, values.apdex)),
),
],
});
```
```tsx
// src/pages/Services/Services.stories.tsx
type ServicesArgs = PageStoryArgs<typeof servicesMocks>;
const meta = {
title: 'Pages/Services',
component: Services,
decorators: [withAppLayout],
...storyMocks(servicesMocks, { route: ROUTES.APPLICATION }),
} satisfies Meta<ServicesArgs>;
```
`PageStoryArgs` folds in the global controls, so a story's `args` can set
`access`, `dataState` or `banner` next to the page's own knobs and stay typed.
## Not a control
- Anything the global controls already cover: banner, side nav, data state,
access preset, permissions, check state.
- A knob whose effect nobody can see on the page. Delete it or find the widget it
was supposed to drive.
- A raw payload as an object control. Controls carry intent (`5 dashboards`,
`viewer`), and the builder turns intent into the payload.
- Anything that needs a module mock or a component prop to work. If the state
cannot be produced from a response, config or module state, say so in the PR
instead of faking it.
## Control or story
Default to a control. Write a story when the state is worth a link:
- the fresh workspace, because that is what a new user sees
- the restricted user, when permissions visibly change the page
- a page-defining mode (a tab, a category) that has its own layout
Combinations of controls do not need stories, which is what the panel is for.
Each story gets one prose doc comment: what it shows, in the page's own terms.
Everywhere else the comment rule in SKILL.md applies: write one only for what
the code cannot show.

View File

@@ -0,0 +1,64 @@
# Mapping a page
Two passes: read the code, then let the running story correct you. Write the
inventory down: it is what the controls are derived from, and the only
protection against a story that renders one state and calls it a page.
## Pass 1: read the page
Start at `src/pages/<Page>/` and follow it outward: the containers it mounts
(`src/container/<Feature>/`), the hooks those use, the components with their own
fetches. Stop at leaf components that take props only.
Grep recipes, run against the page's directories:
| Looking for | Grep |
| --- | --- |
| endpoints | `useQuery\|useMutation\|useInfiniteQuery`, then the `api/` module it calls |
| endpoint URLs | the api module's `axios.get\|post` |
| endpoint URLs behind a generated hook | the hook lives in `src/api/generated/services/<name>/index.ts` and the URL only appears in the fetcher body: `rg 'url: \`' src/api/generated/services/<name>/` |
| url state | `useUrlQuery\|useUrlQueryData\|useUrlSearchState\|useQueryState\|QueryParams\.` |
| navigation | `useSafeNavigate\|history.push\|<Link` |
| permissions | `useAuthZ\|AuthZGuard\|AuthZButton\|hasEditPermission\|routePermission` |
| flags and prefs | `useFeatureFlag\|FeatureKeys\.\|USER_PREFERENCES\.\|userPreferences` |
| empty and error branches | `isLoading\|isError\|isFetching\|length === 0\|!data` |
| render caps | `slice(0,\|PAGE_SIZE\|pageSize\|limit` |
`src/constants/routes.ts` has the route, `src/constants/query.ts` the param names,
`src/lib/authz/README.md` how a permission check resolves.
## The inventory
One table, in the story's PR or scratch notes:
| Endpoint | Feeds | States it can be in |
| --- | --- | --- |
| `GET /api/v1/x` | the header count | populated, zero, error |
Plus four short lists:
- **Query params** the page reads, and what each one switches.
- **Permission checks** the page makes, and what disappears when each is denied.
- **Preferences and flags** that change layout (dismissed banners, onboarding
checklists, opt-in views).
- **Caps**: how many rows each list renders before it truncates or paginates.
A state that appears in this inventory and not in the controls panel is a bug in
the story.
## Pass 2: let it run
Write the story and an empty `defineStoryMocks({ controls: {} })`, point it at the
route, add `withAppLayout`, then open it (see verify.md). The console is the
oracle:
- `[storybook] no msw handler` or a 501 from the catch-all: an endpoint pass 1
missed. Add it to the inventory.
- an msw unhandled-request warning: a request going to an origin the handlers do
not answer on. handlers are declared against `http://localhost`.
- a spinner that never resolves with the Data control on `loaded`: a handler
whose URL does not match what the page calls.
- the navigation overlay on mount: the page redirects, usually because `route`
is wrong or a guard is failing on a permission the controls have not granted.
Repeat until the console is silent. Only then start declaring controls.

View File

@@ -0,0 +1,110 @@
# Verifying a page story
A story is not done because it compiles. It is done when each control has been
seen changing the page and the console is silent.
## Run it
```bash
cd frontend && pnpm storybook --ci --quiet # :6006, background it
```
A newly added `.stories.tsx` takes a few seconds to appear in `index.json` on an
already-running server; an empty first poll is not a broken `stories` glob.
Story ids come from the meta title: `Pages/Services``pages-services`, plus the
story export in kebab-case. Render one story on its own:
```
http://localhost:6006/iframe.html?id=pages-services--default&viewMode=story
```
## Flip controls from the URL
Args are settable in the iframe URL, so a whole sweep runs headless without
touching the panel. Booleans go as `!true` / `!false`, numbers bare, arrays
indexed, several separated by `;`, and the theme through `globals`:
```
&args=services:0;apdex:poor;access:viewer;dataState:loading
&args=signals[0]:logs;signals[1]:traces
&globals=theme:light
```
That is the cheap way to check a control does something: load with and without
it, diff the page text.
## Drive it
Playwright lives in the repo's e2e workspace, so a scratch script can use it
directly:
```js
import pw from '<repo>/tests/e2e/node_modules/playwright/index.js';
const { chromium } = pw;
const browser = await chromium.launch();
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => {
if (m.type() === 'error' || m.type() === 'warning') problems.push(m.text());
});
page.on('pageerror', (e) => problems.push(e.message));
await page.goto(`${story}&args=services:0`, { waitUntil: 'networkidle' });
await page.locator('body').waitFor();
console.log((await page.locator('body').innerText()).slice(0, 1500), problems);
await browser.close();
```
Screenshots are worth taking for `Default` in both themes
(`&globals=theme:light`): text extraction does not catch an unstyled page.
## Gates
- **Console silent.** `[storybook] no msw handler`, a 501 from the catch-all, an
msw unhandled-request warning, a React key or state warning: assume the story
is wrong first. A warning that survives is sometimes the app's. Prove it by
turning off the control that renders the widget and watching the warning go
with it, and by finding the same component elsewhere doing it right. Then
report the app bug in the PR. Never invent a field the API does not return to
silence a warning.
- **No navigation overlay on mount.** "Navigation blocked in Storybook" on load
means the page is trying to leave: wrong `route`, or a guard denying on a
permission the controls did not grant.
- **Every control moves something.** Sweep them one at a time from the URL and
diff the page text. A control with no diff is either wired to nothing or aimed
at a widget that is not rendering. Some only show their effect after an
interaction, such as a tab that has to be clicked or a select that has to be
opened. Drive that interaction rather than calling the control unobservable.
- **Both themes render styled.** An unstyled page means the story is not inside
the provider decorator, or `<body data-theme>` was lost.
- **Roles agree.** `<body data-signoz-story-role>` and
`<body data-signoz-context-role>` disagreeing means the page reads a different
`AppContext` than the story config fills.
- **The page's own navigation works.** Tabs, filters and pagination that write
query params should re-render the page in place; only leaving the page belongs
in the overlay.
## Then the usual
```bash
pnpm tsgo --noEmit
pnpm exec oxlint <changed files>
pnpm exec oxfmt --check <changed files>
```
There is no prettier in this repo. `pnpm exec prettier --check` fetches something
else, prints `Prettier: All files formatted correctly` and exits 254: a pass that is
not one.
## Common failures
| Symptom | Cause |
| --- | --- |
| endless spinner with Data on `loaded` | handler URL does not match the call; handlers answer on `http://localhost` |
| page renders but empty | response shape wrong; compare against the api module's type, not a guess |
| 501 in the console | endpoint nobody mocked; the catch-all is answering |
| new control missing from the panel | project-level control added; the tab needs a reload |
| control flips but nothing changes | the widget is gated by something else: a permission, a flag, a preference |
| shell disappears in `loading` | an endpoint the shell needs went through `response.json`; give it a plain resolver |

View File

@@ -8024,13 +8024,6 @@ components:
- schemaVersion
- spec
type: object
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
properties:
id:
type: string
required:
- id
type: object
ServiceaccounttypesGettableFactorAPIKey:
properties:
createdAt:
@@ -13262,117 +13255,6 @@ paths:
summary: Gets service account roles
tags:
- serviceaccount
post:
deprecated: true
description: This endpoint assigns a role to a service account
operationId: CreateServiceAccountRoleDeprecated
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:attach
- role:attach
- tokenizer:
- serviceaccount:attach
- role:attach
summary: Create service account role
tags:
- serviceaccount
/api/v1/service_accounts/{id}/roles/{rid}:
delete:
deprecated: true
description: This endpoint revokes a role from service account
operationId: DeleteServiceAccountRoleDeprecated
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: rid
required: true
schema:
type: string
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:detach
- role:detach
- tokenizer:
- serviceaccount:detach
- role:detach
summary: Delete service account role
tags:
- serviceaccount
/api/v1/service_accounts/me:
get:
deprecated: false
@@ -14376,9 +14258,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- auth-domain:list
- tokenizer:
- ADMIN
- auth-domain:list
summary: List all auth domains
tags:
- authdomains
@@ -14438,9 +14320,13 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- auth-domain:create
- auth-domain:attach
- role:attach
- tokenizer:
- ADMIN
- auth-domain:create
- auth-domain:attach
- role:attach
summary: Create auth domain
tags:
- authdomains
@@ -14484,9 +14370,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- auth-domain:delete
- tokenizer:
- ADMIN
- auth-domain:delete
summary: Delete auth domain
tags:
- authdomains
@@ -14541,9 +14427,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- auth-domain:read
- tokenizer:
- ADMIN
- auth-domain:read
summary: Get auth domain by ID
tags:
- authdomains
@@ -14597,9 +14483,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- auth-domain:update
- auth-domain:attach
- auth-domain:detach
- role:attach
- role:detach
- tokenizer:
- ADMIN
- auth-domain:update
- auth-domain:attach
- auth-domain:detach
- role:attach
- role:detach
summary: Update auth domain
tags:
- authdomains

6
frontend/.gitignore vendored
View File

@@ -28,4 +28,8 @@ e2e/test-plan/saved-views/
e2e/test-plan/service-map/
e2e/test-plan/services/
e2e/test-plan/traces/
e2e/test-plan/user-preferences/
e2e/test-plan/user-preferences/
# Storybook
/storybook-static/
debug-storybook.log

View File

@@ -0,0 +1,89 @@
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import type { StorybookConfig } from '@storybook/react-vite';
import type { Plugin, PluginOption } from 'vite';
const srcPath = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
/**
* Modules replaced for every story. Same idea as `moduleNameMapper` in
* `jest.config.ts`: the app keeps importing its own paths, Storybook resolves
* them to a mock. Regexes so only exact specifiers match: `lib/history` must
* not catch `lib/historyUtils`.
*
* Each replacement is typed as the module it stands in for, so drift is a
* compile error rather than a story that fails at render. The `jest` note on
* each entry is where the same import lands under the other runner. The two
* only diverge where the runner needs them to.
*/
const mockAliases = [
{
// jest: not replaced, jsdom drives a real browser history.
find: /^(?:src\/)?lib\/history$/,
replacement: `${srcPath}/storybook/navigation/history.alias.ts`,
},
{
// jest: src/__tests__/logEventMock.ts
find: /^(?:src\/)?api\/common\/logEvent$/,
replacement: `${srcPath}/storybook/mocks/logEvent.mock.ts`,
},
{
// jest: __mocks__/env.ts, which leaves `baseURL` empty because jsdom already
// resolves a relative `/api/...` against `http://localhost`.
find: /^(?:src\/)?constants\/env$/,
replacement: `${srcPath}/storybook/mocks/env.mock.ts`,
},
];
/**
* Plugins from `vite.config.ts` that either target the app's `index.html` or
* only pay off in a production build.
*/
const EXCLUDED_PLUGINS = [
'vite-plugin-checker',
'dev-base-path',
'dev-boot-data',
'vite-plugin-image-optimizer',
'vite-plugin-compression',
];
const isExcluded = (plugin: PluginOption): boolean =>
!!plugin &&
typeof plugin === 'object' &&
'name' in plugin &&
EXCLUDED_PLUGINS.includes((plugin as Plugin).name);
const config: StorybookConfig = {
framework: '@storybook/react-vite',
stories: ['../src/**/*.stories.@(ts|tsx)'],
// `../public` carries the fonts, icons and i18n bundles the app expects at
// the root; `./public` carries the msw worker, which must not ship in a
// production build.
staticDirs: ['../public', './public'],
addons: [],
core: { disableTelemetry: true },
viteFinal: async (viteConfig) => {
const plugins = (viteConfig.plugins ?? [])
.flat(Infinity as 1)
.filter((plugin) => !isExcluded(plugin as PluginOption));
const existingAlias = viteConfig.resolve?.alias;
const normalizedAlias = Array.isArray(existingAlias)
? existingAlias
: Object.entries(existingAlias ?? {}).map(([find, replacement]) => ({
find,
replacement: replacement as string,
}));
return {
...viteConfig,
plugins,
resolve: {
...viteConfig.resolve,
alias: [...mockAliases, ...normalizedAlias],
},
};
},
};
export default config;

View File

@@ -0,0 +1,24 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="css/uPlot.min.css" />
<script>
// i18n's language detector would pick the browser locale (e.g. `en-US`), which
// /public/locales does not ship; pin it to the bundled language instead.
window.localStorage.setItem('i18nextLng', 'en');
// The Go backend injects this at boot; every integration it enables is off in
// Storybook so no third-party script loads inside the iframe.
window.signozBootData = {
settings: {
posthog: { enabled: false, apiHost: '', key: '', uiHost: '' },
appcues: { enabled: false, appId: '' },
sentry: { enabled: false, dsn: '', tunnel: '' },
pylon: { enabled: false, appId: '', identitySecret: '' },
},
};
</script>

View File

@@ -0,0 +1,110 @@
import type { Preview } from '@storybook/react-vite';
import type { SetupWorker } from 'msw';
import { setupWorker } from 'msw';
import { withProviders } from '../src/storybook/decorators/withProviders';
import { globalMocks } from '../src/storybook/globals';
import { resetStoryHistory } from '../src/storybook/navigation/containment';
import { clearBlockedNavigations } from '../src/storybook/navigation/blockedNavigationStore';
import {
resolveStory,
type StoryRuntimeContext,
} from '../src/storybook/runtime/resolveStory';
import '../src/ReactI18';
import '../src/styles.scss';
import '../src/storybook/storybook-root.scss';
interface StorybookWorkerHolder {
__signozStorybookWorker?: StorybookWorker;
}
const holder = window as unknown as StorybookWorkerHolder;
/**
* One worker per page, even if this module is re-executed by HMR. Two live
* workers both answer the service worker and the story gets whichever replies
* first.
*/
interface StorybookWorker {
worker: SetupWorker;
ready: Promise<unknown>;
}
const { worker, ready } = (holder.__signozStorybookWorker ??=
((): StorybookWorker => {
const instance = setupWorker();
return {
worker: instance,
ready: instance.start({
serviceWorker: { url: './mockServiceWorker.js' },
// Storybook's own traffic (index.json, HMR, telemetry) goes unhandled by
// design; only flag the app's API calls so a missing handler is obvious.
onUnhandledRequest: (request, print): void => {
const url = new URL(request.url.href);
const isStaticAsset =
/\.(?:woff2?|ttf|otf|css|js|map|png|jpe?g|svg|webp|ico)$/.test(
url.pathname,
);
const isAppRequest =
!isStaticAsset &&
(url.pathname.startsWith('/api/') || url.host !== window.location.host);
if (isAppRequest) {
print.warning();
}
},
}),
};
})());
const preview: Preview = {
parameters: {
layout: 'fullscreen',
controls: { expanded: true },
},
globalTypes: {
theme: {
description: 'SigNoz color scheme',
toolbar: {
title: 'Theme',
icon: 'paintbrush',
items: [
{ value: 'dark', title: 'Dark' },
{ value: 'light', title: 'Light' },
],
dynamicTitle: true,
},
},
},
initialGlobals: { theme: 'dark' },
// Controls every story carries: permissions, banners, and whether the page's
// own endpoints answer, hang or fail.
args: globalMocks.args,
argTypes: globalMocks.argTypes,
decorators: [withProviders],
loaders: [
// Runs on every render, args changes included, and ahead of the decorators:
// the whole story world is put in place here, so the provider tree only has
// to read it. Re-registering the handlers per render also means an edit to a
// handler module takes effect on the next render instead of leaving the
// worker on the set it was created with.
async (context): Promise<void> => {
const world = resolveStory(context as unknown as StoryRuntimeContext);
world.apply();
world.install(worker);
await ready;
},
],
beforeEach: () => {
clearBlockedNavigations();
resetStoryHistory();
},
};
export default preview;

View File

@@ -0,0 +1,303 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker (1.3.2).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
const activeClientIds = new Set()
self.addEventListener('install', function () {
self.skipWaiting()
})
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
const clientId = event.source.id
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: INTEGRITY_CHECKSUM,
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: true,
})
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
self.addEventListener('fetch', function (event) {
const { request } = event
const accept = request.headers.get('accept') || ''
// Bypass server-sent events.
if (accept.includes('text/event-stream')) {
return
}
// Bypass navigation requests.
if (request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
// Generate unique request ID.
const requestId = Math.random().toString(16).slice(2)
event.respondWith(
handleRequest(event, requestId).catch((error) => {
if (error.name === 'NetworkError') {
console.warn(
'[MSW] Successfully emulated a network error for the "%s %s" request.',
request.method,
request.url,
)
return
}
// At this point, any exception indicates an issue with the original request/response.
console.error(
`\
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
request.method,
request.url,
`${error.name}: ${error.message}`,
)
}),
)
})
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const clonedResponse = response.clone()
sendToClient(client, {
type: 'RESPONSE',
payload: {
requestId,
type: clonedResponse.type,
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
body:
clonedResponse.body === null ? null : await clonedResponse.text(),
headers: Object.fromEntries(clonedResponse.headers.entries()),
redirected: clonedResponse.redirected,
},
})
})()
}
return response
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function getResponse(event, client, requestId) {
const { request } = event
const clonedRequest = request.clone()
function passthrough() {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const headers = Object.fromEntries(clonedRequest.headers.entries())
// Remove MSW-specific request headers so the bypassed requests
// comply with the server's CORS preflight check.
// Operate with the headers as an object because request "Headers"
// are immutable.
delete headers['x-msw-bypass']
return fetch(clonedRequest, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Bypass requests with the explicit bypass header.
// Such requests can be issued by "ctx.fetch()".
if (request.headers.get('x-msw-bypass') === 'true') {
return passthrough()
}
// Notify the client that a request has been intercepted.
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
mode: request.mode,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.text(),
bodyUsed: request.bodyUsed,
keepalive: request.keepalive,
},
})
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'MOCK_NOT_FOUND': {
return passthrough()
}
case 'NETWORK_ERROR': {
const { name, message } = clientMessage.data
const networkError = new Error(message)
networkError.name = name
// Rejecting a "respondWith" promise emulates a network error.
throw networkError
}
}
return passthrough()
}
function sendToClient(client, message) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [channel.port2])
})
}
function sleep(timeMs) {
return new Promise((resolve) => {
setTimeout(resolve, timeMs)
})
}
async function respondWithMock(response) {
await sleep(response.delay)
return new Response(response.body, response)
}

View File

@@ -88,6 +88,17 @@ pnpm test
pnpm tsgo --noEmit
```
## Storybook
```bash
pnpm storybook
```
Opens [http://localhost:6006](http://localhost:6006). Pages run against msw
mocks with no backend; query-param navigation works inside a story, leaving the
page is blocked. See [`src/storybook/README.md`](src/storybook/README.md) for the
override surface.
## Linting
```bash

View File

@@ -7,6 +7,8 @@
"preinstall": "npx only-allow pnpm",
"i18n:generate-hash": "node ./i18-generate-hash.cjs",
"dev": "vite",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build -o storybook-static",
"build": "vite build",
"preview": "vite preview",
"prettify": "oxfmt",
@@ -158,6 +160,7 @@
"@commitlint/config-conventional": "20.4.4",
"@jest/globals": "30.4.1",
"@jest/types": "30.2.0",
"@storybook/react-vite": "10.5.9",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "13.4.0",
"@testing-library/user-event": "14.4.3",
@@ -203,6 +206,7 @@
"redux-mock-store": "1.5.4",
"sass": "1.97.3",
"sharp": "0.35.0",
"storybook": "10.5.9",
"stylelint": "17.7.0",
"svgo": "4.0.2",
"ts-jest": "29.4.9",

1080
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,10 +22,7 @@ import type {
CreateServiceAccountKey201,
CreateServiceAccountKeyPathParameters,
CreateServiceAccountRole201,
CreateServiceAccountRoleDeprecated201,
CreateServiceAccountRoleDeprecatedPathParameters,
DeleteServiceAccountPathParameters,
DeleteServiceAccountRoleDeprecatedPathParameters,
DeleteServiceAccountRolePathParameters,
GetMyServiceAccount200,
GetServiceAccount200,
@@ -39,7 +36,6 @@ import type {
ListServiceAccounts200,
RenderErrorResponseDTO,
RevokeServiceAccountKeyPathParameters,
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
ServiceaccounttypesPostableFactorAPIKeyDTO,
ServiceaccounttypesPostableServiceAccountDTO,
ServiceaccounttypesPostableServiceAccountRoleDTO,
@@ -1253,194 +1249,6 @@ export const invalidateGetServiceAccountRoles = async (
return queryClient;
};
/**
* This endpoint assigns a role to a service account
* @deprecated
* @summary Create service account role
*/
export const createServiceAccountRoleDeprecated = (
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
url: `/api/v1/service_accounts/${id}/roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
signal,
});
};
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
> => {
const mutationKey = ['createServiceAccountRoleDeprecated'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return createServiceAccountRoleDeprecated(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
>;
export type CreateServiceAccountRoleDeprecatedMutationBody =
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
| undefined;
export type CreateServiceAccountRoleDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create service account role
*/
export const useCreateServiceAccountRoleDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
> => {
return useMutation(
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
);
};
/**
* This endpoint revokes a role from service account
* @deprecated
* @summary Delete service account role
*/
export const deleteServiceAccountRoleDeprecated = (
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/${id}/roles/${rid}`,
method: 'DELETE',
signal,
});
};
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
> => {
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteServiceAccountRoleDeprecated(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
>;
export type DeleteServiceAccountRoleDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Delete service account role
*/
export const useDeleteServiceAccountRoleDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
> => {
return useMutation(
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
);
};
/**
* This endpoint gets my service account
* @summary Gets my service account

View File

@@ -9092,13 +9092,6 @@ export interface SavedviewtypesUpdatableSavedViewDTO {
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
/**
* @type string
*/
id: string;
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -11020,21 +11013,6 @@ export type GetServiceAccountRoles200 = {
status: string;
};
export type CreateServiceAccountRoleDeprecatedPathParameters = {
id: string;
};
export type CreateServiceAccountRoleDeprecated201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
id: string;
rid: string;
};
export type GetMyServiceAccount200 = {
data: ServiceaccounttypesServiceAccountWithRolesDTO;
/**

View File

@@ -1,5 +0,0 @@
.client-side-qb-search {
.ant-select-selection-search {
width: max-content !important;
}
}

View File

@@ -1,661 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Select, Tooltip } from 'antd';
import {
OPERATORS,
QUERY_BUILDER_OPERATORS_BY_TYPES,
QUERY_BUILDER_SEARCH_VALUES,
} from 'constants/queryBuilder';
import { CustomTagProps } from 'container/QueryBuilder/filters/QueryBuilderSearch';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config';
import { PLACEHOLDER } from 'container/QueryBuilder/filters/QueryBuilderSearch/constant';
import { TypographyText } from 'container/QueryBuilder/filters/QueryBuilderSearch/style';
import {
checkCommaInValue,
getOperatorFromValue,
getOperatorValue,
getTagToken,
isInNInOperator,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import {
DropdownState,
ITag,
Option,
} from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import Suggestions from 'container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { validationMapper } from 'hooks/queryBuilder/useIsValidTag';
import { operatorTypeMapper } from 'hooks/queryBuilder/useOperatorType';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { isArray, isEmpty, isEqual, isObject } from 'lodash-es';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import type { BaseSelectRef } from 'rc-select';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import './ClientSideQBSearch.styles.scss';
import { Badge } from '@signozhq/ui/badge';
export interface AttributeKey {
key: string;
}
export interface AttributeValuesMap {
[key: string]: AttributeValue;
}
interface ClientSideQBSearchProps {
filters: TagFilter;
onChange: (value: TagFilter) => void;
whereClauseConfig?: WhereClauseConfig;
placeholder?: string;
className?: string;
suffixIcon?: React.ReactNode;
attributeValuesMap?: AttributeValuesMap;
attributeKeys: AttributeKey[];
}
interface AttributeValue {
stringAttributeValues: string[] | [];
numberAttributeValues: number[] | [];
boolAttributeValues: boolean[] | [];
}
function ClientSideQBSearch(
props: ClientSideQBSearchProps,
): React.ReactElement {
const {
onChange,
placeholder,
className,
suffixIcon,
whereClauseConfig,
attributeValuesMap,
attributeKeys,
filters,
} = props;
const isDarkMode = useIsDarkMode();
const selectRef = useRef<BaseSelectRef>(null);
const [isOpen, setIsOpen] = useState<boolean>(false);
// create the tags from the initial query here, this should only be computed on the first load as post that tags and query will be always in sync.
const [tags, setTags] = useState<ITag[]>(filters.items as ITag[]);
// this will maintain the current state of in process filter item
const [currentFilterItem, setCurrentFilterItem] = useState<ITag | undefined>();
const [currentState, setCurrentState] = useState<DropdownState>(
DropdownState.ATTRIBUTE_KEY,
);
// to maintain the current running state until the tokenization happens for the tag
const [searchValue, setSearchValue] = useState<string>('');
const [dropdownOptions, setDropdownOptions] = useState<Option[]>([]);
const attributeValues = useMemo(() => {
if (currentFilterItem?.key?.key) {
return attributeValuesMap?.[currentFilterItem.key.key];
}
return {
stringAttributeValues: [],
numberAttributeValues: [],
boolAttributeValues: [],
};
}, [attributeValuesMap, currentFilterItem?.key?.key]);
const handleDropdownSelect = useCallback(
(value: string) => {
let parsedValue: BaseAutocompleteData | string;
try {
parsedValue = JSON.parse(value);
} catch {
parsedValue = value;
}
if (currentState === DropdownState.ATTRIBUTE_KEY) {
setCurrentFilterItem((prev) => ({
...prev,
key: parsedValue as BaseAutocompleteData,
op: '',
value: '',
}));
setCurrentState(DropdownState.OPERATOR);
setSearchValue((parsedValue as BaseAutocompleteData)?.key);
} else if (currentState === DropdownState.OPERATOR) {
if (value === OPERATORS.EXISTS || value === OPERATORS.NOT_EXISTS) {
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: value,
value: '',
} as ITag,
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: value as string,
value: '',
}));
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
setSearchValue(`${currentFilterItem?.key?.key} ${value}`);
}
} else if (currentState === DropdownState.ATTRIBUTE_VALUE) {
const operatorType =
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
const isMulti = operatorType === QUERY_BUILDER_SEARCH_VALUES.MULTIPLY;
if (isMulti) {
const { tagKey, tagOperator, tagValue } = getTagToken(searchValue);
// this condition takes care of adding the IN/NIN multi values when pressed enter on an already existing value.
// not the best interaction but in sync with what we have today!
if (tagValue.includes(String(value))) {
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
setCurrentFilterItem(undefined);
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: currentFilterItem?.op,
value: tagValue,
} as ITag,
]);
return;
}
// this is for adding subsequent comma seperated values
const newSearch = [...tagValue];
newSearch[newSearch.length === 0 ? 0 : newSearch.length - 1] = value;
const newSearchValue = newSearch.join(',');
setSearchValue(`${tagKey} ${tagOperator} ${newSearchValue},`);
} else {
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
setCurrentFilterItem(undefined);
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: currentFilterItem?.op,
value,
} as ITag,
]);
}
}
},
[currentFilterItem?.key, currentFilterItem?.op, currentState, searchValue],
);
const handleSearch = useCallback((value: string) => {
setSearchValue(value);
}, []);
const onInputKeyDownHandler = useCallback(
(event: KeyboardEvent<Element>): void => {
if (event.key === 'Backspace' && !searchValue) {
event.stopPropagation();
setTags((prev) => prev.slice(0, -1));
}
},
[searchValue],
);
const handleOnBlur = useCallback((): void => {
if (searchValue) {
const operatorType =
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
// if key is added and operator is not present then convert to body CONTAINS key
if (
currentFilterItem?.key &&
isEmpty(currentFilterItem?.op) &&
whereClauseConfig?.customKey === 'body' &&
whereClauseConfig?.customOp === OPERATORS.CONTAINS
) {
setTags((prev) => [
...prev,
{
key: {
key: 'body',
dataType: DataTypes.String,
type: '',
id: 'body--string----true',
},
op: OPERATORS.CONTAINS,
value: currentFilterItem?.key?.key,
},
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else if (
currentFilterItem?.op === OPERATORS.EXISTS ||
currentFilterItem?.op === OPERATORS.NOT_EXISTS
) {
// is exists and not exists operator is present then convert directly to tag! no need of value here
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: currentFilterItem?.op,
value: '',
},
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else if (
// if the current state is in sync with the kind of operator used then convert into a tag
validationMapper[operatorType]?.(
isArray(currentFilterItem?.value)
? currentFilterItem?.value.length || 0
: 1,
)
) {
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key as BaseAutocompleteData,
op: currentFilterItem?.op as string,
value: currentFilterItem?.value || '',
},
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
}
}
}, [
currentFilterItem?.key,
currentFilterItem?.op,
currentFilterItem?.value,
searchValue,
whereClauseConfig?.customKey,
whereClauseConfig?.customOp,
]);
// this useEffect takes care of tokenisation based on the search state
useEffect(() => {
// if there is no search value reset to the default state
if (!searchValue) {
setCurrentFilterItem(undefined);
setCurrentState(DropdownState.ATTRIBUTE_KEY);
}
// split the current search value based on delimiters
const { tagKey, tagOperator, tagValue } = getTagToken(searchValue);
if (
// Case 1 - if key is defined but the search text doesn't match with the set key,
// can happen when user selects from dropdown and then deletes a few characters
currentFilterItem?.key &&
currentFilterItem?.key?.key !== tagKey.split(' ')[0]
) {
setCurrentFilterItem(undefined);
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else if (tagOperator && isEmpty(currentFilterItem?.op)) {
// Case 2 -> key is set and now typing for the operator
if (
tagOperator === OPERATORS.EXISTS ||
tagOperator === OPERATORS.NOT_EXISTS
) {
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: tagOperator,
value: '',
} as ITag,
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: tagOperator,
value: '',
}));
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
}
} else if (
// Case 3 -> selected operator from dropdown and then erased a part of it
!isEmpty(currentFilterItem?.op) &&
tagOperator !== currentFilterItem?.op
) {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: '',
value: '',
}));
setCurrentState(DropdownState.OPERATOR);
} else if (currentState === DropdownState.ATTRIBUTE_VALUE) {
// Case 4 -> the final value state where we set the current filter values and the tokenisation happens on either
// dropdown click or blur event
const currentValue = {
key: currentFilterItem?.key as BaseAutocompleteData,
op: currentFilterItem?.op as string,
value: tagValue,
};
if (!isEqual(currentValue, currentFilterItem)) {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: prev?.op as string,
value: tagValue,
}));
}
}
}, [
currentFilterItem,
currentFilterItem?.key,
currentFilterItem?.op,
searchValue,
currentState,
]);
// the useEffect takes care of setting the dropdown values correctly on change of the current state
useEffect(() => {
if (currentState === DropdownState.ATTRIBUTE_KEY) {
const filteredAttributeKeys = attributeKeys.filter((key) =>
key.key.startsWith(searchValue),
);
setDropdownOptions(
filteredAttributeKeys?.map(
(key) =>
({
label: key.key,
value: key,
}) as Option,
) || [],
);
}
if (currentState === DropdownState.OPERATOR) {
const keyOperator = searchValue.split(' ');
const partialOperator = keyOperator?.[1];
const strippedKey = keyOperator?.[0];
let operatorOptions;
if (currentFilterItem?.key?.dataType) {
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES[
currentFilterItem.key
.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
].map((operator) => ({
label: operator,
value: operator,
}));
if (partialOperator) {
operatorOptions = operatorOptions.filter((op) =>
op.label.startsWith(partialOperator.toLocaleUpperCase()),
);
}
setDropdownOptions(operatorOptions);
} else if (strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')) {
operatorOptions = [OPERATORS.HAS, OPERATORS.NHAS].map((operator) => ({
label: operator,
value: operator,
}));
setDropdownOptions(operatorOptions);
} else {
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES.universal.map(
(operator) => ({
label: operator,
value: operator,
}),
);
if (partialOperator) {
operatorOptions = operatorOptions.filter((op) =>
op.label.startsWith(partialOperator.toLocaleUpperCase()),
);
}
setDropdownOptions(operatorOptions);
}
}
if (currentState === DropdownState.ATTRIBUTE_VALUE) {
const values: Array<string | number | boolean> = [];
const { tagValue } = getTagToken(searchValue);
if (isArray(tagValue)) {
if (!isEmpty(tagValue[tagValue.length - 1])) {
values.push(tagValue[tagValue.length - 1]);
}
} else if (!isEmpty(tagValue)) {
values.push(tagValue);
}
const currentAttributeValues =
attributeValues?.stringAttributeValues ||
attributeValues?.numberAttributeValues ||
attributeValues?.boolAttributeValues ||
[];
values.push(...currentAttributeValues);
if (attributeValuesMap) {
setDropdownOptions(
values.map(
(val) =>
({
label: checkCommaInValue(String(val)),
value: val,
}) as Option,
),
);
} else {
// If attributeValuesMap is not provided, don't set dropdown options
setDropdownOptions([]);
}
}
}, [
attributeValues,
currentFilterItem?.key?.dataType,
currentState,
attributeKeys,
searchValue,
attributeValuesMap,
]);
useEffect(() => {
const filterTags: IBuilderQuery['filters'] = {
op: 'AND',
items: [],
};
tags.forEach((tag) => {
const computedTagValue =
tag.value &&
Array.isArray(tag.value) &&
tag.value[tag.value.length - 1] === ''
? tag.value?.slice(0, -1)
: (tag.value ?? '');
filterTags.items.push({
id: tag.id || uuid().slice(0, 8),
key: tag.key,
op: getOperatorValue(tag.op),
value: computedTagValue,
});
});
if (!isEqual(filters, filterTags)) {
onChange(filterTags);
setTags(
filterTags.items.map((tag) => ({
...tag,
op: getOperatorFromValue(tag.op),
})) as ITag[],
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tags]);
const queryTags = useMemo(
() => tags.map((tag) => `${tag.key.key} ${tag.op} ${tag.value}`),
[tags],
);
const onTagRender = ({
value,
closable,
onClose,
}: CustomTagProps): React.ReactElement => {
const { tagOperator } = getTagToken(value);
const isInNin = isInNInOperator(tagOperator);
const chipValue = isInNin
? value?.trim()?.replace(/,\s*$/, '')
: value?.trim();
const indexInQueryTags = queryTags.findIndex((qTag) => isEqual(qTag, value));
const tagDetails = tags[indexInQueryTags];
const onCloseHandler = (): void => {
onClose();
setSearchValue('');
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
};
const tagEditHandler = (value: string): void => {
setCurrentFilterItem(tagDetails);
setSearchValue(value);
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
};
const isDisabled = !!searchValue;
return (
<span className="qb-search-bar-tokenised-tags">
<Badge
color="vanilla"
className={tagDetails?.key?.type || ''}
closable={!searchValue && closable}
onClose={(e): void => {
e.preventDefault();
onCloseHandler();
}}
>
<Tooltip title={chipValue}>
<TypographyText
$isInNin={isInNin}
$isEnabled={!!searchValue}
$disabled={isDisabled}
onClick={(): void => {
if (!isDisabled) {
tagEditHandler(value);
}
}}
>
{chipValue}
</TypographyText>
</Tooltip>
</Badge>
</span>
);
};
const suffixIconContent = useMemo(() => {
if (suffixIcon) {
return suffixIcon;
}
return isOpen ? (
<ChevronUp
size={14}
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
/>
) : (
<ChevronDown
size={14}
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
/>
);
}, [isDarkMode, isOpen, suffixIcon]);
return (
<div className="query-builder-search-v2 ">
<Select
ref={selectRef}
getPopupContainer={popupContainer}
virtual={false}
showSearch
tagRender={onTagRender}
transitionName=""
choiceTransitionName=""
filterOption={false}
open={isOpen}
suffixIcon={suffixIconContent}
onDropdownVisibleChange={setIsOpen}
autoClearSearchValue={false}
mode="multiple"
placeholder={placeholder}
value={queryTags}
searchValue={searchValue}
className={className}
rootClassName="query-builder-search client-side-qb-search"
disabled={!attributeKeys.length}
style={selectStyle}
onSearch={handleSearch}
onSelect={handleDropdownSelect}
onInputKeyDown={onInputKeyDownHandler}
notFoundContent={null}
showAction={['focus']}
onBlur={handleOnBlur}
>
{dropdownOptions.map((option) => {
let val = option.value;
try {
if (isObject(option.value)) {
val = JSON.stringify(option.value);
} else {
val = option.value;
}
} catch {
val = option.value;
}
return (
<Select.Option key={isObject(val) ? `select-option` : val} value={val}>
<Suggestions
label={option.label}
value={option.value}
option={currentState}
searchValue={searchValue}
/>
</Select.Option>
);
})}
</Select>
</div>
);
}
ClientSideQBSearch.defaultProps = {
placeholder: PLACEHOLDER,
className: '',
suffixIcon: null,
whereClauseConfig: {},
attributeValuesMap: {},
};
export default ClientSideQBSearch;

View File

@@ -8,7 +8,7 @@ import {
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import FilterQueryLexer from 'parser/FilterQueryLexer';
import FilterQueryParser, {
AndExpressionContext,

View File

@@ -5,7 +5,7 @@ import {
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { cloneDeep, isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';

View File

@@ -0,0 +1,91 @@
import { renderHook } from '@testing-library/react';
import {
useStableTotalCount,
UseStableTotalCountParams,
} from '../useStableTotalCount';
function renderStableTotalCount(initial: UseStableTotalCountParams): {
result: { current: number };
rerender: (next: UseStableTotalCountParams) => void;
} {
const { result, rerender } = renderHook(
(params: UseStableTotalCountParams) => useStableTotalCount(params),
{ initialProps: initial },
);
return { result, rerender };
}
describe('useStableTotalCount', () => {
it('reports the total once the load settles', () => {
const { result } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
expect(result.current).toBe(100);
});
it('keeps the last count while the same list refetches', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
rerender({ total: 0, isLoading: true, resetKey: 'pods' });
expect(result.current).toBe(100);
});
it('forgets the count when the list changes', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
rerender({ total: 0, isLoading: true, resetKey: 'nodes' });
expect(result.current).toBe(0);
});
it('reports the new list total once it arrives', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
rerender({ total: 0, isLoading: true, resetKey: 'nodes' });
rerender({ total: 25, isLoading: false, resetKey: 'nodes' });
expect(result.current).toBe(25);
});
it('reports zero for a list that is genuinely empty', () => {
const { result } = renderStableTotalCount({
total: 0,
isLoading: false,
resetKey: 'pods',
});
expect(result.current).toBe(0);
});
it('holds nothing back when no reset key is given', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: undefined,
});
// Without a key there is no context to compare, so the cache behaves as it
// always did: it survives the refetch.
rerender({ total: 0, isLoading: true, resetKey: undefined });
expect(result.current).toBe(100);
});
});

View File

@@ -0,0 +1,35 @@
import { useRef } from 'react';
export type UseStableTotalCountParams = {
total: number | undefined;
isLoading: boolean;
/**
* Identifies the list being counted. When it changes, the cached count is
* dropped so the previous list's page count cannot outlive it.
*/
resetKey: string | undefined;
};
/**
* Holds on to the last non-zero total so the pagination does not flash while the
* same list refetches, and forgets it as soon as `resetKey` moves to another list.
*/
export function useStableTotalCount({
total,
isLoading,
resetKey,
}: UseStableTotalCountParams): number {
const prevTotalRef = useRef(total || 0);
const prevResetKeyRef = useRef(resetKey);
if (prevResetKeyRef.current !== resetKey) {
prevResetKeyRef.current = resetKey;
prevTotalRef.current = 0;
}
if (total && total > 0) {
prevTotalRef.current = total;
}
return isLoading ? prevTotalRef.current : total || 0;
}

View File

@@ -2,7 +2,7 @@ import { memo, useMemo } from 'react';
import { Select, Spin } from 'antd';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { useOrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/useOrderByFilter';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/config';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { StringOperators } from 'types/common/queryBuilder';

View File

@@ -10,7 +10,7 @@ import {
RESTRICTED_SELECTED_FIELDS,
} from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import {
BaseAutocompleteData,

View File

@@ -42,7 +42,7 @@ jest.mock('hooks/useDebounce', () => ({
}));
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer',
'container/QueryBuilder/filters/OptionRenderer/OptionRenderer',
() => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,

View File

@@ -1,11 +1,15 @@
import { ReactNode } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { OrderByFilterProps } from './filters/OrderByFilter/OrderByFilter.interfaces';
export type WhereClauseConfig = {
customKey: string;
customOp: string;
};
type FilterConfigs = {
[Key in keyof Omit<IBuilderQuery, 'filters'>]: {
isHidden: boolean;

View File

@@ -1,45 +0,0 @@
import { useMemo } from 'react';
import { InputNumber, InputNumberProps } from 'antd';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { selectStyle } from '../QueryBuilderSearch/config';
function AggregateEveryFilter({
onChange,
query,
disabled,
}: AggregateEveryFilterProps): JSX.Element {
const isMetricsDataSource = useMemo(
() => query.dataSource === DataSource.METRICS,
[query.dataSource],
);
const onChangeHandler: InputNumberProps<number>['onChange'] = (event) => {
if (event && event >= 0) {
onChange(event);
}
};
const isDisabled =
(isMetricsDataSource && !query.aggregateAttribute?.key) || disabled;
return (
<InputNumber
placeholder="Enter in seconds"
disabled={isDisabled}
style={selectStyle}
value={query?.stepInterval}
onChange={onChangeHandler}
min={0}
/>
);
}
interface AggregateEveryFilterProps {
onChange: (values: number) => void;
query: IBuilderQuery;
disabled: boolean;
}
export default AggregateEveryFilter;

View File

@@ -24,8 +24,8 @@ import { DataSource } from 'types/common/queryBuilder';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
// ** Types
import { AgregatorFilterProps } from './AggregatorFilter.intefaces';

View File

@@ -1,6 +1,6 @@
import { InputNumber } from 'antd';
import { selectStyle } from '../../QueryBuilderSearch/config';
import { selectStyle } from '../../QueryBuilderSearchV2/config';
import { handleKeyDownLimitFilter } from '../../utils';
import { LimitFilterProps } from './types';

View File

@@ -4,7 +4,7 @@ import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { MetricAggregateOperator } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../../QueryBuilderSearch/config';
import { selectStyle } from '../../QueryBuilderSearchV2/config';
import { OrderByProps } from './types';
import { useOrderByFormulaFilter } from './useOrderByFormulaFilter';

View File

@@ -16,7 +16,7 @@ import {
mapLabelValuePairs,
orderByValueDelimiter,
} from '../../OrderByFilter/utils';
import { getRemoveOrderFromValue } from '../../QueryBuilderSearch/utils';
import { getRemoveOrderFromValue } from '../../QueryBuilderSearchV2/utils';
import { getUniqueOrderByValues, getValidOrderByResult } from '../../utils';
import { IOrderByFormulaFilterProps } from './types';
import { transformToOrderByStringValuesByFormula } from './utils';

View File

@@ -23,8 +23,8 @@ import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import { GroupByFilterProps } from './GroupByFilter.interfaces';
export const GroupByFilter = memo(function GroupByFilter({

View File

@@ -1,31 +0,0 @@
import { InputNumber } from 'antd';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { selectStyle } from '../QueryBuilderSearch/config';
import { handleKeyDownLimitFilter } from '../utils';
function LimitFilter({ onChange, query }: LimitFilterProps): JSX.Element {
const isMetricsDataSource = query.dataSource === DataSource.METRICS;
const isDisabled = isMetricsDataSource && !query.aggregateAttribute?.key;
return (
<InputNumber
min={1}
type="number"
value={query.limit}
style={selectStyle}
disabled={isDisabled}
onChange={onChange}
onKeyDown={handleKeyDownLimitFilter}
/>
);
}
interface LimitFilterProps {
onChange: (values: number | null) => void;
query: IBuilderQuery;
}
export default LimitFilter;

View File

@@ -31,7 +31,7 @@ jest.mock('hooks/useDebounce', () => ({
default: <T,>(value: T): T => value,
}));
jest.mock('../QueryBuilderSearch/OptionRenderer', () => ({
jest.mock('../OptionRenderer/OptionRenderer', () => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
}));

View File

@@ -16,8 +16,8 @@ import { MetricAggregation } from 'types/api/v5/queryRange';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import './MetricNameSelector.styles.scss';

View File

@@ -2,7 +2,7 @@ import { memo } from 'react';
import { Select } from 'antd';
// ** Types
import { selectStyle } from '../QueryBuilderSearch/config';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import { OperatorsSelectProps } from './OperatorsSelect.interfaces';
export const OperatorsSelect = memo(function OperatorsSelect({

View File

@@ -0,0 +1,22 @@
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
export const TagContainer = styled(Badge).attrs({
color: 'secondary',
variant: 'outline',
})`
&&& {
display: flex;
font-weight: 300;
font-size: 0.6rem;
}
`;
export const TagLabel = styled.span`
font-weight: 400;
`;
export const TagValue = styled.span`
text-transform: capitalize;
font-weight: 400;
`;

View File

@@ -5,7 +5,7 @@ import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import { OrderByFilterProps } from './OrderByFilter.interfaces';
import { useOrderByFilter } from './useOrderByFilter';

View File

@@ -7,7 +7,7 @@ import { parse } from 'papaparse';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
import { getRemoveOrderFromValue } from '../QueryBuilderSearch/utils';
import { getRemoveOrderFromValue } from '../QueryBuilderSearchV2/utils';
import { getUniqueOrderByValues, getValidOrderByResult } from '../utils';
import { ORDERBY_FILTERS } from './config';
import { SIGNOZ_VALUE } from './constants';

View File

@@ -1,70 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { Zap } from '@signozhq/icons';
import { getOptionType } from './utils';
import './QueryBuilderSearch.styles.scss';
function OptionRendererForLogs({
label,
value,
dataType,
isIndexed,
setDynamicPlaceholder,
}: OptionRendererProps): JSX.Element {
const optionType = getOptionType(label);
return (
<span
className="option"
onMouseEnter={(): void => setDynamicPlaceholder(value)}
onFocus={(): void => setDynamicPlaceholder(value)}
>
{optionType ? (
<Tooltip title={value} placement="topLeft">
<div className="logs-options-select">
<section className="left-section">
{isIndexed ? (
<Zap size={12} fill={Color.BG_AMBER_500} />
) : (
<div className="dot" />
)}
<Typography.Text className="text value" truncate={1}>
{value}
</Typography.Text>
</section>
<section className="right-section">
<div className="text tags data-type-tag">{dataType}</div>
<div className={cx('text tags option-type-tag', optionType)}>
<div className="dot" />
{optionType}
</div>
</section>
</div>
</Tooltip>
) : (
<Tooltip title={label} placement="topLeft">
<div className="without-option-type">
<div className="dot" />
<Typography.Text className="text" truncate={1}>
{label}
</Typography.Text>
</div>
</Tooltip>
)}
</span>
);
}
interface OptionRendererProps {
label: string;
value: string;
dataType: string;
isIndexed: boolean;
setDynamicPlaceholder: React.Dispatch<React.SetStateAction<string>>;
}
export default OptionRendererForLogs;

View File

@@ -1,298 +0,0 @@
.query-builder-search-container {
position: relative;
display: flex;
align-items: center;
gap: 12px;
}
.logs-popup {
&.hide-scroll {
.rc-virtual-list-holder {
height: 100px;
}
}
}
.logs-explorer-popup {
padding: 0px;
.ant-select-item-group {
padding: 12px 14px 8px 14px;
color: var(--muted-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 163.636% */
letter-spacing: 0.88px;
text-transform: uppercase;
}
.show-all-filter-props {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 13px;
width: 100%;
cursor: pointer;
.content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.left-section {
display: flex;
align-items: center;
gap: 4px;
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.text:hover {
color: var(--l1-foreground);
}
}
.right-section {
display: flex;
align-items: center;
gap: 4px;
.keyboard-shortcut-slash {
width: 16px;
height: 16px;
flex-shrink: 0;
border-radius: 2.286px;
border-top: 1.143px solid var(--l1-border);
border-right: 1.143px solid var(--l1-border);
border-bottom: 2.286px solid var(--l1-border);
border-left: 1.143px solid var(--l1-border);
background: var(--l2-background);
}
}
}
}
.show-all-filter-props:hover {
background: color-mix(
in srgb,
var(--l1-foreground) 4%,
transparent
) !important;
}
.example-queries {
cursor: default;
.heading {
padding: 12px 14px 8px 14px;
color: var(--muted-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 163.636% */
letter-spacing: 0.88px;
text-transform: uppercase;
}
.query-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0px 12px 12px 12px;
cursor: pointer;
.example-query {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 10px;
border-radius: 2px;
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: -0.07px;
width: fit-content;
}
.example-query:hover {
color: var(--l1-foreground);
}
}
}
.ant-select-item-option-grouped {
padding-inline-start: 0px;
padding: 7px 13px;
}
.keyboard-shortcuts {
display: flex;
align-items: center;
border-radius: 0px 0px 4px 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
padding: 11px 16px;
cursor: default;
.icons {
width: 16px;
height: 16px;
flex-shrink: 0;
border-radius: 2.286px;
border-top: 1.143px solid var(--l3-background);
border-right: 1.143px solid var(--l3-background);
border-bottom: 2.286px solid var(--l3-background);
border-left: 1.143px solid var(--l3-background);
background: var(--l2-background);
}
.keyboard-text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 142.857% */
letter-spacing: -0.07px;
}
.navigate {
display: flex;
align-items: center;
padding-right: 12px;
gap: 4px;
border-right: 1px solid var(--l1-border);
}
.update-query {
display: flex;
align-items: center;
margin-left: 12px;
gap: 4px;
}
}
.without-option-type {
display: flex;
gap: 8px;
align-items: center;
.dot {
height: 5px;
width: 5px;
border-radius: 50%;
background-color: var(--l3-background);
}
}
.logs-options-select {
display: flex;
align-items: center;
justify-content: space-between;
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.tags {
display: flex;
height: 20px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 20px;
}
.dot {
height: 5px;
width: 5px;
border-radius: 50%;
flex-shrink: 0;
}
.left-section {
display: flex;
align-items: center;
gap: 8px;
width: 90%;
.dot {
background-color: var(--l3-background);
}
.value {
width: 100%;
}
}
.right-section {
display: flex;
align-items: center;
gap: 4px;
.data-type-tag {
background: color-mix(in srgb, var(--l1-foreground) 8%, transparent);
}
.option-type-tag {
display: flex;
gap: 4px;
align-items: center;
padding: 0px 6px;
text-transform: capitalize;
}
.tag {
border-radius: 50px;
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
color: var(--bg-sienna-400);
.dot {
background-color: var(--bg-sienna-400);
}
}
.resource {
border-radius: 50px;
background: color-mix(in srgb, var(--bg-sakura-400) 10%, transparent);
color: var(--bg-sakura-400);
.dot {
background-color: var(--bg-sakura-400);
}
}
}
}
.ant-select-item-option-active {
.logs-options-select {
.left-section {
.value {
color: var(--l1-foreground);
}
}
}
}
}
.span-scope-selector {
width: 160px;
}

View File

@@ -1,558 +0,0 @@
import {
KeyboardEvent,
ReactElement,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import { Button, Select, Spin, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import {
INFRA_LONG_TO_SHORT_OPERATOR_MAP,
OPERATORS,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { getDataTypes } from 'container/LogDetailedView/utils';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import {
useAutoComplete,
WhereClauseConfig,
} from 'hooks/queryBuilder/useAutoComplete';
import { useFetchKeysAndValues } from 'hooks/queryBuilder/useFetchKeysAndValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isEqual, isUndefined } from 'lodash-es';
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronUp,
Command,
CornerDownLeft,
Filter,
Slash,
} from '@signozhq/icons';
import type { BaseSelectRef } from 'rc-select';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { selectStyle } from './config';
import { PLACEHOLDER } from './constant';
import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs';
import OptionRenderer from './OptionRenderer';
import OptionRendererForLogs from './OptionRendererForLogs';
import { StyledCheckOutlined, TypographyText } from './style';
import {
convertExampleQueriesToOptions,
getOperatorValue,
getRemovePrefixFromKey,
getTagToken,
isExistsNotExistsOperator,
isInNInOperator,
} from './utils';
import './QueryBuilderSearch.styles.scss';
function getOperatorValueForContext(
op: string,
isInfraMonitoring?: boolean,
): string {
const mappedOp =
isInfraMonitoring && INFRA_LONG_TO_SHORT_OPERATOR_MAP[op]
? INFRA_LONG_TO_SHORT_OPERATOR_MAP[op]
: op;
return getOperatorValue(mappedOp);
}
function QueryBuilderSearch({
query,
onChange,
whereClauseConfig,
className,
placeholder,
suffixIcon,
isInfraMonitoring,
isMetricsExplorer,
disableNavigationShortcuts,
entity,
}: QueryBuilderSearchProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { pathname } = useLocation();
const isLogsExplorerPage = useMemo(
() => pathname === ROUTES.LOGS_EXPLORER,
[pathname],
);
const [isEditingTag, setIsEditingTag] = useState(false);
const {
updateTag,
handleClearTag,
handleKeyDown,
handleOnBlur,
handleSearch,
handleSelect,
tags,
options,
searchValue,
isMulti,
isFetching,
setSearchKey,
setSearchValue,
searchKey,
key,
exampleQueries,
} = useAutoComplete(
query,
whereClauseConfig,
isLogsExplorerPage,
isInfraMonitoring,
entity,
isMetricsExplorer,
);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [showAllFilters, setShowAllFilters] = useState<boolean>(false);
const [dynamicPlacholder, setDynamicPlaceholder] = useState<string>(
placeholder || '',
);
const selectRef = useRef<BaseSelectRef>(null);
const { sourceKeys, handleRemoveSourceKey } = useFetchKeysAndValues(
searchValue,
query,
searchKey,
isLogsExplorerPage,
isInfraMonitoring,
entity,
isMetricsExplorer,
);
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
const { handleRunQuery, currentQuery } = useQueryBuilder();
const toggleEditMode = useCallback(
(value: boolean) => {
// Editing mode is required only in infra monitoring or metrics explorer
if (isInfraMonitoring || isMetricsExplorer) {
setIsEditingTag(value);
}
},
[isInfraMonitoring, isMetricsExplorer],
);
const onTagRender = ({
value,
closable,
onClose,
}: CustomTagProps): ReactElement => {
const { tagOperator } = getTagToken(value);
const isInNin = isInNInOperator(tagOperator);
const chipValue = isInNin
? value?.trim()?.replace(/,\s*$/, '')
: value?.trim();
const onCloseHandler = (): void => {
onClose();
// Editing is done after closing a tag
toggleEditMode(false);
handleSearch('');
setSearchKey('');
};
const tagEditHandler = (value: string): void => {
updateTag(value);
// Editing starts
toggleEditMode(true);
if (isInfraMonitoring || isMetricsExplorer) {
setSearchValue(value);
} else {
handleSearch(value);
}
};
const isDisabled = !!searchValue;
return (
<Badge
color="vanilla"
closable={!searchValue && closable}
onClose={(e): void => {
e.preventDefault();
onCloseHandler();
}}
>
<Tooltip title={chipValue}>
<TypographyText
$isInNin={isInNin}
$isEnabled={!!searchValue}
onClick={(): void => {
if (!isDisabled) {
tagEditHandler(value);
}
}}
>
{chipValue}
</TypographyText>
</Tooltip>
</Badge>
);
};
const onChangeHandler = (value: string[]): void => {
if (!isMulti) {
handleSearch(value[value.length - 1]);
}
};
const onInputKeyDownHandler = (event: KeyboardEvent<Element>): void => {
if (isMulti || event.key === 'Backspace') {
handleKeyDown(event);
}
if (isExistsNotExistsOperator(searchValue)) {
handleKeyDown(event);
}
// Editing is done after enter key press
if (event.key === 'Enter') {
toggleEditMode(false);
}
if (
!disableNavigationShortcuts &&
(event.ctrlKey || event.metaKey) &&
event.key === 'Enter'
) {
event.preventDefault();
event.stopPropagation();
handleRunQuery();
setIsOpen(false);
}
if (
!disableNavigationShortcuts &&
(event.ctrlKey || event.metaKey) &&
event.key === '/'
) {
event.preventDefault();
event.stopPropagation();
setShowAllFilters((prev) => !prev);
}
};
const handleDeselect = useCallback(
(deselectedItem: string) => {
handleClearTag(deselectedItem);
handleRemoveSourceKey(deselectedItem);
},
[handleClearTag, handleRemoveSourceKey],
);
const isMetricsDataSource = useMemo(
() =>
query.dataSource === DataSource.METRICS &&
!isInfraMonitoring &&
!isMetricsExplorer,
[query.dataSource, isInfraMonitoring, isMetricsExplorer],
);
const fetchValueDataType = (value: unknown, operator: string): DataTypes => {
if (operator === OPERATORS.HAS || operator === OPERATORS.NHAS) {
return getDataTypes([value]);
}
return DataTypes.EMPTY;
};
const queryTags = useMemo(() => {
if (!query.aggregateAttribute?.key && isMetricsDataSource) {
return [];
}
return tags;
}, [isMetricsDataSource, query.aggregateAttribute?.key, tags]);
useEffect(() => {
const initialTagFilters: TagFilter = { items: [], op: 'AND' };
const initialSourceKeys = query.filters?.items?.map(
(item) => item.key as BaseAutocompleteData,
);
initialTagFilters.items = tags.map((tag) => {
const { tagKey, tagOperator, tagValue } = getTagToken(tag);
const filterAttribute = [
...(initialSourceKeys || []),
...(sourceKeys || []),
].find((key) => key?.key === getRemovePrefixFromKey(tagKey));
const computedTagValue =
tagValue && Array.isArray(tagValue) && tagValue[tagValue.length - 1] === ''
? tagValue?.slice(0, -1)
: (tagValue ?? '');
return {
id: uuid().slice(0, 8),
key: filterAttribute ?? {
key: tagKey,
dataType: fetchValueDataType(computedTagValue, tagOperator),
type: '',
},
op: getOperatorValueForContext(tagOperator, isInfraMonitoring),
value: computedTagValue,
};
});
// If in infra monitoring or metrics explorer, only run the onChange query when editing is finsished.
if (isInfraMonitoring || isMetricsExplorer) {
if (!isEditingTag) {
onChange(initialTagFilters);
}
} else {
onChange(initialTagFilters);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceKeys]);
const isLastQuery = useMemo(
() =>
isEqual(
currentQuery.builder.queryData[currentQuery.builder.queryData.length - 1],
query,
),
[currentQuery, query],
);
useEffect(() => {
if (isLastQuery && !disableNavigationShortcuts) {
registerShortcut(LogsExplorerShortcuts.FocusTheSearchBar, () => {
// set timeout is needed here else the select treats the hotkey as input value
setTimeout(() => {
selectRef.current?.focus();
}, 0);
});
}
return (): void =>
deregisterShortcut(LogsExplorerShortcuts.FocusTheSearchBar);
}, [
deregisterShortcut,
disableNavigationShortcuts,
isLastQuery,
registerShortcut,
]);
useEffect(() => {
if (!isOpen) {
setDynamicPlaceholder(placeholder || '');
}
}, [isOpen, placeholder]);
const userOs = getUserOperatingSystem();
// conditional changes here to use a seperate component to render the example queries based on the option group label
const customRendererForLogsExplorer = options.map((option) => (
<Select.Option
key={`${option.label}-${option.type || ''}-${option.dataType || ''}`}
value={option.value}
>
<OptionRendererForLogs
label={option.label}
value={option.value}
dataType={option.dataType || ''}
isIndexed={option.isIndexed || false}
setDynamicPlaceholder={setDynamicPlaceholder}
/>
{option.selected && <StyledCheckOutlined />}
</Select.Option>
));
return (
<div className="query-builder-search-container">
<Select
data-testid={'qb-search-select'}
ref={selectRef}
getPopupContainer={getPopupContainer}
transitionName=""
choiceTransitionName=""
virtual={false}
showSearch
tagRender={onTagRender}
filterOption={false}
open={isOpen}
onDropdownVisibleChange={setIsOpen}
autoClearSearchValue={false}
mode="multiple"
placeholder={dynamicPlacholder}
value={queryTags}
searchValue={searchValue}
className={cx(
className,
isLogsExplorerPage ? 'logs-popup' : '',
!showAllFilters && options.length > 3 && !key ? 'hide-scroll' : '',
)}
rootClassName="query-builder-search"
disabled={isMetricsDataSource && !query.aggregateAttribute?.key}
style={selectStyle}
onSearch={handleSearch}
onChange={onChangeHandler}
onSelect={handleSelect}
onDeselect={handleDeselect}
onInputKeyDown={onInputKeyDownHandler}
notFoundContent={isFetching ? <Spin size="small" /> : null}
suffixIcon={
!isUndefined(suffixIcon) ? (
suffixIcon
) : isOpen ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)
}
showAction={['focus']}
onBlur={(e: React.FocusEvent<HTMLInputElement>): void => {
handleOnBlur(e);
// Editing is done after tapping out of the input
toggleEditMode(false);
}}
popupClassName={isLogsExplorerPage ? 'logs-explorer-popup' : ''}
dropdownRender={(menu): ReactElement => (
<div>
{!searchKey && isLogsExplorerPage && (
<div className="ant-select-item-group ">Suggested Filters</div>
)}
{menu}
{isLogsExplorerPage && (
<div>
{!searchKey && tags.length === 0 && (
<div className="example-queries">
<div className="heading"> Example Queries </div>
<div className="query-container">
{convertExampleQueriesToOptions(exampleQueries).map((query) => (
<ExampleQueriesRendererForLogs
key={query.label}
label={query.label}
value={query.value}
handleAddTag={onChange}
/>
))}
</div>
</div>
)}
{!key && !isFetching && !showAllFilters && options.length > 3 && (
<Button
type="text"
className="show-all-filter-props"
onClick={(): void => {
setShowAllFilters(true);
// when clicking on the button the search bar looses the focus
selectRef?.current?.focus();
}}
>
<div className="content">
<section className="left-section">
<Filter size={14} />
<Typography.Text className="text">
Show all filters properties
</Typography.Text>
</section>
<section className="right-section">
{userOs === UserOperatingSystem.MACOS ? (
<Command size={14} className="keyboard-shortcut-slash" />
) : (
<ChevronUp size={14} className="keyboard-shortcut-slash" />
)}
+
<Slash size={14} className="keyboard-shortcut-slash" />
</section>
</div>
</Button>
)}
<div className="keyboard-shortcuts">
<section className="navigate">
<ArrowDown size={10} className="icons" />
<ArrowUp size={10} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
<section className="update-query">
<CornerDownLeft size={10} className="icons" />
<span className="keyboard-text">to update query</span>
</section>
</div>
</div>
)}
</div>
)}
>
{isLogsExplorerPage
? customRendererForLogsExplorer
: options.map((option) => (
<Select.Option
key={`${option.label}-${option.type || ''}-${option.dataType || ''}`}
value={option.value}
>
<OptionRenderer
label={option.label}
value={option.value}
dataType={option.dataType || ''}
type={option.type || ''}
/>
{option.selected && <StyledCheckOutlined />}
</Select.Option>
))}
</Select>
</div>
);
}
interface QueryBuilderSearchProps {
query: IBuilderQuery;
onChange: (value: TagFilter) => void;
whereClauseConfig?: WhereClauseConfig;
className?: string;
placeholder?: string;
suffixIcon?: React.ReactNode;
isInfraMonitoring?: boolean;
disableNavigationShortcuts?: boolean;
// TODO: Remove the dependency of InfraMonitoring from this code
entity?: InfraMonitoringEntity | null;
isMetricsExplorer?: boolean;
}
QueryBuilderSearch.defaultProps = {
whereClauseConfig: undefined,
className: '',
placeholder: PLACEHOLDER,
suffixIcon: undefined,
isInfraMonitoring: false,
disableNavigationShortcuts: false,
entity: null,
isMetricsExplorer: false,
};
export interface CustomTagProps {
label: ReactNode;
value: string;
disabled: boolean;
onClose: () => void;
closable: boolean;
}
export default QueryBuilderSearch;

View File

@@ -1,7 +1,5 @@
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import './QueryBuilderSearch.styles.scss';
function ExampleQueriesRendererForLogs({
label,
value,

View File

@@ -11,8 +11,8 @@ import {
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import ExampleQueriesRendererForLogs from '../QueryBuilderSearch/ExampleQueriesRendererForLogs';
import { convertExampleQueriesToOptions } from '../QueryBuilderSearch/utils';
import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs';
import { convertExampleQueriesToOptions } from './utils';
import { ITag, Option } from './QueryBuilderSearchV2';
import './QueryBuilderSearchV2.styles.scss';

View File

@@ -19,10 +19,10 @@ import {
QUERY_BUILDER_SEARCH_VALUES,
} from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import type { WhereClauseConfig } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
@@ -53,16 +53,16 @@ import { DataSource } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { selectStyle } from '../QueryBuilderSearch/config';
import { PLACEHOLDER } from '../QueryBuilderSearch/constant';
import { TypographyText } from '../QueryBuilderSearch/style';
import { selectStyle } from './config';
import { PLACEHOLDER } from './constant';
import { TypographyText } from './style';
import {
checkCommaInValue,
getOperatorFromValue,
getOperatorValue,
getTagToken,
isInNInOperator,
} from '../QueryBuilderSearch/utils';
} from './utils';
import { filterByOperatorConfig } from '../utils';
import QueryBuilderSearchDropdown from './QueryBuilderSearchDropdown';
import SpanScopeSelector from './SpanScopeSelector';

View File

@@ -7,7 +7,7 @@ import { isEmpty, isObject } from 'lodash-es';
import { Check, Zap } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { getTagToken } from '../QueryBuilderSearch/utils';
import { getTagToken } from './utils';
import { DropdownState } from './QueryBuilderSearchV2';
import './Suggestions.styles.scss';

View File

@@ -1,5 +1,3 @@
import { Check } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
export const TypographyText = styled.span<{
@@ -17,27 +15,3 @@ export const TypographyText = styled.span<{
padding-left: 8px;
${({ $disabled }): string => ($disabled ? 'opacity: 0.6' : '')}
`;
export const StyledCheckOutlined = styled(Check)`
float: right;
`;
export const TagContainer = styled(Badge).attrs({
color: 'secondary',
variant: 'outline',
})`
&&& {
display: flex;
font-weight: 300;
font-size: 0.6rem;
}
`;
export const TagLabel = styled.span`
font-weight: 400;
`;
export const TagValue = styled.span`
text-transform: capitalize;
font-weight: 400;
`;

View File

@@ -1,8 +1,7 @@
import { OPERATORS } from 'constants/queryBuilder';
import { MetricsType } from 'container/MetricsApplication/constant';
import { queryFilterTags } from 'hooks/queryBuilder/useTag';
import { parse } from 'papaparse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import type { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { orderByValueDelimiter } from '../OrderByFilter/utils';
@@ -191,18 +190,6 @@ export function getRemoveOrderFromValue(tag: string): string {
return tag;
}
export function getOptionType(label: string): MetricsType | undefined {
let optionType;
if (label.startsWith('tag_')) {
optionType = MetricsType.Tag;
} else if (label.startsWith('resource_')) {
optionType = MetricsType.Resource;
}
return optionType;
}
/**
*
* @param exampleQueries the example queries based on recommendation engine

View File

@@ -1,4 +1,3 @@
import { AttributeValuesMap } from 'components/ClientSideQBSearch/ClientSideQBSearch';
import { OperatorConfigKeys, OPERATORS_CONFIG } from 'constants/queryBuilder';
import { HAVING_FILTER_REGEXP } from 'constants/regExp';
import { IOption } from 'hooks/useResourceAttribute/types';
@@ -11,7 +10,7 @@ import {
orderByValueDelimiter,
splitOrderByFromString,
} from './OrderByFilter/utils';
import { getRemoveOrderFromValue } from './QueryBuilderSearch/utils';
import { getRemoveOrderFromValue } from './QueryBuilderSearchV2/utils';
export const handleKeyDownLimitFilter: React.KeyboardEventHandler<
HTMLInputElement
@@ -101,23 +100,6 @@ export const getValidOrderByResult = (result: IOption[]): IOption[] =>
return acc;
}, []);
export const transformKeyValuesToAttributeValuesMap = (
attributeValuesMap: Record<string, string[] | number[] | boolean[]>,
): AttributeValuesMap =>
Object.fromEntries(
Object.entries(attributeValuesMap || {}).map(([key, values]) => [
key,
{
stringAttributeValues:
typeof values[0] === 'string' ? (values as string[]) : [],
numberAttributeValues:
typeof values[0] === 'number' ? (values as number[]) : [],
boolAttributeValues:
typeof values[0] === 'boolean' ? (values as boolean[]) : [],
},
]),
);
export const filterByOperatorConfig = (
options: IOption[],
key?: OperatorConfigKeys,

View File

@@ -3,7 +3,7 @@ import {
formatValueForExpression,
removeKeysFromExpression,
} from 'components/QueryBuilderV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { IQueryPair } from 'types/antlrQueryTypes';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isQuoted, unquote } from 'utils/stringUtils';

View File

@@ -0,0 +1,15 @@
import { ReactNode } from 'react';
import { ConfigProvider } from 'antd';
import { useThemeConfig } from 'hooks/useDarkMode';
/**
* `useThemeConfig` has to run under `ThemeProvider`, so the antd
* `ConfigProvider` lives in its own component the way `AppRoutes` does it.
*/
function AntdThemeBridge({ children }: { children: ReactNode }): JSX.Element {
const themeConfig = useThemeConfig();
return <ConfigProvider theme={themeConfig}>{children}</ConfigProvider>;
}
export default AntdThemeBridge;

View File

@@ -0,0 +1,123 @@
import { ReactNode } from 'react';
import { HelmetProvider } from 'react-helmet-async';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
// eslint-disable-next-line no-restricted-imports
import { Store } from 'redux';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
import { ThemeProvider } from 'hooks/useDarkMode';
import { NotificationProvider } from 'hooks/useNotifications';
import { ResourceProvider } from 'hooks/useResourceAttribute';
import { AppContext } from 'providers/App/App';
import { IAppContext } from 'providers/App/types';
import { CmdKProvider } from 'providers/cmdKProvider';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import {
QueryBuilderContext,
QueryBuilderProvider,
} from 'providers/QueryBuilder';
import TimezoneProvider from 'providers/Timezone';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import AntdThemeBridge from './AntdThemeBridge';
/**
* A layer the runner supplies. A render function rather than a component, so an
* inline one does not change identity between renders and remount the tree.
*/
export type HarnessWrapper = (children: ReactNode) => ReactNode;
export interface AppHarnessProps {
children: ReactNode;
/** Stands in for `AppProvider`, whose fetches no harness can make. */
appContext: IAppContext;
store: Store;
queryClient: QueryClient;
/** When set, replaces `QueryBuilderProvider` with a fixed context value. */
queryBuilder?: Partial<QueryBuilderContextType>;
/**
* The router the runner drives: jest a `MemoryRouter`, Storybook a `Router` on
* the contained history. Everything above it in the tree is router-free, so
* the choice stays here.
*/
router: HarnessWrapper;
/** The nuqs adapter: the react one under jest, the testing one in Storybook. */
searchParams: HarnessWrapper;
/** Rendered beside the subject: Storybook's palette, overlay and probe. */
overlays?: ReactNode;
}
/**
* The app's provider tree, as `src/index.tsx` and `src/AppRoutes/index.tsx`
* mount it, minus Sentry, posthog and `AppProvider`, with the pieces a test
* runner has to choose left as props. Both harnesses that render pages outside
* the app (jest's `test-utils`, Storybook's `StorybookProviders`) go through
* here, so a provider added to the app is added once and both see it.
*/
function AppHarness({
children,
appContext,
store,
queryClient,
queryBuilder,
router,
searchParams,
overlays,
}: AppHarnessProps): JSX.Element {
const subject = queryBuilder ? (
<QueryBuilderContext.Provider value={queryBuilder as QueryBuilderContextType}>
{children}
</QueryBuilderContext.Provider>
) : (
<QueryBuilderProvider>{children}</QueryBuilderProvider>
);
return (
<HelmetProvider>
{searchParams(
<ThemeProvider>
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<GlobalTimeStoreAdapter />
<AppContext.Provider value={appContext}>
<AntdThemeBridge>
{router(
<TooltipProvider>
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
<ResourceProvider>
<KeyboardHotkeysProvider>
<PreferenceContextProvider>
{subject}
{overlays}
</PreferenceContextProvider>
</KeyboardHotkeysProvider>
</ResourceProvider>
</ErrorModalProvider>
</NotificationProvider>
</CmdKProvider>
</TooltipProvider>,
)}
</AntdThemeBridge>
</AppContext.Provider>
</Provider>
</QueryClientProvider>
</TimezoneProvider>
</ThemeProvider>,
)}
</HelmetProvider>
);
}
AppHarness.defaultProps = {
queryBuilder: undefined,
overlays: undefined,
};
export default AppHarness;

View File

@@ -9,7 +9,7 @@ import { QueryParams } from 'constants/query';
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';

View File

@@ -1,185 +0,0 @@
import { KeyboardEvent, useCallback, useState } from 'react';
import { OPERATORS } from 'constants/queryBuilder';
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
getRemovePrefixFromKey,
getTagToken,
replaceStringWithMaxLength,
tagRegexp,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { Option } from 'container/QueryBuilder/type';
import { parse } from 'papaparse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { useFetchKeysAndValues } from './useFetchKeysAndValues';
import { useOptions, WHERE_CLAUSE_CUSTOM_SUFFIX } from './useOptions';
import { useSetCurrentKeyAndOperator } from './useSetCurrentKeyAndOperator';
import { useTag } from './useTag';
import { useTagValidation } from './useTagValidation';
export type WhereClauseConfig = {
customKey: string;
customOp: string;
};
export const useAutoComplete = (
query: IBuilderQuery,
whereClauseConfig?: WhereClauseConfig,
shouldUseSuggestions?: boolean,
isInfraMonitoring?: boolean,
entity?: InfraMonitoringEntity | null,
isMetricsExplorer?: boolean,
): IAutoComplete => {
const [searchValue, setSearchValue] = useState<string>('');
const [searchKey, setSearchKey] = useState<string>('');
const { keys, results, isFetching, exampleQueries } = useFetchKeysAndValues(
searchValue,
query,
searchKey,
shouldUseSuggestions,
isInfraMonitoring,
entity,
isMetricsExplorer,
);
const [key, operator, result] = useSetCurrentKeyAndOperator(searchValue, keys);
const handleSearch = (value: string): void => {
const prefixFreeValue = getRemovePrefixFromKey(getTagToken(value).tagKey);
setSearchValue(value);
setSearchKey(prefixFreeValue);
};
const { isValidTag, isExist, isValidOperator, isMulti } = useTagValidation(
operator,
result,
);
const { handleAddTag, handleClearTag, tags, updateTag } = useTag(
isValidTag,
handleSearch,
query,
setSearchKey,
whereClauseConfig,
isInfraMonitoring,
);
const handleSelect = useCallback(
(value: string): void => {
if (isMulti) {
setSearchValue((prev: string) => {
const matches = prev?.matchAll(tagRegexp);
const [match] = matches ? Array.from(matches) : [];
const [, , , matchTagValue] = match;
const data = parse(matchTagValue).data.flat();
return replaceStringWithMaxLength(prev, data as string[], value);
});
}
if (!isMulti) {
handleAddTag(value);
}
},
[handleAddTag, isMulti],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent): void => {
if (
event.key === ' ' &&
(searchValue.endsWith(' ') || searchValue.length === 0)
) {
event.preventDefault();
}
if (event.key === 'Enter' && searchValue && isValidTag) {
if (isMulti) {
event.stopPropagation();
}
event.preventDefault();
handleAddTag(searchValue);
}
if (event.key === 'Backspace' && !searchValue) {
event.stopPropagation();
const last = tags[tags.length - 1];
handleClearTag(last);
}
},
[handleAddTag, handleClearTag, isMulti, isValidTag, searchValue, tags],
);
const handleOnBlur = (event: React.FocusEvent<HTMLInputElement>): void => {
event.preventDefault();
if (searchValue) {
if (
key &&
!operator &&
whereClauseConfig?.customKey === 'body' &&
whereClauseConfig.customOp === OPERATORS.CONTAINS
) {
const value = `${searchValue}${WHERE_CLAUSE_CUSTOM_SUFFIX}`;
handleAddTag(value);
return;
}
handleAddTag(searchValue);
}
};
const options = useOptions(
key,
keys,
operator,
searchValue,
isMulti,
isValidOperator,
isExist,
results,
result,
isFetching,
whereClauseConfig,
isInfraMonitoring,
);
return {
updateTag,
handleSearch,
handleClearTag,
handleSelect,
handleKeyDown,
handleOnBlur,
options,
tags,
searchValue,
isMulti,
isFetching,
setSearchKey,
setSearchValue,
searchKey,
key,
exampleQueries,
};
};
interface IAutoComplete {
updateTag: (value: string) => void;
handleSearch: (value: string) => void;
handleClearTag: (value: string) => void;
handleSelect: (value: string) => void;
handleKeyDown: (event: React.KeyboardEvent) => void;
handleOnBlur: (event: React.FocusEvent<HTMLInputElement>) => void;
options: Option[];
tags: string[];
searchValue: string;
isMulti: boolean;
isFetching: boolean;
setSearchKey: (value: string) => void;
setSearchValue: (value: string) => void;
searchKey: string;
key: string;
exampleQueries: TagFilter[];
isInfraMonitoring?: boolean;
}

View File

@@ -1,308 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDebounce } from 'react-use';
import { getAttributesValues } from 'api/queryBuilder/getAttributesValues';
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import {
GetK8sEntityToAggregateAttribute,
InfraMonitoringEntity,
} from 'container/InfraMonitoringK8sV2/constants';
import {
getRemovePrefixFromKey,
getTagToken,
isInNInOperator,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import useDebounceValue from 'hooks/useDebounce';
import { cloneDeep, isEqual, uniqWith, unset } from 'lodash-es';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { useGetAggregateKeys } from './useGetAggregateKeys';
import { useGetAttributeSuggestions } from './useGetAttributeSuggestions';
type IuseFetchKeysAndValues = {
keys: BaseAutocompleteData[];
results: string[];
isFetching: boolean;
sourceKeys: BaseAutocompleteData[];
handleRemoveSourceKey: (newSourceKey: string) => void;
exampleQueries: TagFilter[];
};
/**
* Custom hook to fetch attribute keys and values from an API
* @param searchValue - the search query value
* @param query - an object containing data for the query
* @returns an object containing the fetched attribute keys, results, and the status of the fetch
*/
export const useFetchKeysAndValues = (
searchValue: string,
query: IBuilderQuery,
searchKey: string,
shouldUseSuggestions?: boolean,
isInfraMonitoring?: boolean,
entity?: InfraMonitoringEntity | null,
isMetricsExplorer?: boolean,
): IuseFetchKeysAndValues => {
const [keys, setKeys] = useState<BaseAutocompleteData[]>([]);
const [exampleQueries, setExampleQueries] = useState<TagFilter[]>([]);
const [sourceKeys, setSourceKeys] = useState<BaseAutocompleteData[]>([]);
const [results, setResults] = useState<string[]>([]);
const [isAggregateFetching, setAggregateFetching] = useState<boolean>(false);
const memoizedSearchParams = useMemo(
() => [
searchKey,
query.dataSource,
query.aggregateOperator,
query.aggregateAttribute?.key,
],
[
searchKey,
query.dataSource,
query.aggregateOperator,
query.aggregateAttribute?.key,
],
);
const searchParams = useDebounceValue(memoizedSearchParams, DEBOUNCE_DELAY);
const queryFiltersWithoutId = useMemo(
() => ({
...query.filters,
items: query.filters?.items?.map((item) => {
const filterWithoutId = cloneDeep(item);
unset(filterWithoutId, 'id');
return filterWithoutId;
}),
}),
[query.filters],
);
const memoizedSuggestionsParams = useMemo(
() => [searchKey, query.dataSource, queryFiltersWithoutId],
[query.dataSource, queryFiltersWithoutId, searchKey],
);
const suggestionsParams = useDebounceValue(
memoizedSuggestionsParams,
DEBOUNCE_DELAY,
);
const isQueryEnabled = useMemo(
() =>
query.dataSource === DataSource.METRICS &&
!isInfraMonitoring &&
!isMetricsExplorer
? !!query.dataSource && !!query.aggregateAttribute?.dataType
: true,
[
isInfraMonitoring,
isMetricsExplorer,
query.aggregateAttribute?.dataType,
query.dataSource,
],
);
const { data, isFetching, status } = useGetAggregateKeys(
{
searchText: searchKey,
dataSource: query.dataSource,
aggregateOperator: query.aggregateOperator || '',
aggregateAttribute:
isInfraMonitoring && entity
? GetK8sEntityToAggregateAttribute(entity)
: query.aggregateAttribute?.key || '',
tagType: query.aggregateAttribute?.type ?? null,
},
{
queryKey: [searchParams],
enabled: isMetricsExplorer ? false : isQueryEnabled && !shouldUseSuggestions,
},
isInfraMonitoring, // isInfraMonitoring
entity, // infraMonitoringEntity
);
const {
data: suggestionsData,
isFetching: isFetchingSuggestions,
status: fetchingSuggestionsStatus,
} = useGetAttributeSuggestions(
{
searchText: searchKey,
dataSource: query.dataSource,
filters: query.filters || { items: [], op: 'AND' },
},
{
queryKey: [suggestionsParams],
enabled: isQueryEnabled && shouldUseSuggestions,
},
);
function isAttributeValuesResponse(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any,
): payload is IAttributeValuesResponse {
return (
payload &&
(Array.isArray(payload.stringAttributeValues) ||
payload.stringAttributeValues === null ||
Array.isArray(payload.numberAttributeValues) ||
payload.numberAttributeValues === null ||
Array.isArray(payload.boolAttributeValues) ||
payload.boolAttributeValues === null)
);
}
/**
* Fetches the options to be displayed based on the selected value
* @param value - the selected value
* @param query - an object containing data for the query
*/
const handleFetchOption = async (
value: string,
query: IBuilderQuery,
keys: BaseAutocompleteData[],
): Promise<void> => {
if (!value) {
return;
}
const { tagKey, tagOperator, tagValue } = getTagToken(value);
const filterAttributeKey = keys.find(
(item) => item.key === getRemovePrefixFromKey(tagKey),
);
setResults([]);
if (!tagKey || !tagOperator) {
return;
}
setAggregateFetching(true);
try {
let payload;
if (isInfraMonitoring && entity) {
const response = await getAttributesValues({
aggregateOperator: 'noop',
dataSource: query.dataSource,
aggregateAttribute:
GetK8sEntityToAggregateAttribute(entity) ||
query.aggregateAttribute?.key ||
'',
attributeKey: filterAttributeKey?.key ?? tagKey,
filterAttributeKeyDataType:
filterAttributeKey?.dataType ?? DataTypes.EMPTY,
tagType: filterAttributeKey?.type ?? '',
searchText: isInNInOperator(tagOperator)
? (tagValue[tagValue.length - 1]?.toString() ?? '')
: (tagValue?.toString() ?? ''),
});
payload = response.payload;
} else {
const response = await getAttributesValues({
aggregateOperator: query.aggregateOperator || '',
dataSource: query.dataSource,
aggregateAttribute: query.aggregateAttribute?.key || '',
attributeKey: filterAttributeKey?.key ?? tagKey,
filterAttributeKeyDataType:
filterAttributeKey?.dataType ?? DataTypes.EMPTY,
tagType: filterAttributeKey?.type ?? '',
searchText: isInNInOperator(tagOperator)
? (tagValue[tagValue.length - 1]?.toString() ?? '')
: (tagValue?.toString() ?? ''),
});
payload = response.payload;
}
if (payload && isAttributeValuesResponse(payload)) {
const dataType = filterAttributeKey?.dataType ?? DataTypes.String;
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
setResults(key ? payload[key] || [] : []);
return;
}
} catch (e) {
console.error(e);
} finally {
setAggregateFetching(false);
}
};
const handleRemoveSourceKey = useCallback((sourceKey: string) => {
setSourceKeys((prevState) =>
prevState.filter((item) => item.key !== sourceKey),
);
}, []);
// creates a ref to the fetch function so that it doesn't change on every render
const clearFetcher = useRef(handleFetchOption).current;
// debounces the fetch function to avoid excessive API calls
useDebounce(() => clearFetcher(searchValue, query, keys), 750, [
clearFetcher,
searchValue,
query,
keys,
]);
// update the fetched keys when the fetch status changes
useEffect(() => {
if (status === 'success' && data?.payload?.attributeKeys) {
setKeys(data.payload.attributeKeys);
setSourceKeys((prevState) =>
uniqWith([...(data.payload.attributeKeys ?? []), ...prevState], isEqual),
);
} else {
setKeys([]);
}
}, [data?.payload?.attributeKeys, status]);
useEffect(() => {
if (
fetchingSuggestionsStatus === 'success' &&
suggestionsData?.payload?.attributes
) {
if (!isInfraMonitoring) {
setKeys(suggestionsData.payload.attributes);
setSourceKeys((prevState) =>
uniqWith(
[...(suggestionsData.payload.attributes ?? []), ...prevState],
isEqual,
),
);
}
} else {
setKeys([]);
}
if (
fetchingSuggestionsStatus === 'success' &&
suggestionsData?.payload?.example_queries
) {
setExampleQueries(suggestionsData.payload.example_queries);
} else {
setExampleQueries([]);
}
}, [
suggestionsData?.payload?.attributes,
fetchingSuggestionsStatus,
suggestionsData?.payload?.example_queries,
isInfraMonitoring,
]);
return {
keys,
results,
isFetching: isFetching || isAggregateFetching || isFetchingSuggestions,
sourceKeys,
handleRemoveSourceKey,
exampleQueries,
};
};

View File

@@ -1,31 +0,0 @@
import { useMemo } from 'react';
import {
OPERATORS,
QUERY_BUILDER_OPERATORS_BY_TYPES,
} from 'constants/queryBuilder';
import { getRemovePrefixFromKey } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
type IOperators =
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.universal
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.string
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.bool
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.int64
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.float64;
export const useOperators = (
key: string,
keys: BaseAutocompleteData[],
): IOperators =>
useMemo(() => {
const currentKey = keys?.find((el) => el.key === getRemovePrefixFromKey(key));
const strippedKey = key.split(' ')[0];
return currentKey?.dataType
? QUERY_BUILDER_OPERATORS_BY_TYPES[
currentKey.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
]
: strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')
? [OPERATORS.HAS, OPERATORS.NHAS]
: QUERY_BUILDER_OPERATORS_BY_TYPES.universal;
}, [keys, key]);

View File

@@ -1,220 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { INFRA_SHORT_TO_LONG_OPERATOR_MAP } from 'constants/queryBuilder';
import {
checkCommaInValue,
getTagToken,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { Option } from 'container/QueryBuilder/type';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { isEmpty } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { WhereClauseConfig } from './useAutoComplete';
import { useOperators } from './useOperators';
export const WHERE_CLAUSE_CUSTOM_SUFFIX = '-custom';
export const useOptions = (
key: string,
keys: BaseAutocompleteData[],
operator: string,
searchValue: string,
isMulti: boolean,
isValidOperator: boolean,
isExist: boolean,
results: string[],
result: string[],
isFetching: boolean,
whereClauseConfig?: WhereClauseConfig,
isInfraMonitoring?: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): Option[] => {
const [options, setOptions] = useState<Option[]>([]);
const operators = useOperators(key, keys);
// get matching dynamic variables to suggest
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const variableName = dashboardDynamicVariables?.find(
(variable) => variable?.dynamicVariablesAttribute === key,
)?.name;
const variableAsValue = variableName ? `$${variableName}` : '';
const getLabel = useCallback(
(data: BaseAutocompleteData): Option['label'] => data?.key,
[],
);
const getOptionsFromKeys = useCallback(
(items: BaseAutocompleteData[]): Option[] =>
items?.map((item) => ({
label: `${getLabel(item)}`,
value: item.key,
dataType: item.dataType,
isIndexed: item?.isIndexed,
type: item?.type || '',
})),
[getLabel],
);
const getKeyOpValue = useCallback(
(items: string[]): Option[] =>
items?.map((item) => ({
label: `${key} ${operator} ${item}`,
value: `${key} ${operator} ${item}`,
})),
[key, operator],
);
const getOptionsWithValidOperator = useCallback(
(key: string, results: string[], searchValue: string) => {
const hasAllResults = results.every((value) => result.includes(value));
let newResults = results;
if (!isEmpty(variableAsValue)) {
newResults = [variableAsValue, ...newResults];
}
const values = getKeyOpValue(newResults);
return hasAllResults
? [
{
label: searchValue,
value: searchValue,
},
]
: [
{
label: searchValue,
value: searchValue,
},
...values,
];
},
[getKeyOpValue, result, variableAsValue],
);
const getKeyOperatorOptions = useCallback(
(key: string) => {
const keyOperator = key.split(' ');
const partialOperator = keyOperator?.[1];
const partialKey = keyOperator?.[0];
const filteredOperators = !isEmpty(partialOperator)
? operators?.filter((operator) =>
operator.startsWith(partialOperator?.toUpperCase()),
)
: operators;
const operatorsOptions = filteredOperators?.map((op) => {
const labelOp =
isInfraMonitoring && INFRA_SHORT_TO_LONG_OPERATOR_MAP[op]
? INFRA_SHORT_TO_LONG_OPERATOR_MAP[op]
: op;
return {
value: `${partialKey} ${op} `,
label: `${partialKey} ${labelOp} `,
};
});
if (whereClauseConfig) {
return [
{
label: `${searchValue} `,
value: `${searchValue}${WHERE_CLAUSE_CUSTOM_SUFFIX}`,
},
...operatorsOptions,
];
}
return operatorsOptions;
},
[isInfraMonitoring, operators, searchValue, whereClauseConfig],
);
useEffect(() => {
let newOptions: Option[] = [];
if (!key) {
newOptions = searchValue
? [
{
label: `${searchValue} `,
value: `${searchValue} `,
},
...getOptionsFromKeys(keys),
]
: getOptionsFromKeys(keys);
} else if (key && !operator) {
newOptions = getKeyOperatorOptions(key);
} else if (key && operator) {
if (isMulti) {
const resultsWithVariable = isEmpty(variableAsValue)
? results
: [variableAsValue, ...results];
newOptions = resultsWithVariable.map((item) => ({
label: checkCommaInValue(String(item)),
value: String(item),
}));
} else if (isExist) {
newOptions = [];
} else if (isValidOperator) {
newOptions = getOptionsWithValidOperator(key, results, searchValue);
}
}
if (newOptions.length > 0) {
setOptions(newOptions);
}
if (isFetching) {
setOptions([]);
}
}, [
whereClauseConfig,
getKeyOpValue,
getOptionsFromKeys,
isExist,
isMulti,
isValidOperator,
key,
keys,
operator,
operators,
result,
results,
searchValue,
getKeyOperatorOptions,
getOptionsWithValidOperator,
isFetching,
variableAsValue,
]);
return useMemo(
() =>
(
options.filter(
(option, index, self) =>
index ===
self.findIndex(
(o) =>
o.label === option.label &&
o.value === option.value &&
(o.type || '') === (option.type || '') &&
(o.dataType || '') === (option.dataType || ''), // keep entries with same key but different type/dataType
) && option.value !== '',
) || []
).map((option) => {
const { tagValue } = getTagToken(searchValue);
if (isMulti) {
return {
...option,
selected: tagValue
.filter((i) => i.trim().replace(/^\s+/, '') === option.value)
.includes(option.value),
};
}
return option;
}),
[isMulti, options, searchValue],
);
};

View File

@@ -1,32 +0,0 @@
import { useMemo } from 'react';
import {
getRemovePrefixFromKey,
getTagToken,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
type ICurrentKeyAndOperator = [string, string, string[]];
export const useSetCurrentKeyAndOperator = (
value: string,
keys: BaseAutocompleteData[],
): ICurrentKeyAndOperator => {
const [key, operator, result] = useMemo(() => {
let key = '';
let operator = '';
let result: string[] = [];
const { tagKey, tagOperator, tagValue } = getTagToken(value);
const isSuggestKey = keys?.some(
(el) => el?.key === getRemovePrefixFromKey(tagKey),
);
if (isSuggestKey || keys.length === 0) {
key = tagKey || '';
operator = tagOperator || '';
result = tagValue || [];
}
return [key, operator, result];
}, [value, keys]);
return [key, operator, result];
};

View File

@@ -1,18 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { INFRA_SHORT_TO_LONG_OPERATOR_MAP } from 'constants/queryBuilder';
import {
getOperatorFromValue,
getTagToken,
isExistsNotExistsOperator,
isInNInOperator,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
} from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { unparse } from 'papaparse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { WhereClauseConfig } from './useAutoComplete';
import type { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
/**
* Helper for formatting a TagFilter object into filter item strings
@@ -42,95 +34,3 @@ export function queryFilterTags(
return `${ele.key?.key} ${displayOp} ${ele.value}`;
});
}
type IUseTag = {
handleAddTag: (value: string) => void;
handleClearTag: (value: string) => void;
tags: string[];
updateTag: (value: string) => void;
};
/**
* A custom React hook for handling tags.
* @param {string} key - A string value to identify tags.
* @param {boolean} isValidTag - A boolean value to indicate whether the tag is valid.
* @param {function} handleSearch - A callback function to handle search.
* @returns {IUseTag} The return object containing handlers and tags.
*/
export const useTag = (
isValidTag: boolean,
handleSearch: (value: string) => void,
query: IBuilderQuery,
setSearchKey: (value: string) => void,
whereClauseConfig?: WhereClauseConfig,
isInfraMonitoring?: boolean,
): IUseTag => {
const initTagsData = useMemo(
() =>
queryFilterTags(
query?.filters || { items: [], op: 'AND' },
isInfraMonitoring,
),
[query?.filters, isInfraMonitoring],
);
const [tags, setTags] = useState<string[]>(initTagsData);
const updateTag = (value: string): void => {
const newTags = tags?.filter((item: string) => item !== value);
setTags(newTags);
};
/**
* Adds a new tag to the tag list.
* @param {string} value - The tag value to be added.
*/
const handleAddTag = useCallback(
(value: string): void => {
const { tagKey } = getTagToken(value);
const parts = tagKey.split('-');
// this is done to ensure that `hello-world` also gets converted to `body CONTAINS hello-world`
let id = parts[parts.length - 1];
let key = parts.slice(0, -1).join('-');
if (parts.length === 1) {
id = '';
[key] = parts;
}
if (id === 'custom') {
const customValue = whereClauseConfig
? `${whereClauseConfig.customKey} ${whereClauseConfig.customOp} ${key}`
: '';
setTags((prevTags) =>
prevTags.includes(customValue) ? prevTags : [...prevTags, customValue],
);
handleSearch('');
setSearchKey('');
return;
}
if ((value && key && isValidTag) || isExistsNotExistsOperator(value)) {
setTags((prevTags) => [...prevTags, value]);
handleSearch('');
setSearchKey('');
}
},
[whereClauseConfig, isValidTag, handleSearch, setSearchKey],
);
/**
* Removes a tag from the tag list.
* @param {string} value - The tag value to be removed.
*/
const handleClearTag = useCallback((value: string): void => {
setTags((prevTags) => prevTags.filter((v) => v !== value));
}, []);
useEffect(() => {
setTags(initTagsData);
}, [initTagsData]);
return { handleAddTag, handleClearTag, tags, updateTag };
};

View File

@@ -6,7 +6,7 @@ import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBui
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';

View File

@@ -135,3 +135,5 @@ export const closeAuthZDevModal = (): void =>
useAuthZDevStore.getState().closeModal();
export const toggleAuthZDevModal = (): void =>
useAuthZDevStore.getState().toggleModal();
export const clearAllAuthZDevOverrides = (): void =>
useAuthZDevStore.getState().clearAllOverrides();

View File

@@ -56,17 +56,6 @@ export class UPlotScaleBuilder extends ConfigBuilder<
maxTime = fallbackMax;
}
// Align max time to "endTime - 1 minute", rounded down to minute precision
// This matches legacy getXAxisScale behavior and avoids empty space at the right edge
const oneMinuteAgoTimestamp = (maxTime - 60) * 1000;
const currentDate = new Date(oneMinuteAgoTimestamp);
currentDate.setSeconds(0);
currentDate.setMilliseconds(0);
const unixTimestampSeconds = Math.floor(currentDate.getTime() / 1000);
maxTime = unixTimestampSeconds;
return {
[scaleKey]: {
time: true,

View File

@@ -44,7 +44,7 @@ describe('UPlotScaleBuilder', () => {
expect(adjustSpy).toHaveBeenCalledWith(null, null, undefined, undefined);
});
it('handles time scales using explicit min/max and rounds max down to the previous minute', () => {
it('handles time scales using explicit min/max', () => {
const min = 1_700_000_000; // seconds
const max = 1_700_000_600; // seconds
@@ -62,21 +62,25 @@ describe('UPlotScaleBuilder', () => {
expect(xScale.time).toBe(true);
expect(xScale.auto).toBe(false);
expect(Array.isArray(xScale.range)).toBe(true);
expect(xScale.range).toStrictEqual([min, max]);
});
const [resolvedMin, resolvedMax] = xScale.range as [number, number];
it('keeps short time windows intact', () => {
const min = 1_786_527_160;
const max = 1_786_527_183;
// min is passed through
expect(resolvedMin).toBe(min);
const builder = new UPlotScaleBuilder(
createScaleProps({
scaleKey: 'x',
time: true,
min,
max,
}),
);
// max is coerced to "endTime - 1 minute" and rounded down to minute precision
const oneMinuteAgoTimestamp = (max - 60) * 1000;
const currentDate = new Date(oneMinuteAgoTimestamp);
currentDate.setSeconds(0);
currentDate.setMilliseconds(0);
const expectedMax = Math.floor(currentDate.getTime() / 1000);
const config = builder.getConfig();
expect(resolvedMax).toBe(expectedMax);
expect(config.x.range).toStrictEqual([min, max]);
});
it('falls back to getFallbackMinMaxTimeStamp when time scale has no min/max', () => {
@@ -99,9 +103,7 @@ describe('UPlotScaleBuilder', () => {
expect(getFallbackMinMaxSpy).toHaveBeenCalled();
expect(resolvedMin).toBe(100);
// max is aligned to "fallbackMax - 60 seconds" minute boundary
expect(resolvedMax).toBeLessThanOrEqual(200);
expect(resolvedMax).toBeGreaterThan(100);
expect(resolvedMax).toBe(200);
});
it('pipes limits through soft-limit adjustment and log-scale normalization before range config', () => {

View File

@@ -0,0 +1,195 @@
import { rest } from 'msw';
import {
countControl,
choiceControl,
multiChoiceControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
buildAlertRules,
buildServices,
homeFeatureFlags,
homeUserPreferences,
HOME_CHECKLIST_STEPS,
isSavedViewSignal,
metricsOnboardingResponse,
queryRangeV5ScalarResponse,
recentDashboardsResponse,
SAVED_VIEW_SIGNALS,
type SavedViewSignal,
savedViewsResponse,
SERVICES_SOURCES,
type ServicesSource,
spanMetricsResponse,
topLevelOperationsResponse,
} from './__mockdata__/home';
const SIGNALS = 'Home · signals';
const ONBOARDING = 'Home · onboarding';
const LISTS = 'Home · lists';
const INGESTED_COUNT = 4213;
/** Home caps every list at five rows, so the control has to go past that. */
const LIST_MAX = 8;
const CHECKLIST_VISIBILITY = ['visible', 'dismissed'] as const;
type ChecklistVisibility = (typeof CHECKLIST_VISIBILITY)[number];
interface QueryRangeV5Body {
compositeQuery?: { queries?: { spec?: { signal?: string } }[] };
}
/**
* Home detects logs and traces with one `query_range` call each, told apart by
* the signal on the query spec.
*/
const signalOf = (body: QueryRangeV5Body): string | undefined =>
body.compositeQuery?.queries?.[0]?.spec?.signal;
export const homeMocks = defineStoryMocks({
controls: {
logsIngestion: toggleControl('Logs ingestion', {
group: SIGNALS,
value: true,
}),
tracesIngestion: toggleControl('Traces ingestion', {
group: SIGNALS,
value: true,
}),
metricsIngestion: toggleControl('Metrics ingestion', {
group: SIGNALS,
value: true,
}),
welcomeChecklist: choiceControl<ChecklistVisibility>('Welcome checklist', {
group: ONBOARDING,
description:
'Dismissing it moves the checklist behind the header button, as "I\'ll do this later" does.',
options: CHECKLIST_VISIBILITY,
value: 'visible',
}),
skippedSteps: multiChoiceControl('Skipped steps', {
group: ONBOARDING,
description: 'Steps the user chose to skip. Completion follows the data.',
options: HOME_CHECKLIST_STEPS,
value: [],
}),
alertRules: countControl('Alert rules', {
group: LISTS,
value: 5,
max: LIST_MAX,
}),
dashboards: countControl('Recent dashboards', {
group: LISTS,
value: 5,
max: LIST_MAX,
}),
savedViews: countControl('Saved views per signal', {
group: LISTS,
value: 5,
max: LIST_MAX,
}),
savedViewSignals: multiChoiceControl<SavedViewSignal>('Signals with views', {
group: LISTS,
description:
'Explorer tabs that have views; the rest fall back to their empty state.',
options: SAVED_VIEW_SIGNALS,
value: SAVED_VIEW_SIGNALS,
}),
services: countControl('Services', {
group: LISTS,
value: 6,
max: LIST_MAX,
}),
servicesSource: choiceControl<ServicesSource>('Services source', {
group: LISTS,
description:
'`span-metrics` turns on the feature flag that swaps the services card for the span-metrics one.',
options: SERVICES_SOURCES,
value: 'traces',
}),
},
handlers: (values, response) => [
rest.get('http://localhost/api/v2/metrics/onboarding', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json(metricsOnboardingResponse(values.metricsIngestion)),
),
),
rest.post('http://localhost/api/v5/query_range', async (req, res, ctx) => {
const signal = signalOf((await req.json()) as QueryRangeV5Body);
const isActive =
signal === 'traces' ? values.tracesIngestion : values.logsIngestion;
return res(
ctx.status(200),
ctx.json(queryRangeV5ScalarResponse(isActive ? INGESTED_COUNT : 0)),
);
}),
rest.get('http://localhost/api/v1/user/preferences', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: homeUserPreferences({
checklistDismissed: values.welcomeChecklist === 'dismissed',
skippedSteps: values.skippedSteps,
}),
}),
),
),
rest.get(
'http://localhost/api/v2/users/me/dashboards',
response.json(() => recentDashboardsResponse(values.dashboards)),
),
rest.get(
'http://localhost/api/v2/rules',
response.json(() => ({
status: 'success',
data: buildAlertRules(values.alertRules),
})),
),
rest.get(
'http://localhost/api/v1/explorer/views',
response.json((req) => {
const sourcePage = req.url.searchParams.get('sourcePage') ?? 'logs';
const signal = isSavedViewSignal(sourcePage) ? sourcePage : 'logs';
return savedViewsResponse(
values.savedViewSignals.includes(signal) ? values.savedViews : 0,
signal,
);
}),
),
rest.post(
'http://localhost/api/v2/services',
response.json(() => ({
status: 'success',
data: buildServices(values.services),
})),
),
rest.post(
'http://localhost/api/v1/service/top_level_operations',
response.json(() => topLevelOperationsResponse(values.services)),
),
rest.post(
'http://localhost/api/v4/query_range',
response.json(() => spanMetricsResponse()),
),
],
config: ({ servicesSource }) => ({
appContext: { featureFlags: homeFeatureFlags(servicesSource) },
}),
});

View File

@@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ROUTES from 'constants/routes';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
import { homeMocks } from './HomePage.stories.mocks';
import HomePage from './HomePage';
type HomeArgs = PageStoryArgs<typeof homeMocks>;
const meta = {
title: 'Pages/Home',
component: HomePage,
decorators: [withAppLayout],
...storyMocks(homeMocks, { route: ROUTES.HOME }),
} satisfies Meta<HomeArgs>;
export default meta;
type Story = StoryObj<HomeArgs>;
/**
* Every widget carrying data: all three signals ingesting, alert rules across
* severities, recent dashboards, saved views on each explorer tab and a
* services table with failing services.
*/
export const Default: Story = {};
/** Fresh workspace: nothing ingested yet, so the welcome checklist takes over. */
export const NoIngestion: Story = {
args: {
logsIngestion: false,
tracesIngestion: false,
metricsIngestion: false,
alertRules: 0,
dashboards: 0,
savedViews: 0,
services: 0,
},
};
/**
* Telemetry reads only: no permission to manage anything, so the create actions
* and the legacy editor role are both gone.
*/
export const ViewerAccess: Story = {
args: { access: 'viewer' },
};
/** Widgets stuck in their loading state, shell included. */
export const Loading: Story = {
args: { dataState: 'loading' },
};

View File

@@ -0,0 +1,311 @@
import { FeatureKeys } from 'constants/features';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { checkListStepToPreferenceKeyMap } from 'container/Home/constants';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import type { ServiceDataProps } from 'api/metrics/getTopLevelOperations';
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
import { explorerView } from 'mocks-server/__mockdata__/explorer_views';
import { defaultFeatureFlags } from 'tests/fixtures/appContextMock';
import type { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
import type { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
import type { ServicesList } from 'types/api/metrics/getService';
import type { UserPreference } from 'types/api/preferences/preference';
import { baseUserPreferences } from '@/storybook/msw/__mockdata__/appShell';
import { queryRangeV5ScalarResponse } from '@/storybook/msw/__mockdata__/queryRange';
export { queryRangeV5ScalarResponse };
/** Shapes follow the fields the components read, not the full generated DTOs. */
export const metricsOnboardingResponse = (
hasMetrics: boolean,
): Record<string, unknown> => ({
status: 'success',
data: { hasMetrics },
});
export const HOME_CHECKLIST_STEPS = [
'SEND_LOGS',
'SEND_TRACES',
'SEND_METRICS',
'SETUP_ALERTS',
'SETUP_SAVED_VIEWS',
'SETUP_DASHBOARDS',
] as const;
export type HomeChecklistStep = (typeof HOME_CHECKLIST_STEPS)[number];
const skippedPreference = (name: string): UserPreference => ({
name,
description: 'Welcome checklist step skipped',
valueType: 'boolean',
defaultValue: false,
allowedValues: ['true', 'false'],
allowedScopes: ['org'],
value: true,
});
/**
* The welcome checklist reads its dismissed and skipped flags off the user
* preferences list, one entry per step.
*/
export const homeUserPreferences = ({
checklistDismissed,
skippedSteps,
}: {
checklistDismissed: boolean;
skippedSteps: readonly HomeChecklistStep[];
}): UserPreference[] => [
...baseUserPreferences,
...(checklistDismissed
? [skippedPreference(ORG_PREFERENCES.WELCOME_CHECKLIST_DO_LATER)]
: []),
...skippedSteps.map((step) =>
skippedPreference(checkListStepToPreferenceKeyMap[step]),
),
];
const DASHBOARDS = [
{
name: 'Kubernetes cluster health',
tags: [{ key: 'team', value: 'platform' }, { key: 'k8s' }],
},
{ name: 'API latency overview', tags: [{ key: 'sre' }] },
{
name: 'Checkout funnel',
tags: [{ key: 'team', value: 'payments' }, { key: 'business' }],
},
{ name: 'Postgres slow queries', tags: [{ key: 'database' }] },
{
name: 'Kafka consumer lag',
tags: [{ key: 'team', value: 'data' }, { key: 'streaming' }],
},
{ name: 'Ingress error budget', tags: [{ key: 'sre' }, { key: 'slo' }] },
{ name: 'Cost per service', tags: [{ key: 'finops' }] },
{
name: 'Redis cache hit rate',
tags: [{ key: 'database' }, { key: 'cache' }],
},
];
export const recentDashboardsResponse = (
count: number,
): Record<string, unknown> => ({
status: 'success',
data: {
dashboards: DASHBOARDS.slice(0, count).map((dashboard, index) => ({
id: `storybook-dashboard-${index + 1}`,
name: dashboard.name,
spec: { display: { name: dashboard.name } },
tags: dashboard.tags,
})),
},
});
const ALERT_NAMES = [
'Checkout p99 above 2s',
'Payment failure rate',
'Log volume spike',
'Kafka consumer lag',
'Pod restart storm',
'Disk usage above 85%',
'Frontend error rate',
'Postgres connections saturated',
];
/**
* Cycles the jest fixtures so the list keeps their severity and firing spread.
* `updatedAt` descends because that is the order the page sorts on.
*/
export const buildAlertRules = (count: number): RuletypesRuleDTO[] =>
Array.from({ length: count }, (_, index) => ({
...alertRulesFixture[index % alertRulesFixture.length],
id: `storybook-rule-${index + 1}`,
alert: ALERT_NAMES[index % ALERT_NAMES.length],
updatedAt: new Date(Date.UTC(2026, 7, 20 - index, 9)).toISOString(),
}));
export const SAVED_VIEW_SIGNALS = ['logs', 'traces', 'metrics'] as const;
export type SavedViewSignal = (typeof SAVED_VIEW_SIGNALS)[number];
const VIEW_NAMES: Record<SavedViewSignal, string[]> = {
logs: [
'Checkout errors',
'Auth service warnings',
'Slow SQL statements',
'Payment webhooks',
'Rate limited requests',
'Cron job failures',
],
traces: [
'Slowest checkout spans',
'Failed payment traces',
'Cart to order funnel',
'External API calls',
'Cold start requests',
'Retried gRPC calls',
],
metrics: [
'Pod memory by namespace',
'Queue depth by topic',
'HTTP throughput',
'Container CPU throttling',
'JVM heap usage',
'Cache hit ratio',
],
};
export const isSavedViewSignal = (value: string): value is SavedViewSignal =>
SAVED_VIEW_SIGNALS.includes(value as SavedViewSignal);
export const savedViewsResponse = (
count: number,
sourcePage: SavedViewSignal,
): Record<string, unknown> => {
const names = VIEW_NAMES[sourcePage];
return {
status: 'success',
data: Array.from({ length: Math.min(count, names.length) }, (_, index) => ({
...explorerView.data[0],
id: `storybook-${sourcePage}-view-${index + 1}`,
name: names[index],
sourcePage,
tags: [sourcePage],
})),
};
};
/** Ordered by p99 with errors at both ends, so any slice keeps the spread. */
const SERVICES: ServicesList[] = [
{
serviceName: 'payments',
p99: 2_940_100_000,
avgDuration: 921_440_000,
numCalls: 46_080,
callRate: 25.6,
numErrors: 5990,
errorRate: 13,
},
{
serviceName: 'checkout',
p99: 1_248_900_000,
avgDuration: 486_310_000,
numCalls: 92_160,
callRate: 51.2,
numErrors: 4608,
errorRate: 5,
},
{
serviceName: 'frontend',
p99: 812_450_000,
avgDuration: 274_120_000,
numCalls: 184_320,
callRate: 102.4,
numErrors: 1843,
errorRate: 1,
},
{
serviceName: 'shipping',
p99: 486_200_000,
avgDuration: 192_800_000,
numCalls: 23_040,
callRate: 12.8,
numErrors: 691,
errorRate: 3,
},
{
serviceName: 'cart',
p99: 214_800_000,
avgDuration: 88_640_000,
numCalls: 138_240,
callRate: 76.8,
numErrors: 0,
errorRate: 0,
},
{
serviceName: 'catalogue',
p99: 96_300_000,
avgDuration: 41_220_000,
numCalls: 276_480,
callRate: 153.6,
numErrors: 276,
errorRate: 0.1,
},
{
serviceName: 'recommendations',
p99: 64_100_000,
avgDuration: 28_400_000,
numCalls: 61_440,
callRate: 34.1,
numErrors: 61,
errorRate: 0.1,
},
{
serviceName: 'notifications',
p99: 38_700_000,
avgDuration: 15_900_000,
numCalls: 12_288,
callRate: 6.8,
numErrors: 0,
errorRate: 0,
},
];
export const buildServices = (count: number): ServicesList[] =>
SERVICES.slice(0, count);
export const SERVICES_SOURCES = ['traces', 'span-metrics'] as const;
export type ServicesSource = (typeof SERVICES_SOURCES)[number];
/** `USE_SPAN_METRICS` swaps the services card for the span-metrics one. */
export const homeFeatureFlags = (source: ServicesSource): FeatureFlagProps[] =>
defaultFeatureFlags.map((flag) =>
flag.name === FeatureKeys.USE_SPAN_METRICS
? { ...flag, active: source === 'span-metrics' }
: flag,
);
/** The span-metrics card takes its row set from the top level operations. */
export const topLevelOperationsResponse = (count: number): ServiceDataProps =>
Object.fromEntries(
buildServices(count).map((service) => [
service.serviceName,
['HTTP GET /', 'HTTP POST /checkout'],
]),
);
/**
* Span-metrics latency, error rate and ops per second come off a table panel.
* The page reads them from `newResult`, which the table branch of
* `GetMetricQueryRange` never adds, so the columns render as zero however
* complete this body is. That is the app's gap, not the mock's.
*/
export const spanMetricsResponse = (): {
status: string;
data: MetricRangePayloadV3['data'];
} => ({
status: 'success',
data: {
resultType: 'table',
result: [
{
queryName: '',
legend: '',
series: null,
list: null,
table: {
columns: [
{ name: 'A', queryName: 'A', isValueColumn: true },
{ name: 'D', queryName: 'D', isValueColumn: true },
{ name: 'F1', queryName: 'F1', isValueColumn: true },
],
rows: [{ data: { A: 148_000_000, D: 12.4, F1: 1.8 } }],
},
},
],
},
});

View File

@@ -16,7 +16,7 @@ import LogsError from 'container/LogsError/LogsError';
import { EmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import createQueryParams from 'lib/createQueryParams';
import { Compass } from '@signozhq/icons';

View File

@@ -4,7 +4,7 @@ import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { OPERATORS } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';

View File

@@ -7,7 +7,7 @@ import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBui
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { PANEL_TYPES, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { ArrowDownToDot, ArrowUpFromDot } from '@signozhq/icons';

View File

@@ -0,0 +1,305 @@
# Storybook
Runs SigNoz pages and components with no backend: every request is answered by
msw, and the providers the app mounts at boot are replaced by story-controlled
values.
```bash
pnpm storybook # dev server on :6006
pnpm storybook:build # static build into storybook-static/
```
## Layout
| Path | What lives there |
| ----------------- | ---------------------------------------------------------------------- |
| `runtime/` | `resolveStory`: story context in, the world the story renders in out |
| `controls/` | Declaring controls (`defineStoryMocks`) and composing mock modules |
| `globals/` | The mock modules every story carries: app shell and access |
| `access/` | What a permission grant allows, and the legacy role it derives |
| `providers/` | The Storybook adapter over `src/harness/AppHarness` |
| `navigation/` | Keeping a story on its page, and reporting what it tried to leave for |
| `msw/` | The default handler set and the shell's endpoints |
| `mocks/` | Modules aliased in place of the app's own |
| `decorators/` | `withProviders` (global) and `withAppLayout` (opt-in per page) |
A page's own mocks live with the page, not here. See [Adding a page
story](#adding-a-page-story).
## What a story gets for free
`withProviders` (global decorator, `.storybook/preview.tsx`) wraps every story in
`StorybookProviders`, the Storybook adapter over `src/harness/AppHarness`.
`AppHarness` is the app's provider tree from `src/index.tsx` +
`src/AppRoutes/index.tsx`, minus Sentry, posthog and `AppProvider`, with the
pieces a runner has to choose left as props: the router, the nuqs adapter, the
store, the query client and the mocked `AppContext`.
`tests/test-utils` mounts its own, smaller tree for jest and does not go through
`AppHarness`: the suite has ~20 files that mock `hooks/useDarkMode`,
`hooks/useNotifications` or `providers/cmdKProvider` down to a single export, so
the providers those modules also carry would come back `undefined`. A provider
added to the app therefore still needs adding in both places.
Storybook fills the seams with:
- `AppContext` from `tests/fixtures/appContextMock`, the same fixture the jest
suite uses, so a story and a test see the same user, license and flags.
- A fresh react-query client and redux store per story: no cache or state bleed.
- `nuqs` on its testing adapter, so query-param state lives in memory and never
touches the iframe URL.
- Theme from the toolbar (dark/light). `applyThemeBodyClass` puts `<body>` in the
state the app gets from `index.html` plus `AppLayout`: `data-theme="default"`
(every `@signozhq/design-tokens` semantic token is scoped to it, and without it
`--l1-background` and friends resolve to nothing and the page renders
unstyled) and the `darkMode`/`dark`/`lightMode` classes.
## The story runtime
`runtime/resolveStory.ts` is the one place that turns a story's parameters and
the controls panel's current values into everything the story runs on: the msw
handlers in resolution order, the provider config, the theme, the remount key,
and the module-level state to seed. The preview loader applies it before the
decorators run; the decorator reads the same result, memoised on the story and
its args.
Handlers resolve first-match-wins, in this order:
1. the story's own `parameters.msw.handlers`;
2. the page's control-driven handlers;
3. the global mocks' handlers (access);
4. `msw/appShellHandlers.ts`, the endpoints the shell hits on every route, and
the ones whose jest fixture is too thin to show it doing its job;
5. `src/mocks-server/handlers.ts`, the jest handlers verbatim. An endpoint both
runners need belongs here so jest gets it too;
6. a catch-all for `http://localhost/api/*` that logs and answers 501, so an
endpoint nobody mocked fails loudly instead of hanging on a refused
connection.
The whole set is re-registered on every story render rather than handed to
`setupWorker` once. Editing a handler module then takes effect on the next
render; with the handlers baked in at worker creation, a long-lived dev server
kept answering with the set it started with, and endpoints added later showed up
as failed requests.
The handlers are declared against `http://localhost`, which is why
`constants/env` is mocked to that origin. msw intercepts before the request
leaves the page, so nothing reaches the network.
## Overrides
Per-story, through `parameters`:
```tsx
export const Elsewhere: Story = {
parameters: {
signoz: {
route: '/home?relativeTime=1h',
appContext: { featureFlags: [] },
reduxState: { globalTime: { ... } },
theme: 'light',
},
msw: {
handlers: [
rest.get('http://localhost/api/v2/rules', handleInternalServerError),
],
},
},
};
```
`parameters.signoz` is typed by `SignozStoryConfig` in `src/storybook/types.ts`.
Who the story runs as is not in there. See [Access](#access).
Anything a page declares as a control belongs in `args`, not in `parameters`.
## The app shell
A page story always runs inside the real `AppLayout` (side nav, top nav,
banners). A page without its shell is not the page anyone sees. Declare it once
on the meta so every story of that page inherits it:
```tsx
const meta = {
title: 'Pages/Home',
component: HomePage,
decorators: [withAppLayout],
} satisfies Meta<typeof HomePage>;
```
## Controls
A page declares what about its mocks is adjustable, and the controls panel drives
it. Every control is a knob on the response, not a prop on the component: turning
one re-registers the msw handlers and remounts the story with an empty query
cache, so the page fetches again and renders the new data.
```tsx
// src/pages/HomePage/HomePage.stories.mocks.ts
export const homeMocks = defineStoryMocks({
controls: {
logsIngestion: toggleControl('Logs ingestion', { group: SIGNALS, value: true }),
dashboards: countControl('Recent dashboards', { group: LISTS, value: 5, max: 8 }),
welcomeChecklist: choiceControl<ChecklistVisibility>('Welcome checklist', {
group: ONBOARDING,
options: CHECKLIST_VISIBILITY,
value: 'visible',
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v2/users/me/dashboards',
response.json(() => recentDashboardsResponse(values.dashboards)),
),
],
});
```
```tsx
// src/pages/HomePage/HomePage.stories.tsx
type HomeArgs = PageStoryArgs<typeof homeMocks>;
const meta = {
title: 'Pages/Home',
component: HomePage,
decorators: [withAppLayout],
...storyMocks(homeMocks, { route: ROUTES.HOME }),
} satisfies Meta<HomeArgs>;
export const NoIngestion: StoryObj<HomeArgs> = {
args: { logsIngestion: false, tracesIngestion: false, metricsIngestion: false },
};
```
`toggleControl`, `countControl`, `choiceControl` and `multiChoiceControl` build
the panel row and carry the value's type, so `values` inside `handlers` is typed
and a story's `args` are checked.
The hooks a mock module can answer, all optional. `handlers` answers the page's
endpoints; `config` returns the provider-level knobs no endpoint covers;
`responseState` says how the endpoints declared through `response` answer;
`effect` seeds module-level state no provider exposes, such as no-auth mode; and
`role` derives the legacy role, which only `authzMocks` does.
Endpoints declared through `response.json` follow the response state (`loaded`,
`loading` or `error`) which the `Data` control drives, so one declaration covers
all three. Endpoints the page cannot render without, such as ingestion detection
and preferences, take a plain msw resolver so they keep answering while the rest
of the page hangs or fails.
The mock modules every story carries are registered in `globals/index.ts`:
`appShellMocks` (app-wide banners, side nav state, `Data`) and `authzMocks`
(below). Adding one there publishes its controls and widens `PageStoryArgs` in
the same edit.
## Access
Permissions are the knob, not roles. `POST /api/v1/authz/check` is the single
gate the app reads: route guards, `AuthZGuard`, `AuthZButton` and `user.role` all
resolve through it, so the controls answer that endpoint and everything
downstream follows. `access/access.ts` is what decides:
`accessFor(preset, extra)` returns the permission set, whether a given check is
allowed, and the legacy role it derives.
- **Access**: `admin`, `editor`, `viewer`, `anonymous`, `grant-all`, `deny-all`,
`custom`, `dev-tools`.
- **Permissions**: granted on top of the preset, as `relation:kind`
(`read:logs`, `create:serviceaccount`, …); `custom` starts from nothing, so
there the list is the whole grant. Generated from
`lib/authz/hooks/useAuthZ/permissions.type.ts`, so a resource added to the
catalogue shows up without touching Storybook. A selector-scoped check
(`update` on `role:some-id`) matches the entry for its kind; the legacy
`assignee:role:signoz-*` permissions are listed individually.
- **Check state**: `loaded`, `loading` or `error`, the same forcing the AuthZ
dev modal offers.
`user.role` comes from the same grant, derived the way `AppProvider` derives it,
so the legacy role, `hasEditPermission`, `routePermission` and
`componentPermission` all follow the same control and no story can set them to
something the check endpoint disagrees with. The runtime writes the result to
`<body data-signoz-story-role>`, and the provider tree writes what
`useAppContext()` actually yields to `<body data-signoz-context-role>`, so
whether the page is reading it is one glance away in the Elements panel.
Granting no legacy role at all (`deny-all`, or `custom` without one ticked)
derives `ANONYMOUS`, exactly as `AppProvider` does. The legacy checks are written
as `role !== VIEWER`, so an anonymous user passes them and sees *more* than a
viewer. That is the app's gap, faithfully reproduced: to see the viewer UI, grant
the viewer role. The role-named presets exist because those checks still exist;
when the roles go, delete the presets and the derivation. The permission list
stays.
For anything finer than a preset, the app's own dev tools are mounted in every
story: `⌘K`**AuthZ DevTools** lists the permissions the page actually checked
and overrides them one by one (granted, denied, delayed, error). Set Access to
`dev-tools` first, because the other values reset the override store on render, so
a leftover override from a real dev session cannot answer for the controls panel.
Overrides only apply while `IS_DEV` is true, which means the dev server, not a
static build.
Adding or renaming a project-level control needs a tab reload: Vite hot-updates
`preview.tsx` without re-preparing the open stories, so the panel keeps the
controls, and the arg values, it was built with.
## Module mocks
Aliased for every story in `.storybook/main.ts`, the same way `jest.config.ts`
does it through `moduleNameMapper`. Each replacement is typed as the module it
stands in for, so an export added to the real module is a compile error here
rather than a story that fails at render:
| Module | Replacement | Why |
| --------------------- | ---------------------------------- | ------------------------------------------ |
| `lib/history` | `navigation/history.alias.ts` | keeps a story on its page, see below |
| `api/common/logEvent` | `mocks/logEvent.mock.ts` | analytics never leave the iframe |
| `constants/env` | `mocks/env.mock.ts` | pins the API origin the handlers answer on |
Mocks use `fn()` from `storybook/test`, so a play function can assert on them:
```tsx
import logEvent from 'api/common/logEvent';
play: async () => {
await expect(logEvent).toHaveBeenCalledWith('Homepage: Visited', {});
},
```
## Navigation
A story renders one page, so leaving that page would unmount it.
`navigation/pageScope.ts` holds the rule, `navigation/containment.ts` is what the
app sees in place of `lib/history`, and the two tell a navigation apart by
pathname:
- **Same page**: a query-param or hash change, which is how tabs, filters,
pagination and time ranges are driven. It is applied, and the page re-renders
the way it does in the app. Anchors are covered too: an in-page `<a href="?tab=x">`
or `<Link to="/home?tab=x">` is intercepted and pushed onto the story's history
rather than followed, which would navigate the iframe out of the story.
- **Another page**: a different pathname, an off-site href, `window.open` (what
`useSafeNavigate({ newTab })` calls) or a relative `go`/`goBack`, which carries
no target to compare against. It is swallowed and reported to
`NavigationBlockedOverlay`, which lists what was attempted. Nothing is silently
dropped.
`nuqs` is the one gap: it runs on its testing adapter and keeps its own copy of
the query string, seeded from the story's `route`. A page that writes params
through both `useQueryState` and `history.push({ search })` sees the two diverge
inside a story; a page that stays on one mechanism does not.
## Adding a page story
The `signoz-page-story` skill in `.claude/skills/` carries this as a workflow:
mapping the page, deriving its controls, and the checks a story has to pass.
1. Point the story at the page component under `src/pages/<Page>`.
2. Declare the page's mocks in `<Page>.stories.mocks.ts` next to it, with its
payload builders under `<Page>/__mockdata__/`, and spread
`storyMocks(<page>Mocks, { route })` into the meta.
3. Add `decorators: [withAppLayout]` to the meta.
4. Give the default story every widget populated. A page story earns its keep by
showing what the page looks like with data, not with empty states.
5. Run it and watch the console: an msw warning or a `[storybook] no msw handler`
line is an endpoint the page hits that no handler covers yet.
6. Reach for a control before a story. A variant earns a story only when it is
worth linking to; anything else is a control someone can turn.

View File

@@ -0,0 +1,155 @@
import type { AuthtypesTransactionDTO } from 'api/generated/services/sigNoz.schemas';
import {
IsAdminPermission,
IsAnonymousPermission,
IsEditorPermission,
IsViewerPermission,
} from 'lib/authz/hooks/useAuthZ/legacy';
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
import {
formatPermission,
gettableTransactionToPermission,
} from 'lib/authz/hooks/useAuthZ/utils';
import { ROLES, USER_ROLES } from 'types/roles';
/**
* `relation:kind` for every verb the backend allows on a resource, from the
* generated permission catalogue, so a resource added there shows up here
* without anyone touching this file. Selector-scoped checks (`role:some-id`)
* match the entry for their kind.
*/
export const permissionCatalogue = (): string[] => {
const entries = new Set<string>();
for (const [relation, types] of Object.entries(
permissionsType.data.relations,
)) {
for (const resource of permissionsType.data.resources) {
const appliesToResource = (types as readonly string[]).includes(
resource.type,
);
const allowsVerb = (resource.allowedVerbs as readonly string[]).includes(
relation,
);
if (appliesToResource && allowsVerb) {
entries.add(`${relation}:${resource.kind}`);
}
}
}
return [...entries].sort();
};
const CATALOGUE = permissionCatalogue();
const TELEMETRY_READS = CATALOGUE.filter((permission) =>
permissionsType.data.resources.some(
(resource) =>
resource.type === 'telemetryresource' &&
permission === `read:${resource.kind}`,
),
);
/**
* `user.role` is itself an authz check in the real app (`AppProvider` derives it
* from these), so a caller that wants a role grants the matching permission
* rather than setting the role directly.
*/
export const LEGACY_ROLE_PERMISSIONS = {
[USER_ROLES.ADMIN]: formatPermission(IsAdminPermission),
[USER_ROLES.EDITOR]: formatPermission(IsEditorPermission),
[USER_ROLES.VIEWER]: formatPermission(IsViewerPermission),
[USER_ROLES.ANONYMOUS]: formatPermission(IsAnonymousPermission),
};
export const ACCESS_PRESETS = [
'admin',
'editor',
'viewer',
'anonymous',
'grant-all',
'deny-all',
'custom',
'dev-tools',
] as const;
export type AccessPreset = (typeof ACCESS_PRESETS)[number];
/**
* The legacy roles only differ in authz for the resources the backend already
* covers: an admin manages roles, service accounts and API keys, while editor
* and viewer are telemetry readers and differ through `user.role` alone. The
* presets go away with the roles; the permission list does not.
*/
const ADMIN_PERMISSIONS = [
LEGACY_ROLE_PERMISSIONS[USER_ROLES.ADMIN],
// Without the `assignee` wildcard, which would hand out every legacy role at
// once. That is what `grant-all` is for.
...CATALOGUE.filter((permission) => permission !== 'assignee:role'),
];
const PRESET_PERMISSIONS: Record<AccessPreset, readonly string[]> = {
admin: ADMIN_PERMISSIONS,
editor: [LEGACY_ROLE_PERMISSIONS[USER_ROLES.EDITOR], ...TELEMETRY_READS],
viewer: [LEGACY_ROLE_PERMISSIONS[USER_ROLES.VIEWER], ...TELEMETRY_READS],
anonymous: [LEGACY_ROLE_PERMISSIONS[USER_ROLES.ANONYMOUS]],
'grant-all': [...Object.values(LEGACY_ROLE_PERMISSIONS), ...CATALOGUE],
'deny-all': [],
custom: [],
'dev-tools': ADMIN_PERMISSIONS,
};
/** Every permission a caller can grant on top of a preset. */
export const PERMISSION_OPTIONS = [
...Object.values(LEGACY_ROLE_PERMISSIONS),
...CATALOGUE,
];
export interface AccessGrant {
permissions: ReadonlySet<string>;
/** Answers one `authz/check` transaction the way the backend would. */
allows(transaction: AuthtypesTransactionDTO): boolean;
/**
* The legacy role the granted `assignee:role:signoz-*` permissions derive,
* the way `AppProvider` derives it. No legacy role granted lands on
* `ANONYMOUS`.
*/
legacyRole: ROLES;
}
const deriveLegacyRole = (granted: ReadonlySet<string>): ROLES => {
const role = Object.entries(LEGACY_ROLE_PERMISSIONS).find(([, permission]) =>
granted.has(permission),
);
return (role?.[0] ?? USER_ROLES.ANONYMOUS) as ROLES;
};
/**
* The permission set a preset plus its extra grants resolve to, and the two
* questions the app asks of it. `custom` and `deny-all` start from nothing, so
* there the extra grants are the whole set.
*/
export const accessFor = (
preset: AccessPreset,
extraPermissions: readonly string[] = [],
): AccessGrant => {
const permissions = new Set([
...PRESET_PERMISSIONS[preset],
...extraPermissions,
]);
return {
permissions,
allows: (transaction): boolean =>
permissions.has(
formatPermission(gettableTransactionToPermission(transaction)),
) ||
permissions.has(
`${transaction.relation}:${transaction.object.resource.kind}`,
),
legacyRole: deriveLegacyRole(permissions),
};
};

View File

@@ -0,0 +1,34 @@
import type { AnyStoryMocks, ControlDescriptor, StoryMockArgs } from './types';
type UnionToIntersection<TUnion> = (
TUnion extends unknown ? (arg: TUnion) => void : never
) extends (arg: infer TIntersection) => void
? TIntersection
: never;
/** The args of every mock module in a list, as one object type. */
export type ComposedMockArgs<TMocks extends readonly AnyStoryMocks[]> =
UnionToIntersection<StoryMockArgs<TMocks[number]>>;
export interface ComposedStoryMocks<TMocks extends readonly AnyStoryMocks[]> {
/** In resolution order: the first module to answer a question wins. */
members: TMocks;
args: ComposedMockArgs<TMocks>;
argTypes: Record<string, ControlDescriptor>;
}
/**
* One registration point for a set of mock modules: the panel rows they publish
* and the args type a story is checked against are both derived from the list,
* so adding a module is a single edit.
*/
export const composeStoryMocks = <TMocks extends readonly AnyStoryMocks[]>(
...members: TMocks
): ComposedStoryMocks<TMocks> => ({
members,
args: Object.assign(
{},
...members.map((mocks) => mocks.args),
) as ComposedMockArgs<TMocks>,
argTypes: Object.assign({}, ...members.map((mocks) => mocks.argTypes)),
});

View File

@@ -0,0 +1,73 @@
import type { ControlDescriptor, MockControl } from './types';
interface ControlOptions {
/** Controls-panel group, so a page's knobs stay together. */
group: string;
description?: string;
}
const describe = (
name: string,
{ group, description }: ControlOptions,
value: unknown,
): ControlDescriptor => ({
name,
description,
table: {
category: group,
defaultValue: { summary: JSON.stringify(value) },
},
});
export const toggleControl = (
name: string,
options: ControlOptions & { value: boolean },
): MockControl<boolean> => ({
defaultValue: options.value,
argType: {
...describe(name, options, options.value),
control: { type: 'boolean' },
},
});
/**
* Item count for a list. `max` should go past what the page renders so a story
* can show the cap being hit.
*/
export const countControl = (
name: string,
options: ControlOptions & { value: number; max: number },
): MockControl<number> => ({
defaultValue: options.value,
argType: {
...describe(name, options, options.value),
control: { type: 'range', min: 0, max: options.max, step: 1 },
},
});
export const choiceControl = <TOption extends string>(
name: string,
options: ControlOptions & { value: TOption; options: readonly TOption[] },
): MockControl<TOption> => ({
defaultValue: options.value,
argType: {
...describe(name, options, options.value),
control: { type: 'select' },
options: [...options.options],
},
});
export const multiChoiceControl = <TOption extends string>(
name: string,
options: ControlOptions & {
value: readonly TOption[];
options: readonly TOption[];
},
): MockControl<TOption[]> => ({
defaultValue: [...options.value],
argType: {
...describe(name, options, options.value),
control: { type: 'check' },
options: [...options.options],
},
});

View File

@@ -0,0 +1,59 @@
import type {
ControlDescriptor,
MockControlMap,
MockControlValues,
StoryMocks,
StoryMocksDefinition,
} from './types';
import type { SignozStoryConfig, StoryOwnedConfig } from '../types';
/**
* Turns a page's control declarations into the `args` / `argTypes` its meta
* exposes and the reader the runtime uses to fold the panel's current values
* back into mock responses.
*/
export const defineStoryMocks = <TControls extends MockControlMap>(
definition: StoryMocksDefinition<TControls>,
): StoryMocks<TControls> => {
const entries = Object.entries(definition.controls);
const args = Object.fromEntries(
entries.map(([name, control]) => [name, control.defaultValue]),
) as MockControlValues<TControls>;
const argTypes: Record<string, ControlDescriptor> = Object.fromEntries(
entries.map(([name, control]) => [name, control.argType]),
);
return {
...definition,
args,
argTypes,
read: (storyArgs): MockControlValues<TControls> =>
Object.fromEntries(
entries.map(([name, control]) => [
name,
storyArgs[name] ?? control.defaultValue,
]),
) as MockControlValues<TControls>,
};
};
interface StoryMocksMeta<TControls extends MockControlMap> {
args: MockControlValues<TControls>;
argTypes: Record<string, ControlDescriptor>;
parameters: { signoz: SignozStoryConfig };
}
/**
* Meta fragment a page spreads to publish its controls:
* `...storyMocks(homeMocks, { route: ROUTES.HOME })`.
*/
export const storyMocks = <TControls extends MockControlMap>(
mocks: StoryMocks<TControls>,
config?: StoryOwnedConfig,
): StoryMocksMeta<TControls> => ({
args: mocks.args,
argTypes: mocks.argTypes,
parameters: { signoz: { ...config, mocks } },
});

View File

@@ -0,0 +1,63 @@
import type { ArgTypes } from '@storybook/react-vite';
import type { RequestHandler } from 'msw';
import type { MockResponse } from '../msw/types';
import type { ResponseState } from '../runtime/responseState';
import type { StoryOwnedConfig, StoryRole } from '../types';
export type ControlDescriptor = ArgTypes[string];
/** One row of the Storybook controls panel plus the value it starts at. */
export interface MockControl<TValue> {
defaultValue: TValue;
argType: ControlDescriptor;
}
export type MockControlMap = Record<string, MockControl<unknown>>;
export type MockControlValues<TControls extends MockControlMap> = {
[TName in keyof TControls]: TControls[TName] extends MockControl<infer TValue>
? TValue
: never;
};
/**
* What a mock module contributes to the story it runs in. Only `controls` is
* required; the rest are the ways a control can reach the page.
*/
export interface StoryMocksDefinition<TControls extends MockControlMap> {
controls: TControls;
handlers?(
values: MockControlValues<TControls>,
response: MockResponse,
): RequestHandler[];
/** Provider-level knobs no endpoint covers. */
config?(values: MockControlValues<TControls>): Partial<StoryOwnedConfig>;
/** Seeds module-level app state no provider exposes, e.g. no-auth mode. */
effect?(values: MockControlValues<TControls>): void;
/**
* How the endpoints declared through `response` answer. The first module that
* answers decides, page modules ahead of the global ones.
*/
responseState?(values: MockControlValues<TControls>): ResponseState;
/**
* The legacy role the story runs as. Derived, never set by a story. See
* `authzMocks`, which derives it from the permissions it grants.
*/
role?(values: MockControlValues<TControls>): StoryRole;
}
export interface StoryMocks<
TControls extends MockControlMap,
> extends StoryMocksDefinition<TControls> {
args: MockControlValues<TControls>;
argTypes: Record<string, ControlDescriptor>;
read(args: Record<string, unknown>): MockControlValues<TControls>;
}
export type AnyStoryMocks = StoryMocks<MockControlMap>;
export type StoryMockArgs<TMocks extends AnyStoryMocks> =
TMocks extends StoryMocks<infer TControls>
? MockControlValues<TControls>
: never;

View File

@@ -0,0 +1,9 @@
import type { Decorator } from '@storybook/react-vite';
import AppLayout from 'container/AppLayout';
/** Opt in per story with `decorators: [withAppLayout]`. */
export const withAppLayout: Decorator = (Story) => (
<AppLayout>
<Story />
</AppLayout>
);

View File

@@ -0,0 +1,23 @@
import type { Decorator } from '@storybook/react-vite';
import StorybookProviders from '../providers/StorybookProviders';
import {
resolveStory,
type StoryRuntimeContext,
} from '../runtime/resolveStory';
/**
* Global decorator: every story renders inside the mocked provider tree the
* story runtime resolved. Everything the tree needs in place first (handlers,
* module-level state, theme) is applied by the preview loader, which runs
* ahead of this.
*/
export const withProviders: Decorator = (Story, context) => {
const world = resolveStory(context as unknown as StoryRuntimeContext);
return (
<StorybookProviders key={world.key} {...world.config}>
<Story />
</StorybookProviders>
);
};

View File

@@ -0,0 +1,267 @@
import { StatusCodes } from 'http-status-codes';
import { FeatureKeys } from 'constants/features';
import { USER_PREFERENCES } from 'constants/userPreferences';
import type { IAppContext } from 'providers/App/types';
import { createAppContextMock } from 'tests/fixtures/appContextMock';
import APIError from 'types/api/error';
import type { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
import {
LicenseEvent,
LicensePlatform,
type LicenseResModel,
LicenseState,
} from 'types/api/licensesV3/getActive';
import type { UserPreference } from 'types/api/preferences/preference';
import { USER_ROLES } from 'types/roles';
import { setNoAuthMode } from 'utils/noAuthMode';
import { choiceControl } from '../controls/controls';
import { defineStoryMocks } from '../controls/defineStoryMocks';
import type { StoryMockArgs } from '../controls/types';
import { RESPONSE_STATES, type ResponseState } from '../runtime/responseState';
const APP_SHELL = 'App shell';
const DATA = 'Data';
const LICENSE = 'License';
const DAY_IN_SECONDS = 24 * 60 * 60;
const LICENSES = [
'cloud',
'enterprise',
'community-enterprise',
'community',
] as const;
const BANNERS = [
'none',
'trial-expiry',
'payment-failed',
'license-expired',
'license-terminated',
'no-auth',
] as const;
type License = (typeof LICENSES)[number];
type Banner = (typeof BANNERS)[number];
const SIDENAV_STATES = ['pinned', 'collapsed'] as const;
type SidenavState = (typeof SIDENAV_STATES)[number];
const {
activeLicense: baseLicense,
trialInfo: baseTrialInfo,
featureFlags: baseFeatureFlags,
versionData: baseVersionData,
} = createAppContextMock(USER_ROLES.ADMIN);
/**
* The status code `/licenses/active` failed with is itself the signal
* `useGetTenantLicense` reads: 404 is the enterprise build running unlicensed,
* 501 the community build, where the endpoint does not exist at all.
*/
const licenseFetchError = (httpStatusCode: StatusCodes): APIError =>
new APIError({
httpStatusCode,
error: {
code: 'license_unavailable',
message: 'storybook: no active license',
url: '',
errors: [],
},
});
const feature = (name: FeatureKeys, active: boolean): FeatureFlagProps => ({
name,
active,
usage: 0,
usage_limit: -1,
route: '',
});
/**
* What the backend serves an unlicensed enterprise build — the same keys as the
* enterprise plan, all inactive. Mirrors `BasicPlan` in
* `pkg/types/licensetypes/plan.go`; the community build serves none at all.
*/
const BASIC_PLAN: FeatureFlagProps[] = [
FeatureKeys.SSO,
FeatureKeys.GATEWAY,
FeatureKeys.PREMIUM_SUPPORT,
FeatureKeys.ANOMALY_DETECTION,
].map((name) => feature(name, false));
/**
* Which of the four deployments `useGetTenantLicense` distinguishes the story
* runs on. The license drives the plan the app believes it is on, so the feature
* flags and the enterprise/community build marker follow it.
*/
const licenseContext = (license: License): Partial<IAppContext> => {
switch (license) {
case 'enterprise':
return {
activeLicense: baseLicense && {
...baseLicense,
platform: LicensePlatform.SELF_HOSTED,
},
activeLicenseFetchError: null,
};
case 'community-enterprise':
return {
activeLicense: null,
activeLicenseFetchError: licenseFetchError(StatusCodes.NOT_FOUND),
featureFlags: BASIC_PLAN,
};
case 'community':
return {
activeLicense: null,
activeLicenseFetchError: licenseFetchError(StatusCodes.NOT_IMPLEMENTED),
featureFlags: [],
versionData: baseVersionData && { ...baseVersionData, ee: 'N' },
};
default:
return {
activeLicense: baseLicense,
activeLicenseFetchError: null,
featureFlags: baseFeatureFlags,
};
}
};
/**
* A banner that reads the license needs one to read, so the community
* deployments fall back to the licensed fixture rather than showing nothing.
*/
const licensedBanner = (
activeLicense: LicenseResModel | null,
extend: (license: LicenseResModel) => LicenseResModel,
): Partial<IAppContext> => {
const license = activeLicense ?? baseLicense;
return {
activeLicense: license && extend(license),
activeLicenseFetchError: null,
};
};
const bannerContext = (
banner: Banner,
activeLicense: LicenseResModel | null,
): Partial<IAppContext> => {
const nowInSeconds = Math.floor(Date.now() / 1000);
switch (banner) {
case 'trial-expiry':
return {
trialInfo: {
...baseTrialInfo,
onTrial: true,
trialStart: nowInSeconds - 27 * DAY_IN_SECONDS,
trialEnd: nowInSeconds + 3 * DAY_IN_SECONDS,
workSpaceBlock: false,
trialConvertedToSubscription: false,
gracePeriodEnd: -1,
},
};
case 'payment-failed':
return licensedBanner(activeLicense, (license) => ({
...license,
event_queue: {
...license.event_queue,
event: LicenseEvent.DEFAULT,
scheduled_at: new Date(
Date.now() + 7 * DAY_IN_SECONDS * 1000,
).toISOString(),
},
}));
// Both restricted-workspace banners need a self-hosted license; the cloud
// platform never reaches that branch.
case 'license-expired':
return licensedBanner(activeLicense, (license) => ({
...license,
platform: LicensePlatform.SELF_HOSTED,
state: LicenseState.EXPIRED,
}));
case 'license-terminated':
return licensedBanner(activeLicense, (license) => ({
...license,
platform: LicensePlatform.SELF_HOSTED,
state: LicenseState.TERMINATED,
}));
default:
return {};
}
};
/**
* `AppLayout` lays the shell out from the context rather than the API, so the
* side nav only matches the real app when this is seeded.
*/
const sidenavPreferences = (pinned: boolean): UserPreference[] => [
{
name: USER_PREFERENCES.SIDENAV_PINNED,
description: 'Keep the side navigation pinned open',
valueType: 'boolean',
defaultValue: false,
allowedValues: ['true', 'false'],
allowedScopes: ['user'],
value: pinned,
},
];
/** Who is looking at the page is `authzMocks`; everything else is here. */
export const appShellMocks = defineStoryMocks({
controls: {
license: choiceControl<License>('License', {
group: LICENSE,
description:
'The deployment the story runs on, as `useGetTenantLicense` reads it. `cloud` and `enterprise` are licensed and carry the enterprise plan; `community-enterprise` is the enterprise build with no license (basic plan, every feature inactive) and `community` the open-source build (no plan, `ee: N`).',
options: LICENSES,
value: 'cloud',
}),
banner: choiceControl<Banner>('Banner', {
group: APP_SHELL,
description:
'License, trial and no-auth banners above the shell. The license ones need a license to read, so they override an unlicensed License control.',
options: BANNERS,
value: 'none',
}),
sidenav: choiceControl<SidenavState>('Side nav', {
group: APP_SHELL,
options: SIDENAV_STATES,
value: 'pinned',
}),
dataState: choiceControl<ResponseState>('State', {
group: DATA,
description: 'How the endpoints the page owns answer.',
options: RESPONSE_STATES,
value: 'loaded',
}),
},
responseState: ({ dataState }) => dataState,
config: ({ license, banner, sidenav }) => {
const tenant = licenseContext(license);
return {
appContext: {
...tenant,
...bannerContext(banner, tenant.activeLicense ?? null),
userPreferences: sidenavPreferences(sidenav === 'pinned'),
},
};
},
effect: ({ banner }) => {
setNoAuthMode(banner === 'no-auth');
},
});
export type AppShellArgs = StoryMockArgs<typeof appShellMocks>;

View File

@@ -0,0 +1,83 @@
import { rest } from 'msw';
import type { AuthtypesTransactionDTO } from 'api/generated/services/sigNoz.schemas';
import { clearAllAuthZDevOverrides } from 'lib/authz/devtools/useAuthZDevStore';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
} from 'lib/authz/utils/authz-test-utils';
import {
accessFor,
ACCESS_PRESETS,
type AccessPreset,
PERMISSION_OPTIONS,
} from '../access/access';
import { choiceControl, multiChoiceControl } from '../controls/controls';
import { defineStoryMocks } from '../controls/defineStoryMocks';
import type { StoryMockArgs } from '../controls/types';
import {
respondWith,
RESPONSE_STATES,
type ResponseState,
} from '../runtime/responseState';
const ACCESS = 'Access';
/**
* Every permission check a story makes, answered from the controls panel rather
* than from a role. `POST /api/v1/authz/check` is the single gate the app reads:
* route guards, `AuthZGuard`, `AuthZButton` and `user.role` all resolve through
* it, and `access/access.ts` decides what the grant allows.
*/
export const authzMocks = defineStoryMocks({
controls: {
access: choiceControl<AccessPreset>('Access', {
group: ACCESS,
description:
'Base permission set the check endpoint answers with. `custom` starts from nothing, so only the list below counts; `dev-tools` grants an admin set and leaves the AuthZ dev modal (⌘K) in charge. Granting no legacy role lands on `ANONYMOUS`, which the role-based checks still treat as "not a viewer".',
options: ACCESS_PRESETS,
value: 'admin',
}),
permissions: multiChoiceControl('Permissions', {
group: ACCESS,
description:
'Granted on top of the preset, as `relation:kind`. A selector-scoped check matches its kind. With Access on `custom` this is the whole list.',
options: PERMISSION_OPTIONS,
value: [],
}),
authzState: choiceControl<ResponseState>('Check state', {
group: ACCESS,
description:
'How `authz/check` answers, the way the dev modal can force it.',
options: RESPONSE_STATES,
value: 'loaded',
}),
},
handlers: ({ access, permissions, authzState }) => {
const granted = accessFor(access, permissions);
return [
rest.post(
AUTHZ_CHECK_URL,
respondWith(authzState, async (req) => {
const payload = (await req.json()) as AuthtypesTransactionDTO[];
return authzMockResponse(
payload,
payload.map((transaction) => granted.allows(transaction)),
);
}),
),
];
},
role: ({ access, permissions }) => accessFor(access, permissions).legacyRole,
// Overrides persist in localStorage, so a leftover one from a real dev session
// would silently answer for the controls panel.
effect: ({ access }) => {
if (access !== 'dev-tools') {
clearAllAuthZDevOverrides();
}
},
});
export type AuthzArgs = StoryMockArgs<typeof authzMocks>;

View File

@@ -0,0 +1,12 @@
import { composeStoryMocks } from '../controls/composeStoryMocks';
import { appShellMocks } from './appShellMocks';
import { authzMocks } from './authzMocks';
/**
* The mock modules every story carries, page or component, declared at project
* level in `.storybook/preview.tsx`. Adding one here publishes its controls and
* widens `PageStoryArgs` in the same edit.
*/
export const globalMocks = composeStoryMocks(authzMocks, appShellMocks);
export type GlobalMockArgs = typeof globalMocks.args;

View File

@@ -0,0 +1,8 @@
import { IAppContext } from 'providers/App/types';
import { fn } from 'storybook/test';
import { createAppContextMock } from 'tests/fixtures/appContextMock';
export const createStoryAppContext = (
role: string,
overrides?: Partial<IAppContext>,
): IAppContext => createAppContextMock(role, overrides, () => fn());

View File

@@ -0,0 +1,20 @@
/**
* Replaces `constants/env` in Storybook (aliased in `.storybook/main.ts`).
*
* The base URL must stay `http://localhost` so the msw handlers shared with
* jest (`src/mocks-server/handlers.ts`), which are declared against that
* origin, match requests issued from the Storybook iframe. msw intercepts
* before the request leaves the page, so the cross-origin URL never hits the
* network and CORS never applies.
*
* The annotation checks the module's shape against the real one, so a value
* added to `constants/env` fails to compile here rather than at render.
*/
const libEnv: typeof import('constants/env') = {
ENVIRONMENT: {
baseURL: 'http://localhost',
wsURL: 'ws://localhost',
},
};
export const { ENVIRONMENT } = libEnv;

View File

@@ -0,0 +1,20 @@
import { fn } from 'storybook/test';
/**
* Replaces `api/common/logEvent` in Storybook (aliased in `.storybook/main.ts`)
* so analytics never leave the iframe. Stories can assert on the calls:
* `import logEvent from 'api/common/logEvent'` then `expect(logEvent)...`.
*
* The annotation checks the module's shape against the real one, so a change to
* `logEvent`'s signature fails to compile here rather than at render.
*/
const libLogEvent: typeof import('api/common/logEvent') = {
default: fn(async () => ({
statusCode: 200 as const,
error: null,
message: 'success',
payload: { status: 'success', data: '' },
})).mockName('logEvent'),
};
export default libLogEvent.default;

View File

@@ -0,0 +1,105 @@
import { USER_PREFERENCES } from 'constants/userPreferences';
import type { UserPreference } from 'types/api/preferences/preference';
/**
* Wire-shaped payloads for the endpoints the app shell calls on every route.
* Shapes follow the fields the components read, not the full generated DTOs.
*/
export const baseUserPreferences: UserPreference[] = [
{
name: USER_PREFERENCES.SIDENAV_PINNED,
description: 'Keep the side navigation pinned open',
valueType: 'boolean',
defaultValue: false,
allowedValues: ['true', 'false'],
allowedScopes: ['user'],
value: true,
},
];
export const userPreferencesResponse = (
preferences: UserPreference[] = baseUserPreferences,
): Record<string, unknown> => ({
status: 'success',
data: preferences,
});
export const zeusHostsResponse = {
status: 'success',
data: {
hosts: [{ url: 'https://ingest.us.signoz.cloud:443', is_default: true }],
},
};
export const versionResponse = {
version: 'v0.99.0',
ee: 'Y',
setupCompleted: true,
};
export const latestGithubReleaseResponse = {
tag_name: 'v0.99.0',
name: 'v0.99.0',
html_url: 'https://github.com/SigNoz/signoz/releases/tag/v0.99.0',
};
export const globalConfigResponse = {
status: 'success',
data: {
ai_assistant_url: null,
external_url: 'https://storybook.signoz.local',
ingestion_url: 'https://ingest.us.signoz.cloud:443',
mcp_url: null,
},
};
/**
* `ChangelogSchema` for the current version. Kept non-empty because
* `getChangelogByVersion` treats an empty list as a failure, and media is left
* null so no story reaches out for an image.
*/
export const changelogResponse = {
data: [
{
id: 1,
documentId: 'changelog-v0-99-0',
version: 'v0.99.0',
release_date: '2026-08-12',
bug_fixes:
'Fixed dashboard variables losing their selection on refresh.\nFixed alert history pagination.',
maintenance: 'Upgraded the query service to Go 1.24.',
createdAt: '2026-08-12T09:00:00.000Z',
updatedAt: '2026-08-12T09:00:00.000Z',
publishedAt: '2026-08-12T09:00:00.000Z',
features: [
{
id: 11,
documentId: 'feature-metrics-explorer',
title: 'Metrics explorer',
sort_order: 1,
createdAt: '2026-08-12T09:00:00.000Z',
updatedAt: '2026-08-12T09:00:00.000Z',
publishedAt: '2026-08-12T09:00:00.000Z',
description:
'Browse every metric you send, inspect its labels and turn it into a panel without writing a query.',
deployment_type: 'All',
media: null,
},
{
id: 12,
documentId: 'feature-trace-funnels',
title: 'Trace funnels',
sort_order: 2,
createdAt: '2026-08-12T09:00:00.000Z',
updatedAt: '2026-08-12T09:00:00.000Z',
publishedAt: '2026-08-12T09:00:00.000Z',
description:
'Measure conversion and drop-off across a multi-service request path.',
deployment_type: 'All',
media: null,
},
],
},
],
};

View File

@@ -0,0 +1,68 @@
import type { MetricRangePayloadV5 } from 'types/api/v5/queryRange';
/** Typed builders for the query_range v5 response shapes. */
export const queryRangeV5ScalarResponse = (
value: number,
queryName = 'A',
): MetricRangePayloadV5 => ({
data: {
type: 'scalar',
data: {
results: [
{
columns: [
{
name: '__result_0',
queryName,
aggregationIndex: 0,
columnType: 'aggregation',
},
],
data: [[value]],
},
],
},
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
},
});
export const queryRangeV5EmptyResponse = (
queryName = 'A',
): MetricRangePayloadV5 => ({
data: {
type: 'raw',
data: {
results: [{ queryName, nextCursor: '', rows: [] }],
},
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
},
});
export const queryRangeV5RawResponse = <T>(
rows: Array<{ timestamp: string; data: T }>,
options: { queryName?: string; hasMore?: boolean } = {},
): MetricRangePayloadV5 => {
const { queryName = 'A', hasMore = false } = options;
return {
data: {
type: 'raw',
data: {
results: [
{
queryName,
nextCursor: hasMore ? 'next-cursor-token' : '',
rows,
},
],
},
meta: {
rowsScanned: rows.length,
bytesScanned: 0,
durationMs: 0,
stepIntervals: {},
},
},
};
};

View File

@@ -0,0 +1,48 @@
import { rest } from 'msw';
import {
changelogResponse,
globalConfigResponse,
latestGithubReleaseResponse,
userPreferencesResponse,
versionResponse,
zeusHostsResponse,
} from './__mockdata__/appShell';
/**
* Endpoints the app shell hits on every route that the jest handlers in
* `src/mocks-server/handlers.ts` either do not cover or answer with fixtures
* too thin to show the shell doing its job. Resolved ahead of the shared set,
* and a page's own control-driven handlers are resolved ahead of these.
*/
export const appShellHandlers = [
rest.get('http://localhost/api/v1/user/preferences', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(userPreferencesResponse())),
),
rest.put('http://localhost/api/v1/user/preferences/:name', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.get('http://localhost/api/v2/zeus/hosts', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(zeusHostsResponse)),
),
rest.get('http://localhost/api/v1/global/config', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(globalConfigResponse)),
),
rest.get('http://localhost/api/v1/version', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(versionResponse)),
),
rest.get(
'https://api.github.com/repos/signoz/signoz/releases/latest',
(_req, res, ctx) =>
res(ctx.status(200), ctx.json(latestGithubReleaseResponse)),
),
rest.get('https://cms.signoz.cloud/api/release-changelogs', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(changelogResponse)),
),
];

View File

@@ -0,0 +1,36 @@
import { rest } from 'msw';
import { handlers as sharedHandlers } from 'mocks-server/handlers';
import { appShellHandlers } from './appShellHandlers';
/**
* Last resort: every axios instance is built on `ENVIRONMENT.baseURL`, which
* `mocks/env.mock.ts` pins to `http://localhost`, so an endpoint nobody mocked
* lands here instead of leaving the browser. Failing loudly beats a request
* that hangs until the connection is refused.
*/
const unmockedApiGuard = [
rest.all('http://localhost/api/*', (req, res, ctx) => {
console.error(
`[storybook] no msw handler for ${req.method} ${req.url.pathname}. Add one to the page's mocks or to src/storybook/msw/appShellHandlers.ts`,
);
return res(
ctx.status(501),
ctx.json({ status: 'error', error: 'not mocked in Storybook' }),
);
}),
];
/**
* Default handler set for every story, resolved first match wins: the
* Storybook-only shell handlers override the jest ones where the shell needs
* richer data, and both a page's control-driven handlers and a story's own
* `parameters.msw.handlers` are layered on top at render time. An endpoint both
* runners need belongs in `src/mocks-server/handlers.ts` instead.
*/
export const storybookHandlers = [
...appShellHandlers,
...sharedHandlers,
...unmockedApiGuard,
];

View File

@@ -0,0 +1,35 @@
import type { RequestHandler } from 'msw';
/**
* Handlers may be grouped under names, so unrelated overrides in the same story
* stay readable.
*/
export type StoryMswParameter =
| RequestHandler[]
| {
handlers?: RequestHandler[] | Record<string, RequestHandler[] | undefined>;
};
export const collectStoryHandlers = (
msw: StoryMswParameter | undefined,
): RequestHandler[] => {
if (!msw) {
return [];
}
if (Array.isArray(msw)) {
return msw;
}
const { handlers } = msw;
if (!handlers) {
return [];
}
return Array.isArray(handlers)
? handlers
: Object.values(handlers)
.filter((group): group is RequestHandler[] => Boolean(group))
.flat();
};

View File

@@ -0,0 +1,28 @@
import type {
DefaultBodyType,
PathParams,
ResponseResolver,
RestContext,
RestRequest,
} from 'msw';
export type MockRequest = RestRequest<DefaultBodyType, PathParams>;
export type MockResolver = ResponseResolver<
MockRequest,
RestContext,
DefaultBodyType
>;
/**
* Resolver factory handed to a mock module's `handlers`. Endpoints declared
* through it follow the response state, so one declaration covers the loaded,
* loading and failed states. Endpoints that have to answer for the page to
* render at all (ingestion detection, preferences) take a plain resolver
* instead.
*/
export interface MockResponse {
json: <TBody>(
build: (req: MockRequest) => TBody | Promise<TBody>,
) => MockResolver;
}

View File

@@ -0,0 +1,55 @@
.overlay {
position: fixed;
right: var(--spacing-8);
bottom: var(--spacing-8);
z-index: 2000;
display: flex;
max-width: 420px;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-6) var(--spacing-7);
border: 1px solid var(--accent-amber);
border-radius: 6px;
background: var(--l2-background);
box-shadow: 0 8px 24px color-mix(in srgb, var(--base-black) 45%, transparent);
color: var(--l1-foreground);
font-family: var(--font-sans);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
}
.actions {
display: flex;
gap: var(--spacing-4);
}
.button {
padding: var(--spacing-1) var(--spacing-4);
border: 1px solid var(--l1-border);
border-radius: 4px;
background: transparent;
color: inherit;
cursor: pointer;
&:hover {
background: var(--l2-background-hover);
border-color: var(--l2-border);
}
}
.list {
margin: 0;
max-height: 160px;
padding-left: var(--spacing-8);
overflow-y: auto;
}
.item {
margin-bottom: var(--spacing-1);
word-break: break-all;
}

View File

@@ -0,0 +1,59 @@
import { Typography } from '@signozhq/ui/typography';
import { clearBlockedNavigations } from './blockedNavigationStore';
import { useBlockedNavigations } from './useBlockedNavigations';
import styles from './NavigationBlockedOverlay.module.scss';
/**
* Surfaces every navigation the story swallowed. Rendered by `withProviders`,
* so any story that tries to leave the page says so instead of silently
* doing nothing.
*/
function NavigationBlockedOverlay(): JSX.Element | null {
const blockedNavigations = useBlockedNavigations();
if (blockedNavigations.length === 0) {
return null;
}
return (
<aside
className={styles.overlay}
aria-live="polite"
data-testid="navigation-blocked-overlay"
>
<div className={styles.header}>
<Typography.Text as="span" size="small" weight="semibold" color="warning">
Navigation blocked in Storybook
</Typography.Text>
<div className={styles.actions}>
<button
type="button"
className={styles.button}
onClick={clearBlockedNavigations}
data-testid="navigation-blocked-clear"
>
<Typography.Text as="span" size="small" weight="medium">
clear
</Typography.Text>
</button>
</div>
</div>
<ul className={styles.list}>
{blockedNavigations.map((navigation) => (
<li key={navigation.id} className={styles.item}>
<Typography.Text as="span" size="small" color="muted">
{navigation.via}
</Typography.Text>{' '}
<Typography.Text as="span" size="small">
{navigation.to}
</Typography.Text>
</li>
))}
</ul>
</aside>
);
}
export default NavigationBlockedOverlay;

View File

@@ -0,0 +1,40 @@
export interface BlockedNavigation {
id: number;
/** History method the app called, e.g. `push`, `replace`, `window.open`. */
via: string;
/** Target the app tried to reach, already resolved to an href. */
to: string;
}
type Listener = () => void;
let blockedNavigations: BlockedNavigation[] = [];
let nextId = 1;
const listeners = new Set<Listener>();
const emit = (): void => {
listeners.forEach((listener) => listener());
};
export const subscribeToBlockedNavigations = (
listener: Listener,
): (() => void) => {
listeners.add(listener);
return (): void => {
listeners.delete(listener);
};
};
export const getBlockedNavigations = (): BlockedNavigation[] =>
blockedNavigations;
export const recordBlockedNavigation = (via: string, to: string): void => {
blockedNavigations = [...blockedNavigations, { id: nextId, via, to }];
nextId += 1;
emit();
};
export const clearBlockedNavigations = (): void => {
blockedNavigations = [];
emit();
};

View File

@@ -0,0 +1,65 @@
import {
History,
LocationDescriptor,
LocationDescriptorObject,
parsePath,
} from 'history';
import { fn, type Mock } from 'storybook/test';
import { recordBlockedNavigation } from './blockedNavigationStore';
import { navigateWithinPage, storyHistory, toHref } from './pageScope';
const guardedNavigate = (
via: 'push' | 'replace',
): Mock<(to: LocationDescriptor, state?: unknown) => void> =>
fn((to: LocationDescriptor, state?: unknown): void => {
const target: LocationDescriptorObject =
typeof to === 'string' ? { ...parsePath(to), state } : { state, ...to };
if (navigateWithinPage(target, { replace: via === 'replace' })) {
return;
}
recordBlockedNavigation(via, toHref(to));
}).mockName(`history.${via}`);
const blockedRelativeNavigate = (via: string): Mock<(delta?: number) => void> =>
fn((delta?: number): void => {
recordBlockedNavigation(via, delta === undefined ? via : `${via}(${delta})`);
}).mockName(`history.${via}`);
const overriddenMethods = {
push: guardedNavigate('push'),
replace: guardedNavigate('replace'),
go: blockedRelativeNavigate('go'),
goBack: blockedRelativeNavigate('goBack'),
goForward: blockedRelativeNavigate('goForward'),
} as const;
type OverriddenMethod = keyof typeof overriddenMethods;
const isOverriddenMethod = (prop: string | symbol): prop is OverriddenMethod =>
typeof prop === 'string' && prop in overriddenMethods;
/**
* What the app sees in place of `lib/history`. Reads (`location`, `action`,
* `listen`) are proxied to the story's memory history so react-router renders
* normally; navigation goes through `pageScope`, and whatever would leave the
* page is swallowed and reported to `blockedNavigationStore`.
* `react-router-dom-v5-compat` drives its `useNavigate` through this same
* object, so `useSafeNavigate` is covered too.
*/
export const containedHistory: History = new Proxy(storyHistory, {
get(target, prop, receiver) {
if (isOverriddenMethod(prop)) {
return overriddenMethods[prop];
}
return Reflect.get(target, prop, receiver);
},
});
export const hasInAppHistory = (): boolean => false;
export const resetStoryHistory = (): void => {
Object.values(overriddenMethods).forEach((method) => method.mockClear());
};

View File

@@ -0,0 +1,20 @@
import {
containedHistory,
hasInAppHistory as containedHasInAppHistory,
} from './containment';
/**
* Replaces `lib/history` in Storybook (aliased in `.storybook/main.ts`). The
* containment rule lives in `containment.ts`; this file only has to keep the
* module's shape, and the annotation is what checks it against the real one.
* An export added to `lib/history` fails to compile here instead of failing at
* render in whichever component imports it.
*/
const libHistory: typeof import('lib/history') = {
default: containedHistory,
hasInAppHistory: containedHasInAppHistory,
};
export default libHistory.default;
export const { hasInAppHistory } = libHistory;

View File

@@ -0,0 +1,55 @@
import { recordBlockedNavigation } from './blockedNavigationStore';
import {
isBlockableHref,
navigateWithinPage,
toStoryLocation,
} from './pageScope';
/**
* Takes over the navigations that never reach the story's history: plain anchors
* and `window.open` (used by `useSafeNavigate` for `newTab`). An anchor staying
* on the story's page is applied, because letting the browser follow it would
* navigate the iframe away and unmount the story. Anything else is reported as
* blocked. Returns the teardown.
*/
export const interceptExternalNavigation = (): (() => void) => {
const onClick = (event: MouseEvent): void => {
const target = event.target as Element | null;
const anchor = target?.closest?.('a[href]');
if (!anchor) {
return;
}
const href = anchor.getAttribute('href') ?? '';
if (!isBlockableHref(href)) {
return;
}
// Ahead of react-router's own `Link` handler, which skips a click that is
// already handled, so an in-page link is never pushed twice.
event.preventDefault();
const to = toStoryLocation(href, window.location.href);
if (to && navigateWithinPage(to)) {
return;
}
recordBlockedNavigation('link', href);
};
document.addEventListener('click', onClick, true);
const originalOpen = window.open;
window.open = (url?: string | URL): null => {
recordBlockedNavigation('window.open', String(url ?? ''));
return null;
};
return (): void => {
document.removeEventListener('click', onClick, true);
window.open = originalOpen;
};
};

View File

@@ -0,0 +1,85 @@
import {
createMemoryHistory,
LocationDescriptor,
MemoryHistory,
parsePath,
} from 'history';
/**
* The story's own history. A story renders one page, so this never leaves it:
* `pageScope` decides what counts as staying, and `containment.ts` is what the
* app sees in place of `lib/history`.
*/
export const storyHistory: MemoryHistory = createMemoryHistory({
initialEntries: ['/'],
});
export const toHref = (to: LocationDescriptor): string =>
typeof to === 'string' ? to : storyHistory.createHref(to);
/** `/home/` and `/home` are the same page as far as a story is concerned. */
const normalizePathname = (pathname: string): string =>
pathname.length > 1 && pathname.endsWith('/')
? pathname.slice(0, -1)
: pathname;
export const isSamePagePathname = (pathname: string | undefined): boolean =>
!pathname ||
normalizePathname(pathname) ===
normalizePathname(storyHistory.location.pathname);
/**
* Applies a navigation that stays on the story's page: a query-param or hash
* change, which is how tabs, filters and pagination are driven. Returns false
* when the target is another page, leaving the caller to report it as blocked.
*/
export const navigateWithinPage = (
to: LocationDescriptor,
{ replace = false }: { replace?: boolean } = {},
): boolean => {
const target = typeof to === 'string' ? parsePath(to) : to;
if (!isSamePagePathname(target.pathname)) {
return false;
}
storyHistory[replace ? 'replace' : 'push']({
...target,
pathname: storyHistory.location.pathname,
});
return true;
};
/** Places the story at a route without going through the block. */
export const setStoryLocation = (to: LocationDescriptor): void => {
storyHistory.replace(to);
};
/**
* An in-page anchor or a `javascript:` href is the browser's business, not the
* story's: it is left alone rather than applied or reported.
*/
export const isBlockableHref = (href: string): boolean =>
href.length > 0 && !href.startsWith('#') && !href.startsWith('javascript:');
/**
* An anchor href as a location the story's history understands, or `undefined`
* when it leads off the page. Relative hrefs (`?tab=logs`) carry no pathname and
* stay on the page; app links do, and are resolved against the iframe so an
* off-site href fails the host check before its path is compared.
*/
export const toStoryLocation = (
href: string,
base: string,
): string | undefined => {
if (href.startsWith('?')) {
return href;
}
const url = new URL(href, base);
return url.host === new URL(base).host
? `${url.pathname}${url.search}${url.hash}`
: undefined;
};

View File

@@ -0,0 +1,10 @@
import { useSyncExternalStore } from 'react';
import {
BlockedNavigation,
getBlockedNavigations,
subscribeToBlockedNavigations,
} from './blockedNavigationStore';
export const useBlockedNavigations = (): BlockedNavigation[] =>
useSyncExternalStore(subscribeToBlockedNavigations, getBlockedNavigations);

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