Compare commits

..

12 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
Vinicius Lourenço
5b94d79e46 fix(infrastructure-monitoring): page reset on switch category & page outside total (#12453)
## Pull Request

---

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

This PR fixes the following issues:

- page not resetting to 1 when switch
  - bug was only detected/present when coming from deep link
- page not resetting to 1 when page produces a offset higher than total
  - you had to switch to hosts to be able to see data again

#### Screenshots / Screen Recordings (if applicable)
> Include screenshots or screen recordings that clearly show the
behavior before the change and the result after the change. This helps
reviewers quickly understand the impact and verify the update.

Before:

Issue with page not reseting to 1 when changing category (after
refresh):


https://github.com/user-attachments/assets/00872b38-1263-43c1-8322-64d31ee1ee6a

Issue with page outside the offset:


https://github.com/user-attachments/assets/5194fb2e-5af3-491b-baf7-b4aa3a330c83

---

After:

Issue with page not reseting to 1 when changing category (after
refresh):


https://github.com/user-attachments/assets/545e5914-c26f-4189-b15a-dc399bdee28b

Issue with page outside the offset:


https://github.com/user-attachments/assets/1b93d162-22a3-41c8-802e-aa2aa6012db9

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

Closes https://github.com/SigNoz/pulse-pod/issues/208

---

###  Change Type
_Select all that apply_

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

---

### 🐛 Bug Context
> Required if this PR fixes a bug

Both issues are caused after the refactor to the new table component and
after joining the categories into single component (without
unmount/mount when switching categories).

#### Root Cause
> What caused the issue?  
> Regression, faulty assumption, edge case, refactor, etc.

Lack of reset the page to 1, and no proper way to detect and reset page
to 1 when outside the boundaries.

#### Fix Strategy
> How does this PR address the root cause?

Reset to page 1 after switch category and also include hook on tanstack
to ensure we reset page to last when outside the params.

---

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

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

---

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

- Blast radius: Infrastructure Monitoring
- Potential regressions: -
- Rollback plan: Open a new PR to fix the issue

---

### 📝 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 | Bug Fix |
| Description | We fixed two issues around pagination inside
Infrastructure Monitoring causing the page not resetting to 1 after
switch category or when offset is higher than total amount of items. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-19 17:56:21 +00:00
Jugal Kishore
60a6a5b38c feat(onboarding): add Grok Build, GitHub Copilot, Serilog, GCP Integration datasources (#12595)
#### Description

- Adds Grok Build, GitHub Copilot, Serilog, and GCP Integration to the
onboarding data source picker.
- Adds a runtime step under AWS Lambda → Traces, so the new Go SDK guide
is reachable alongside the auto-instrumentation layers.
- New `github-copilot.svg`; the other three reuse existing logos
(`grok`, `dotnet`, `gcp`).

#### Issues closed by this PR

Closes SigNoz/signoz.io#3999
Closes SigNoz/signoz.io#3982
Closes SigNoz/signoz.io#3972
Closes SigNoz/signoz.io#3947
Closes SigNoz/signoz.io#3806
2026-08-19 17:25:12 +00:00
Srikanth Chekuri
b47245d46a feat: support semconv evolution in services (#12443)
#### Description

- The v2 services module (`/api/v2/services`) needs no change here: it
renders a QBv5 filter expression and runs through the querier, so the
merged #12442 resolution covers it when the `resolve_semconv_families`
flag is on.
- This layer covers the services read paths that do not go through QBv5,
behind the same flag (default: disabled). Part of #6143.
- The v1 services endpoints (services list, top operations) build the
legacy resource sub-query: it merges family members with current-wins
precedence, keeps the trailing `''` so keyless rows stay in negative
filters, widens positive index hints to any member, and drops negated
hints for correctness.
- The dependency graph accepts every family spelling as a filter key;
each spelling targets the historical `deployment_environment` column.
- The reader evaluates the flag per request from the org in the request
claims. The legacy logs and traces v4 explorer paths stay literal. With
the flag off, every generated query is the same as main; tests pin this.

#### Additional Information

- Stack: #12441 (merged) → #12442 (merged) → **#12443** → #12444 → …
This layer bases on main.
- The quick-filter default change and the stored-row migration from the
earlier version of this layer are deferred to the rollout phase:
persisted rows cannot be gated by a flag.
2026-08-19 15:31:12 +00:00
Aditya Singh
52ec7bf128 fix(logs): correct log details timestamp; enable new drawer view (#12624)
<!--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

- Fixes the wrong timestamp shown in the log details drawer on the
dashboard list panel.
- Enables the new log details drawer on dashboards.

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

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

<!--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
<img width="1631" height="865" alt="dashboard before"
src="https://github.com/user-attachments/assets/ebfcab65-9d5e-4a71-bf50-b67cdfceba9a"
/>

After

<img width="1608" height="813" alt="dashboard after"
src="https://github.com/user-attachments/assets/80d3e7ad-ffe2-4b5d-9d42-647b06f2c44d"
/>
2026-08-19 14:37:57 +00:00
Aditya Singh
afe62d77b0 fix(logs-infra): body filtering on infra monitoring; enable new drawer view (#12623)
<!--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
- Enables the new log details drawer on infra monitoring.
- Fixes filtering from the drawer on infra. Filters, including body and
nested-field filters that were not working.
[RCA](https://github.com/SigNoz/engineering-pod/issues/5937#issuecomment-5339825308)
This is now fixed since body and other keys are all rendered from the
same place which uses the same passed addQuery util from EntityLogs

- Group by only show on logs explorer page.

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

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

<!--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/da6cd2bf-4409-4362-af6b-f4871bf49005



After



https://github.com/user-attachments/assets/4967af5a-d3c9-4198-86a4-3b865983b7c2
2026-08-19 14:02:26 +00:00
175 changed files with 8090 additions and 4352 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:
@@ -8807,7 +8800,6 @@ components:
- span
- trace
- resource
- scope
- attribute
- body
- ""
@@ -13263,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
@@ -14377,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
@@ -14439,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
@@ -14485,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
@@ -14542,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
@@ -14598,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

@@ -3492,7 +3492,6 @@ export enum TelemetrytypesFieldContextDTO {
span = 'span',
trace = 'trace',
resource = 'resource',
scope = 'scope',
attribute = 'attribute',
body = 'body',
'' = '',
@@ -9093,13 +9092,6 @@ export interface SavedviewtypesUpdatableSavedViewDTO {
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
/**
* @type string
*/
id: string;
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -11021,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

@@ -0,0 +1,3 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#0078D4" d="M22.379 23.343a1.62 1.62 0 0 0 1.536-2.14v.002L17.35 1.76A1.62 1.62 0 0 0 15.816.657H8.184A1.62 1.62 0 0 0 6.65 1.76L.086 21.204a1.62 1.62 0 0 0 1.536 2.139h4.741a1.62 1.62 0 0 0 1.535-1.103l.977-2.892 4.947 3.675c.28.208.618.32.966.32m-3.084-12.531 3.624 10.739a.54.54 0 0 1-.51.713v-.001h-.03a.54.54 0 0 1-.322-.106l-9.287-6.9h4.853m6.313 7.006c.116-.326.13-.694.007-1.058L9.79 1.76a1.722 1.722 0 0 0-.007-.02h6.034a.54.54 0 0 1 .512.366l6.562 19.445a.54.54 0 0 1-.338.684"/>
</svg>

After

Width:  |  Height:  |  Size: 583 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,5 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Dify</title>
<path d="M7.043 6.487c1.635 0 2.241-1.003 2.241-2.243S8.681 2 7.044 2C5.405 2 4.801 3.003 4.801 4.244c0 1.24.604 2.243 2.241 2.243z" fill="#03F" />
<path d="M14.883 6.97v1.443h-3.679v3.203h3.68v8.012H8.801V8.41h-8v3.203h4.48v8.012H0v3.203h24v-3.203h-5.6v-8.012H24V8.41h-5.6V5.206H24V2.003h-4.161a4.97 4.97 0 00-4.961 4.967h.005z" fill="#03F" />
</svg>

After

Width:  |  Height:  |  Size: 447 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill-rule:evenodd;clip-rule:evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill:#D1D5DB;fill-rule:evenodd;clip-rule:evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,3 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#9CA3AF" d="M23.922 16.997C23.061 18.492 18.063 22.02 12 22.02 5.937 22.02.939 18.492.078 16.997A.641.641 0 0 1 0 16.741v-2.869a.883.883 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.098 10.098 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952C7.255 2.937 9.248 1.98 11.978 1.98c2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.841.841 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256Zm-11.75-5.992h-.344a4.359 4.359 0 0 1-.355.508c-.77.947-1.918 1.492-3.508 1.492-1.725 0-2.989-.359-3.782-1.259a2.137 2.137 0 0 1-.085-.104L4 11.746v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.359 4.359 0 0 1-.355-.508Zm2.328 3.25c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm-5 0c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm3.313-6.185c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,24 @@
<svg viewBox="0 0 44.8 40" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="kong-a" x1="38.204" x2="8.732" y1="18.417" y2="48.543" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
<linearGradient id="kong-b" x1="38.107" x2="8.635" y1="18.322" y2="48.448" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
<linearGradient id="kong-c" x1="29.439" x2="-0.033" y1="9.842" y2="39.968" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
<linearGradient id="kong-d" x1="30.291" x2="0.819" y1="10.676" y2="40.801" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
</defs>
<path d="m14.7 32.9-1.1 1.4 2.5 3.9-.3 1.8h10.6l.7-1.8-4.2-5.3z" fill="url(#kong-a)" />
<path d="M20.5 9.4 16.7 16l18.6 22-.5 2h8.5l1.5-7.1L24.9 9.4z" fill="url(#kong-b)" />
<path d="m23 4.4-1.8 3.3h4.5l7.7 9.2 4.6-3.8v-2.4l-1.6-2.2 1.2-1.2L28.4 0z" fill="url(#kong-c)" />
<path d="M9.1 22.9H6.6L0 31.3V40h7.1l1.3-1.6 5.5-7.1h7.9l2.4-3.7-8.6-10.2z" fill="url(#kong-d)" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg fill="none" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M63 0.018v63.535L38.418 42.25v21.303H0V0l63 .018ZM7.723 55.839h22.972V25.323l24.583 21.725V7.729L7.723 7.716v48.123Z" fill="#37C38F" />
</svg>

After

Width:  |  Height:  |  Size: 226 B

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

@@ -19,6 +19,7 @@ export type LogDetailProps = {
onScrollToLog?: (logId: string) => void;
handleOpenInExplorer?: MouseEventHandler;
getContainer?: DrawerProps['getContainer'];
onApplyLogFilter?: (expression: string) => void;
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
Pick<DrawerProps, 'onClose'>;

View File

@@ -16,6 +16,7 @@ import {
Link,
} from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import { normalizeTimeToMs } from 'utils/timeUtils';
import { ILog } from 'types/api/logs/log';
import { MouseEvent, MouseEventHandler } from 'react';
import { useCopyToClipboard } from 'react-use';
@@ -67,6 +68,11 @@ function LogDetailsHeader({
},
];
const rawTimestamp = log.date ?? log.timestamp;
const displayTimestamp = Number.isNaN(Number(rawTimestamp))
? rawTimestamp
: normalizeTimeToMs(rawTimestamp);
return (
<div className={styles.header} data-log-detail-ignore="true">
<div className={styles.leftSection}>
@@ -76,7 +82,7 @@ function LogDetailsHeader({
data-testid="log-details-header-timestamp"
>
{formatTimezoneAdjustedTimestamp(
log.date ?? log.timestamp,
displayTimestamp,
DATE_TIME_FORMATS.DASH_DATETIME,
)}
</Typography.Text>

View File

@@ -91,6 +91,24 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
);
});
it('normalizes a nanosecond-epoch timestamp in the header', () => {
localStorage.setItem(LOCALSTORAGE.PREFERRED_TIMEZONE, 'UTC');
// Same instant as mockLog but as epoch nanoseconds (e.g. dashboard list panel).
// Must scale to ms, not render a wildly wrong date.
renderDrawer({
log: {
...mockLog,
date: '1705311930000000000',
timestamp: 1705311930000000000,
},
});
expect(screen.getByTestId('log-details-header-timestamp')).toHaveTextContent(
'Jan 15, 2024 ⎯ 09:45:30',
);
});
it('copies the log link from the ⋯ menu', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });

View File

@@ -75,6 +75,7 @@ function LogDetailInner({
onScrollToLog,
handleOpenInExplorer,
getContainer,
onApplyLogFilter,
}: LogDetailInnerProps): JSX.Element {
const initialContextQuery = useInitialQuery(log);
const [contextQuery, setContextQuery] = useState<Query | undefined>(
@@ -519,6 +520,7 @@ function LogDetailInner({
selectedOptions={options}
listViewPanelSelectedFields={listViewPanelSelectedFields}
handleChangeSelectedView={handleChangeSelectedView}
onApplyLogFilter={onApplyLogFilter}
/>
)}
{!isLogDetailsV2 && selectedView === VIEW_TYPES.JSON && (

View File

@@ -1,9 +1,11 @@
import ROUTES from 'constants/routes';
import { useLocation } from 'react-router-dom';
// v2 is rolled out only on the logs explorer route for now; every other surface
// (dashboards, infra monitoring, etc.) keeps the v1 log details view.
export function useIsLogDetailsV2(): boolean {
const { pathname } = useLocation();
return pathname === ROUTES.LOGS_EXPLORER;
return (
pathname === ROUTES.LOGS_EXPLORER ||
pathname.startsWith(ROUTES.INFRASTRUCTURE_MONITORING_BASE) ||
pathname.startsWith(`${ROUTES.ALL_DASHBOARD}/`)
);
}

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,280 @@
import { renderHook } from '@testing-library/react';
import {
useRecoverFromEmptyPage,
UseRecoverFromEmptyPageParams,
} from '../useRecoverFromEmptyPage';
const REPLACE = { history: 'replace' };
function renderRecovery(
overrides: Partial<UseRecoverFromEmptyPageParams> = {},
): { setPage: jest.Mock; rerender: (next?: unknown) => void } {
const setPage = jest.fn();
const props: UseRecoverFromEmptyPageParams = {
page: 1,
pageSize: 10,
rowCount: 10,
total: 100,
isFetching: false,
setPage,
...overrides,
};
const { rerender } = renderHook(
(next: UseRecoverFromEmptyPageParams) => useRecoverFromEmptyPage(next),
{ initialProps: props },
);
return {
setPage,
rerender: (next?: unknown): void =>
rerender({ ...props, ...(next as Partial<UseRecoverFromEmptyPageParams>) }),
};
}
describe('useRecoverFromEmptyPage', () => {
it('leaves the page alone while it still holds rows', () => {
const { setPage } = renderRecovery({ page: 3, rowCount: 10 });
expect(setPage).not.toHaveBeenCalled();
});
it('leaves the page alone on page 1 with no rows at all', () => {
const { setPage } = renderRecovery({ page: 1, rowCount: 0, total: 0 });
expect(setPage).not.toHaveBeenCalled();
});
it('jumps to the last page that holds data when the page is out of range', () => {
const { setPage } = renderRecovery({
page: 7,
pageSize: 10,
rowCount: 0,
total: 25,
});
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
});
it('replaces the history entry so the back button does not return to the empty page', () => {
const { setPage } = renderRecovery({ page: 4, rowCount: 0, total: 10 });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('falls back to page 1 when the total is unknown', () => {
const { setPage } = renderRecovery({ page: 5, rowCount: 0, total: 0 });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('steps back one page when the total claims the page should have data', () => {
// total says 100 rows exist, yet page 5 came back empty — step back rather
// than stall on a page the query cannot actually serve.
const { setPage } = renderRecovery({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
});
expect(setPage).toHaveBeenCalledWith(4, REPLACE);
});
it('clamps a page below the first one', () => {
const { setPage } = renderRecovery({ page: 0, rowCount: 10 });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('falls back to page 1 when pageSize is zero', () => {
const { setPage } = renderRecovery({
page: 5,
pageSize: 0,
rowCount: 0,
total: 100,
});
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('waits for the request to settle before moving the user', () => {
const { setPage, rerender } = renderRecovery({
page: 3,
rowCount: 0,
total: 10,
isFetching: true,
});
expect(setPage).not.toHaveBeenCalled();
rerender({ page: 3, rowCount: 0, total: 10, isFetching: false });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('clamps a page below the first one even when the query failed', () => {
// A negative offset is what made the request fail (400 "offset cannot be
// negative"), so retrying the same page loops forever — clamp regardless.
const { setPage } = renderRecovery({
page: 0,
rowCount: 0,
total: 0,
isDisabled: true,
});
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('clamps a page below the first one while the query is still in flight', () => {
const { setPage } = renderRecovery({
page: -2,
rowCount: 0,
isFetching: true,
});
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('keeps the page when the query failed so a retry lands where the user was', () => {
const { setPage } = renderRecovery({
page: 3,
rowCount: 0,
total: 0,
isDisabled: true,
});
expect(setPage).not.toHaveBeenCalled();
});
it('clamps a page below the first one exactly once while the request settles', () => {
// The clamp runs ahead of both gates, so a request settling underneath an
// uncorrected page must not re-issue the same history rewrite.
const { setPage, rerender } = renderRecovery({
page: 0,
rowCount: 0,
total: 0,
isFetching: true,
});
expect(setPage).toHaveBeenCalledTimes(1);
rerender({ page: 0, rowCount: 0, total: 0, isFetching: false });
expect(setPage).toHaveBeenCalledTimes(1);
});
it('stops correcting once the corrected page comes back with rows', () => {
const { setPage, rerender } = renderRecovery({
page: 7,
pageSize: 10,
rowCount: 0,
total: 25,
});
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
// The correction lands: the query refetches, then resolves with the rows page 3 holds.
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 25, isFetching: true });
rerender({
page: 3,
pageSize: 10,
rowCount: 5,
total: 25,
isFetching: false,
});
expect(setPage).toHaveBeenCalledTimes(1);
});
it('does not correct again while the same page is still being observed', () => {
const { setPage, rerender } = renderRecovery({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
});
expect(setPage).toHaveBeenCalledTimes(1);
// A refetch cycle that leaves the page untouched — the correction is already in flight.
rerender({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
isFetching: true,
});
rerender({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
isFetching: false,
});
expect(setPage).toHaveBeenCalledTimes(1);
});
it('gives up on step-backs and jumps to page 1 when the total keeps lying', () => {
// `total` claims 400 rows exist, but every page comes back empty. Walking back one
// page at a time would cost a request per hop, so bail out to page 1 instead.
const { setPage, rerender } = renderRecovery({
page: 40,
pageSize: 10,
rowCount: 0,
total: 400,
});
expect(setPage).toHaveBeenNthCalledWith(1, 39, REPLACE);
rerender({ page: 39, pageSize: 10, rowCount: 0, total: 400 });
expect(setPage).toHaveBeenNthCalledWith(2, 38, REPLACE);
rerender({ page: 38, pageSize: 10, rowCount: 0, total: 400 });
expect(setPage).toHaveBeenNthCalledWith(3, 1, REPLACE);
expect(setPage).toHaveBeenCalledTimes(3);
});
it('corrects again when the user returns to a page that is still empty', () => {
const { setPage, rerender } = renderRecovery({
page: 3,
pageSize: 10,
rowCount: 0,
total: 10,
});
expect(setPage).toHaveBeenNthCalledWith(1, 1, REPLACE);
rerender({ page: 1, pageSize: 10, rowCount: 10, total: 10 });
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 10 });
expect(setPage).toHaveBeenNthCalledWith(2, 1, REPLACE);
});
it('does not re-run the correction when setPage is a fresh function each render', () => {
// The hook reads setPage through a ref, so an inline arrow must not turn the
// ungated `page < 1` clamp into a per-render history rewrite.
const setPage = jest.fn();
const { rerender } = renderHook(
() =>
useRecoverFromEmptyPage({
page: 0,
pageSize: 10,
rowCount: 0,
total: 0,
isFetching: false,
setPage: (nextPage, options): void => setPage(nextPage, options),
}),
{ initialProps: undefined },
);
rerender(undefined);
rerender(undefined);
expect(setPage).toHaveBeenCalledTimes(1);
});
});

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

@@ -9,6 +9,7 @@ export * from './useCalculatedPageSize';
export * from './useColumnState';
export * from './useColumnStore';
export * from './usePreferredPageSize.store';
export * from './useRecoverFromEmptyPage';
export * from './useTableParams';
/**
@@ -285,6 +286,51 @@ export * from './useTableParams';
*
* **Pagination shows "Auto" option** when `calculatedPageSize` is passed, allowing users
* to reset to auto-calculated size.
*
* **`setPage` accepts history options**: `setPage(page, { history: 'replace' })` rewrites the
* current history entry instead of pushing a new one. Use `replace` for corrections the user
* did not ask for — otherwise the back button walks straight back into the state that was just
* corrected. Only applies when the page is synced to the URL; local (non-URL) pages ignore it.
*
* @example useRecoverFromEmptyPage — send the user back to a page that has data
*
* When rows disappear underneath the current page (filters narrowed, time range moved, items
* deleted), the user is stranded on an empty page they cannot leave by scrolling. This hook
* watches the fetched result and corrects the page with `history: 'replace'`, so the back
* button does not return to the empty page.
*
* Correction rules:
* - `page < 1` → jump to page 1, even while fetching or disabled. Such a page usually maps to a
* negative offset the API rejects (400 `offset cannot be negative`), so the response can never
* confirm the page is empty — deferring to it would strand the user on a permanent error.
* - Page is empty and not page 1 → go to `min(ceil(total / pageSize), page - 1)`. When `total`
* is trustworthy that lands on the last page holding data; when `total` is unknown or zero it
* lands on page 1; and when `total` claims this page should have had rows it steps back a
* single page. Repeated step-backs give up and jump to page 1 after the second one, so a
* badly inflated `total` cannot walk the user down one request at a time.
* - Page has rows, or the user is already on page 1 → do nothing (an empty page 1 means there
* is genuinely nothing to show).
*
* Pass `isFetching` so the hook waits for the request to settle, and `isDisabled` so a failed
* request is not mistaken for an empty page. Neither gate suppresses the `page < 1` clamp.
*
* ```tsx
* import { useRecoverFromEmptyPage, useTableParams } from 'components/TanStackTableView';
*
* const { page, limit, setPage } = useTableParams(QUERY_PARAMS, { page: 1, limit: 20 });
* const { data, isLoading, isFetching, isError } = useListQuery({ page, limit });
*
* useRecoverFromEmptyPage({
* page,
* pageSize: limit,
* rowCount: data?.rows.length ?? 0,
* total: data?.total ?? 0,
* isFetching: isLoading || isFetching,
* // Skip correction on errors — no rows there means "request failed", not "page is empty".
* isDisabled: isError,
* setPage,
* });
* ```
*/
const TanStackTable = Object.assign(TanStackTableBase, {
Text: TanStackTableText,

View File

@@ -0,0 +1,107 @@
import { useEffect, useRef } from 'react';
import { SetPageOptions } from './useTableParams';
const FIRST_PAGE = 1;
const REPLACE_HISTORY: SetPageOptions = { history: 'replace' };
/**
* How many single-page step-backs to attempt before giving up and going to page 1.
*
* A step-back only happens when `total` claims the current page should hold data but the
* response came back empty. Each hop costs a request, so an inflated `total` on a high page
* number would otherwise walk the user down one page at a time behind a spinner.
*/
const MAX_STEP_BACKS = 2;
type Correction = {
from: number;
to: number;
};
export type UseRecoverFromEmptyPageParams = {
page: number;
pageSize: number;
rowCount: number;
total: number;
isFetching: boolean;
isDisabled?: boolean;
setPage: (page: number, options?: SetPageOptions) => void;
};
export function useRecoverFromEmptyPage({
page,
pageSize,
rowCount,
total,
isFetching,
isDisabled = false,
setPage,
}: UseRecoverFromEmptyPageParams): void {
const setPageRef = useRef(setPage);
const lastCorrectionRef = useRef<Correction | null>(null);
const stepBacksRef = useRef(0);
useEffect(() => {
setPageRef.current = setPage;
});
useEffect(() => {
if (lastCorrectionRef.current && lastCorrectionRef.current.from !== page) {
lastCorrectionRef.current = null;
}
const correctTo = (nextPage: number): boolean => {
if (lastCorrectionRef.current?.to === nextPage) {
return false;
}
lastCorrectionRef.current = { from: page, to: nextPage };
setPageRef.current(nextPage, REPLACE_HISTORY);
return true;
};
// A page below the first one is invalid on its own terms — it usually maps to a
// negative offset the API rejects outright, so waiting for a response that will
// never arrive (or trusting a failed one) would strand the user for good.
if (page < FIRST_PAGE) {
stepBacksRef.current = 0;
void correctTo(FIRST_PAGE);
return;
}
if (isFetching || isDisabled) {
return;
}
// The page has data, or there is genuinely nothing to show anywhere.
if (rowCount > 0 || page === FIRST_PAGE) {
stepBacksRef.current = 0;
return;
}
const currentPage = Math.floor(page);
const lastPageWithData =
pageSize > 0 && total > 0 ? Math.ceil(total / pageSize) : FIRST_PAGE;
const nextPage = Math.max(
FIRST_PAGE,
Math.min(lastPageWithData, currentPage - 1),
);
// `total` disagrees with the response: it says this page should have rows, so the
// only safe move is one page back. Cap how often that repeats — every hop is a
// request, and a badly inflated `total` would otherwise crawl down from page 40.
const isStepBack = nextPage === currentPage - 1;
if (isStepBack && stepBacksRef.current >= MAX_STEP_BACKS) {
if (correctTo(FIRST_PAGE)) {
stepBacksRef.current = 0;
}
return;
}
if (correctTo(nextPage) && isStepBack) {
stepBacksRef.current += 1;
}
}, [isFetching, isDisabled, page, pageSize, rowCount, total]);
}

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

@@ -29,12 +29,16 @@ type Defaults = {
cleanupOnUnmount?: boolean;
};
export type SetPageOptions = {
history?: 'push' | 'replace';
};
export type TableParamsResult = {
page: number;
limit: number;
orderBy: SortState | null;
expanded: ExpandedState;
setPage: (p: number) => void;
setPage: (p: number, options?: SetPageOptions) => void;
setLimit: (l: number) => void;
setOrderBy: (s: SortState | null) => void;
setExpanded: (updaterOrValue: Updater<ExpandedState>) => void;
@@ -249,6 +253,17 @@ export function useTableParams(
[],
);
const setUrlPageWithOptions = useCallback(
(page: number, options?: SetPageOptions): void => {
void setUrlPage(page, options);
},
[setUrlPage],
);
const setLocalPageValue = useCallback((page: number): void => {
setLocalPage(page);
}, []);
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
const prevOrderByRef = useRef<string | null>(null);
@@ -303,7 +318,7 @@ export function useTableParams(
limit: useUrlForLimit ? urlLimit : localLimit,
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
setPage: useUrlForPage ? setUrlPage : setLocalPage,
setPage: useUrlForPage ? setUrlPageWithOptions : setLocalPageValue,
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist,
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,

View File

@@ -10,7 +10,6 @@ const fieldContextToSuggestionMap: Record<
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
// no maps for the following values on suggestion context
[TelemetrytypesFieldContextDTO.trace]: undefined,
[TelemetrytypesFieldContextDTO.scope]: undefined,
[TelemetrytypesFieldContextDTO.body]: undefined,
[TelemetrytypesFieldContextDTO.metric]: undefined,
[TelemetrytypesFieldContextDTO.log]: undefined,

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

@@ -7,6 +7,7 @@ import TanStackTable, {
TableColumnDef,
useCalculatedPageSize,
useHiddenColumnIds,
useRecoverFromEmptyPage,
useTableParams,
} from 'components/TanStackTableView';
import { InfraMonitoringEvents } from 'constants/events';
@@ -136,6 +137,7 @@ export function K8sBaseList<
page: currentPage,
limit: currentPageSize,
setLimit,
setPage,
} = useTableParams(
{
page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
@@ -243,6 +245,16 @@ export function K8sBaseList<
const totalCount = data?.total || 0;
const hasFilters = !!expression?.trim();
useRecoverFromEmptyPage({
page: currentPage,
pageSize: currentPageSize,
rowCount: pageData.length,
total: totalCount,
isFetching: isLoading || isFetching,
isDisabled: isError || Boolean(data?.error),
setPage,
});
const getGroupKeyFn = useCallback(
(item: T) => getGroupedByMeta(item, groupBy),
[groupBy],

View File

@@ -591,12 +591,14 @@ describe('K8sBaseList', () => {
});
describe('with empty data', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [],
@@ -605,6 +607,7 @@ describe('K8sBaseList', () => {
});
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
@@ -625,6 +628,177 @@ describe('K8sBaseList', () => {
expect(fetchListDataMock).toHaveBeenCalled();
});
});
it('should not rewrite the page when already on the first page', async () => {
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const pageUpdates = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('page'))
.filter(Boolean);
expect(pageUpdates).toHaveLength(0);
});
});
describe('with a page beyond the end of the list', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
// 25 rows exist, so pages 1-3 serve data and page 7 of 10 comes back empty.
const rows: TestItem[] = Array.from({ length: 25 }, (_, index) => ({
id: `pod-${index + 1}`,
}));
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
// Offset-aware on purpose: a mock that answers empty for every offset would let
// the assertions pass against a page the recovery has already moved on from.
fetchListDataMock.mockImplementation(async ({ offset = 0, limit = 10 }) => ({
data: rows.slice(offset, offset + limit),
total: rows.length,
error: null,
}));
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: { page: '7', pageSize: '10' },
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should send the user back to the last page holding data', async () => {
// The rows of page 3 on screen are what proves the recovery settled there,
// rather than passing through on its way somewhere else.
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
const pageUpdates = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('page'))
.filter(Boolean);
expect(pageUpdates).toStrictEqual(['3']);
});
it('should correct the page in a single hop', async () => {
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
// Only the original out-of-range page and the corrected one are requested.
expect(
fetchListDataMock.mock.calls.map((call) => call[0].offset),
).toStrictEqual([60, 20]);
});
it('should replace the history entry instead of pushing the correction', async () => {
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
const pageCorrection = onUrlUpdateMock.mock.calls.find(
(call) => call[0].searchParams.get('page') === '3',
);
expect(pageCorrection?.[0].options.history).toBe('replace');
});
});
describe('with a page below the first one', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
// page=0 turns into offset=-10, which the API rejects outright — the list
// can only recover by clamping the page, never by reading the response.
fetchListDataMock.mockImplementation(async ({ offset = 0 }) => {
if (offset < 0) {
throw new APIError({
httpStatusCode: 400,
error: {
code: 'invalid_input',
message: 'offset cannot be negative',
url: '',
errors: [],
},
});
}
return { data: [{ id: 'pod-1' }], total: 1, error: null };
});
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: { page: '0', pageSize: '10' },
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should reject the request that carried the negative offset', async () => {
await waitFor(() => {
expect(
fetchListDataMock.mock.calls.some((call) => call[0].offset === -10),
).toBe(true);
});
await expect(
fetchListDataMock.mock.results[0].value as Promise<unknown>,
).rejects.toThrow('offset cannot be negative');
});
it('should clamp the page to the first one even though the request failed', async () => {
await waitFor(() => {
expect(onUrlUpdateMock).toHaveBeenCalled();
});
// Page 1 is the default, so the correction drops the param rather than
// writing `page=1`.
const pageCorrection = onUrlUpdateMock.mock.calls.find(
(call) => call[0].searchParams.get('page') === null,
);
expect(pageCorrection).toBeDefined();
expect(pageCorrection?.[0].queryString).toBe('?pageSize=10');
});
it('should replace the history entry instead of pushing the correction', async () => {
await waitFor(() => {
expect(onUrlUpdateMock).toHaveBeenCalled();
});
const pageCorrection = onUrlUpdateMock.mock.calls.find(
(call) => call[0].searchParams.get('page') === null,
);
expect(pageCorrection?.[0].options.history).toBe('replace');
});
it('should refetch with a non-negative offset after clamping', async () => {
await waitFor(() => {
expect(
fetchListDataMock.mock.calls.some((call) => call[0].offset === 0),
).toBe(true);
});
await waitFor(() => {
expect(screen.getByText('pod-1')).toBeInTheDocument();
});
});
});
describe('with error response', () => {

View File

@@ -82,6 +82,7 @@ function EntityLogsContent({
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
useLogDetailHandlers();
// TODO: Move away from using onAddToQuery after old drawer cleanup
const onAddToQuery = useCallback(
(fieldKey: string, fieldValue: string, operator: string): void => {
handleCloseLogDetail();
@@ -104,6 +105,21 @@ function EntityLogsContent({
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
);
const onApplyLogFilter = useCallback(
(expression: string): void => {
handleCloseLogDetail();
const newUser = userExpression.trim()
? `${userExpression} AND ${expression}`
: expression;
querySearchOnRun(newUser);
logInfraDrawerFilterCustomizedEvent(category, 'logs', newUser, 'logs');
},
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
);
const {
logs,
loadMoreLogs,
@@ -328,6 +344,7 @@ function EntityLogsContent({
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onApplyLogFilter={onApplyLogFilter}
onScrollToLog={handleScrollToLog}
handleOpenInExplorer={(e) => handleOpenInExplorer(e, activeLog)}
getContainer={(): HTMLElement =>

View File

@@ -47,6 +47,7 @@ import {
useInfraMonitoringCategory,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringSelectedItemParams,
} from './hooks';
@@ -67,6 +68,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setOrderBy] = useInfraMonitoringOrderBy();
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const compositeQuery = useGetCompositeQueryParam();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
@@ -218,6 +220,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
void setSelectedCategory(key as string);
void setOrderBy(null);
void setGroupBy(null);
void setCurrentPage(null);
setSelectedItemParams(null);
redirectWithQueryBuilderData({
...currentQuery,

View File

@@ -0,0 +1,128 @@
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter as MemoryRouterV5 } from 'react-router-dom-v5-compat';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing';
import { AppProvider } from 'providers/App/App';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import TimezoneProvider from 'providers/Timezone';
import store from 'store';
import { K8sCategories } from '../constants';
import InfraMonitoringK8s from '../InfraMonitoringK8s';
// Quick filters fire their own field APIs and are irrelevant to pagination.
jest.mock('components/QuickFilters/QuickFilters', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="quick-filters" />,
}));
// The list owns its own page recovery; stubbing it keeps the page param under the
// sole control of the category handler being tested here.
jest.mock('../Base/K8sDynamicList', () => ({
__esModule: true,
K8sDynamicList: (): JSX.Element => <div data-testid="k8s-dynamic-list" />,
default: (): JSX.Element => <div data-testid="k8s-dynamic-list" />,
}));
// Analytics only; jsdom lacks the Performance navigation entries it reads.
jest.mock('lib/navigation', () => ({
getNavigationReferrer: (): string => 'direct',
}));
function renderPage(
queryParams: Record<string, string>,
onUrlUpdate: jest.Mock<void, [UrlUpdateEvent]>,
): void {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<MemoryRouter>
<MemoryRouterV5>
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<AppProvider>
<Provider store={store}>
<NuqsTestingAdapter
searchParams={queryParams}
onUrlUpdate={onUrlUpdate}
>
<TooltipProvider>
<QueryBuilderProvider>
<InfraMonitoringK8s />
</QueryBuilderProvider>
</TooltipProvider>
</NuqsTestingAdapter>
</Provider>
</AppProvider>
</QueryClientProvider>
</TimezoneProvider>
</MemoryRouterV5>
</MemoryRouter>,
);
}
describe('InfraMonitoringK8s', () => {
describe('when the category changes from a page other than the first', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
beforeEach(async () => {
onUrlUpdateMock.mockClear();
renderPage(
{ category: K8sCategories.PODS, page: '3', pageSize: '10' },
onUrlUpdateMock,
);
await screen.findByTestId(`category-${K8sCategories.NODES}`);
});
it('should drop the page so the new category starts at the first one', async () => {
fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`));
// Page 3 of pods says nothing about nodes — keeping it asks the new entity
// for an offset it may not have. The param is cleared rather than set to 1,
// since an absent page already means the first one.
await waitFor(() => {
const categorySwitch = onUrlUpdateMock.mock.calls.find(
(call) => call[0].searchParams.get('category') === K8sCategories.NODES,
);
expect(categorySwitch).toBeDefined();
expect(categorySwitch?.[0].searchParams.get('page')).toBeNull();
});
});
it('should keep the page size, which is not category specific', async () => {
fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`));
await waitFor(() => {
const categorySwitch = onUrlUpdateMock.mock.calls.find(
(call) => call[0].searchParams.get('category') === K8sCategories.NODES,
);
expect(categorySwitch?.[0].searchParams.get('pageSize')).toBe('10');
});
});
it('should leave the page alone when the same category is clicked again', async () => {
fireEvent.click(screen.getByTestId(`category-${K8sCategories.PODS}`));
await waitFor(() => {
expect(screen.getByTestId('k8s-dynamic-list')).toBeInTheDocument();
});
const droppedPage = onUrlUpdateMock.mock.calls.some(
(call) => !call[0].searchParams.has('page'),
);
expect(droppedPage).toBe(false);
});
});
});

View File

@@ -34,6 +34,9 @@ export const useInfraMonitoringPageListing = (): UseQueryStateReturn<
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
// do not use .withDefault here, this can cause bugs when
// two hooks of nuqs define default twice, this is also
// defined at useTableParams
parseAsInteger.withOptions(defaultNuqsOptions),
);

View File

@@ -41,6 +41,7 @@ interface OverviewProps {
selectedOptions: OptionsQuery;
listViewPanelSelectedFields?: IField[] | null;
handleChangeSelectedView?: ChangeViewFunctionType;
onApplyLogFilter?: (expression: string) => void;
}
type Props = OverviewProps &
@@ -55,6 +56,7 @@ function Overview({
selectedOptions,
listViewPanelSelectedFields,
handleChangeSelectedView,
onApplyLogFilter,
}: Props): JSX.Element {
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
@@ -67,6 +69,7 @@ function Overview({
const { actions, visibleActions } = useLogAttributeActions({
handleChangeSelectedView,
isListViewPanel,
onApplyLogFilter,
});
const isLogDetailsV2 = useIsLogDetailsV2();

View File

@@ -1,6 +1,7 @@
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
@@ -15,6 +16,7 @@ import {
VisibleActionsConfig,
} from 'periscope/components/PrettyView/PrettyView';
import { useAppContext } from 'providers/App/App';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { LogDetailsAction } from '../constants';
import {
@@ -27,6 +29,7 @@ import {
interface UseLogAttributeActionsParams {
handleChangeSelectedView?: ChangeViewFunctionType;
isListViewPanel?: boolean;
onApplyLogFilter?: (expression: string) => void;
}
interface UseLogAttributeActionsResult {
@@ -50,6 +53,7 @@ const ALL_LEAF_ACTIONS = [
export function useLogAttributeActions({
handleChangeSelectedView,
isListViewPanel = false,
onApplyLogFilter,
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
@@ -65,9 +69,6 @@ export function useLogAttributeActions({
const filterFor = useCallback(
(context: FieldContext, isFilterIn: boolean): void => {
if (!stagedQuery) {
return;
}
const target = buildLogFilterTarget(
context.fieldKeyPath,
context.fieldValue,
@@ -77,6 +78,29 @@ export function useLogAttributeActions({
? target.filterInOperator
: target.filterOutOperator;
// Non-explorer surfaces (infra monitoring, etc.) apply a ready v5
// expression fragment to their own query.
if (onApplyLogFilter) {
const base = {
filters: { items: [], op: 'AND' },
} as unknown as IBuilderQuery;
const nextFilters = getFilterQueryData(
base,
target,
context.fieldValue,
operator,
).filters ?? { items: [], op: 'AND' };
const { expression } = convertFiltersToExpression(nextFilters);
if (expression) {
onApplyLogFilter(expression);
}
return;
}
if (!stagedQuery) {
return;
}
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
@@ -99,6 +123,7 @@ export function useLogAttributeActions({
updateQueriesData,
viewName,
handleChangeSelectedView,
onApplyLogFilter,
],
);
@@ -179,20 +204,25 @@ export function useLogAttributeActions({
buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.isRestricted;
// The using surface must provide an apply path.
const canApplyFilter = !!handleChangeSelectedView || !!onApplyLogFilter;
return [
{
key: LogDetailsAction.FILTER_IN,
label: 'Filter for value',
icon: <CirclePlus size={12} />,
onClick: (context): void => filterFor(context, true),
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
shouldHide: (_key, fieldKeyPath): boolean =>
!canApplyFilter || isRestricted(fieldKeyPath),
},
{
key: LogDetailsAction.FILTER_OUT,
label: 'Filter out value',
icon: <CircleMinus size={12} />,
onClick: (context): void => filterFor(context, false),
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
shouldHide: (_key, fieldKeyPath): boolean =>
!canApplyFilter || isRestricted(fieldKeyPath),
},
{
key: LogDetailsAction.GROUP_BY,
@@ -200,8 +230,10 @@ export function useLogAttributeActions({
icon: <Layers size={12} />,
onClick: groupBy,
shouldHide: (_key, fieldKeyPath): boolean =>
!handleChangeSelectedView ||
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.groupBySupported || isOldExplorerOrLive,
.groupBySupported ||
isOldExplorerOrLive,
},
{
key: LogDetailsAction.REPLACE_FILTER,
@@ -209,7 +241,9 @@ export function useLogAttributeActions({
icon: <RefreshCw size={12} />,
onClick: replaceFilter,
shouldHide: (_key, fieldKeyPath): boolean =>
isRestricted(fieldKeyPath) || isOldExplorerOrLive,
!handleChangeSelectedView ||
isRestricted(fieldKeyPath) ||
isOldExplorerOrLive,
},
];
}, [
@@ -218,6 +252,8 @@ export function useLogAttributeActions({
replaceFilter,
isBodyJsonQueryEnabled,
isOldExplorerOrLive,
handleChangeSelectedView,
onApplyLogFilter,
]);
const visibleActions = useMemo<VisibleActionsConfig>(

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

@@ -21,6 +21,7 @@ import azureMysqlUrl from '@/assets/Logos/azure-mysql.svg';
import azureOpenaiUrl from '@/assets/Logos/azure-openai.svg';
import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metrics.svg';
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
import azureUrl from '@/assets/Logos/azure.svg';
import basetenUrl from '@/assets/Logos/baseten.svg';
import cassandraUrl from '@/assets/Logos/cassandra.svg';
import celeryUrl from '@/assets/Logos/celery.svg';
@@ -28,6 +29,7 @@ import certManagerUrl from '@/assets/Logos/cert-manager.svg';
import claudeCodeUrl from '@/assets/Logos/claude-code.svg';
import clickhouseUrl from '@/assets/Logos/clickhouse.svg';
import cloudflareUrl from '@/assets/Logos/cloudflare.svg';
import cloudnativePgUrl from '@/assets/Logos/cloudnative-pg.svg';
import cloudwatchLogsUrl from '@/assets/Logos/cloudwatch-logs.svg';
import cohereUrl from '@/assets/Logos/cohere.svg';
import confluentKafkaUrl from '@/assets/Logos/confluent-kafka.svg';
@@ -39,6 +41,7 @@ import datadogUrl from '@/assets/Logos/datadog.svg';
import dbosUrl from '@/assets/Logos/dbos.svg';
import deepseekUrl from '@/assets/Logos/deepseek.svg';
import denoUrl from '@/assets/Logos/deno.svg';
import difyUrl from '@/assets/Logos/dify.svg';
import dockerUrl from '@/assets/Logos/docker.svg';
import documentLoadUrl from '@/assets/Logos/document-load.svg';
import dotnetUrl from '@/assets/Logos/dotnet.svg';
@@ -70,6 +73,8 @@ import gcpCloudStorageUrl from '@/assets/Logos/gcp-cloud-storage.svg';
import gcpComputeEngineUrl from '@/assets/Logos/gcp-compute-engine.svg';
import gcpGkeUrl from '@/assets/Logos/gcp-gke.svg';
import gcpVpcUrl from '@/assets/Logos/gcp-vpc.svg';
import gcpUrl from '@/assets/Logos/gcp.svg';
import githubCopilotUrl from '@/assets/Logos/github-copilot.svg';
import githubUrl from '@/assets/Logos/github.svg';
import goUrl from '@/assets/Logos/go.svg';
import googleAdkUrl from '@/assets/Logos/google-adk.svg';
@@ -96,6 +101,8 @@ import javascriptUrl from '@/assets/Logos/javascript.svg';
import jbossUrl from '@/assets/Logos/jboss.svg';
import jenkinsUrl from '@/assets/Logos/jenkins.svg';
import kafkaUrl from '@/assets/Logos/kafka.svg';
import kedaUrl from '@/assets/Logos/keda.svg';
import kongUrl from '@/assets/Logos/kong.svg';
import kubernetesUrl from '@/assets/Logos/kubernetes.svg';
import lambdaUrl from '@/assets/Logos/lambda.svg';
import langchainUrl from '@/assets/Logos/langchain.svg';
@@ -114,6 +121,7 @@ import microsoftSqlServerUrl from '@/assets/Logos/microsoft-sql-server.svg';
import mistralUrl from '@/assets/Logos/mistral.svg';
import mongoUrl from '@/assets/Logos/mongo.svg';
import n8nUrl from '@/assets/Logos/n8n.svg';
import neonUrl from '@/assets/Logos/neon.svg';
import newrelicUrl from '@/assets/Logos/newrelic.svg';
import nextjsUrl from '@/assets/Logos/nextjs.svg';
import nginxUrl from '@/assets/Logos/nginx.svg';
@@ -2859,6 +2867,25 @@ const onboardingConfigWithLinks = [
label: 'Traces',
imgUrl: lambdaUrl,
link: '/docs/aws-monitoring/lambda/lambda-traces/',
question: {
desc: 'Which runtime does your Lambda function use?',
helpText:
'Python, Node.js, Java, and Ruby use the OpenTelemetry auto-instrumentation layer. Go has no layer, so you add the SDK to your code.',
options: [
{
key: 'aws-lambda-traces-auto',
label: 'Python, Node.js, Java, Ruby',
imgUrl: lambdaUrl,
link: '/docs/aws-monitoring/lambda/lambda-traces/',
},
{
key: 'aws-lambda-traces-golang',
label: 'Go',
imgUrl: goUrl,
link: '/docs/aws-monitoring/lambda/lambda-traces-golang/',
},
],
},
},
{
key: 'aws-lambda-metrics',
@@ -5510,8 +5537,10 @@ const onboardingConfigWithLinks = [
module: 'metrics',
relatedSearchKeywords: [
'integrations',
'logs',
'metrics',
'supabase',
'supabase logs',
'supabase metrics',
'supabase monitoring',
'supabase observability',
@@ -5545,43 +5574,23 @@ const onboardingConfigWithLinks = [
label: 'Traefik',
imgUrl: opentelemetryUrl,
tags: ['infrastructure monitoring'],
module: 'infrastructure',
module: 'apm',
relatedSearchKeywords: [
'infrastructure',
'traefik',
'traefik access logs',
'traefik logs',
'traefik metrics',
'traefik monitoring',
'traefik observability',
'traefik tracing',
],
link: '/docs/tutorial/traefik-observability/',
question: {
desc: 'Which Traefik signals do you want to send to SigNoz?',
type: 'select',
options: [
{
key: 'traefik-metrics-traces',
label: 'Metrics & Traces',
imgUrl: opentelemetryUrl,
link: '/docs/tutorial/traefik-observability/',
},
{
key: 'traefik-logs',
label: 'Access Logs',
imgUrl: opentelemetryUrl,
link: '/docs/integrations/opentelemetry-traefik/',
},
],
},
},
{
dataSource: 'mongodb-atlas',
label: 'MongoDB (Atlas)',
imgUrl: mongoUrl,
tags: ['database'],
module: 'database',
module: 'metrics',
relatedSearchKeywords: [
'atlas',
'atlas metrics',
@@ -5600,36 +5609,15 @@ const onboardingConfigWithLinks = [
label: 'MySQL',
imgUrl: opentelemetryUrl,
tags: ['database'],
module: 'database',
module: 'metrics',
relatedSearchKeywords: [
'database',
'mysql',
'mysql error log',
'mysql logs',
'mysql metrics',
'mysql monitoring',
'mysql observability',
'mysql slow query log',
],
link: '/docs/metrics-management/mysql-metrics/',
question: {
desc: 'Which MySQL signals do you want to send to SigNoz?',
type: 'select',
options: [
{
key: 'mysql-metrics',
label: 'Metrics',
imgUrl: opentelemetryUrl,
link: '/docs/metrics-management/mysql-metrics/',
},
{
key: 'mysql-logs',
label: 'Logs',
imgUrl: opentelemetryUrl,
link: '/docs/integrations/opentelemetry-mysql/',
},
],
},
},
{
dataSource: 'jmx',
@@ -6992,5 +6980,514 @@ const onboardingConfigWithLinks = [
id: 'dspy',
link: '/docs/dspy-observability/',
},
{
dataSource: 'grok-build',
label: 'Grok Build',
imgUrl: grokUrl,
tags: ['LLM Monitoring'],
module: 'metrics',
relatedSearchKeywords: [
'coding agent',
'grok build',
'grok build events',
'grok build logs',
'grok build metrics',
'grok build monitoring',
'grok build observability',
'llm',
'llm monitoring',
'metrics',
'monitoring',
'observability',
'otel grok build integration',
'terminal coding agent',
'token usage',
'xai',
],
id: 'grok-build',
link: '/docs/grok-build-observability/',
},
{
dataSource: 'neon',
label: 'Neon',
imgUrl: neonUrl,
tags: ['database'],
module: 'metrics',
relatedSearchKeywords: [
'database',
'neon',
'neon database',
'neon db',
'neon logs',
'neon metrics',
'neon monitoring',
'neon observability',
'neondb',
'opentelemetry neon',
'postgres',
'postgresql',
'serverless postgres',
],
id: 'neon',
link: '/docs/integrations/opentelemetry-neondb/',
},
{
dataSource: 'dify',
label: 'Dify',
imgUrl: difyUrl,
tags: ['LLM Monitoring'],
module: 'apm',
relatedSearchKeywords: [
'ai agent',
'dify',
'dify monitoring',
'dify observability',
'dify traces',
'llm',
'llm monitoring',
'metrics',
'no code ai',
'observability',
'opentelemetry dify',
'traces',
],
id: 'dify',
link: '/docs/dify-observability/',
},
{
dataSource: 'firecrawl',
label: 'Firecrawl',
imgUrl: llmMonitoringUrl,
tags: ['LLM Monitoring'],
module: 'apm',
relatedSearchKeywords: [
'crawl',
'firecrawl',
'firecrawl metrics',
'firecrawl monitoring',
'firecrawl observability',
'firecrawl traces',
'llm',
'llm monitoring',
'opentelemetry firecrawl',
'scrape',
'traces',
'web scraping',
],
id: 'firecrawl',
link: '/docs/firecrawl-monitoring/',
},
{
dataSource: 'keda',
label: 'KEDA',
imgUrl: kedaUrl,
tags: ['infrastructure monitoring', 'metrics'],
module: 'metrics',
relatedSearchKeywords: [
'autoscaling',
'event driven autoscaling',
'keda',
'keda metrics',
'keda monitoring',
'kubernetes',
'kubernetes autoscaling',
'metrics',
'opentelemetry keda',
'scaled jobs',
'scaled objects',
],
id: 'keda',
link: '/docs/metrics-management/keda-metrics/',
},
{
dataSource: 'opentelemetry-collector-metrics',
label: 'OpenTelemetry Collector Metrics',
imgUrl: opentelemetryUrl,
tags: ['infrastructure monitoring', 'metrics'],
module: 'metrics',
relatedSearchKeywords: [
'collector health',
'collector internal metrics',
'collector metrics',
'metrics',
'opentelemetry',
'opentelemetry collector',
'otel collector',
'otelcol metrics',
'pipeline health',
],
id: 'opentelemetry-collector-metrics',
link: '/docs/metrics-management/opentelemetry-collector-metrics/',
},
{
dataSource: 'cloudnative-pg',
label: 'CloudNativePG',
imgUrl: cloudnativePgUrl,
tags: ['database'],
module: 'metrics',
relatedSearchKeywords: [
'cloud native postgres',
'cloudnativepg',
'cnpg',
'cnpg metrics',
'database',
'kubernetes postgres',
'metrics',
'opentelemetry cloudnativepg',
'postgres',
'postgresql',
],
id: 'cloudnative-pg',
link: '/docs/metrics-management/opentelemetry-cloudnative-pg/',
},
{
dataSource: 'kong-gateway',
label: 'Kong Gateway',
imgUrl: kongUrl,
tags: ['infrastructure monitoring'],
module: 'apm',
relatedSearchKeywords: [
'api gateway',
'kong',
'kong gateway',
'kong logs',
'kong metrics',
'kong monitoring',
'kong observability',
'kong traces',
'opentelemetry kong',
'proxy',
'traces',
],
id: 'kong-gateway',
link: '/docs/integrations/kong-gateway/',
},
{
dataSource: 'github-copilot',
label: 'GitHub Copilot',
imgUrl: githubCopilotUrl,
tags: ['LLM Monitoring'],
module: 'apm',
relatedSearchKeywords: [
'coding agent',
'copilot',
'copilot chat',
'github copilot',
'github copilot metrics',
'github copilot monitoring',
'github copilot observability',
'github copilot traces',
'llm',
'llm monitoring',
'monitoring',
'observability',
'otel github copilot integration',
'traces',
'tracing',
'vs code',
],
id: 'github-copilot',
link: '/docs/github-copilot-monitoring/',
},
{
dataSource: 'serilog',
label: 'Serilog',
imgUrl: dotnetUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'.net',
'asp.net',
'c#',
'csharp',
'dotnet',
'dotnet logs',
'logging',
'logs',
'monitoring',
'observability',
'otel serilog integration',
'serilog',
'serilog logs',
'serilog sink',
'structured logging',
],
id: 'serilog',
link: '/docs/logs-management/send-logs/serilog-to-signoz/',
},
{
dataSource: 'gcp-integration',
label: 'GCP Integration',
imgUrl: gcpUrl,
tags: ['GCP'],
module: 'metrics',
relatedSearchKeywords: [
'cloud monitoring metrics',
'connect gcp account',
'gcp',
'gcp integration',
'gcp manual setup',
'gcp metrics',
'gcp monitoring',
'gcp observability',
'gcp service account',
'google cloud',
'google cloud integration',
'metrics',
'monitoring',
'observability',
'opentelemetry collector gcp',
],
id: 'gcp-integration',
link: '/docs/integrations/gcp/gcp-integration/',
},
{
dataSource: 'azure-cosmos-db',
label: 'Azure Cosmos DB',
imgUrl: azureUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure cosmos db',
'azure integration',
'cosmos db',
'cosmosdb',
'database',
'metrics',
'monitoring',
'nosql',
'observability',
'one click azure',
'request units',
],
id: 'azure-cosmos-db',
link: '/integrations/azure?service=cosmosdb',
internalRedirect: true,
},
{
dataSource: 'azure-mongodb',
label: 'Azure MongoDB vCore',
imgUrl: mongoUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure integration',
'azure mongodb',
'azure mongodb vcore',
'database',
'metrics',
'mongodb',
'mongodb vcore',
'monitoring',
'nosql',
'observability',
'one click azure',
],
id: 'azure-mongodb',
link: '/integrations/azure?service=mongodb',
internalRedirect: true,
},
{
dataSource: 'azure-postgresql-flexible-server',
label: 'Azure PostgreSQL Flexible Server',
imgUrl: postgresqlUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure integration',
'azure postgresql',
'azure postgresql flexible server',
'database',
'metrics',
'monitoring',
'observability',
'one click azure',
'postgres',
'postgresql',
'postgresql flexible server',
],
id: 'azure-postgresql-flexible-server',
link: '/integrations/azure?service=postgresqlflexibleserver',
internalRedirect: true,
},
{
dataSource: 'azure-cache-redis',
label: 'Azure Cache for Redis',
imgUrl: redisUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure cache for redis',
'azure integration',
'azure redis',
'cache',
'database',
'metrics',
'monitoring',
'observability',
'one click azure',
'redis',
],
id: 'azure-cache-redis',
link: '/integrations/azure?service=redis',
internalRedirect: true,
},
{
dataSource: 'azure-sql-managed-instance',
label: 'Azure SQL Managed Instance',
imgUrl: azureSqlDatabaseMetricsUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure integration',
'azure sql',
'azure sql managed instance',
'database',
'managed instance',
'metrics',
'monitoring',
'observability',
'one click azure',
'sql server',
],
id: 'azure-sql-managed-instance',
link: '/integrations/azure?service=sqldatabasemi',
internalRedirect: true,
},
{
dataSource: 'azure-cassandra-db',
label: 'Azure Managed Instance for Apache Cassandra',
imgUrl: cassandraUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'apache cassandra',
'azure',
'azure cassandra',
'azure integration',
'azure managed instance for apache cassandra',
'cassandra',
'database',
'metrics',
'monitoring',
'observability',
'one click azure',
],
id: 'azure-cassandra-db',
link: '/integrations/azure?service=cassandradb',
internalRedirect: true,
},
{
dataSource: 'gcp-cloud-sql-postgresql',
label: 'GCP Cloud SQL for PostgreSQL',
imgUrl: gcpCloudSqlUrl,
tags: ['GCP'],
module: 'dashboards',
relatedSearchKeywords: [
'cloud sql',
'cloud sql for postgresql',
'database',
'gcp',
'gcp integration',
'google cloud',
'metrics',
'monitoring',
'observability',
'postgres',
'postgresql',
],
id: 'gcp-cloud-sql-postgresql',
link: '/integrations/gcp?service=cloudsql_postgres',
internalRedirect: true,
},
{
dataSource: 'gcp-memorystore-redis',
label: 'GCP Memorystore for Redis',
imgUrl: redisUrl,
tags: ['GCP'],
module: 'dashboards',
relatedSearchKeywords: [
'cache',
'database',
'gcp',
'gcp integration',
'google cloud',
'memorystore',
'memorystore for redis',
'metrics',
'monitoring',
'observability',
'redis',
],
id: 'gcp-memorystore-redis',
link: '/integrations/gcp?service=memorystore_redis',
internalRedirect: true,
},
{
dataSource: 'supabase-logs',
label: 'Supabase Logs',
imgUrl: supabaseUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'database',
'logging',
'logs',
'postgres',
'postgresql',
'send supabase logs',
'supabase',
'supabase log drains',
'supabase logs',
'supabase observability',
],
id: 'supabase-logs',
link: '/docs/logs-management/send-logs/supabase-logs/',
},
{
dataSource: 'traefik-logs',
label: 'Traefik Access Logs',
imgUrl: opentelemetryUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'access logs',
'logging',
'logs',
'opentelemetry traefik',
'proxy',
'reverse proxy',
'traefik',
'traefik access logs',
'traefik logs',
],
id: 'traefik-logs',
link: '/docs/integrations/opentelemetry-traefik/',
},
{
dataSource: 'mysql-logs',
label: 'MySQL Logs',
imgUrl: opentelemetryUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'database',
'logging',
'logs',
'mysql',
'mysql error log',
'mysql general query log',
'mysql logs',
'mysql slow query log',
'opentelemetry mysql',
],
id: 'mysql-logs',
link: '/docs/integrations/opentelemetry-mysql/',
},
];
export default onboardingConfigWithLinks;

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.

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