Compare commits

...

11 Commits

Author SHA1 Message Date
Nikhil Mantri
8bc4c5f79c feat(authz): rule FGA (#13253)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- Move `/api/v2/rules*`, `/api/v3/rules` and `/api/v2/rules/test` from
role gates to `CheckResources` on rule verbs.
- Create, update, patch, clone and test also check attach and detach on
the notification channels the rule names, diffed against the stored
rule. Unknown channel names are rejected with 400.
- Create, update, patch, clone and test also check read on the telemetry
the rule's query touches, so a rule cannot be used to read signals the
caller cannot query directly. Managed roles already hold these reads,
only custom roles are affected.
- PATCH checks telemetry read only when the body carries
`condition.compositeQuery`, so disabling or renaming a rule needs no
read on its stored query. This adds an opt-in `Optional` flag on
`TelemetryResourceDef` for that one route, every other telemetry def
keeps failing closed.
- Detach of a channel name that no longer exists is skipped instead of
rejected, so a rule whose channel was removed can still be updated.
Attach stays strict.
- Integration tests cover viewer, editor and a custom role on every
gate. No migration, rule tuples come from #13251 and channel links from
#13249.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#286
Closes SigNoz/pulse-pod#355

#### Additional Information

Fifth of five stacked PRs splitting #12927. Stacked on #13252.
2026-11-13 09:15:38 +00:00
Naman Verma
198230f064 fix: remove algorithm from all rule examples and request builders (#13567)
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/732
2026-11-13 09:11:00 +00:00
Aswin K.M.
0791306e52 feat(build-staging): release staging through the deployments repo on merges and labels (#13571)
#### Description

- Merges to `main` now build and release the staging base
(`overlays/staging/*/signoz/base`), which every staging overlay
inherits.
- `staging:<deployment>` labels on PRs still release only that
deployment's overlay.
- Releases go to `signoz.staging.cl.gcp.deployments` with a `{path,
image, tag}` payload instead of `{deployment, signoz_version, source}`.
- The deployment label is checked against
`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` before it goes into the path glob and
the JSON payload.
- `checkout`, `create-github-app-token` and `cache` are pinned to
release SHAs.
2026-11-13 09:04:43 +00:00
Ashwin Bhatkal
792de1376d test(storybook): make plays pass on every control value (#13538)
#### Description

- The plays on the alert rules page, the dashboards list and the
metrics, traces, logs and pods explorers now check the args they depend
on: whether rows are listed, which permissions are held, whether the
data loaded, which tab, view or format is open, and how a write answers.
Run once against every control value, 167 of 969 alert rules runs, 336
of 1,372 dashboards list runs and 212 of about 2,200 explorer runs
failed on `main`. All of them pass now.
- The explorer pages' plays moved out of their story files into play
files, as the skill asks. Plays that had become the same play under a
different control value are now one.
- `grantsAll` in `src/storybook/access/access.ts` answers a permission
check from a story's access controls, the same way the `authz/check`
mock does.
- The `signoz-page-story` skill gets a **Plays** section listing the
cases these plays missed and how to guard them with the page's own code,
and `.claude/rules/storybook.md` points at it. CI runs each play only
with its story's own args, so these rules are what keeps a new play from
missing the same cases.
- No screen renders differently: only plays, their mock helpers and the
skill changed.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/727
2026-11-13 06:05:06 +00:00
Ashwin Bhatkal
20e6d44289 fix(api): send array query params as repeated keys (#13457)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- The generated API client now sends array query params as repeated keys
(`states=firing&states=pending`) instead of axios' default `states[]=`.
The backend binds only repeated keys, so a `states` filter sent through
a generated hook was ignored and every rule came back.
- The alert list page and the home page alerts card drop their own
requests that worked around this.
- `states` on `GET /api/v3/rules` is the only array query param in the
generated and AI assistant clients today, so no other request changes.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/719
2026-11-12 16:50:41 +00:00
Ashwin Bhatkal
9fef8eea69 test(alerts): stop the saved views tests timing out on CI (#13481)
#### Description

- `ListAlertRules.views.test.tsx` now gives each test 15 s instead of
Jest's default 5 s. Other slow page tests use the same timeout.
- "deleting the active view falls back to All" is the slowest test in
the file, about 0.8 s locally. CI runs the file about 4x slower (48 to
52 s against 13 s locally), which pushes it past 5 s. It failed the
merge queue for [#13476](https://github.com/SigNoz/signoz/pull/13476)
and [#13301](https://github.com/SigNoz/signoz/pull/13301), and two
unrelated PRs.
- Each step in it finishes well inside the 1 s `waitFor` limit. Only the
total runs over, so this is load, not a stuck wait.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/726
2026-11-12 15:46:37 +00:00
Swapnil Nakade
d00471c6b9 refactor: using X-Forwarded-For header for client.address (#13543)
<!--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
- using X-Forwarded-For header for client.address as audit log attribute

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
closes https://github.com/SigNoz/keystone-pod/issues/213
2026-11-12 11:18:59 +00:00
Swapnil Nakade
b91f7e9835 refactor: making querier changes for audit schema (#13438)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (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
- Audit query_range reads the new `signoz_audit` schema: `body`,
`scope`, `attributes` and `resource` are JSON columns, and the old typed
maps are gone.
- Audit skips the attribute and resource key tables, which the new
schema no longer has. Keys resolve from the intrinsic fields plus a
fixed list of the resource keys the audit writer emits, so bare resource
keys (e.g. `signoz.resource.kind`) work without the `resource.` prefix
until keys move to `signoz_metadata`.
- Raw audit rows return `resource` with flat dotted keys
(`resource.attributes["service.name"]`) instead of nesting at every dot.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
closes https://github.com/SigNoz/keystone-pod/issues/201
2026-11-12 11:02:10 +00:00
Aditya Singh
0b48326b10 feat(api-monitoring): move the endpoint filters and group-by off v3 autocomplete (#13160)
#### Description

- The All Endpoints and Endpoint Stats pages use `QuerySearch` for the
filter and `GroupByFilter` for the group-by, the query builder's own
components. This removes the last v3 autocomplete calls in API
monitoring.
- Each drawer tab keeps its filter in `QuerySearchV2Provider`, with its
own URL param (`apiMonitoringAllEndpointsExpression`,
`apiMonitoringEndpointStatsExpression`), the same way infra monitoring's
tabs do. The payload builders and the row-click drill-down work on the
expression directly, so the items conversion is gone. The filter applies
on Ctrl+Enter or when focus leaves the box.
- The group-by page no longer fetches keys or caches them to resolve
picks. `GroupByFilter` returns full keys, so about 80 lines of page
logic are gone.
- A Run button sits next to the filter on both pages. It applies the
filter the same way leaving the box does, and shows a spinner while that
tab's queries load.
- The filter shows the drawer's locked scope as a chip in front of the
text, like infra monitoring: `http_host = '<domain>' +1`, plus
`http_url` on Endpoint Stats. The chip is a new opt-in `scopeClauses`
prop on `QuerySearch`.
- Endpoint Stats and Top 10 Errors pick the endpoint from a searchable
select above the filter, not from text in the expression. The endpoint
is added to the request filter when the request is built.
- Removed the dead v4 fallback on the stats page and an unused
zero-state payload builder. Both have had no effect since November 2025.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/143
Closes https://github.com/SigNoz/events-pod/issues/144

#### Screenshots

sbshot:
https://claude.ai/artifact/YLourNcAvCqsmVbtnGhH2L?sk=bVmSGZMwZ7VSmSukZLyNAw



https://github.com/user-attachments/assets/bf6e9930-4d14-40f7-ba66-fe36045eb4ef

url now carries filter exp


https://github.com/user-attachments/assets/3310000a-d03a-4a54-9119-dde748aa6dc7





#### Additional Information

- The filter applies on Ctrl+Enter or blur, not on every completed chip.
Free-form expressions (OR, NOT, functions) now work.
- Group-by keys store the v5 field context (`attribute` / `resource`)
where v3 wrote `tag`. Keys restored from the URL carry the name only; v5
resolves them by name.
- The group-by has no clear-all cross and no `+N` collapse because the
shared component has neither. Every selected key stays visible with its
own ×.
- The filter row on the stats page now grows with a multi-line
expression instead of overflowing.
- Links saved with the old items-shaped filters in `apiMonitoringParams`
still open. The list page moves those filters into the tab params once
and drops the old keys.
- The drawer starts from the list's filter when you open it by clicking
a domain. A drawer opened from a link uses only the filters in the link.
A cleared tab filter stays cleared across tab switches and reloads.
- The user's filter is joined to the page's base filter as `base AND
(filter)`, so an `OR` in the user's filter stays scoped to the domain
and endpoint.
- Clicking a grouped row whose group value is empty now drills down with
`key NOT EXISTS`. It used to send `key = null`, which matched nothing.
- A hand-typed `http_url = …` in the All Endpoints filter now carries
into Endpoint Stats as part of the user's filter. Before, opening a row
dropped it, so "All endpoints" showed every endpoint. Now the clause
still narrows the data and the endpoint list. Other `http_url` operators
behave as before.
- Fixed a URL bug: writes to `apiMonitoringParams` now build on the live
URL. Before, several writes in one render kept only the last one. On
main, picking a time range in the drawer dropped `selectedInterval` from
the URL.
- `QuerySearchV2Provider` now follows URL changes after mount. Before,
it read its URL param once and wrote its stored filter back over later
changes, so Back and Forward did not restore a filter. Using it here
exposed this. Infra monitoring's Logs, Traces, Events and Overview tabs
share the provider and get the same fix.
- The `QuerySearch` changes are opt-in props (`showRunButton`,
`isLoadingQueries`, `scopeClauses`). Its other callers, infra monitoring
included, render as before.
- Storybook: the existing drawer stories (Domain Endpoints, Endpoint
Stats, Service Filtered, Port Domain, Failing Domain, Top Errors)
already show the picker, the Run button and the chip. No story shows the
Run button busy.
- Part of https://github.com/SigNoz/events-pod/issues/156.
2026-11-12 10:53:04 +00:00
ashu8912
95ee58eba5 fix(alerts): clean up the alert rules list styling (#13530)
#### Description

Visual cleanup of the alert rules list, to match the dashboards list:
- Removed the gap between the tab bar and the list.
- Rows use one black background instead of alternating colors.
- Rule labels are neutral grey instead of red, so only firing state and
severity draw attention.
- Column header dividers show only on hover.

#### Additional Information

- The existing `Pages/Alerts/Rules` stories already render these states;
no story change needed.
2026-11-12 07:56:31 +00:00
Jugal Kishore
9100d9aea6 feat(onboarding): add Pixeltable and Claude Cowork datasources (#13512)
#### Description

- Add Pixeltable and Claude Cowork cards to the onboarding data sources.
Pixeltable opens the trace-first guide. Claude Cowork opens the
log-event guide.
- Add a Pixeltable logo (`Logos/pixeltable.svg`). Pixeltable has no
vector logo, so I redrew its dot-matrix "P" on a 24x24 grid.
- Add the `python celery` search keyword to the Celery card. Searching
`python celery` returned no results before.
- Fix the built-in and AWS integration card links. The old
`/integrations?integration=...` links only opened the integrations list.
They now use `/integrations/builtin-<id>` and
`/integrations/aws?service=<id>`.

#### Issues closed by this PR

Closes SigNoz/signoz.io#4487
Closes SigNoz/signoz.io#4482
Closes SigNoz/signoz.io#3279
2026-11-12 07:45:54 +00:00
118 changed files with 5909 additions and 3211 deletions

View File

@@ -11,7 +11,7 @@ Page stories are written with the `signoz-page-story` skill ([`.claude/skills/si
- **New page, new story. New state, new control or variant.** The skill decides which: a control on a response, a variant worth linking to, or a `play` story for a state only a click reaches.
- **A shared component is covered through its pages.** A change under `components/` or `container/` with no story of its own updates the stories of the pages that render it.
- **Plays live with the mocks.** A `play` is a named function exported from `stories/__story_mockdata__/<page>.play.ts`, which is collapsed for review like the rest of the AI-owned mocks. The story file only names it: `play: openEditDrawer`. A story file that still holds play bodies moves them there the first time you edit it.
- **A play passes for every control value.** Branch each check on the args it depends on (panel kind, data state, row count, hidden header). A value the page supports must never fail the play.
- **A play passes for every control value.** Branch each check on the args it depends on (panel kind, data state, access, check state and revoked permissions, license, held-open tooltips, a count at 0, a view or tab that drops the target, a write state). A value the page supports must never fail the play. The `signoz-page-story` skill's **Plays** section lists the cases plays have missed.
- **Mocks answer every request a reachable state makes.** Selecting a row, opening a view or switching a tab that fetches more hits a handler, never the 501 catch-all.
- **Already covered, say so.** When the existing story and its controls already reach the changed state, no story edit is needed; state that in the PR description.
- **Opt out only when there is no visual diff.** Skip stories only when the change renders nothing differently: types, api clients, hooks with no UI branch, tests. Deleting a page deletes its stories.

View File

@@ -106,6 +106,27 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
**Delete a story once its difference disappears.** Say a story checks that a button reads "View" rather than "Edit" when a permission is missing. If the label later becomes the same either way, that story passes no matter which permissions are held, including all of them, and it is testing nothing. When a behaviour stops depending on a permission, delete the story or rewrite it to lock in the new behaviour, and say which one you did in the PR.
## Plays
CI runs each play once, with its story's own args. Anyone can flip a control in the panel, and nothing runs the play against that value, so a play that breaks on it goes unnoticed. Write each play so it cannot.
Before writing the clicks, list what has to be on screen for the play's target to exist, and return early when the args take it away. These are the cases plays have missed:
- The controls every story has. `State` on loading or error renders no rows and no filter suggestions. `Access`, `Permissions` and `Revoked` remove rows, row actions and gated buttons, and the `editor` and `viewer` presets grant telemetry reads only, so a page gated on its own resource shows them nothing. `Check state` on loading disables every gated control, and on error an `AuthZButton` stays enabled while an `AuthZGuardContent` set to fail closed hides what it wraps. `License` and `Banner` decide the plan, so a cloud-only form is gone on the others. `Hold tooltips open` shows a label a second time, inside its tooltip.
- A count at 0. The page can swap its whole list for an empty state, and the header, filters and column picker go with it.
- A view, filter or invalid query that leaves no rows, even when the count is above 0.
- A tab without the widget. A filter box that one tab has is not on the next.
- A second list beside the main one, such as saved views. At 0, loading or failed, its actions are gone.
- Text that depends on an arg. A disabled rule offers Enable, not Disable. A write that fails raises a different toast from one that lands, and one left loading raises none. Pick the expected text from the args.
- Rows that move. A view, a sort or pinning changes which row is locked, pinned or legacy, so find the row by what it is, not by its index.
Make those checks with the page's own code instead of restating a condition. Build the rows with the builder the handler uses and the view's own filter, and check a permission with the page's own check through `accessFor(...).allows(...)`, or the role against the constant the page gates on, such as `componentPermission.action`. Export the checks from the mocks module, next to the handlers, so a handler change moves them too. The alert rules page has `listedRules`, `hasRowMenu`, `showsNoRulesPage` and `listsSavedViews`; the dashboards list has `listedDashboards`, `canList`, `canCreate` and `canUpdate`.
Two habits cover the rest:
- Use `findAllBy*` and take the first whenever the text can appear twice, such as in a tooltip or a popover title.
- Wrap `toBeVisible` in `waitFor` for anything that fades in. A tooltip or toast found the moment it mounts is not visible yet.
## 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.
@@ -116,7 +137,7 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
- **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**: every story file for a page lives under `src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`, payload builders in `stories/__story_mockdata__/<page>.ts`, play functions in `stories/__story_mockdata__/<page>.play.ts`. Nothing page-specific in `src/storybook/controls/`. A page that is a tab strip over several routes gets one story file per tab, in its own folder under the module page (`LogsModulePage/Pipelines/stories/Pipelines.stories.tsx`), each with its own mocks and `__story_mockdata__/`; the builders more than one tab needs stay in the module page's own `stories/__story_mockdata__/` (`AlertList/stories/__story_mockdata__/alerts.ts`), which a tab reaches as `../../stories/__story_mockdata__/alerts`. Every one of them renders the module page, so the tab strip is there, and the `route` its mocks return decides which tab is open. A page's permission stories go one level further down, in `stories/authz/<Page>.authz.stories.tsx`, on the page's own mocks: see **Permission stories**.
- **A state only a click reaches is a story with a `play` function**, not a control: a drawer, a modal, an edit mode the page holds in component state. Drive it with `userEvent` and the queries from `storybook/test`, take the first of a repeated row action, and wait on the state's own text. The page fetches before it renders a row, so the finder needs a timeout past the 1s default. The play is a named function exported from `__story_mockdata__/<page>.play.ts` and the story file only names it (`play: openEditDrawer`), so the reviewer reads states, not clicks; a story file that still holds play bodies moves them there the first time you edit it. It branches every check on the args it depends on: a control value the page supports never fails it. A state the app drops again on its own, such as one keyed on an array identity that a refetch replaces, does not get a story: it would not survive being looked at. A _sequence_ of such states, a wizard's steps or a questionnaire's pages, is still a control: declare the steps in the mocks module and walk them from a `play` on the meta that destructures `mount`, which is what makes Storybook replay it on an arg change. See [references/controls.md](references/controls.md).
- **A state only a click reaches is a story with a `play` function**, not a control: a drawer, a modal, an edit mode the page holds in component state. Drive it with `userEvent` and the queries from `storybook/test`, take the first of a repeated row action, and wait on the state's own text. The page fetches before it renders a row, so the finder needs a timeout past the 1s default. The play is a named function exported from `__story_mockdata__/<page>.play.ts` and the story file only names it (`play: openEditDrawer`), so the reviewer reads states, not clicks; a story file that still holds play bodies moves them there the first time you edit it. It branches every check on the args it depends on: a control value the page supports never fails it. See **Plays**. A state the app drops again on its own, such as one keyed on an array identity that a refetch replaces, does not get a story: it would not survive being looked at. A _sequence_ of such states, a wizard's steps or a questionnaire's pages, is still a control: declare the steps in the mocks module and walk them from a `play` on the meta that destructures `mount`, which is what makes Storybook replay it on an arg change. See [references/controls.md](references/controls.md).
- **The mocks are AI-owned and say so.** `<Page>.stories.mocks.tsx` and every file under a `__story_mockdata__/` open with this banner, above the imports:
```ts
@@ -143,7 +164,7 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
- [ ] a page tagged `authz` has its `Authz` folder: one story per permission it reads, each reached by `revoked`, none of them a role preset, and each one checked by the `disabled` state of what the permission gates
- [ ] the mocks module and every `__story_mockdata__` file carry the AI-owned banner
- [ ] every control flipped once, its effect seen on screen
- [ ] every play passes with each control value its story exposes, and the story file holds no play body
- [ ] every play passes with each control value its story exposes, checked against the cases in **Plays**, and the story file holds no play body
- [ ] every request a reachable state makes (a selected row, an opened view, a switched tab) has a handler
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled request, no React warning
- [ ] no navigation overlay on mount

View File

@@ -18,25 +18,25 @@ env:
jobs:
prepare:
runs-on: ubuntu-latest
if: ${{ contains(github.event.label.name, 'staging:') || github.event.ref == 'refs/heads/main' }}
if: ${{ github.event_name == 'push' || startsWith(github.event.label.name, 'staging:') }}
outputs:
version: ${{ steps.build-info.outputs.version }}
hash: ${{ steps.build-info.outputs.hash }}
time: ${{ steps.build-info.outputs.time }}
branch: ${{ steps.build-info.outputs.branch }}
deployment: ${{ steps.build-info.outputs.deployment }}
path: ${{ steps.build-info.outputs.path }}
steps:
- name: self-checkout
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: token
name: github-token-gen
uses: actions/create-github-app-token@v1
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
app-id: ${{ secrets.PRIMUS_APP_ID }}
private-key: ${{ secrets.PRIMUS_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: primus-checkout
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: signoz/primus
ref: main
@@ -44,22 +44,29 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: build-info
id: build-info
env:
LABEL: ${{ github.event.label.name }}
run: |
echo "version=$($MAKE info-version)" >> $GITHUB_OUTPUT
echo "hash=$($MAKE info-commit-short)" >> $GITHUB_OUTPUT
echo "time=$($MAKE info-timestamp)" >> $GITHUB_OUTPUT
echo "branch=$($MAKE info-branch)" >> $GITHUB_OUTPUT
staging_label="${{ github.event.label.name }}"
if [[ "${staging_label}" == "staging:"* ]]; then
deployment=${staging_label#"staging:"}
elif [[ "${{ github.event.ref }}" == "refs/heads/main" ]]; then
deployment="staging"
# merges to main release the base every overlay inherits, a staging:<deployment> label releases that overlay
if [[ "${GITHUB_EVENT_NAME}" == push ]]; then
path="overlays/staging/*/signoz/base"
else
echo "error: not able to determine deployment - please verify the PR label or the branch"
# the deployment is spliced into the release path glob and the JSON payload
deployment="${LABEL#staging:}"
if [[ ! "${deployment}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then
echo "::error::label ${LABEL} must be staging:<deployment>, where the deployment is lowercase letters, digits and dashes"
exit 1
fi
path="overlays/staging/*/signoz/overlays/${deployment}"
fi
echo "deployment=${deployment}" >> $GITHUB_OUTPUT
{
echo "version=$($MAKE info-version)"
echo "hash=$($MAKE info-commit-short)"
echo "time=$($MAKE info-timestamp)"
echo "branch=$($MAKE info-branch)"
echo "path=${path}"
} >> "${GITHUB_OUTPUT}"
- name: create-dotenv
run: |
mkdir -p frontend
@@ -71,7 +78,7 @@ jobs:
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
- name: cache-dotenv
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: frontend/.env
key: staging-dotenv-${{ github.sha }}
@@ -115,7 +122,6 @@ jobs:
DOCKER_MANIFEST: true
DOCKER_PROVIDERS: gcp
staging:
if: ${{ contains(github.event.label.name, 'staging:') || github.event.ref == 'refs/heads/main' }}
uses: signoz/primus.workflows/.github/workflows/github-trigger.yaml@main
secrets: inherit
needs: [prepare, go-build]
@@ -123,6 +129,6 @@ jobs:
PRIMUS_REF: main
GITHUB_ENVIRONMENT: staging
GITHUB_SILENT: true
GITHUB_REPOSITORY_NAME: charts-saas-v3-staging
GITHUB_EVENT_NAME: releaser
GITHUB_EVENT_PAYLOAD: '{"deployment": "${{ needs.prepare.outputs.deployment }}", "signoz_version": "${{ needs.prepare.outputs.version }}"}'
GITHUB_REPOSITORY_NAME: signoz.staging.cl.gcp.deployments
GITHUB_EVENT_NAME: release
GITHUB_EVENT_PAYLOAD: '{"path": "${{ needs.prepare.outputs.path }}", "image": "us-central1-docker.pkg.dev/signoz-registry/signoz/signoz", "tag": "${{ needs.prepare.outputs.version }}"}'

View File

@@ -25613,7 +25613,6 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -26280,9 +26279,23 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Create alert rule
tags:
- rules
@@ -26327,9 +26340,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:delete
- tokenizer:
- EDITOR
- rule:delete
summary: Delete alert rule
tags:
- rules
@@ -26385,9 +26398,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- tokenizer:
- VIEWER
- rule:read
summary: Get alert rule by ID
tags:
- rules
@@ -26569,7 +26582,6 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -27242,9 +27254,27 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Patch alert rule
tags:
- rules
@@ -27426,7 +27456,6 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -28087,9 +28116,27 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Update alert rule
tags:
- rules
@@ -28156,9 +28203,25 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:read
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:read
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Clone alert rule
tags:
- rules
@@ -29045,7 +29108,6 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -29712,9 +29774,23 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Test alert rule
tags:
- rules
@@ -31809,9 +31885,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: List alert rules (v3)
tags:
- rules

View File

@@ -11,6 +11,8 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
// generated API Instance
const generatedAPIAxiosInstance = axios.create({
baseURL: ENVIRONMENT.baseURL,
// The backend binds repeated keys (`states=a&states=b`); axios' default sends `states[]=`.
paramsSerializer: { indexes: null },
});
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect x="1.25" y="1.25" width="21.5" height="21.5" rx="3.5" fill="none" stroke="#7A8291" stroke-width="1.5"/><g fill="#7A8291"><circle cx="7" cy="4.5" r="1.05"/><circle cx="9.5" cy="4.5" r="1.05"/><circle cx="12" cy="4.5" r="1.05"/><circle cx="14.5" cy="4.5" r="1.05"/><circle cx="7" cy="7" r="1.05"/><circle cx="17" cy="7" r="1.05"/><circle cx="7" cy="9.5" r="1.05"/><circle cx="17" cy="9.5" r="1.05"/><circle cx="7" cy="12" r="1.05"/><circle cx="9.5" cy="12" r="1.05"/><circle cx="12" cy="12" r="1.05"/><circle cx="14.5" cy="12" r="1.05"/><circle cx="7" cy="14.5" r="1.05"/><circle cx="7" cy="17" r="1.05"/><circle cx="7" cy="19.5" r="1.05"/></g></svg>

After

Width:  |  Height:  |  Size: 726 B

View File

@@ -48,22 +48,59 @@ export function QuerySearchV2Provider({
}, [initialExpression, store]);
const isInitialized = useRef(false);
useEffect(() => {
if (!isInitialized.current && urlExpression) {
const cleanedExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
store.getState().initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [urlExpression, initialExpression, store]);
const lastCommittedRef = useRef(committedExpression);
useEffect(() => {
if (isInitialized.current || !urlExpression) {
setUrlExpression(committedExpression || null);
return;
}
}, [committedExpression, setUrlExpression, urlExpression]);
isInitialized.current = true;
store
.getState()
.initializeFromUrl(
getUserExpressionFromCombined(initialExpression, urlExpression),
);
}, [urlExpression, initialExpression, store]);
// Back and Forward. Reads the browser's URL: after a write, nuqs can briefly
// report the value it replaced.
useEffect(() => {
const onPopState = (): void => {
isInitialized.current = true;
const urlValue = new URLSearchParams(window.location.search).get(
queryParamKey,
);
const expression = urlValue
? getUserExpressionFromCombined(initialExpression, urlValue)
: '';
// Another param changed; keep any text typed but not run.
if (expression === store.getState().committedExpression) {
return;
}
// Already in the URL, so the write below must not run for it.
lastCommittedRef.current = expression;
if (expression) {
store.getState().initializeFromUrl(expression);
} else {
store.getState().resetExpression();
}
};
window.addEventListener('popstate', onPopState);
return (): void => window.removeEventListener('popstate', onPopState);
}, [queryParamKey, initialExpression, store]);
// Write only when the committed filter changes, never in reply to a URL change.
useEffect(() => {
if (committedExpression === lastCommittedRef.current) {
return;
}
lastCommittedRef.current = committedExpression;
isInitialized.current = true;
const next = committedExpression || null;
if (next === urlExpression) {
return;
}
void setUrlExpression(next);
}, [committedExpression, urlExpression, setUrlExpression]);
useEffect(() => {
return (): void => {

View File

@@ -1,9 +1,10 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { act, renderHook } from 'tests/test-utils';
import {
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
@@ -34,10 +35,22 @@ function createWrapper(
};
}
// Back or Forward. nuqs has not caught up yet, so its value is still the old one.
function goTo(expression: string | null): void {
act(() => {
const search = expression
? `?${new URLSearchParams({ testExpression: expression })}`
: '';
window.history.pushState(null, '', `/${search}`);
window.dispatchEvent(new PopStateEvent('popstate'));
});
}
function useTestHooks(): {
expression: string;
userExpression: string;
initialExpression: string;
inputExpression: string;
querySearchInitialExpressionProp: string | undefined;
onChange: (expr: string) => void;
onRun: (expr: string) => void;
@@ -46,6 +59,7 @@ function useTestHooks(): {
expression: useExpression(),
userExpression: useUserExpression(),
initialExpression: useInitialExpression(),
inputExpression: useInputExpression(),
querySearchInitialExpressionProp: useQuerySearchInitialExpressionProp(),
onChange: useQuerySearchOnChange(),
onRun: useQuerySearchOnRun(),
@@ -56,6 +70,7 @@ describe('QuerySearchExpressionProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUrlValue = null;
window.history.replaceState(null, '', '/');
});
it('should provide initial context values', () => {
@@ -110,6 +125,96 @@ describe('QuerySearchExpressionProvider', () => {
expect(result.current.expression).toBe('status = 500');
});
it('follows Back and Forward from the browser URL', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
goTo('status = 404');
expect(result.current.userExpression).toBe('status = 404');
expect(mockSetQueryState).not.toHaveBeenCalled();
});
it('clears the filter on Back to a URL without the param', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
goTo(null);
expect(result.current.userExpression).toBe('');
expect(mockSetQueryState).not.toHaveBeenCalled();
});
it('keeps text typed but not run when Back changes another param', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onChange('status = 404');
});
goTo('status = 500');
expect(result.current.inputExpression).toBe('status = 404');
});
it('writes a run filter to the URL once and ignores it coming back', () => {
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onRun('status = 500');
});
expect(mockSetQueryState).toHaveBeenCalledTimes(1);
expect(mockSetQueryState).toHaveBeenLastCalledWith('status = 500');
mockUrlValue = 'status = 500';
rerender();
expect(result.current.userExpression).toBe('status = 500');
expect(mockSetQueryState).toHaveBeenCalledTimes(1);
});
it('keeps a run filter when the URL briefly reports the value it replaced', () => {
mockUrlValue = 'status = 500';
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onRun('status = 404');
});
mockUrlValue = 'status = 404';
rerender();
mockUrlValue = 'status = 500';
rerender();
expect(result.current.userExpression).toBe('status = 404');
});
it('keeps a newer run when a late URL value arrives after an empty mount', () => {
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onRun('status = 500');
});
act(() => {
result.current.onRun('status = 404');
});
mockUrlValue = 'status = 500';
rerender();
expect(result.current.userExpression).toBe('status = 404');
});
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useExpression());

View File

@@ -15,11 +15,41 @@ $max-recents-shown: 5;
Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', sans-serif;
.query-where-clause-row {
display: flex;
align-items: flex-start;
gap: 8px;
// single-line editor: 36px .cm-line + 1px .cm-content border on each side
--button-height: 38px;
.query-where-clause-editor-container {
flex: 1;
min-width: 0;
}
}
.query-where-clause-editor-container {
position: relative;
display: flex;
flex-direction: row;
// The field draws one outline around the scope chip and the editor, so
// the editor inside draws none
&.hasScopeChip {
align-items: center;
gap: var(--spacing-3);
padding-left: var(--spacing-3);
border: 1px solid var(--l2-border);
border-radius: 2px;
background: var(--l1-background);
--query-builder-v2-border-color: transparent;
--query-builder-v2-background-color: transparent;
.query-where-clause-editor {
min-width: 0;
}
}
.query-search-initial-scope-label {
position: absolute;
left: 8px;

View File

@@ -1,6 +1,12 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CircleCheck, Info, TriangleAlert, Filter } from '@signozhq/icons';
import {
CircleCheck,
Info,
TriangleAlert,
Filter,
Play,
} from '@signozhq/icons';
import {
autocompletion,
closeCompletion,
@@ -16,6 +22,7 @@ import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Button as SignozButton } from '@signozhq/ui/button';
import cx from 'classnames';
import {
negationQueryOperatorSuggestions,
@@ -59,6 +66,7 @@ import {
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import { ScopeChip } from './ScopeChip/ScopeChip';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -105,9 +113,14 @@ interface QuerySearchProps {
signalSource?: string;
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void;
showRunButton?: boolean;
/** Shows the Run button busy while the page's queries fetch. */
isLoadingQueries?: boolean;
showFilterSuggestionsWithoutMetric?: boolean;
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
initialExpression?: string;
/** Shows `initialExpression` as a locked chip in the field instead of the scope icon. */
showScopeChip?: boolean;
/** When set, replaces the generic value-suggestion API with a custom fetcher. */
valueSuggestionsOverride?: (
key: string,
@@ -125,10 +138,13 @@ function QuerySearch({
queryData,
dataSource,
onRun,
showRunButton,
isLoadingQueries,
signalSource,
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
initialExpression,
showScopeChip: showScopeChipProp,
metricNamespace,
valueSuggestionsOverride,
}: QuerySearchProps): JSX.Element {
@@ -148,6 +164,7 @@ function QuerySearch({
const editorRef = useRef<EditorView | null>(null);
const isScopedFilter = initialExpression !== undefined;
const showScopeChip = !!showScopeChipProp && !!initialExpression;
const validateExpressionForEditor = useCallback(
(editorDoc: string): void => {
@@ -173,6 +190,18 @@ function QuerySearch({
[],
);
const handleRun = useCallback((): void => {
if (!onRun) {
return;
}
const user = getCurrentExpression();
onRun(
isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', user)
: user,
);
}, [onRun, getCurrentExpression, isScopedFilter, initialExpression]);
const updateEditorValue = useCallback(
(value: string, options: { skipOnChange?: boolean } = {}): void => {
const view = editorRef.current;
@@ -1557,169 +1586,182 @@ function QuerySearch({
</div>
)}
<div className="query-where-clause-editor-container">
{isScopedFilter ? (
<Tooltip title={initialExpression || ''} placement="left">
<div className="query-search-initial-scope-label">
<Filter
<div className="query-where-clause-row">
<div
className={cx('query-where-clause-editor-container', {
hasScopeChip: showScopeChip,
})}
>
{showScopeChip && <ScopeChip expression={initialExpression ?? ''} />}
{isScopedFilter && !showScopeChip ? (
<Tooltip title={initialExpression || ''} placement="left">
<div className="query-search-initial-scope-label">
<Filter
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</div>
</Tooltip>
) : null}
<Tooltip
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
placement="left"
>
<a
href="https://signoz.io/docs/userguide/search-syntax/"
target="_blank"
rel="noopener noreferrer"
style={{
position: 'absolute',
top: 8,
right: validation.isValid === false && getCurrentExpression() ? 40 : 8, // Move left when error shown
cursor: 'help',
zIndex: 10,
transition: 'right 0.2s ease',
display: 'inline-flex',
alignItems: 'center',
color: '#8c8c8c',
}}
onClick={(e): void => e.stopPropagation()}
>
<Info
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</div>
</a>
</Tooltip>
) : null}
<Tooltip
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
placement="left"
>
<a
href="https://signoz.io/docs/userguide/search-syntax/"
target="_blank"
rel="noopener noreferrer"
style={{
position: 'absolute',
top: 8,
right: validation.isValid === false && getCurrentExpression() ? 40 : 8, // Move left when error shown
cursor: 'help',
zIndex: 10,
transition: 'right 0.2s ease',
display: 'inline-flex',
alignItems: 'center',
color: '#8c8c8c',
}}
onClick={(e): void => e.stopPropagation()}
>
<Info
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</a>
</Tooltip>
<CodeMirror
theme={isDarkMode ? copilot : githubLight}
onChange={handleChange}
onUpdate={handleUpdate}
onCreateEditor={handleEditorCreate}
data-testid="query-where-clause-editor"
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0,
hasInitialExpression: isScopedFilter,
})}
extensions={[
autocompletion({
override: [combinedSuggestions],
defaultKeymap: true,
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 50,
addToOptions: [{ render: renderRecentDeleteButton, position: 100 }],
}),
javascript({ jsx: false, typescript: false }),
EditorView.lineWrapping,
stopEventsExtension,
Prec.highest(
keymap.of([
...completionKeymap,
{
key: 'Escape',
run: closeCompletion,
},
{
key: 'Enter',
preventDefault: true,
// Prevent default behavior of Enter to add new line
// and instead run a custom action
run: (): boolean => true,
},
{
key: 'Mod-Enter',
preventDefault: true,
// Prevent default behavior of Mod-Enter to add new line
// and instead run a custom action
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
const user = getCurrentExpression();
onRun(
isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', user)
: user,
);
}
return true;
},
},
{
key: 'Shift-Enter',
preventDefault: true,
// Prevent default behavior of Shift-Enter to add new line
run: (): boolean => true,
},
]),
),
]}
placeholder={placeholder}
basicSetup={{
lineNumbers: false,
}}
onFocus={(): void => {
setIsFocused(true);
}}
onBlur={handleBlur}
/>
{getCurrentExpression() && validation.isValid === false && !isFocused && (
<div
className={cx('query-status-container', {
<CodeMirror
theme={isDarkMode ? copilot : githubLight}
onChange={handleChange}
onUpdate={handleUpdate}
onCreateEditor={handleEditorCreate}
data-testid="query-where-clause-editor"
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0,
hasInitialExpression: isScopedFilter && !showScopeChip,
})}
data-testid="query-validation-status"
>
<Popover
placement="bottomRight"
showArrow={false}
content={
<div className="query-status-content">
<div className="query-status-content-header">
<div className="query-validation">
<div className="query-validation-errors">
{validation.errors.map((error) => (
<div key={error.message} className="query-validation-error">
<div className="query-validation-error">
{error.line}:{error.column} - {error.message}
extensions={[
autocompletion({
override: [combinedSuggestions],
defaultKeymap: true,
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 50,
addToOptions: [{ render: renderRecentDeleteButton, position: 100 }],
}),
javascript({ jsx: false, typescript: false }),
EditorView.lineWrapping,
stopEventsExtension,
Prec.highest(
keymap.of([
...completionKeymap,
{
key: 'Escape',
run: closeCompletion,
},
{
key: 'Enter',
preventDefault: true,
// Prevent default behavior of Enter to add new line
// and instead run a custom action
run: (): boolean => true,
},
{
key: 'Mod-Enter',
preventDefault: true,
// Prevent default behavior of Mod-Enter to add new line
// and instead run a custom action
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
handleRun();
return true;
},
},
{
key: 'Shift-Enter',
preventDefault: true,
// Prevent default behavior of Shift-Enter to add new line
run: (): boolean => true,
},
]),
),
]}
placeholder={placeholder}
basicSetup={{
lineNumbers: false,
}}
onFocus={(): void => {
setIsFocused(true);
}}
onBlur={handleBlur}
/>
{getCurrentExpression() && validation.isValid === false && !isFocused && (
<div
className={cx('query-status-container', {
hasErrors: validation.errors.length > 0,
})}
data-testid="query-validation-status"
>
<Popover
placement="bottomRight"
showArrow={false}
content={
<div className="query-status-content">
<div className="query-status-content-header">
<div className="query-validation">
<div className="query-validation-errors">
{validation.errors.map((error) => (
<div key={error.message} className="query-validation-error">
<div className="query-validation-error">
{error.line}:{error.column} - {error.message}
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
</div>
</div>
}
overlayClassName="query-status-popover"
>
{validation.isValid ? (
<Button
type="text"
icon={<CircleCheck size="md" />}
className="periscope-btn ghost"
/>
) : (
<Button
type="text"
icon={<TriangleAlert size={14} color={Color.BG_CHERRY_500} />}
className="periscope-btn ghost"
/>
)}
</Popover>
</div>
}
overlayClassName="query-status-popover"
>
{validation.isValid ? (
<Button
type="text"
icon={<CircleCheck size="md" />}
className="periscope-btn ghost"
/>
) : (
<Button
type="text"
icon={<TriangleAlert size={14} color={Color.BG_CHERRY_500} />}
className="periscope-btn ghost"
/>
)}
</Popover>
</div>
)}
</div>
{showRunButton && (
<SignozButton
variant="solid"
color="secondary"
size="md"
prefix={<Play size={14} />}
loading={isLoadingQueries}
onClick={handleRun}
testId="query-search-run-button"
>
Run
</SignozButton>
)}
</div>
@@ -1813,12 +1855,15 @@ function QuerySearch({
QuerySearch.defaultProps = {
onRun: undefined,
showRunButton: false,
isLoadingQueries: false,
signalSource: '',
hardcodedAttributeKeys: undefined,
placeholder:
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
showFilterSuggestionsWithoutMetric: false,
initialExpression: undefined,
showScopeChip: false,
};
export default QuerySearch;

View File

@@ -0,0 +1,57 @@
.scopeChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
flex: 0 1 auto;
min-width: 0;
max-width: 420px;
padding: var(--spacing-1) var(--spacing-3);
border-radius: 2px;
background: var(--l3-background);
color: var(--l2-foreground);
font-size: 12px;
line-height: 20px;
cursor: default;
}
.scopeIcon {
flex: none;
}
.scopeMore {
flex: none;
color: var(--l3-foreground);
}
// The editor beside it is CodeMirror on the copilot theme in dark and
// githubLight in light; the chip borrows those token colours so one filter
// does not read as two
.scopeKey {
color: #939da5;
}
.scopeOperator {
color: #939da5;
}
.scopeValue {
color: #5bec95;
}
:global(.lightMode) {
.scopeKey,
.scopeOperator {
color: #005cc5;
}
.scopeValue {
color: #032f62;
}
}
.scopeExpression {
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,41 @@
import { useMemo } from 'react';
import { Lock } from '@signozhq/icons';
import { Tooltip } from '@signozhq/ui/tooltip';
import { extractQueryPairs } from 'utils/queryContextUtils';
import styles from './ScopeChip.module.scss';
interface ScopeChipProps {
expression: string;
}
/**
* The locked half of the filter. It reads like the editor beside it: the last
* clause carries the relation, the ones scoping it are counted instead.
*/
export function ScopeChip({ expression }: ScopeChipProps): JSX.Element {
const clauses = useMemo(() => extractQueryPairs(expression), [expression]);
const relation = clauses[clauses.length - 1];
return (
<Tooltip title={expression} side="top">
<span className={styles.scopeChip} data-testid="query-search-scope-chip">
<Lock size={12} className={styles.scopeIcon} />
<span className={styles.scopeExpression}>
{relation ? (
<>
<span className={styles.scopeKey}>{relation.key}</span>
<span className={styles.scopeOperator}>{` ${relation.operator} `}</span>
<span className={styles.scopeValue}>{relation.value}</span>
</>
) : (
expression
)}
</span>
{clauses.length > 1 && (
<span className={styles.scopeMore}>{`+${clauses.length - 1}`}</span>
)}
</span>
</Tooltip>
);
}

View File

@@ -224,6 +224,134 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
await waitFor(() => expect(onRun).toHaveBeenCalled(), { timeout: 2000 });
});
it('does not render the run button by default', () => {
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
onRun={jest.fn()}
/>,
);
expect(
screen.queryByTestId('query-search-run-button'),
).not.toBeInTheDocument();
});
it('calls onRun with the editor text when the run button is clicked', async () => {
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={{
...initialQueriesMap.logs.builder.queryData[0],
filter: { expression: SAMPLE_STATUS_QUERY },
}}
dataSource={DataSource.LOGS}
onRun={onRun}
showRunButton
/>,
);
await waitFor(() =>
expect(document.querySelector(CM_EDITOR_SELECTOR)?.textContent).toBe(
SAMPLE_STATUS_QUERY,
),
);
await userEvent.click(screen.getByTestId('query-search-run-button'));
expect(onRun).toHaveBeenCalledWith(SAMPLE_STATUS_QUERY);
});
it('shows the locked scope as a chip in place of the scope icon', () => {
const scope = "kind_string = 'Client' AND http_host = 'api.example.com'";
const { container } = render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.traces.builder.queryData[0]}
dataSource={DataSource.TRACES}
initialExpression={scope}
showScopeChip
/>,
);
const chip = screen.getByTestId('query-search-scope-chip');
expect(chip).toHaveTextContent("http_host = 'api.example.com'");
expect(chip).toHaveTextContent('+1');
expect(chip).not.toHaveTextContent('kind_string');
expect(
container.querySelector('.query-search-initial-scope-label'),
).not.toBeInTheDocument();
});
it('keeps the scope icon unless the chip is asked for', () => {
const { container } = render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.traces.builder.queryData[0]}
dataSource={DataSource.TRACES}
initialExpression="kind_string = 'Client'"
/>,
);
expect(
screen.queryByTestId('query-search-scope-chip'),
).not.toBeInTheDocument();
expect(
container.querySelector('.query-search-initial-scope-label'),
).toBeInTheDocument();
});
it('shows the run button busy while the queries load', () => {
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
onRun={jest.fn()}
showRunButton
isLoadingQueries
/>,
);
expect(screen.getByTestId('query-search-run-button')).toHaveAttribute(
'aria-busy',
'true',
);
});
it('runs the locked scope combined with the editor text', async () => {
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
const scope = "kind_string = 'Client'";
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={{
...initialQueriesMap.traces.builder.queryData[0],
filter: { expression: SAMPLE_STATUS_QUERY },
}}
dataSource={DataSource.TRACES}
onRun={onRun}
initialExpression={scope}
showRunButton
/>,
);
await waitFor(() =>
expect(document.querySelector(CM_EDITOR_SELECTOR)?.textContent).toBe(
SAMPLE_STATUS_QUERY,
),
);
await userEvent.click(screen.getByTestId('query-search-run-button'));
expect(onRun).toHaveBeenCalledWith(`${scope} AND (${SAMPLE_STATUS_QUERY})`);
});
it('initializes CodeMirror with expression from queryData.filter.expression on mount', async () => {
const testExpression =
"http.status_code >= 500 AND service.name = 'frontend'";

View File

@@ -69,8 +69,6 @@ export const REACT_QUERY_KEY = {
GET_NESTED_ENDPOINTS_LIST: 'GET_NESTED_ENDPOINTS_LIST',
GET_ENDPOINT_METRICS_DATA: 'GET_ENDPOINT_METRICS_DATA',
GET_ENDPOINT_STATUS_CODE_DATA: 'GET_ENDPOINT_STATUS_CODE_DATA',
GET_ENDPOINT_RATE_OVER_TIME_DATA: 'GET_ENDPOINT_RATE_OVER_TIME_DATA',
GET_ENDPOINT_LATENCY_OVER_TIME_DATA: 'GET_ENDPOINT_LATENCY_OVER_TIME_DATA',
GET_ENDPOINT_DROPDOWN_DATA: 'GET_ENDPOINT_DROPDOWN_DATA',
GET_ENDPOINT_DEPENDENT_SERVICES_DATA: 'GET_ENDPOINT_DEPENDENT_SERVICES_DATA',
GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA:

View File

@@ -1,20 +1,24 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Select } from 'antd';
import { useCallback, useEffect, useMemo } from 'react';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap } from 'constants/queryBuilder';
import {
getAllEndpointsWidgetData,
getGroupByFiltersFromGroupByValues,
getGroupByExpressionFromRowData,
} from 'container/ApiMonitoring/utils';
import GridCard from 'container/WidgetCard/Card';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { isEqual } from 'lodash-es';
import { parseAsString, useQueryState } from 'nuqs';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { ENDPOINT_STATS_EXPRESSION_KEY } from '../../../filterParamKeys';
import { useApiMonitoringParams } from '../../../queryParams';
import { SPAN_ATTRIBUTES, VIEWS } from './constants';
import { useFilterExpression } from './hooks/useFilterExpression';
import { useWidgetQueries } from './hooks/useWidgetQueries';
function AllEndPoints({
domainName,
@@ -23,8 +27,6 @@ function AllEndPoints({
groupBy,
setGroupBy,
timeRange,
initialFilters,
setInitialFiltersEndPointStats,
}: {
domainName: string;
setSelectedEndPointName: (name: string) => void;
@@ -35,113 +37,50 @@ function AllEndPoints({
startTime: number;
endTime: number;
};
initialFilters: IBuilderQuery['filters'];
setInitialFiltersEndPointStats: (filters: IBuilderQuery['filters']) => void;
}): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const [groupBySearchValue, setGroupBySearchValue] = useState<string>('');
const [allAvailableGroupByOptions, setAllAvailableGroupByOptions] = useState<{
[key: string]: any;
}>({});
const { data: groupByFiltersData, isLoading: isLoadingGroupByFilters } =
useGetAggregateKeys({
dataSource: DataSource.TRACES,
aggregateAttribute: '',
aggregateOperator: 'noop',
searchText: groupBySearchValue,
tagType: '',
});
const [groupByOptions, setGroupByOptions] = useState<
{ value: string; label: string }[]
>([]);
const [, setEndPointStatsExpression] = useQueryState(
ENDPOINT_STATS_EXPRESSION_KEY,
parseAsString,
);
// --- FILTERS STATE SYNC ---
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
if (params.allEndpointsLocalFilters) {
return params.allEndpointsLocalFilters;
}
// Initialize filters based on the initial endPointName prop
const initialItems = [...(initialFilters?.items || [])];
return { op: 'AND', items: initialItems };
const {
expression: filterExpression,
initialExpression: scopeExpression,
queryData,
onDraftChange,
onRun,
onBlur: handleFilterBlur,
} = useFilterExpression({
groupBy,
});
// Sync params to local filters state on param change
useEffect(() => {
if (
params.allEndpointsLocalFilters &&
!isEqual(params.allEndpointsLocalFilters, filters)
) {
setFilters(params.allEndpointsLocalFilters);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.allEndpointsLocalFilters]);
// Handler for changes from the QueryBuilderSearchV2 component
const handleFilterChange = useCallback(
(newFilters: IBuilderQuery['filters']): void => {
setFilters(newFilters);
setParams({ allEndpointsLocalFilters: newFilters });
},
[setParams],
);
const currentQuery = initialQueriesMap[DataSource.TRACES];
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
filters, // Use the local filters state
},
],
},
}),
[filters, currentQuery],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
const allEndpointsWidgetData = useMemo(
() => getAllEndpointsWidgetData(groupBy, domainName, filters),
[groupBy, domainName, filters],
() => getAllEndpointsWidgetData(groupBy, domainName, filterExpression),
[groupBy, domainName, filterExpression],
);
const tableWidgets = useMemo(
() => [allEndpointsWidgetData],
[allEndpointsWidgetData],
);
const { isFetching: isFetchingTable, refetch: refetchTable } =
useWidgetQueries(tableWidgets);
const handleRun = useCallback(
(value: string): void => onRun(value, refetchTable),
[onRun, refetchTable],
);
// --- GROUP BY STATE SYNC (existing) ---
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
const newGroupBy = [];
for (let index = 0; index < value.length; index++) {
const element = value[index] as unknown as string;
// Check if the key exists in our cached options first
if (allAvailableGroupByOptions[element]) {
newGroupBy.push(allAvailableGroupByOptions[element]);
} else {
// If not found in cache, check the current filtered results
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(key) => key.key === element,
);
if (key) {
newGroupBy.push(key);
}
}
}
setGroupBy(newGroupBy);
setGroupBySearchValue('');
setGroupBy(value);
// Update params
setParams({ groupBy: newGroupBy.map((g) => g.key) });
setParams({ groupBy: value.map((g) => g.key) });
},
[groupByFiltersData, setGroupBy, allAvailableGroupByOptions, setParams],
[setGroupBy, setParams],
);
// Sync params to local groupBy state on param change
@@ -154,77 +93,31 @@ function AllEndPoints({
groupBy.map((g) => g.key),
)
) {
// Only update if different
const newGroupBy = params.groupBy
.map((key) => allAvailableGroupByOptions[key])
.filter(Boolean);
if (newGroupBy.length === params.groupBy.length) {
setGroupBy(newGroupBy);
}
}
}, [params.groupBy, allAvailableGroupByOptions, groupBy, setGroupBy]);
useEffect(() => {
if (groupByFiltersData?.payload) {
// Update dropdown options
setGroupByOptions(
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
setGroupBy(
params.groupBy.map((key) => chooseAutocompleteFromCustomValue([], key)),
);
// Cache all available options to preserve selected values using functional update
setAllAvailableGroupByOptions((prevOptions) => {
const newOptions = { ...prevOptions };
groupByFiltersData?.payload?.attributeKeys?.forEach((filter) => {
newOptions[filter.key] = filter;
});
return newOptions;
});
}
}, [groupByFiltersData]); // Only depends on groupByFiltersData now
// Cache existing selected options on component mount
useEffect(() => {
if (groupBy && groupBy.length > 0) {
setAllAvailableGroupByOptions((prevOptions) => {
const newOptions = { ...prevOptions };
groupBy.forEach((option) => {
newOptions[option.key] = option;
});
return newOptions;
});
}
}, [groupBy]);
}, [params.groupBy, groupBy, setGroupBy]);
const onRowClick = useCallback(
(props: any): void => {
setSelectedEndPointName(props[SPAN_ATTRIBUTES.HTTP_URL] as string);
setSelectedView(VIEWS.ENDPOINT_STATS);
const initialItems = [
...(filters?.items || []),
...(getGroupByFiltersFromGroupByValues(props, groupBy)?.items || []),
];
setInitialFiltersEndPointStats({
items: initialItems,
op: 'AND',
});
const statsExpression = combineInitialAndUserExpression(
getGroupByExpressionFromRowData(props),
filterExpression,
);
void setEndPointStatsExpression(statsExpression || null);
setParams({
selectedEndPointName: props[SPAN_ATTRIBUTES.HTTP_URL] as string,
selectedView: VIEWS.ENDPOINT_STATS,
endPointDetailsLocalFilters: {
items: initialItems,
op: 'AND',
},
});
},
[
filters,
setInitialFiltersEndPointStats,
filterExpression,
setEndPointStatsExpression,
setSelectedEndPointName,
setSelectedView,
groupBy,
setParams,
],
);
@@ -232,28 +125,28 @@ function AllEndPoints({
return (
<div className="all-endpoints-container">
<div className="all-endpoints-header">
<div className="filter-container">
<QueryBuilderSearchV2
query={query}
onChange={handleFilterChange}
<div className="filter-container" onBlur={handleFilterBlur}>
<QuerySearch
queryData={queryData}
onChange={onDraftChange}
onRun={handleRun}
showRunButton
isLoadingQueries={isFetchingTable}
initialExpression={scopeExpression}
showScopeChip
dataSource={DataSource.TRACES}
placeholder="Search for filters..."
/>
</div>
</div>
<div className="group-by-container">
<div className="group-by-label"> Group by </div>
<Select
className="group-by-select"
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}
allowClear
maxTagCount="responsive"
placeholder="Search for attribute"
options={groupByOptions}
<GroupByFilter
query={queryData}
onChange={handleGroupByChange}
onSearch={(value: string): void => setGroupBySearchValue(value)}
/>{' '}
disabled={false}
placeholder="Search for attribute"
/>
</div>
<div className="endpoints-table-container">
<GridCard

View File

@@ -195,16 +195,6 @@
line-height: 18px;
letter-spacing: -0.07px;
}
.group-by-select {
width: 100%;
.ant-select-selector {
font-size: 14px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
}
// Add border-bottom to table cells when pagination is not present
.ant-spin-container:not(:has(.ant-pagination)) .ant-table-cell {
@@ -365,16 +355,9 @@
.top-errors-dropdown-container {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
align-items: center;
.endpoint-details-filters-container-dropdown {
width: 100%;
}
.endpoint-details-filters-container-search {
flex: 1;
}
}
.endpoint-details-container {
@@ -416,29 +399,8 @@
.endpoint-details-filters-container {
display: flex;
flex-direction: row;
align-items: center;
border: 1px solid var(--l1-border);
height: 36px;
box-sizing: content-box;
.ant-select-selector {
border: none !important;
}
.endpoint-details-filters-container-dropdown {
width: 120px;
border-right: 1px solid var(--l1-border);
height: 36px;
display: flex;
align-items: center;
.ant-select-single {
height: 32px;
}
}
.endpoint-details-filters-container-search {
flex: 1;
}
flex-direction: column;
gap: 8px;
}
.ant-select-item,

View File

@@ -6,6 +6,7 @@ import { Button, Drawer } from 'antd';
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { QuerySearchV2Provider } from 'components/QueryBuilderV2';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
@@ -17,7 +18,12 @@ import { AppState } from 'store/reducers';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from '../../../filterParamKeys';
import { useApiMonitoringParams } from '../../../queryParams';
import { getDomainScopeExpression } from '../../../utils';
import AllEndPoints from './AllEndPoints';
import DomainMetrics from './components/DomainMetrics';
import { VIEW_TYPES, VIEWS } from './constants';
@@ -34,14 +40,14 @@ function DomainDetails({
selectedDomainIndex,
setSelectedDomainIndex,
domainListLength,
domainListFilters,
domainListFilterExpression,
}: {
domainData: any;
handleClose: () => void;
selectedDomainIndex: number;
setSelectedDomainIndex: (index: number) => void;
domainListLength: number;
domainListFilters: IBuilderQuery['filters'];
domainListFilterExpression: string;
}): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const [selectedView, setSelectedView] = useState<VIEWS>(
@@ -53,8 +59,16 @@ function DomainDetails({
const [endPointsGroupBy, setEndPointsGroupBy] = useState<
IBuilderQuery['groupBy']
>([]);
const [initialFiltersEndPointStats, setInitialFiltersEndPointStats] =
useState<IBuilderQuery['filters']>(domainListFilters);
const domainName = domainData?.domainName ?? '';
const allEndpointsScope = useMemo(
() => getDomainScopeExpression(domainName),
[domainName],
);
const endPointStatsScope = useMemo(
() => getDomainScopeExpression(domainName, selectedEndPointName),
[domainName, selectedEndPointName],
);
const handleTabChange = (value: string): void => {
setSelectedView(value as VIEWS);
@@ -222,7 +236,7 @@ function DomainDetails({
<>
<DomainMetrics
domainName={domainData.domainName}
domainListFilters={domainListFilters}
domainListFilterExpression={domainListFilterExpression}
timeRange={modalTimeRange}
/>
<div className="views-tabs-container">
@@ -253,34 +267,43 @@ function DomainDetails({
/>
</div>
{selectedView === VIEW_TYPES.ALL_ENDPOINTS && (
<AllEndPoints
domainName={domainData.domainName}
setSelectedEndPointName={handleEndPointChange}
setSelectedView={setSelectedView}
groupBy={endPointsGroupBy}
setGroupBy={setEndPointsGroupBy}
timeRange={modalTimeRange}
initialFilters={domainListFilters}
setInitialFiltersEndPointStats={setInitialFiltersEndPointStats}
/>
<QuerySearchV2Provider
queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}
initialExpression={allEndpointsScope}
persistOnUnmount
>
<AllEndPoints
domainName={domainData.domainName}
setSelectedEndPointName={handleEndPointChange}
setSelectedView={setSelectedView}
groupBy={endPointsGroupBy}
setGroupBy={setEndPointsGroupBy}
timeRange={modalTimeRange}
/>
</QuerySearchV2Provider>
)}
{selectedView === VIEW_TYPES.ENDPOINT_STATS && (
<EndPointDetails
domainName={domainData.domainName}
endPointName={selectedEndPointName}
setSelectedEndPointName={handleEndPointChange}
initialFilters={initialFiltersEndPointStats}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
/>
<QuerySearchV2Provider
queryParamKey={ENDPOINT_STATS_EXPRESSION_KEY}
initialExpression={endPointStatsScope}
persistOnUnmount
>
<EndPointDetails
domainName={domainData.domainName}
endPointName={selectedEndPointName}
setSelectedEndPointName={handleEndPointChange}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
/>
</QuerySearchV2Provider>
)}
{selectedView === VIEW_TYPES.TOP_ERRORS && (
<TopErrors
domainName={domainData.domainName}
timeRange={modalTimeRange}
initialFilters={domainListFilters}
initialFilterExpression={domainListFilterExpression}
/>
)}
</>

View File

@@ -1,17 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import { useQueries } from 'react-query';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap } from 'constants/queryBuilder';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
extractPortAndEndpoint,
getEndPointDetailsQueryPayload,
getHttpUrlExpression,
getLatencyOverTimeWidgetData,
getRateOverTimeWidgetData,
} from 'container/ApiMonitoring/utils';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import {
CustomTimeType,
Time,
@@ -19,36 +19,28 @@ import {
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { extractQueryPairs } from 'utils/queryContextUtils';
import DependentServices from './components/DependentServices';
import EndPointMetrics from './components/EndPointMetrics';
import EndPointsDropDown from './components/EndPointsDropDown';
import EndPointPicker from './components/EndPointPicker';
import MetricOverTimeGraph from './components/MetricOverTimeGraph';
import StatusCodeBarCharts from './components/StatusCodeBarCharts';
import StatusCodeTable from './components/StatusCodeTable';
import { SPAN_ATTRIBUTES } from './constants';
const httpUrlKey = {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.HTTP_URL,
type: 'tag',
};
import { useFilterExpression } from './hooks/useFilterExpression';
import { useWidgetQueries } from './hooks/useWidgetQueries';
function EndPointDetails({
domainName,
endPointName,
setSelectedEndPointName,
initialFilters,
timeRange,
handleTimeChange,
}: {
domainName: string;
endPointName: string;
setSelectedEndPointName: (value: string) => void;
initialFilters: IBuilderQuery['filters'];
timeRange: {
startTime: number;
endTime: number;
@@ -59,159 +51,66 @@ function EndPointDetails({
) => void;
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
const [params, setParams] = useApiMonitoringParams();
const currentQuery = initialQueriesMap[DataSource.TRACES];
// Search filters; the endpoint filter is combined in at query time
const {
expression: filterExpression,
initialExpression: scopeExpression,
queryData,
onDraftChange,
onRun,
onBlur: handleFilterBlur,
} = useFilterExpression();
// Local state for filters, combining endpoint filter and search filters
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
// Initialize filters based on the initial endPointName prop
const initialItems = params.endPointDetailsLocalFilters
? [...(params.endPointDetailsLocalFilters?.items || [])]
: [...(initialFilters?.items || [])];
if (endPointName) {
initialItems.push({
id: '92b8a1c1',
key: httpUrlKey,
op: '=',
value: endPointName,
});
}
return { op: 'AND', items: initialItems };
});
// Effect to synchronize local filters when the endPointName prop changes (e.g., from dropdown)
useEffect(() => {
setFilters((currentFilters) => {
const existingHttpUrlFilter = currentFilters?.items?.find(
(item) => item.key?.key === httpUrlKey.key,
);
const existingHttpUrlValue = (existingHttpUrlFilter?.value as string) || '';
// Only update filters if the prop value is different from what's already in filters
if (endPointName === existingHttpUrlValue) {
return currentFilters; // No change needed, prevents loop
}
// Rebuild filters: Keep non-http_url filters and add/update http_url filter based on prop
const otherFilters = currentFilters?.items?.filter(
(item) => item.key?.key !== httpUrlKey.key,
);
const newItems = [...(otherFilters || [])];
if (endPointName) {
newItems.push({
id: '92b8a1c1',
key: httpUrlKey,
op: '=',
value: endPointName,
});
}
return { op: 'AND', items: newItems };
});
}, [endPointName]);
// Separate effect to update params when filters change
useEffect(() => {
const filtersWithoutHttpUrl = {
op: 'AND',
items:
filters?.items?.filter((item) => item.key?.key !== httpUrlKey.key) || [],
};
setParams({ endPointDetailsLocalFilters: filtersWithoutHttpUrl });
}, [filters, setParams]);
// Handler for changes from the QueryBuilderSearchV2 component
const handleFilterChange = useCallback(
(newFilters: IBuilderQuery['filters']): void => {
// 1. Update local filters state immediately
setFilters(newFilters);
// Filter out http_url filter before saving to params
const filteredNewFilters = {
op: 'AND',
items:
newFilters?.items?.filter((item) => item.key?.key !== httpUrlKey.key) ||
[],
};
setParams({ endPointDetailsLocalFilters: filteredNewFilters });
// 2. Derive the endpoint name from the *new* filters state
const httpUrlFilter = newFilters?.items?.find(
(item) => item.key?.key === httpUrlKey.key,
);
const derivedEndPointName = (httpUrlFilter?.value as string) || '';
// 3. If the derived endpoint name is different from the current prop,
// it means the search change modified the effective endpoint.
// Notify the parent component.
if (derivedEndPointName !== endPointName) {
setSelectedEndPointName(derivedEndPointName);
}
},
[endPointName, setSelectedEndPointName, setParams], // Dependencies for the callback
const httpUrlExpression = useMemo(
() => getHttpUrlExpression(endPointName),
[endPointName],
);
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
filters, // Use the local filters state
},
],
},
}),
[filters, currentQuery],
const scopedExpression = useMemo(
() => combineInitialAndUserExpression(httpUrlExpression, filterExpression),
[httpUrlExpression, filterExpression],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
const isServicesFilterApplied = useMemo(
() => filters?.items?.some((item) => item.key?.key === 'service.name'),
[filters],
() =>
extractQueryPairs(filterExpression).some(
(pair) => pair.key === 'service.name',
),
[filterExpression],
);
const endPointDetailsQueryPayload = useMemo(
() => getEndPointDetailsQueryPayload(domainName, minTime, maxTime, filters),
[domainName, filters, minTime, maxTime],
() =>
getEndPointDetailsQueryPayload(
domainName,
minTime,
maxTime,
scopedExpression,
),
[domainName, scopedExpression, minTime, maxTime],
);
const V5_QUERIES = [
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_METRICS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
] as const;
const endPointDetailsDataQueries = useQueries(
endPointDetailsQueryPayload.map((payload, index) => {
const queryKey = END_POINT_DETAILS_QUERY_KEYS_ARRAY[index];
const version = (V5_QUERIES as readonly string[]).includes(queryKey)
? ENTITY_VERSION_V5
: ENTITY_VERSION_V4;
return {
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
payload,
...(filters?.items?.length ? filters.items : []), // Include filters.items in queryKey for better caching
version,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, version),
enabled: !!payload,
};
}),
endPointDetailsQueryPayload.map((payload, index) => ({
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
payload,
ENTITY_VERSION_V5,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
enabled: !!payload,
// Keeps the table's rows, and so its height, while a new filter loads
keepPreviousData:
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index] ===
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
})),
);
const [
endPointMetricsDataQuery,
endPointStatusCodeDataQuery,
endPointDropDownDataQuery,
endPointDependentServicesDataQuery,
endPointStatusCodeBarChartsDataQuery,
endPointStatusCodeLatencyBarChartsDataQuery,
@@ -222,7 +121,6 @@ function EndPointDetails({
endPointDetailsDataQueries[2],
endPointDetailsDataQueries[3],
endPointDetailsDataQueries[4],
endPointDetailsDataQueries[5],
],
[endPointDetailsDataQueries],
);
@@ -234,10 +132,32 @@ function EndPointDetails({
const [rateOverTimeWidget, latencyOverTimeWidget] = useMemo(
() => [
getRateOverTimeWidgetData(domainName, endPointName, filters),
getLatencyOverTimeWidgetData(domainName, endPointName, filters),
getRateOverTimeWidgetData(domainName, endPointName, scopedExpression),
getLatencyOverTimeWidgetData(domainName, endPointName, scopedExpression),
],
[domainName, endPointName, filters], // Use combinedFilters
[domainName, endPointName, scopedExpression],
);
const graphWidgets = useMemo(
() => [rateOverTimeWidget, latencyOverTimeWidget],
[rateOverTimeWidget, latencyOverTimeWidget],
);
const { isFetching: isFetchingGraphs, refetch: refetchGraphs } =
useWidgetQueries(graphWidgets);
const isFetchingQueries =
isFetchingGraphs ||
endPointDetailsDataQueries.some((query) => query.isFetching);
const refetchQueries = useCallback((): void => {
refetchGraphs();
endPointDetailsDataQueries.forEach((query) => {
void query.refetch();
});
}, [refetchGraphs, endPointDetailsDataQueries]);
const handleRun = useCallback(
(value: string): void => onRun(value, refetchQueries),
[onRun, refetchQueries],
);
// // [TODO] Fix this later
@@ -257,19 +177,23 @@ function EndPointDetails({
return (
<div className="endpoint-details-container">
<div className="endpoint-details-filters-container">
<div className="endpoint-details-filters-container-dropdown">
<EndPointsDropDown
selectedEndPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointDropDownDataQuery}
parentContainerDiv=".endpoint-details-filters-container"
dropdownStyle={{ width: 'calc(100% - 36px)' }}
/>
</div>
<div className="endpoint-details-filters-container-search">
<QueryBuilderSearchV2
query={query}
onChange={handleFilterChange}
<EndPointPicker
domainName={domainName}
timeRange={timeRange}
filterExpression={filterExpression}
value={endPointName}
onChange={setSelectedEndPointName}
/>
<div onBlur={handleFilterBlur}>
<QuerySearch
queryData={queryData}
onChange={onDraftChange}
onRun={handleRun}
showRunButton
isLoadingQueries={isFetchingQueries}
initialExpression={scopeExpression}
showScopeChip
dataSource={DataSource.TRACES}
placeholder="Search for filters..."
/>
</div>
@@ -299,7 +223,7 @@ function EndPointDetails({
endPointStatusCodeLatencyBarChartsDataQuery
}
domainName={domainName}
filters={filters}
filterExpression={scopedExpression}
timeRange={timeRange}
onDragSelect={onDragSelect}
/>

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
import { QueryFunctionContext, useQuery } from 'react-query';
import { Spin, Table, Tooltip } from 'antd';
import { Info, Loader } from '@signozhq/icons';
import { Switch } from '@signozhq/ui/switch';
@@ -7,41 +7,36 @@ import { Typography } from '@signozhq/ui/typography';
import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
import { MetricRangePayloadV5, ScalarData } from 'api/v5/v5';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { withErrorBoundary } from 'components/ErrorBoundaryHOC';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
formatTopErrorsDataForTable,
getEndPointDetailsQueryPayload,
getHttpUrlExpression,
getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters,
getTopErrorsQueryPayload,
} from 'container/ApiMonitoring/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { SuccessResponse, SuccessResponseV2 } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SuccessResponseV2 } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import EndPointsDropDown from './components/EndPointsDropDown';
import EndPointPicker from './components/EndPointPicker';
import ErrorState from './components/ErrorState';
import { SPAN_ATTRIBUTES } from './constants';
function TopErrors({
domainName,
timeRange,
initialFilters,
initialFilterExpression,
}: {
domainName: string;
timeRange: {
startTime: number;
endTime: number;
};
initialFilters: IBuilderQuery['filters'];
initialFilterExpression: string;
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
@@ -55,27 +50,10 @@ function TopErrors({
domainName,
minTime,
maxTime,
{
items: endPointName
? [
// Remove any existing http_url filters from initialFilters to avoid duplicates
...(initialFilters?.items?.filter(
(item) => item.key?.key !== SPAN_ATTRIBUTES.HTTP_URL,
) || []),
{
id: '92b8a1c1',
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.HTTP_URL,
type: 'tag',
},
op: '=',
value: endPointName,
},
]
: [...(initialFilters?.items || [])],
op: 'AND',
},
combineInitialAndUserExpression(
getHttpUrlExpression(endPointName),
initialFilterExpression,
),
showStatusCodeErrors,
),
[
@@ -83,7 +61,7 @@ function TopErrors({
endPointName,
minTime,
maxTime,
initialFilters,
initialFilterExpression,
showStatusCodeErrors,
],
);
@@ -120,35 +98,6 @@ function TopErrors({
[topErrorsData],
);
const endPointDropDownQueryPayload = useMemo(
() => [
getEndPointDetailsQueryPayload(domainName, minTime, maxTime, {
items: [],
op: 'AND',
})[2],
],
[domainName, minTime, maxTime],
);
const endPointDropDownDataQueries = useQueries(
endPointDropDownQueryPayload.map((payload) => ({
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[2],
payload,
ENTITY_VERSION_V5,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
enabled: !!payload,
staleTime: 60 * 1000,
})),
);
const [endPointDropDownDataQuery] = useMemo(
() => [endPointDropDownDataQueries[0]],
[endPointDropDownDataQueries],
);
const navigateToExplorer = useNavigateToExplorer();
if (isError) {
@@ -162,14 +111,13 @@ function TopErrors({
return (
<div className="all-endpoints-container">
<div className="top-errors-dropdown-container">
<div className="endpoint-details-filters-container-dropdown">
<EndPointsDropDown
selectedEndPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointDropDownDataQuery}
parentContainerDiv=".endpoint-details-filters-container"
/>
</div>
<EndPointPicker
domainName={domainName}
timeRange={timeRange}
filterExpression=""
value={endPointName}
onChange={setSelectedEndPointName}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Switch
color="primary"

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { UseQueryResult } from 'react-query';
import { Skeleton, Table, TablePaginationConfig } from 'antd';
import { Table, TablePaginationConfig } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { QueryParams } from 'constants/query';
import {
@@ -54,10 +54,6 @@ function DependentServices({
[isExpanded, dependentServicesData.length],
);
if (isLoading || isRefetching) {
return <Skeleton />;
}
if (isError) {
return <ErrorState refetch={refetch} />;
}

View File

@@ -3,7 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react';
import { TraceAggregation } from 'api/v5/v5';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../constants';
import DomainMetrics from './DomainMetrics';
@@ -34,10 +33,7 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
startTime: 1758259531000,
endTime: 1758261331000,
},
domainListFilters: {
items: [],
op: 'AND' as const,
} as IBuilderQuery['filters'],
domainListFilterExpression: '',
};
const mockSuccessResponse = {
@@ -174,35 +170,12 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
it('includes custom filters in filter expressions', async () => {
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'my-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND' as const,
};
const customFilterExpression =
"service.name = 'my-service' AND deployment.environment = 'production'";
renderComponent({
...mockProps,
domainListFilters: customFilters,
domainListFilterExpression: customFilterExpression,
});
await waitFor(() => {

View File

@@ -14,18 +14,17 @@ import {
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import ErrorState from './ErrorState';
function DomainMetrics({
domainName,
timeRange,
domainListFilters,
domainListFilterExpression,
}: {
domainName: string;
timeRange: { startTime: number; endTime: number };
domainListFilters: IBuilderQuery['filters'];
domainListFilterExpression: string;
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
@@ -35,9 +34,9 @@ function DomainMetrics({
domainName,
minTime,
maxTime,
domainListFilters,
domainListFilterExpression,
),
[domainName, minTime, maxTime, domainListFilters],
[domainName, minTime, maxTime, domainListFilterExpression],
);
// Since only one query here

View File

@@ -114,10 +114,7 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
const domainName = 'api.example.com';
const startTime = 1758259531000;
const endTime = 1758261331000;
const filters = {
items: [],
op: 'AND' as const,
};
const filters = '';
// Get the actual payload that would be generated
const payloads = getEndPointDetailsQueryPayload(
@@ -237,31 +234,8 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
it('includes custom domainListFilters in all query expressions', async () => {
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
const customFilters = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'payment-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'staging',
},
],
op: 'AND' as const,
};
const customFilters =
"service.name = 'payment-service' AND deployment.environment = 'staging'";
const payloads = getEndPointDetailsQueryPayload(
'api.internal.com',

View File

@@ -0,0 +1,18 @@
.picker {
width: 460px;
max-width: 100%;
// Popover fits its longest option by default; match the trigger so long URLs truncate.
[data-slot='combobox-content'] {
--popover-width: var(--radix-popover-trigger-width);
}
}
.option {
display: block;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,92 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
import { ComboboxSimple, ComboboxSimpleItem } from '@signozhq/ui/combobox';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
getEndPointDropDownQueryPayload,
getFormattedEndPointDropDownData,
} from 'container/ApiMonitoring/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import styles from './EndPointPicker.module.scss';
const VIRTUALIZE_AFTER = 100;
const ALL_ENDPOINTS = '__all_endpoints__';
interface EndPointPickerProps {
domainName: string;
timeRange: {
startTime: number;
endTime: number;
};
filterExpression: string;
value: string;
onChange: (endPointName: string) => void;
}
function EndPointPicker({
domainName,
timeRange,
filterExpression,
value,
onChange,
}: EndPointPickerProps): JSX.Element {
const payload = useMemo(
() =>
getEndPointDropDownQueryPayload(
domainName,
timeRange.startTime,
timeRange.endTime,
filterExpression,
),
[domainName, timeRange.startTime, timeRange.endTime, filterExpression],
);
const { data, isLoading, isFetching } = useQuery({
queryKey: [
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
payload,
ENTITY_VERSION_V5,
],
queryFn: () => GetMetricQueryRange(payload, ENTITY_VERSION_V5),
staleTime: 60 * 1000,
});
const items = useMemo(
(): ComboboxSimpleItem[] => [
{ value: ALL_ENDPOINTS, label: 'All endpoints' },
...getFormattedEndPointDropDownData(
data?.payload?.data?.result?.[0]?.table?.rows ?? [],
).map(({ value: url }) => ({
value: url,
label: (
<span className={styles.option} title={url}>
{url}
</span>
),
})),
],
[data],
);
return (
<div className={styles.picker}>
<ComboboxSimple
items={items}
value={value || ALL_ENDPOINTS}
onChange={(next): void =>
onChange(!next || next === ALL_ENDPOINTS ? '' : (next as string))
}
inputPlaceholder="Search endpoints"
emptyPlaceholder="No endpoints found"
loading={isLoading || isFetching}
virtualized={items.length > VIRTUALIZE_AFTER}
withPortal={false}
testId="endpoint-picker"
/>
</div>
);
}
export default EndPointPicker;

View File

@@ -1,65 +0,0 @@
import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { Select } from 'antd';
import { getFormattedEndPointDropDownData } from 'container/ApiMonitoring/utils';
import { SuccessResponse } from 'types/api';
interface EndPointsDropDownProps {
selectedEndPointName?: string;
setSelectedEndPointName: (value: string) => void;
endPointDropDownDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
parentContainerDiv?: string;
dropdownStyle?: React.CSSProperties;
}
const defaultProps = {
selectedEndPointName: '',
parentContainerDiv: '',
dropdownStyle: {},
};
function EndPointsDropDown({
selectedEndPointName,
setSelectedEndPointName,
endPointDropDownDataQuery,
parentContainerDiv,
dropdownStyle,
}: EndPointsDropDownProps): JSX.Element {
const { data, isLoading, isFetching } = endPointDropDownDataQuery;
const handleChange = (value: string): void => {
setSelectedEndPointName(value);
};
const formattedData = useMemo(
() =>
getFormattedEndPointDropDownData(data?.payload.data.result[0].table.rows),
[data?.payload.data.result],
);
return (
<Select
value={selectedEndPointName || undefined}
placeholder="Select endpoint"
loading={isLoading || isFetching}
style={{ width: '100%' }}
onChange={handleChange}
options={formattedData}
getPopupContainer={
parentContainerDiv
? (): HTMLElement =>
document.querySelector(parentContainerDiv) as HTMLElement
: (triggerNode): HTMLElement => triggerNode.parentNode as HTMLElement
}
dropdownStyle={dropdownStyle}
allowClear
onClear={(): void => {
setSelectedEndPointName('');
}}
/>
);
}
EndPointsDropDown.defaultProps = defaultProps;
export default EndPointsDropDown;

View File

@@ -24,7 +24,6 @@ import { LegendPosition } from 'lib/uPlotV2/components/types';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/widgets/widget';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import ErrorState from './ErrorState';
import {
@@ -37,7 +36,7 @@ function StatusCodeBarCharts({
endPointStatusCodeBarChartsDataQuery,
endPointStatusCodeLatencyBarChartsDataQuery,
domainName,
filters,
filterExpression,
timeRange,
onDragSelect,
}: {
@@ -50,7 +49,7 @@ function StatusCodeBarCharts({
unknown
>;
domainName: string;
filters: IBuilderQuery['filters'];
filterExpression: string;
timeRange: {
startTime: number;
endTime: number;
@@ -130,12 +129,8 @@ function StatusCodeBarCharts({
);
const widget = useMemo<Widgets>(
() =>
getStatusCodeBarChartWidgetData(domainName, {
items: [...(filters?.items || [])],
op: filters?.op || 'AND',
}),
[domainName, filters],
() => getStatusCodeBarChartWidgetData(domainName, filterExpression),
[domainName, filterExpression],
);
const activeApiResponse = useMemo(

View File

@@ -0,0 +1,157 @@
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { FilterProviders } from 'container/ApiMonitoring/__tests__/FilterProviders';
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
import {
act,
render,
renderHook,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { useFilterExpression } from '../useFilterExpression';
const PARAM_KEY = 'testExpression';
const linkExpression = "service.name = 'frontend'";
const linkSearch = `${PARAM_KEY}=${encodeURIComponent(linkExpression)}`;
function renderFilterHook(
searchParams = '',
): ReturnType<
typeof renderHook<ReturnType<typeof useFilterExpression>, unknown>
> {
return renderHook(() => useFilterExpression(), {
wrapper: ({ children }) => (
<FilterProviders queryParamKey={PARAM_KEY} searchParams={searchParams}>
{children}
</FilterProviders>
),
});
}
function FilterBox(): JSX.Element {
const { expression, onDraftChange, onBlur } = useFilterExpression();
return (
<div onBlur={onBlur}>
<span data-testid="applied">{expression}</span>
<input
data-testid="filter-input"
onChange={(e): void => onDraftChange(e.target.value)}
/>
<button type="button" data-testid="inside-button">
Run
</button>
</div>
);
}
describe('useFilterExpression', () => {
beforeEach(() => {
resetNuqsState();
});
it('reads the filter from the link', async () => {
const { result } = renderFilterHook(linkSearch);
await waitFor(() => expect(result.current.expression).toBe(linkExpression));
expect(result.current.queryData.dataSource).toBe('traces');
expect(result.current.queryData.filter?.expression).toBe(linkExpression);
});
it('applies a valid expression on run and ignores an invalid one', async () => {
const { result } = renderFilterHook(linkSearch);
await waitFor(() => expect(result.current.expression).toBe(linkExpression));
act(() => {
result.current.onRun('service.name =');
});
expect(result.current.expression).toBe(linkExpression);
act(() => {
result.current.onRun('http.status_code = 500');
});
expect(result.current.expression).toBe('http.status_code = 500');
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(PARAM_KEY)).toBe(
'http.status_code = 500',
),
);
});
it('runs again instead of applying when the filter is already applied', async () => {
const scope = "http_host = 'api.example.com'";
const { result } = renderHook(() => useFilterExpression(), {
wrapper: ({ children }) => (
<FilterProviders
queryParamKey={PARAM_KEY}
initialExpression={scope}
searchParams={linkSearch}
>
{children}
</FilterProviders>
),
});
await waitFor(() => expect(result.current.expression).toBe(linkExpression));
const onUnchanged = jest.fn() as jest.MockedFunction<() => void>;
act(() => {
result.current.onRun(
combineInitialAndUserExpression(scope, linkExpression),
onUnchanged,
);
});
expect(onUnchanged).toHaveBeenCalledTimes(1);
act(() => {
result.current.onRun(
combineInitialAndUserExpression(scope, 'http.status_code = 500'),
onUnchanged,
);
});
expect(onUnchanged).toHaveBeenCalledTimes(1);
expect(result.current.expression).toBe('http.status_code = 500');
});
it('applies the draft on blur only when focus leaves the container', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<FilterProviders queryParamKey={PARAM_KEY}>
<FilterBox />
</FilterProviders>,
);
await user.type(screen.getByTestId('filter-input'), "service.name = 'cart'");
await user.click(screen.getByTestId('inside-button'));
expect(screen.getByTestId('applied')).toHaveTextContent('');
await user.click(document.body);
expect(screen.getByTestId('applied')).toHaveTextContent(
"service.name = 'cart'",
);
});
it('keeps only the user text when the scope is set', async () => {
const scope = "http_host = 'api.example.com'";
const { result } = renderHook(() => useFilterExpression(), {
wrapper: ({ children }) => (
<FilterProviders queryParamKey={PARAM_KEY} initialExpression={scope}>
{children}
</FilterProviders>
),
});
await waitFor(() => expect(result.current.initialExpression).toBe(scope));
act(() => {
result.current.onRun(`${scope} AND (service.name = 'cart')`);
});
expect(result.current.expression).toBe("service.name = 'cart'");
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(PARAM_KEY)).toBe(
"service.name = 'cart'",
),
);
});
});

View File

@@ -0,0 +1,82 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider, QueryObserver } from 'react-query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { act, renderHook, waitFor } from 'tests/test-utils';
import { Widgets } from 'types/api/widgets/widget';
import { useWidgetQueries } from '../useWidgetQueries';
const widgetWithQueryId = (id: string): Widgets =>
getWidgetQueryBuilder({
query: { ...initialQueriesMap.traces, id },
panelTypes: PANEL_TYPES.TABLE,
});
const gridCardKey = (queryId: string): unknown[] => [
REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE,
0,
0,
{ id: queryId },
];
function startGridCardFetch(client: QueryClient, queryId: string): void {
void client.prefetchQuery(gridCardKey(queryId), () => new Promise(() => {}));
}
function renderWidgetQueries(
client: QueryClient,
widgets: Widgets[],
): ReturnType<typeof renderHook<ReturnType<typeof useWidgetQueries>, unknown>> {
return renderHook(() => useWidgetQueries(widgets), {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
});
}
describe('useWidgetQueries', () => {
it('counts only the given widgets’ grid card queries', async () => {
const client = new QueryClient();
const { result } = renderWidgetQueries(client, [widgetWithQueryId('table')]);
await act(async () => {
startGridCardFetch(client, 'other-tab-chart');
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(client.isFetching()).toBe(1);
expect(result.current.isFetching).toBe(false);
act(() => startGridCardFetch(client, 'table'));
await waitFor(() => expect(result.current.isFetching).toBe(true));
});
it('refetches only the given widgets’ grid card queries', async () => {
const client = new QueryClient();
const fetchTable = jest.fn(() =>
Promise.resolve('table'),
) as jest.MockedFunction<() => Promise<string>>;
const fetchOther = jest.fn(() =>
Promise.resolve('other'),
) as jest.MockedFunction<() => Promise<string>>;
const unsubscribers = [
new QueryObserver(client, {
queryKey: gridCardKey('table'),
queryFn: fetchTable,
}).subscribe(() => {}),
new QueryObserver(client, {
queryKey: gridCardKey('other-tab-chart'),
queryFn: fetchOther,
}).subscribe(() => {}),
];
await waitFor(() => expect(client.isFetching()).toBe(0));
const { result } = renderWidgetQueries(client, [widgetWithQueryId('table')]);
act(() => result.current.refetch());
await waitFor(() => expect(fetchTable).toHaveBeenCalledTimes(2));
expect(fetchOther).toHaveBeenCalledTimes(1);
unsubscribers.forEach((unsubscribe) => unsubscribe());
});
});

View File

@@ -0,0 +1,93 @@
import { FocusEvent, useCallback, useEffect, useMemo, useRef } from 'react';
import {
useInitialExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { initialQueriesMap } from 'constants/queryBuilder';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
interface UseFilterExpressionParams {
groupBy?: IBuilderQuery['groupBy'];
}
interface UseFilterExpressionReturn {
expression: string;
/** The provider's locked scope, for `QuerySearch`'s `initialExpression` */
initialExpression: string | undefined;
queryData: IBuilderQuery;
onDraftChange: (value: string) => void;
/** `onUnchanged` runs instead when the filter is already applied. */
onRun: (value: string, onUnchanged?: () => void) => void;
onBlur: (event: FocusEvent<HTMLDivElement>) => void;
}
/** Must be used inside a `QuerySearchV2Provider`. */
export function useFilterExpression({
groupBy,
}: UseFilterExpressionParams = {}): UseFilterExpressionReturn {
const expression = useUserExpression();
const scope = useInitialExpression();
const initialExpression = useQuerySearchInitialExpressionProp();
const querySearchOnRun = useQuerySearchOnRun();
const queryData = useMemo(
(): IBuilderQuery => ({
...initialQueriesMap[DataSource.TRACES].builder.queryData[0],
dataSource: DataSource.TRACES,
filter: { expression },
...(groupBy ? { groupBy } : {}),
}),
[expression, groupBy],
);
const onRun = useCallback(
(value: string, onUnchanged?: () => void): void => {
if (!validateQuery(value).isValid) {
return;
}
if (getUserExpressionFromCombined(scope, value) === expression) {
onUnchanged?.();
return;
}
querySearchOnRun(value);
},
[querySearchOnRun, scope, expression],
);
// A ref, not the provider's draft, so typing does not re-render the page.
const draftRef = useRef(expression);
useEffect(() => {
draftRef.current = expression;
}, [expression]);
const onDraftChange = useCallback((value: string): void => {
draftRef.current = value;
}, []);
const onBlur = useCallback(
(event: FocusEvent<HTMLDivElement>): void => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) {
return;
}
onRun(combineInitialAndUserExpression(scope, draftRef.current));
},
[onRun, scope],
);
return {
expression,
initialExpression,
queryData,
onDraftChange,
onRun,
onBlur,
};
}

View File

@@ -0,0 +1,45 @@
import { useCallback, useMemo } from 'react';
import {
Query as ReactQuery,
useIsFetching,
useQueryClient,
} from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { Widgets } from 'types/api/widgets/widget';
interface UseWidgetQueriesReturn {
isFetching: boolean;
refetch: () => void;
}
/**
* These widgets' `GridCard` queries. Matches on the widget's query id, so
* another tab's cards are neither counted nor refetched.
*/
export function useWidgetQueries(widgets: Widgets[]): UseWidgetQueriesReturn {
const queryClient = useQueryClient();
const predicate = useMemo(() => {
const queryIds = new Set(
widgets
.map((widget) => widget.query?.id)
.filter((id): id is string => Boolean(id)),
);
return ({ queryKey }: ReactQuery): boolean =>
Array.isArray(queryKey) &&
queryKey[0] === REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE &&
queryKey.some((part) => {
const id = (part as Query | undefined)?.id;
return !!id && queryIds.has(id);
});
}, [widgets]);
const isFetching = useIsFetching({ predicate }) > 0;
const refetch = useCallback((): void => {
void queryClient.refetchQueries({ predicate, active: true });
}, [queryClient, predicate]);
return { isFetching, refetch };
}

View File

@@ -19,6 +19,7 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useListOverview } from 'hooks/thirdPartyApis/useListOverview';
import { get } from 'lodash-es';
import { parseAsString, useQueryStates } from 'nuqs';
import { AppState } from 'store/reducers';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
@@ -27,16 +28,26 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from '../../filterParamKeys';
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
import { columnsConfig, formatDataForTable } from '../../utils';
import DomainDetails from './DomainDetails/DomainDetails';
import { useConvertLegacyFilterLinks } from './hooks/useConvertLegacyFilterLinks';
import '../Explorer.styles.scss';
function DomainList(): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const { showIP, selectedDomain } = params;
const [, setDrawerFilters] = useQueryStates({
[ALL_ENDPOINTS_EXPRESSION_KEY]: parseAsString,
[ENDPOINT_STATS_EXPRESSION_KEY]: parseAsString,
});
const [selectedDomainIndex, setSelectedDomainIndex] = useState<number>(-1);
useConvertLegacyFilterLinks();
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
@@ -55,6 +66,8 @@ function DomainList(): JSX.Element {
});
const compositeData = useGetCompositeQueryParam();
const listFilterExpression =
compositeData?.builder?.queryData[0]?.filter?.expression ?? '';
const [isCancelled, setIsCancelled] = useState(false);
@@ -155,6 +168,7 @@ function DomainList(): JSX.Element {
dataSource={DataSource.TRACES}
queryData={query}
onChange={handleSearchChange}
onRun={handleStageAndRunQuery}
placeholder="Enter your filter query (e.g., deployment.environment = 'otel-demo' AND service.name = 'frontend')"
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
/>
@@ -225,6 +239,11 @@ function DomainList(): JSX.Element {
);
setSelectedDomainIndex(dataIndex);
setParams({ selectedDomain: record.domainName });
// The drawer tabs start from the list's filter
void setDrawerFilters({
[ALL_ENDPOINTS_EXPRESSION_KEY]: listFilterExpression || null,
[ENDPOINT_STATS_EXPRESSION_KEY]: listFilterExpression || null,
});
logEvent('API Monitoring: Domain name row clicked', {});
}
},
@@ -245,8 +264,12 @@ function DomainList(): JSX.Element {
handleClose={(): void => {
setSelectedDomainIndex(-1);
setParams(DEFAULT_PARAMS);
void setDrawerFilters({
[ALL_ENDPOINTS_EXPRESSION_KEY]: null,
[ENDPOINT_STATS_EXPRESSION_KEY]: null,
});
}}
domainListFilters={query?.filters}
domainListFilterExpression={listFilterExpression}
/>
)}
</section>

View File

@@ -0,0 +1,122 @@
import { ReactNode, useEffect, useState } from 'react';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from 'container/ApiMonitoring/filterParamKeys';
import { getApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
getCurrentNuqsSearchParams,
onNuqsUrlUpdate,
resetNuqsState,
} from 'tests/nuqs-helpers';
import { mockAppLocation, mockNavigate } from 'tests/router-mocks';
import { renderHook, waitFor } from 'tests/test-utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
import { useConvertLegacyFilterLinks } from '../useConvertLegacyFilterLinks';
const legacyFilter = (key: string, value: string): TagFilter => ({
op: 'AND',
items: [
{
id: key,
key: { key, dataType: DataTypes.String, type: 'tag' },
op: '=',
value,
},
],
});
// NuqsTestingAdapter resets its update queue on every render, not only on mount,
// dropping writes queued just before it re-renders.
function UrlAdapter({
searchParams,
children,
}: {
searchParams: string;
children: ReactNode;
}): JSX.Element {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={onNuqsUrlUpdate}
rateLimitFactor={0}
resetUrlUpdateQueueOnMount={!mounted}
hasMemory
>
{children}
</NuqsTestingAdapter>
);
}
function renderWithLink(linkParams: object): jest.SpyInstance {
const search = `?apiMonitoringParams=${encodeURIComponent(
encodeURIComponent(JSON.stringify(linkParams)),
)}`;
__setSearchParamsGetterForTest(() => new URLSearchParams(search));
mockAppLocation({ search });
const navigateSpy = mockNavigate();
renderHook(() => useConvertLegacyFilterLinks(), {
wrapper: ({ children }: { children: ReactNode }) => (
<UrlAdapter searchParams={search}>{children}</UrlAdapter>
),
});
return navigateSpy;
}
describe('useConvertLegacyFilterLinks', () => {
beforeEach(() => {
resetNuqsState();
});
afterEach(() => {
__resetSearchParamsGetter();
});
it('moves an older link’s filters into the drawer params once', async () => {
const navigateSpy = renderWithLink({
selectedDomain: 'api.example.com',
allEndpointsLocalFilters: legacyFilter('service.name', 'cart'),
endPointDetailsLocalFilters: legacyFilter('http.status_code', '500'),
});
await waitFor(() => {
const params = getCurrentNuqsSearchParams();
expect(params.get(ALL_ENDPOINTS_EXPRESSION_KEY)).toBe(
"service.name = 'cart'",
);
expect(params.get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
"http.status_code = '500'",
);
});
const [to, options] = navigateSpy.mock.calls[0];
const rewritten = getApiMonitoringParams(
typeof to === 'string' ? to : (to.search ?? ''),
);
expect(rewritten.selectedDomain).toBe('api.example.com');
expect(rewritten).not.toHaveProperty('allEndpointsLocalFilters');
expect(options).toStrictEqual({ replace: true });
});
it('leaves a link without legacy filters alone', () => {
const navigateSpy = renderWithLink({ selectedDomain: 'api.example.com' });
expect(navigateSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ search: expect.any(String) }),
expect.anything(),
);
expect(getCurrentNuqsSearchParams().has(ALL_ENDPOINTS_EXPRESSION_KEY)).toBe(
false,
);
});
});

View File

@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { parseAsString, useQueryStates } from 'nuqs';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from '../../../filterParamKeys';
import {
getLegacyFilterExpressions,
useApiMonitoringParams,
} from '../../../queryParams';
/**
* Moves the filters an older link keeps inside `apiMonitoringParams` into the
* drawer's own params, then rewrites `apiMonitoringParams` without them, so it
* happens once.
*/
export function useConvertLegacyFilterLinks(): void {
const [, setParams] = useApiMonitoringParams();
const [, setDrawerFilters] = useQueryStates({
[ALL_ENDPOINTS_EXPRESSION_KEY]: parseAsString,
[ENDPOINT_STATS_EXPRESSION_KEY]: parseAsString,
});
useEffect(() => {
const legacy = getLegacyFilterExpressions(
`?${getUnstableCurrentSearchParams().toString()}`,
);
if (
legacy.allEndpoints === undefined &&
legacy.endPointDetails === undefined
) {
return;
}
void setDrawerFilters({
[ALL_ENDPOINTS_EXPRESSION_KEY]: legacy.allEndpoints || null,
[ENDPOINT_STATS_EXPRESSION_KEY]: legacy.endPointDetails || null,
});
setParams({}, true);
}, [setParams, setDrawerFilters]);
}

View File

@@ -1,6 +1,4 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import {
@@ -11,20 +9,14 @@ import {
getFormattedEndPointDropDownData,
getFormattedEndPointStatusCodeChartData,
getFormattedEndPointStatusCodeData,
getGroupByFiltersFromGroupByValues,
getGroupByExpressionFromRowData,
getHttpUrlExpression,
getStatusCodeBarChartWidgetData,
getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters,
} from '../utils';
import { APIMonitoringColumnsMock } from './mock';
// Mock or define DataTypes since it seems to be missing from imports
const DataTypes = {
String: 'string',
Float64: 'float64',
bool: 'bool',
};
// Mock the external utils dependencies that are used within our tested functions
jest.mock('../utils', () => {
// Import the actual module to partial mock
@@ -90,140 +82,39 @@ describe('API Monitoring Utils', () => {
expect(result[0].lastUsed).toBe('-');
});
});
describe('getGroupByFiltersFromGroupByValues', () => {
it('should convert row data to filters correctly', () => {
// Arrange
const rowData = {
'http.method': 'GET',
'http.status_code': '200',
'service.name': 'api-service',
// Fields that should be filtered out
data: 'someValue',
key: 'someKey',
};
const groupBy = [
{
id: 'group-by-1',
key: 'http.method',
dataType: DataTypes.String,
type: '',
},
{
id: 'group-by-2',
key: 'http.status_code',
dataType: DataTypes.String,
type: '',
},
{
id: 'group-by-3',
key: 'service.name',
dataType: DataTypes.String,
type: 'tag',
},
];
// Act
const result = getGroupByFiltersFromGroupByValues(
rowData,
groupBy as BaseAutocompleteData[],
);
// Assert
expect(result).toBeDefined();
expect(result?.op).toBe('AND');
// The implementation includes all keys from rowData, not just those in groupBy
expect(result?.items?.length).toBeGreaterThanOrEqual(3);
// Verify each filter matches the corresponding groupBy
describe('getGroupByExpressionFromRowData', () => {
it('builds an AND expression from the row values, skipping the url and metric columns', () => {
expect(
result?.items?.some(
(item) =>
item.key &&
item.key.key === 'http.method' &&
item.value === 'GET' &&
item.op === '=',
),
).toBe(true);
expect(
result?.items?.some(
(item) =>
item.key &&
item.key.key === 'http.status_code' &&
item.value === '200' &&
item.op === '=',
),
).toBe(true);
expect(
result?.items?.some(
(item) =>
item.key &&
item.key.key === 'service.name' &&
item.value === 'api-service' &&
item.op === '=',
),
).toBe(true);
getGroupByExpressionFromRowData({
http_url: '/api/test',
key: '3f2a-row-uuid',
A: 10,
B: 20,
C: 1,
F1: 5,
'service.name': 'api-service',
'http.status_code': 200,
}),
).toBe("service.name = 'api-service' AND http.status_code = 200");
});
it('should handle fields not in groupBy', () => {
// Arrange
const rowData = {
'http.method': 'GET',
'unknown.field': 'someValue',
};
const groupBy = [
{
id: 'group-by-1',
key: 'http.method',
dataType: DataTypes.String,
type: '',
},
];
// Act
const result = getGroupByFiltersFromGroupByValues(
rowData,
groupBy as BaseAutocompleteData[],
it('escapes quotes in values', () => {
expect(getGroupByExpressionFromRowData({ 'user.name': "o'brien" })).toBe(
"user.name = 'o\\'brien'",
);
// Assert
expect(result).toBeDefined();
// The implementation includes all keys from rowData, not just those in groupBy
expect(result?.items?.length).toBeGreaterThanOrEqual(1);
// Should include the known field with the proper dataType from groupBy
const knownField = result?.items?.find(
(item) => item.key && item.key.key === 'http.method',
);
expect(knownField).toBeDefined();
if (knownField && knownField.key) {
expect(knownField.key.dataType).toBe(DataTypes.String);
}
// Should include the unknown field
const unknownField = result?.items?.find(
(item) => item.key && item.key.key === 'unknown.field',
);
expect(unknownField).toBeDefined();
if (unknownField && unknownField.key) {
expect(unknownField.key.dataType).toBe(DataTypes.String); // Default
}
});
it('should handle empty input', () => {
// Arrange
const rowData = {};
const groupBy: any[] = [];
it('matches spans without the attribute when the group has no value', () => {
expect(
getGroupByExpressionFromRowData({
'service.name': 'cart',
'ai.embeddings': null,
}),
).toBe("service.name = 'cart' AND ai.embeddings NOT EXISTS");
});
// Act
const result = getGroupByFiltersFromGroupByValues(rowData, groupBy);
// Assert
expect(result).toBeDefined();
expect(result?.op).toBe('AND');
expect(result?.items).toHaveLength(0);
it('returns an empty expression for a row without group columns', () => {
expect(getGroupByExpressionFromRowData({ http_url: '/x', A: 1 })).toBe('');
});
});
@@ -703,13 +594,9 @@ describe('API Monitoring Utils', () => {
it('should generate widget configuration for status code bar chart', () => {
// Arrange
const domainName = 'test-domain';
const filters = { items: [], op: 'AND' };
// Act
const result = getStatusCodeBarChartWidgetData(
domainName,
filters as IBuilderQuery['filters'],
);
const result = getStatusCodeBarChartWidgetData(domainName, '');
// Assert
expect(result).toBeDefined();
@@ -723,47 +610,29 @@ describe('API Monitoring Utils', () => {
const queryData = result.query.builder.queryData[0];
// Should have domain filter
const domainFilter = queryData.filters?.items?.find(
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
expect(queryData.filter?.expression).toBe(
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}'`,
);
expect(domainFilter).toBeDefined();
if (domainFilter) {
expect(domainFilter.value).toBe(domainName);
}
});
it('should include custom filters in the widget configuration', () => {
// Arrange
const domainName = 'test-domain';
const customFilter = {
id: 'custom-filter',
key: {
dataType: 'string',
key: 'custom.key',
type: '',
},
op: '=',
value: 'custom-value',
};
const filters = { items: [customFilter], op: 'AND' };
const customFilterExpression = "custom.key = 'custom-value'";
// Act
const result = getStatusCodeBarChartWidgetData(
domainName,
filters as IBuilderQuery['filters'],
customFilterExpression,
);
// Assert
const queryData = result.query.builder.queryData[0];
// Should include our custom filter
const includedFilter = queryData.filters?.items?.find(
(item) => item.id === 'custom-filter',
// Should include our custom filter after the domain filter
expect(queryData.filter?.expression).toBe(
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}' AND (${customFilterExpression})`,
);
expect(includedFilter).toBeDefined();
if (includedFilter) {
expect(includedFilter.value).toBe('custom-value');
}
});
});
@@ -927,4 +796,17 @@ describe('API Monitoring Utils', () => {
expect(latencyColumn?.title).toBeDefined();
});
});
describe('getHttpUrlExpression', () => {
it('returns no clause when no endpoint is picked', () => {
expect(getHttpUrlExpression('')).toBe('');
});
it('quotes the endpoint and escapes apostrophes', () => {
expect(getHttpUrlExpression('/x')).toBe("http_url = '/x'");
expect(getHttpUrlExpression("/users/o'brien")).toBe(
"http_url = '/users/o\\'brien'",
);
});
});
});

View File

@@ -1,11 +1,17 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import {
getAllEndpointsWidgetData,
getGroupByFiltersFromGroupByValues,
getGroupByExpressionFromRowData,
} from 'container/ApiMonitoring/utils';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from 'container/ApiMonitoring/filterParamKeys';
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
import { cleanup, fireEvent, render, screen, waitFor } from 'tests/test-utils';
import AllEndPoints from '../Explorer/Domains/DomainDetails/AllEndPoints';
import { FilterProviders } from './FilterProviders';
import {
SPAN_ATTRIBUTES,
VIEWS,
@@ -14,7 +20,7 @@ import {
// Mock the dependencies
jest.mock('container/ApiMonitoring/utils', () => ({
getAllEndpointsWidgetData: jest.fn(),
getGroupByFiltersFromGroupByValues: jest.fn(),
getGroupByExpressionFromRowData: jest.fn(),
}));
jest.mock('container/WidgetCard/Card', () => ({
@@ -34,53 +40,47 @@ jest.mock('container/WidgetCard/Card', () => ({
)),
}));
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="query-builder-mock">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void =>
onChange({
items: [{ id: 'test', key: 'test', op: '=', value: 'test' }],
op: 'AND',
})
}
>
Change Filter
</button>
</div>
)),
}),
);
jest.mock('hooks/queryBuilder/useGetAggregateKeys', () => ({
useGetAggregateKeys: jest.fn(),
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onRun }) => (
<div data-testid="query-builder-mock">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void => onRun("test = 'test'")}
>
Change Filter
</button>
</div>
)),
}));
jest.mock('antd', () => {
const originalModule = jest.requireActual('antd');
return {
...originalModule,
Select: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="select-mock">
<button
data-testid="select-change-button"
type="button"
onClick={(): void => onChange(['http.status_code'])}
>
Change GroupBy
</button>
</div>
)),
};
});
jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
GroupByFilter: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="select-mock">
<button
data-testid="select-change-button"
type="button"
onClick={(): void =>
onChange([
{
id: 'http.status_code--string--attribute',
key: 'http.status_code',
dataType: 'string',
type: 'attribute',
},
])
}
>
Change GroupBy
</button>
</div>
)),
}));
// Mock useApiMonitoringParams hook
jest.mock('container/ApiMonitoring/queryParams', () => ({
...jest.requireActual('container/ApiMonitoring/queryParams'),
useApiMonitoringParams: jest.fn().mockReturnValue([
{
showIP: true,
@@ -88,8 +88,6 @@ jest.mock('container/ApiMonitoring/queryParams', () => ({
selectedView: 'all_endpoints',
selectedEndPointName: '',
groupBy: [],
allEndpointsLocalFilters: undefined,
endPointDetailsLocalFilters: undefined,
modalTimeRange: undefined,
selectedInterval: undefined,
},
@@ -108,29 +106,21 @@ describe('AllEndPoints', () => {
startTime: 1609459200000,
endTime: 1609545600000,
},
initialFilters: { op: 'AND', items: [] },
setInitialFiltersEndPointStats: jest.fn(),
};
const renderAllEndPoints = (): void => {
render(
<FilterProviders queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}>
<AllEndPoints {...mockProps} />
</FilterProviders>,
);
};
beforeEach(() => {
jest.clearAllMocks();
resetNuqsState();
// Setup mock implementations
(useGetAggregateKeys as jest.Mock).mockReturnValue({
data: {
payload: {
attributeKeys: [
{
key: 'http.status_code',
dataType: 'string',
type: '',
},
],
},
},
isLoading: false,
});
(getAllEndpointsWidgetData as jest.Mock).mockReturnValue({
id: 'test-widget',
title: 'Endpoint Overview',
@@ -139,10 +129,9 @@ describe('AllEndPoints', () => {
queryData: [],
});
(getGroupByFiltersFromGroupByValues as jest.Mock).mockReturnValue({
items: [{ id: 'group-filter', key: 'status', op: '=', value: '200' }],
op: 'AND',
});
(getGroupByExpressionFromRowData as jest.Mock).mockReturnValue(
"status = '200'",
);
});
// Add cleanup after each test
@@ -151,7 +140,7 @@ describe('AllEndPoints', () => {
});
it('renders component correctly', () => {
render(<AllEndPoints {...mockProps} />);
renderAllEndPoints();
// Verify basic component rendering
expect(screen.getByText('Group by')).toBeInTheDocument();
@@ -161,7 +150,7 @@ describe('AllEndPoints', () => {
});
it('handles filter changes', () => {
render(<AllEndPoints {...mockProps} />);
renderAllEndPoints();
// Trigger filter change
fireEvent.click(screen.getByTestId('filter-change-button'));
@@ -170,25 +159,24 @@ describe('AllEndPoints', () => {
expect(getAllEndpointsWidgetData).toHaveBeenCalledWith(
expect.anything(),
'test-domain',
expect.objectContaining({
items: expect.arrayContaining([expect.objectContaining({ id: 'test' })]),
op: 'AND',
}),
"test = 'test'",
);
});
it('handles group by changes', () => {
render(<AllEndPoints {...mockProps} />);
renderAllEndPoints();
// Trigger group by change
fireEvent.click(screen.getByTestId('select-change-button'));
// Check if setGroupBy was called with updated group by value
expect(mockProps.setGroupBy).toHaveBeenCalled();
// The picked keys are stored as given and written to the URL as bare names
expect(mockProps.setGroupBy).toHaveBeenCalledWith([
expect.objectContaining({ key: 'http.status_code', type: 'attribute' }),
]);
});
it('handles row click in grid card', async () => {
render(<AllEndPoints {...mockProps} />);
renderAllEndPoints();
// Trigger row click
fireEvent.click(screen.getByTestId('row-click-button'));
@@ -196,7 +184,11 @@ describe('AllEndPoints', () => {
// Check if proper functions were called
expect(mockProps.setSelectedEndPointName).toHaveBeenCalledWith('/api/test');
expect(mockProps.setSelectedView).toHaveBeenCalledWith(VIEWS.ENDPOINT_STATS);
expect(mockProps.setInitialFiltersEndPointStats).toHaveBeenCalled();
expect(getGroupByFiltersFromGroupByValues).toHaveBeenCalled();
expect(getGroupByExpressionFromRowData).toHaveBeenCalled();
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
"status = '200'",
),
);
});
});

View File

@@ -14,16 +14,12 @@ import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
describe('AllEndpointsWidget - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
const emptyFilters = '';
const emptyGroupBy: BaseAutocompleteData[] = [];
describe('1. V5 Format Migration - All Four Queries', () => {

View File

@@ -121,8 +121,7 @@ describe('DependentServices', () => {
);
});
it('renders loading state correctly', () => {
// Arrange
it('keeps the table in place while loading', () => {
const mockQuery: MockQueryResult = {
isLoading: true,
isRefetching: false,
@@ -131,16 +130,15 @@ describe('DependentServices', () => {
refetch: refetchFn,
};
// Act
const { container } = render(
render(
<DependentServices
dependentServicesQuery={mockQuery as any}
timeRange={mockTimeRange}
/>,
);
// Assert
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
expect(screen.getByTestId('dependent-services-table')).toBeInTheDocument();
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
});
it('renders error state correctly', () => {
@@ -225,8 +223,7 @@ describe('DependentServices', () => {
expect(screen.getByTestId('page-size')).toHaveTextContent('5');
});
it('handles refetching state correctly', () => {
// Arrange
it('shows the rows under the spinner while refetching', () => {
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: true,
@@ -235,16 +232,16 @@ describe('DependentServices', () => {
refetch: refetchFn,
};
// Act
const { container } = render(
render(
<DependentServices
dependentServicesQuery={mockQuery as any}
timeRange={mockTimeRange}
/>,
);
// Assert
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
expect(screen.getByTestId('row-count')).toHaveTextContent('2');
expect(screen.getByTestId('table-row-0')).toHaveTextContent('auth-service');
});
it('handles row click correctly', () => {

View File

@@ -0,0 +1,60 @@
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import { render, screen } from 'tests/test-utils';
import { VIEWS } from '../Explorer/Domains/DomainDetails/constants';
import DomainDetails from '../Explorer/Domains/DomainDetails/DomainDetails';
jest.mock('container/ApiMonitoring/queryParams', () => ({
...jest.requireActual('container/ApiMonitoring/queryParams'),
useApiMonitoringParams: jest.fn(),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('../Explorer/Domains/DomainDetails/components/DomainMetrics', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('../Explorer/Domains/DomainDetails/EndPointDetails', () => {
const { useInitialExpression } = jest.requireActual(
'components/QueryBuilderV2',
);
return {
__esModule: true,
default: function MockEndPointDetails(): JSX.Element {
return (
<span data-testid="endpoint-stats-scope">{useInitialExpression()}</span>
);
},
};
});
describe('DomainDetails', () => {
it('scopes Endpoint Stats to the domain and the picked endpoint', () => {
jest
.mocked(useApiMonitoringParams)
.mockReturnValue([
{ selectedView: VIEWS.ENDPOINT_STATS, selectedEndPointName: '/v1/charges' },
jest.fn(),
]);
render(
<DomainDetails
domainData={{ domainName: 'api.example.com' }}
handleClose={jest.fn()}
selectedDomainIndex={0}
setSelectedDomainIndex={jest.fn()}
domainListLength={1}
domainListFilterExpression=""
/>,
);
expect(screen.getByTestId('endpoint-stats-scope')).toHaveTextContent(
"kind_string = 'Client' AND http_host = 'api.example.com' AND http_url = '/v1/charges'",
);
});
});

View File

@@ -0,0 +1,84 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import DomainList from '../Explorer/Domains/DomainList';
const FILTER = "http_host = 'api.example.com'";
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(
({
onChange,
onRun,
}: {
onChange: (query: string) => void;
onRun?: (query: string) => void;
}) => (
<>
<button
type="button"
data-testid="query-search-type"
onClick={(): void => onChange("http_host = 'api.example.com'")}
>
Type
</button>
<button
type="button"
data-testid="query-search-run"
onClick={(): void => onRun?.("http_host = 'api.example.com'")}
>
Run
</button>
</>
),
),
}));
jest.mock('container/Toolbar/Toolbar', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('../Explorer/Domains/DomainDetails/DomainDetails', () => ({
__esModule: true,
default: (): null => null,
}));
// Under the test router it keeps rewriting the URL, which re-renders the page
// without end.
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
useShareBuilderUrl: jest.fn(),
}));
describe('DomainList', () => {
it('runs the typed filter from the filter box', async () => {
let listRequests = 0;
server.use(
rest.post('*/third-party-apis/overview/list', (_req, res, ctx) => {
listRequests += 1;
return res(ctx.status(200), ctx.json({ data: { data: { results: [] } } }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<DomainList />, undefined, {
initialRoute: '/api-monitoring/explorer',
});
await waitFor(() => expect(listRequests).toBe(1));
await user.click(screen.getByTestId('query-search-type'));
await user.click(screen.getByTestId('query-search-run'));
await waitFor(() => expect(listRequests).toBe(2));
const [url] = safeNavigateMock.mock.calls[0];
const compositeQuery = new URLSearchParams(String(url).split('?')[1]).get(
'compositeQuery',
);
expect(decodeURIComponent(compositeQuery ?? '')).toContain(
`"expression":"${FILTER}"`,
);
});
});

View File

@@ -1,5 +1,7 @@
import { useQueries } from 'react-query';
import { fireEvent, render, screen } from '@testing-library/react';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ENDPOINT_STATS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
extractPortAndEndpoint,
@@ -11,14 +13,13 @@ import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import EndPointPicker from '../Explorer/Domains/DomainDetails/components/EndPointPicker';
import EndPointDetails from '../Explorer/Domains/DomainDetails/EndPointDetails';
import { FilterProviders } from './FilterProviders';
// Mock dependencies
jest.mock('react-query', () => ({
@@ -26,72 +27,32 @@ jest.mock('react-query', () => ({
useQueries: jest.fn(),
}));
// Mock useApiMonitoringParams hook
jest.mock('container/ApiMonitoring/queryParams', () => ({
useApiMonitoringParams: jest.fn().mockReturnValue([
{
showIP: true,
selectedDomain: '',
selectedView: 'all_endpoints',
selectedEndPointName: '',
groupBy: [],
allEndpointsLocalFilters: undefined,
endPointDetailsLocalFilters: undefined,
modalTimeRange: undefined,
selectedInterval: undefined,
},
jest.fn(),
]),
}));
jest.mock('container/ApiMonitoring/utils', () => ({
END_POINT_DETAILS_QUERY_KEYS_ARRAY: [
'endPointMetricsData',
'endPointStatusCodeData',
'endPointDropDownData',
'endPointDependentServicesData',
'endPointStatusCodeBarChartsData',
'endPointStatusCodeLatencyBarChartsData',
],
END_POINT_DETAILS_QUERY_KEYS_ARRAY: jest.requireActual(
'container/ApiMonitoring/utils',
).END_POINT_DETAILS_QUERY_KEYS_ARRAY,
extractPortAndEndpoint: jest.fn(),
getHttpUrlExpression: jest.requireActual('container/ApiMonitoring/utils')
.getHttpUrlExpression,
getEndPointDetailsQueryPayload: jest.fn(),
getLatencyOverTimeWidgetData: jest.fn(),
getRateOverTimeWidgetData: jest.fn(),
}));
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="query-builder-search">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void =>
onChange({
items: [
{
id: 'test-filter',
key: {
key: 'test.key',
dataType: DataTypes.String,
type: 'tag',
},
op: '=',
value: 'test-value',
},
],
op: 'AND',
})
}
>
Change Filter
</button>
</div>
)),
}),
);
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onRun }) => (
<div data-testid="query-builder-search">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void => onRun("test.key = 'test-value'")}
>
Change Filter
</button>
</div>
)),
}));
// Mock all child components to simplify testing
jest.mock(
@@ -107,15 +68,15 @@ jest.mock(
);
jest.mock(
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ setSelectedEndPointName }) => (
default: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="endpoints-dropdown">
<button
type="button"
data-testid="select-endpoint-button"
onClick={(): void => setSelectedEndPointName('/api/new-endpoint')}
onClick={(): void => onChange('/api/new-endpoint')}
>
Select Endpoint
</button>
@@ -172,8 +133,23 @@ jest.mock(
}),
);
function renderStats(ui: JSX.Element, linkFilter = ''): void {
render(
<FilterProviders
queryParamKey={ENDPOINT_STATS_EXPRESSION_KEY}
searchParams={
linkFilter
? `${ENDPOINT_STATS_EXPRESSION_KEY}=${encodeURIComponent(linkFilter)}`
: ''
}
>
{ui}
</FilterProviders>,
);
}
describe('EndPointDetails Component', () => {
const mockQueryResults = Array(6).fill({
const mockQueryResults = Array(5).fill({
data: { data: [] },
isLoading: false,
isError: false,
@@ -184,7 +160,6 @@ describe('EndPointDetails Component', () => {
domainName: 'test-domain',
endPointName: '/api/test',
setSelectedEndPointName: jest.fn(),
initialFilters: { items: [], op: 'AND' } as TagFilter,
timeRange: {
startTime: 1609459200000,
endTime: 1609545600000,
@@ -197,6 +172,7 @@ describe('EndPointDetails Component', () => {
beforeEach(() => {
jest.clearAllMocks();
resetNuqsState();
(extractPortAndEndpoint as jest.Mock).mockReturnValue({
port: '8080',
@@ -209,7 +185,6 @@ describe('EndPointDetails Component', () => {
{ id: 'query3', label: 'Query 3' },
{ id: 'query4', label: 'Query 4' },
{ id: 'query5', label: 'Query 5' },
{ id: 'query6', label: 'Query 6' },
]);
(getRateOverTimeWidgetData as jest.Mock).mockReturnValue({
@@ -226,7 +201,7 @@ describe('EndPointDetails Component', () => {
});
it('renders the component correctly', () => {
render(<EndPointDetails {...mockProps} />);
renderStats(<EndPointDetails {...mockProps} />);
// Check all major components are rendered
expect(screen.getByTestId('query-builder-search')).toBeInTheDocument();
@@ -246,44 +221,29 @@ describe('EndPointDetails Component', () => {
});
it('calls getEndPointDetailsQueryPayload with correct parameters', () => {
render(<EndPointDetails {...mockProps} />);
renderStats(<EndPointDetails {...mockProps} />);
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
'test-domain',
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
op: 'AND',
}),
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`,
);
});
it('adds endpoint filter to initial filters', () => {
render(<EndPointDetails {...mockProps} />);
renderStats(<EndPointDetails {...mockProps} />);
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
}),
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
);
});
it('updates filters when QueryBuilderSearch changes', () => {
render(<EndPointDetails {...mockProps} />);
it('updates filters when the search expression is run', async () => {
renderStats(<EndPointDetails {...mockProps} />);
// Trigger filter change
fireEvent.click(screen.getByTestId('filter-change-button'));
@@ -294,19 +254,37 @@ describe('EndPointDetails Component', () => {
expect.anything(),
expect.anything(),
expect.anything(),
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test' AND (test.key = 'test-value')`,
);
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
"test.key = 'test-value'",
),
);
});
it('keeps the endpoint out of the editor and the picker options', () => {
renderStats(<EndPointDetails {...mockProps} />, "service.name = 'checkout'");
expect(QuerySearch).toHaveBeenLastCalledWith(
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'test.key' }),
value: 'test-value',
}),
]),
queryData: expect.objectContaining({
filter: { expression: "service.name = 'checkout'" },
}),
}),
expect.anything(),
);
expect(EndPointPicker).toHaveBeenLastCalledWith(
expect.objectContaining({
filterExpression: "service.name = 'checkout'",
value: '/api/test',
}),
expect.anything(),
);
});
it('handles endpoint dropdown selection', () => {
render(<EndPointDetails {...mockProps} />);
renderStats(<EndPointDetails {...mockProps} />);
// Trigger endpoint selection
fireEvent.click(screen.getByTestId('select-endpoint-button'));
@@ -318,63 +296,50 @@ describe('EndPointDetails Component', () => {
});
it('does not display dependent services when service filter is applied', () => {
const propsWithServiceFilter = {
...mockProps,
initialFilters: {
items: [
{
id: 'service-filter',
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'tag',
},
op: '=',
value: 'test-service',
},
] as TagFilterItem[],
op: 'AND',
} as TagFilter,
};
render(<EndPointDetails {...propsWithServiceFilter} />);
renderStats(
<EndPointDetails {...mockProps} />,
"service.name = 'test-service'",
);
// Dependent services should not be displayed
expect(screen.queryByTestId('dependent-services')).not.toBeInTheDocument();
});
it('passes the correct parameters to widget data generators', () => {
render(<EndPointDetails {...mockProps} />);
renderStats(<EndPointDetails {...mockProps} />);
expect(getRateOverTimeWidgetData).toHaveBeenCalledWith(
'test-domain',
'/api/test',
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
}),
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
);
expect(getLatencyOverTimeWidgetData).toHaveBeenCalledWith(
'test-domain',
'/api/test',
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
}),
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
);
});
it('keeps the previous dependent services while a new filter loads', () => {
renderStats(<EndPointDetails {...mockProps} />);
const queries = jest.mocked(useQueries).mock.calls[0][0];
expect(
queries.map(({ queryKey, keepPreviousData }) => [
(queryKey as unknown[])[0],
keepPreviousData,
]),
).toStrictEqual(
END_POINT_DETAILS_QUERY_KEYS_ARRAY.map((key) => [
key,
key === REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
]),
);
});
it('generates correct query parameters for useQueries', () => {
render(<EndPointDetails {...mockProps} />);
renderStats(<EndPointDetails {...mockProps} />);
// Check if useQueries was called with correct parameters
expect(useQueries).toHaveBeenCalledWith(

View File

@@ -0,0 +1,115 @@
import {
GetMetricQueryRange,
GetQueryResultsProps,
} from 'lib/dashboard/getQueryResults';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import EndPointPicker from '../Explorer/Domains/DomainDetails/components/EndPointPicker';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
jest.mock('lib/dashboard/getQueryResults', () => ({
GetMetricQueryRange: jest.fn(),
}));
const mockGetMetricQueryRange = jest.mocked(GetMetricQueryRange);
const rowsResponse = (urls: string[]): MetricQueryRangeSuccessResponse => ({
statusCode: 200,
message: 'success',
error: null,
payload: {
data: {
resultType: '',
newResult: { data: { result: [], resultType: '' } },
result: [
{
metric: {},
queryName: 'A',
values: [],
table: {
columns: [],
rows: urls.map((url) => ({
data: { [SPAN_ATTRIBUTES.HTTP_URL]: url, A: 1 },
})),
},
},
],
},
},
});
const baseProps = {
domainName: 'api.example.com',
timeRange: { startTime: 1000, endTime: 2000 },
filterExpression: "service.name = 'checkout'",
value: '',
};
describe('EndPointPicker', () => {
beforeEach(() => {
mockGetMetricQueryRange.mockResolvedValue(
rowsResponse(['/v1/charges', '/v1/refunds']),
);
});
it('loads options for the domain scoped by the user filter', async () => {
render(<EndPointPicker {...baseProps} onChange={jest.fn()} />);
await waitFor(() => expect(mockGetMetricQueryRange).toHaveBeenCalled());
const payload: GetQueryResultsProps =
mockGetMetricQueryRange.mock.calls[0][0];
expect(payload.query.builder.queryData[0].filter?.expression).toBe(
`http_host = 'api.example.com' AND kind_string = 'Client' AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS AND (service.name = 'checkout')`,
);
});
it('reports the picked endpoint', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const onChange = jest.fn() as jest.MockedFunction<
(endPointName: string) => void
>;
render(<EndPointPicker {...baseProps} onChange={onChange} />);
await waitFor(() => expect(mockGetMetricQueryRange).toHaveBeenCalled());
await user.click(screen.getByTestId('endpoint-picker'));
await user.click(await screen.findByText('/v1/refunds'));
expect(onChange).toHaveBeenCalledWith('/v1/refunds');
});
it('shows All endpoints when none is picked and clears through it', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const onChange = jest.fn() as jest.MockedFunction<
(endPointName: string) => void
>;
const { rerender } = render(
<EndPointPicker {...baseProps} onChange={onChange} />,
);
expect(screen.getByTestId('endpoint-picker')).toHaveTextContent(
'All endpoints',
);
rerender(
<EndPointPicker {...baseProps} value="/v1/charges" onChange={onChange} />,
);
await waitFor(() => expect(mockGetMetricQueryRange).toHaveBeenCalled());
await user.click(screen.getByTestId('endpoint-picker'));
await user.click(
await screen.findByRole('option', { name: 'All endpoints' }),
);
expect(onChange).toHaveBeenCalledWith('');
});
it('shows the selected endpoint', async () => {
render(
<EndPointPicker {...baseProps} value="/v1/charges" onChange={jest.fn()} />,
);
await waitFor(() =>
expect(screen.getByText('/v1/charges')).toBeInTheDocument(),
);
});
});

View File

@@ -1,210 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { getFormattedEndPointDropDownData } from 'container/ApiMonitoring/utils';
import EndPointsDropDown from '../Explorer/Domains/DomainDetails/components/EndPointsDropDown';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
// Mock the Select component from antd
jest.mock('antd', () => {
const originalModule = jest.requireActual('antd');
return {
...originalModule,
Select: jest
.fn()
.mockImplementation(({ value, loading, onChange, options, onClear }) => (
<div data-testid="mock-select">
<div data-testid="select-value">{value}</div>
<div data-testid="select-loading">
{loading ? 'loading' : 'not-loading'}
</div>
<select
data-testid="select-element"
value={value || ''}
onChange={(e): void => onChange(e.target.value)}
>
<option value="">Select...</option>
{options?.map((option: { value: string; label: string; key: string }) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button data-testid="select-clear-button" type="button" onClick={onClear}>
Clear
</button>
</div>
)),
};
});
// Mock the utilities
jest.mock('container/ApiMonitoring/utils', () => ({
getFormattedEndPointDropDownData: jest.fn(),
}));
describe('EndPointsDropDown Component', () => {
const mockEndPoints = [
{ key: '1', value: '/api/endpoint1', label: '/api/endpoint1' },
{ key: '2', value: '/api/endpoint2', label: '/api/endpoint2' },
];
const mockSetSelectedEndPointName = jest.fn();
// Create a mock that satisfies the UseQueryResult interface
const createMockQueryResult = (overrides: any = {}): any => ({
data: {
payload: {
data: {
result: [
{
table: {
rows: [],
},
},
],
},
},
},
dataUpdatedAt: 0,
error: null,
errorUpdatedAt: 0,
failureCount: 0,
isError: false,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isIdle: false,
isLoading: false,
isLoadingError: false,
isPlaceholderData: false,
isPreviousData: false,
isRefetchError: false,
isRefetching: false,
isStale: false,
isSuccess: true,
refetch: jest.fn(),
remove: jest.fn(),
status: 'success',
...overrides,
});
const defaultProps = {
selectedEndPointName: '',
setSelectedEndPointName: mockSetSelectedEndPointName,
endPointDropDownDataQuery: createMockQueryResult(),
};
beforeEach(() => {
jest.clearAllMocks();
(getFormattedEndPointDropDownData as jest.Mock).mockReturnValue(
mockEndPoints,
);
});
it('renders the component correctly', () => {
render(<EndPointsDropDown {...defaultProps} />);
expect(screen.getByTestId('mock-select')).toBeInTheDocument();
expect(screen.getByTestId('select-loading')).toHaveTextContent('not-loading');
});
it('shows loading state when data is loading', () => {
const loadingProps = {
...defaultProps,
endPointDropDownDataQuery: createMockQueryResult({
isLoading: true,
}),
};
render(<EndPointsDropDown {...loadingProps} />);
expect(screen.getByTestId('select-loading')).toHaveTextContent('loading');
});
it('shows loading state when data is fetching', () => {
const fetchingProps = {
...defaultProps,
endPointDropDownDataQuery: createMockQueryResult({
isFetching: true,
}),
};
render(<EndPointsDropDown {...fetchingProps} />);
expect(screen.getByTestId('select-loading')).toHaveTextContent('loading');
});
it('displays the selected endpoint', () => {
const selectedProps = {
...defaultProps,
selectedEndPointName: '/api/endpoint1',
};
render(<EndPointsDropDown {...selectedProps} />);
expect(screen.getByTestId('select-value')).toHaveTextContent(
'/api/endpoint1',
);
});
it('calls setSelectedEndPointName when an option is selected', () => {
render(<EndPointsDropDown {...defaultProps} />);
// Get the select element and change its value
const selectElement = screen.getByTestId('select-element');
fireEvent.change(selectElement, { target: { value: '/api/endpoint2' } });
expect(mockSetSelectedEndPointName).toHaveBeenCalledWith('/api/endpoint2');
});
it('calls setSelectedEndPointName with empty string when cleared', () => {
render(<EndPointsDropDown {...defaultProps} />);
// Click the clear button
const clearButton = screen.getByTestId('select-clear-button');
fireEvent.click(clearButton);
expect(mockSetSelectedEndPointName).toHaveBeenCalledWith('');
});
it('passes dropdown style prop correctly', () => {
const styleProps = {
...defaultProps,
dropdownStyle: { maxHeight: '200px' },
};
render(<EndPointsDropDown {...styleProps} />);
// We can't easily test style props in our mock, but at least ensure the component rendered
expect(screen.getByTestId('mock-select')).toBeInTheDocument();
});
it('formats data using the utility function', () => {
const mockRows = [
{ data: { [SPAN_ATTRIBUTES.HTTP_URL]: '/api/test', A: 10 } },
];
const dataProps = {
...defaultProps,
endPointDropDownDataQuery: createMockQueryResult({
data: {
payload: {
data: {
result: [
{
table: {
rows: mockRows,
},
},
],
},
},
},
}),
};
render(<EndPointsDropDown {...dataProps} />);
expect(getFormattedEndPointDropDownData).toHaveBeenCalledWith(mockRows);
});
});

View File

@@ -1,8 +1,8 @@
/**
* V5 Migration Tests for Endpoint Dropdown Query
*
* These tests validate the migration from V4 to V5 format for the third payload
* in getEndPointDetailsQueryPayload (endpoint dropdown data):
* These tests validate the migration from V4 to V5 format for
* getEndPointDropDownQueryPayload (endpoint picker options):
* - Filter format change: filters.items[] → filter.expression
* - Domain handling: http_host = '${domainName}'
* - Kind filter: kind_string = 'Client'
@@ -10,9 +10,8 @@
* - Aggregation: count() expression
* - GroupBy: http_url with type 'attribute'
*/
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
import { getEndPointDropDownQueryPayload } from 'container/ApiMonitoring/utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
@@ -20,22 +19,16 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const mockStartTime = 1000;
const mockEndTime = 2000;
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
const emptyFilters = '';
describe('1. V5 Format Migration - Structure and Base Filters', () => {
it('migrates to V5 format with correct filter expression structure, aggregations, and groupBy', () => {
const payload = getEndPointDetailsQueryPayload(
const dropdownQuery = getEndPointDropDownQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
emptyFilters,
);
// Third payload is the endpoint dropdown query (index 2)
const dropdownQuery = payload[2];
const queryA = dropdownQuery.query.builder.queryData[0];
// CRITICAL V5 MIGRATION: filter.expression (not filters.items)
@@ -77,46 +70,21 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
describe('2. Custom Filters Integration', () => {
it('merges custom filters into filter expression with AND logic', () => {
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
const payload = getEndPointDetailsQueryPayload(
const dropdownQuery = getEndPointDropDownQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
customFilters,
);
const dropdownQuery = payload[2];
const expression =
dropdownQuery.query.builder.queryData[0].filter?.expression;
// Exact filter expression with custom filters merged
expect(expression).toBe(
`${SPAN_ATTRIBUTES.SERVER_NAME} = 'api.example.com' AND kind_string = 'Client' AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS service.name = 'user-service' AND deployment.environment = 'production'`,
`${SPAN_ATTRIBUTES.SERVER_NAME} = 'api.example.com' AND kind_string = 'Client' AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS AND (service.name = 'user-service' AND deployment.environment = 'production')`,
);
});
});

View File

@@ -0,0 +1,34 @@
import { ReactNode } from 'react';
import { QuerySearchV2Provider } from 'components/QueryBuilderV2';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { onNuqsUrlUpdate } from 'tests/nuqs-helpers';
/** Mounts a drawer tab's filter provider over an in-memory URL. */
export function FilterProviders({
queryParamKey,
initialExpression,
searchParams = '',
children,
}: {
queryParamKey: string;
initialExpression?: string;
searchParams?: string;
children: ReactNode;
}): JSX.Element {
return (
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={onNuqsUrlUpdate}
rateLimitFactor={0}
hasMemory
>
<QuerySearchV2Provider
queryParamKey={queryParamKey}
initialExpression={initialExpression}
persistOnUnmount
>
{children}
</QuerySearchV2Provider>
</NuqsTestingAdapter>
);
}

View File

@@ -2,16 +2,11 @@ import {
getLatencyOverTimeWidgetData,
getRateOverTimeWidgetData,
} from 'container/ApiMonitoring/utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
describe('MetricOverTime - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const mockEndpointName = '/api/users';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
const emptyFilters = '';
describe('1. Rate Over Time - V5 Payload Structure', () => {
it('generates V5 filter expression format (not V3 filters.items)', () => {
@@ -51,31 +46,8 @@ describe('MetricOverTime - V5 Migration Validation', () => {
});
it('merges custom filters into filter expression', () => {
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: DataTypes.String,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
const widget = getRateOverTimeWidgetData(
mockDomainName,
@@ -137,21 +109,8 @@ describe('MetricOverTime - V5 Migration Validation', () => {
});
it('merges custom filters into filter expression', () => {
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
op: '=',
value: 'user-service',
},
],
op: 'AND',
};
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
const widget = getLatencyOverTimeWidgetData(
mockDomainName,
@@ -163,7 +122,7 @@ describe('MetricOverTime - V5 Migration Validation', () => {
// Verify domain filter is present
expect(queryData?.filter?.expression).toContain(
`http_host = '${mockDomainName}' service.name = 'user-service'`,
`http_host = '${mockDomainName}' AND (service.name = 'user-service'`,
);
});
});

View File

@@ -5,7 +5,6 @@ import {
getStatusCodeBarChartWidgetData,
} from 'container/ApiMonitoring/utils';
import { SuccessResponse } from 'types/api';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import ErrorState from '../Explorer/Domains/DomainDetails/components/ErrorState';
import StatusCodeBarCharts from '../Explorer/Domains/DomainDetails/components/StatusCodeBarCharts';
@@ -156,7 +155,6 @@ jest.mock('antd', () => {
describe('StatusCodeBarCharts', () => {
// Default props for tests
const mockFilters: IBuilderQuery['filters'] = { items: [], op: 'AND' };
const mockTimeRange = {
startTime: 1609459200000,
endTime: 1609545600000,
@@ -251,7 +249,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filters={mockFilters}
filterExpression=""
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -286,7 +284,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filters={mockFilters}
filterExpression=""
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -328,7 +326,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filters={mockFilters}
filterExpression=""
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -372,7 +370,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filters={mockFilters}
filterExpression=""
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -419,7 +417,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filters={mockFilters}
filterExpression=""
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -433,26 +431,13 @@ describe('StatusCodeBarCharts', () => {
// but we've confirmed the function is mocked and ready to be tested
expect(getStatusCodeBarChartWidgetData).toHaveBeenCalledWith(
mockDomainName,
expect.objectContaining({
items: [],
op: 'AND',
}),
'',
);
});
it('handles widget generation with current filters', () => {
// Arrange
const mockCustomFilters = {
items: [
{
id: 'custom-filter',
key: { key: 'test-key' },
op: '=',
value: 'test-value',
},
],
op: 'AND',
};
const mockCustomFilterExpression = "test-key = 'test-value'";
const mockData = {
payload: mockFormattedData,
@@ -480,7 +465,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filters={mockCustomFilters as IBuilderQuery['filters']}
filterExpression={mockCustomFilterExpression}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -489,12 +474,7 @@ describe('StatusCodeBarCharts', () => {
// Assert widget creation was called with the correct parameters
expect(getStatusCodeBarChartWidgetData).toHaveBeenCalledWith(
mockDomainName,
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({ id: 'custom-filter' }),
]),
op: 'AND',
}),
mockCustomFilterExpression,
);
});
});

View File

@@ -2,7 +2,7 @@
* V5 Migration Tests for Status Code Bar Chart Queries
*
* These tests validate the migration to V5 format for the bar chart payloads
* in getEndPointDetailsQueryPayload (5th and 6th payloads):
* in getEndPointDetailsQueryPayload (4th and 5th payloads):
* - Number of Calls Chart (count aggregation)
* - Latency Chart (p99 aggregation)
*
@@ -15,16 +15,12 @@
*/
import { TraceAggregation } from 'api/v5/v5';
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
describe('StatusCodeBarCharts - V5 Migration Validation', () => {
const mockDomainName = '0.0.0.0';
const mockStartTime = 1762573673000;
const mockEndTime = 1762832873000;
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
const emptyFilters = '';
describe('1. Number of Calls Chart - V5 Payload Structure', () => {
it('generates correct V5 payload for count aggregation grouped by status code', () => {
@@ -35,8 +31,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
emptyFilters,
);
// 5th payload (index 4) is the number of calls bar chart
const callsChartQuery = payload[4];
// 4th payload (index 3) is the number of calls bar chart
const callsChartQuery = payload[3];
const queryA = callsChartQuery.query.builder.queryData[0];
// V5 format: filter.expression (not filters.items)
@@ -84,8 +80,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
emptyFilters,
);
// 6th payload (index 5) is the latency bar chart
const latencyChartQuery = payload[5];
// 5th payload (index 4) is the latency bar chart
const latencyChartQuery = payload[4];
const queryA = latencyChartQuery.query.builder.queryData[0];
// V5 format: filter.expression (not filters.items)
@@ -130,31 +126,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
describe('3. Custom Filters Integration', () => {
it('merges custom filters into filter expression for both charts', () => {
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
@@ -163,8 +136,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
customFilters,
);
const callsChartQuery = payload[4];
const latencyChartQuery = payload[5];
const callsChartQuery = payload[3];
const latencyChartQuery = payload[4];
const callsExpression =
callsChartQuery.query.builder.queryData[0].filter?.expression;

View File

@@ -13,16 +13,12 @@
*/
import { TraceAggregation } from 'api/v5/v5';
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
describe('StatusCodeTable - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const mockStartTime = 1000;
const mockEndTime = 2000;
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
const emptyFilters = '';
describe('1. V5 Format Migration with Base Filters', () => {
it('migrates to V5 format with correct filter expression structure and base filters', () => {
@@ -109,31 +105,8 @@ describe('StatusCodeTable - V5 Migration Validation', () => {
describe('3. Custom Filters Integration', () => {
it('merges custom filters into filter expression with AND logic', () => {
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
const payload = getEndPointDetailsQueryPayload(
mockDomainName,

View File

@@ -1,31 +1,40 @@
import { BuilderQuery } from 'api/v5/v5';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
within,
} from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors';
import { getTopErrorsQueryPayload } from '../utils';
// Mock the EndPointsDropDown component to avoid issues
interface QueryRangeRequestBody {
compositeQuery: { queries: { spec: { filter: { expression: string } } }[] };
}
// Mock the EndPointPicker component to avoid issues
jest.mock(
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
() => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(({ setSelectedEndPointName }): JSX.Element => (
<div data-testid="endpoints-dropdown-mock">
<select
data-testid="endpoints-select"
onChange={(e): void => setSelectedEndPointName(e.target.value)}
>
<option value="/api/test">/api/test</option>
<option value="/api/new-endpoint">/api/new-endpoint</option>
</select>
</div>
)),
default: jest.fn().mockImplementation(({ onChange }): JSX.Element => (
<div data-testid="endpoints-dropdown-mock">
<select
data-testid="endpoints-select"
onChange={(e): void => onChange(e.target.value)}
>
<option value="/api/test">/api/test</option>
<option value="/api/new-endpoint">/api/new-endpoint</option>
</select>
</div>
)),
}),
);
@@ -43,10 +52,7 @@ describe('TopErrors', () => {
startTime: 1000000000,
endTime: 1000010000,
},
initialFilters: {
items: [],
op: 'AND',
},
initialFilterExpression: '',
};
// Helper function to wait for table data to load
@@ -227,22 +233,30 @@ describe('TopErrors', () => {
});
});
it('updates endpoint filter when dropdown value changes', async () => {
it('scopes the query to the endpoint picked in the picker', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const expressions: string[] = [];
server.use(
rest.post(V5_QUERY_RANGE_API_PATH, async (req) => {
const body: QueryRangeRequestBody = await req.json();
expressions.push(body.compositeQuery.queries[0].spec.filter.expression);
}),
);
render(<TopErrors {...mockProps} />);
await waitFor(() => expect(expressions).toHaveLength(1));
expect(expressions[0]).not.toContain(`${SPAN_ATTRIBUTES.HTTP_URL} =`);
await user.selectOptions(
screen.getByTestId('endpoints-select'),
'/api/new-endpoint',
);
// Wait for initial load
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(expressions[expressions.length - 1]).toContain(
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/new-endpoint'`,
);
});
// Find the dropdown
const dropdown = screen.getByRole('combobox');
// Mock the change
fireEvent.change(dropdown, { target: { value: '/api/new-endpoint' } });
// Component should re-render with new filter
expect(dropdown).toBeInTheDocument();
});
it('handles status message toggle correctly', async () => {
@@ -306,7 +320,7 @@ describe('TopErrors', () => {
'test-domain',
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
{ items: [], op: 'AND' },
'',
false,
);

View File

@@ -2,6 +2,7 @@ import {
ApiMonitoringParams,
DEFAULT_PARAMS,
getApiMonitoringParams,
getLegacyFilterExpressions,
setApiMonitoringParams,
} from 'container/ApiMonitoring/queryParams';
import { mockNavigate } from 'tests/router-mocks';
@@ -52,6 +53,50 @@ describe('API Monitoring Query Params', () => {
expect(getApiMonitoringParams(search)).toStrictEqual(DEFAULT_PARAMS);
});
it('converts filter items saved by older links into an expression', () => {
const legacyParams = {
selectedDomain: 'test-domain',
allEndpointsLocalFilters: {
op: 'AND',
items: [
{
id: 'a',
key: { key: 'service.name', dataType: 'string', type: 'resource' },
op: '=',
value: 'frontend',
},
],
},
endPointDetailsLocalFilters: {
op: 'AND',
items: [
{
id: 'b',
key: { key: 'http.status_code', dataType: 'string', type: '' },
op: '=',
value: '500',
},
],
},
};
const urlParams = new URLSearchParams();
urlParams.set(
'apiMonitoringParams',
encodeURIComponent(JSON.stringify(legacyParams)),
);
const search = `?${urlParams.toString()}`;
expect(getLegacyFilterExpressions(search)).toStrictEqual({
allEndpoints: "service.name = 'frontend'",
endPointDetails: "http.status_code = '500'",
});
const result = getApiMonitoringParams(search);
expect(result.selectedDomain).toBe('test-domain');
expect(result).not.toHaveProperty('allEndpointsLocalFilters');
expect(result).not.toHaveProperty('endPointDetailsLocalFilters');
});
});
describe('setApiMonitoringParams', () => {

View File

@@ -0,0 +1,65 @@
import { ALL_ENDPOINTS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
import {
getApiMonitoringParams,
useApiMonitoringParams,
} from 'container/ApiMonitoring/queryParams';
import { mockAppLocation, mockNavigate } from 'tests/router-mocks';
import { act, renderHook } from 'tests/test-utils';
// The stub writes to the browser URL, which the hook reads back; the router's
// own location (`useAppLocation`) stays at its first value.
function renderParamsHook(): ReturnType<
typeof renderHook<ReturnType<typeof useApiMonitoringParams>, unknown>
> {
mockNavigate().mockImplementation((to) => {
const url = typeof to === 'string' ? to : `/${to.search ?? ''}`;
window.history.pushState(null, '', url);
});
mockAppLocation();
return renderHook(() => useApiMonitoringParams());
}
describe('useApiMonitoringParams', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/');
});
it('keeps every write made before the next render', () => {
const { result } = renderParamsHook();
act(() => {
const [, setParams] = result.current;
setParams({ selectedView: 'endpoint_stats' });
setParams({ selectedInterval: '15m' });
});
expect(getApiMonitoringParams(window.location.search)).toMatchObject({
selectedView: 'endpoint_stats',
selectedInterval: '15m',
});
});
it('keeps a drawer filter written outside the router', () => {
window.history.replaceState(
null,
'',
`/?${ALL_ENDPOINTS_EXPRESSION_KEY}=${encodeURIComponent(
"service.name = 'cart'",
)}`,
);
const { result } = renderParamsHook();
act(() => {
const [, setParams] = result.current;
setParams({ selectedView: 'endpoint_stats' });
});
const search = new URLSearchParams(window.location.search);
expect(search.get(ALL_ENDPOINTS_EXPRESSION_KEY)).toBe(
"service.name = 'cart'",
);
expect(getApiMonitoringParams(window.location.search).selectedView).toBe(
'endpoint_stats',
);
});
});

View File

@@ -0,0 +1,5 @@
// The drawer filters live in their own URL params, owned by `QuerySearchV2Provider`.
export const ALL_ENDPOINTS_EXPRESSION_KEY =
'apiMonitoringAllEndpointsExpression';
export const ENDPOINT_STATS_EXPRESSION_KEY =
'apiMonitoringEndpointStatsExpression';

View File

@@ -1,6 +1,10 @@
import { useCallback } from 'react';
import { useAppLocation } from 'lib/router/useAppLocation';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { navigate } from 'lib/router/navigation';
import { useAppLocation } from 'lib/router/useAppLocation';
import { omit } from 'lodash-es';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
// --- Types for all API Monitoring query params ---
export interface ApiMonitoringParams {
@@ -9,8 +13,6 @@ export interface ApiMonitoringParams {
selectedView?: string;
selectedEndPointName?: string;
groupBy?: string[];
allEndpointsLocalFilters?: any;
endPointDetailsLocalFilters?: any;
modalTimeRange?: { startTime: number; endTime: number };
selectedInterval?: string;
// Add more params as needed
@@ -22,8 +24,6 @@ export const DEFAULT_PARAMS: ApiMonitoringParams = {
selectedView: 'all_endpoints',
selectedEndPointName: '',
groupBy: [],
allEndpointsLocalFilters: undefined,
endPointDetailsLocalFilters: undefined,
modalTimeRange: undefined,
selectedInterval: undefined,
};
@@ -35,15 +35,50 @@ function encodeParams(params: ApiMonitoringParams): string {
return encodeURIComponent(JSON.stringify(params));
}
function decodeParams(value: string | null): ApiMonitoringParams {
// Older shared links carry the filters as TagFilter items.
type LegacyApiMonitoringParams = ApiMonitoringParams & {
allEndpointsLocalFilters?: TagFilter;
endPointDetailsLocalFilters?: TagFilter;
};
function parseParams(value: string | null): LegacyApiMonitoringParams | null {
if (!value) {
return DEFAULT_PARAMS;
return null;
}
try {
return JSON.parse(decodeURIComponent(value));
} catch {
return null;
}
}
// Drops the legacy filters, so the next write leaves them out of the URL.
function decodeParams(value: string | null): ApiMonitoringParams {
const parsed = parseParams(value);
if (!parsed) {
return DEFAULT_PARAMS;
}
return omit(parsed, [
'allEndpointsLocalFilters',
'endPointDetailsLocalFilters',
]);
}
export interface LegacyFilterExpressions {
allEndpoints?: string;
endPointDetails?: string;
}
export function getLegacyFilterExpressions(
search: string,
): LegacyFilterExpressions {
const parsed = parseParams(new URLSearchParams(search).get(PARAM_KEY));
const toExpression = (filters?: TagFilter): string | undefined =>
filters ? convertFiltersToExpression(filters).expression : undefined;
return {
allEndpoints: toExpression(parsed?.allEndpointsLocalFilters),
endPointDetails: toExpression(parsed?.endPointDetailsLocalFilters),
};
}
// --- Read query params from URL ---
@@ -74,11 +109,18 @@ export function useApiMonitoringParams(): [
const location = useAppLocation();
const params = getApiMonitoringParams(location.search);
// Read the browser's URL, not the router's: several writes can land before
// the next render, and the drawer filters are written by nuqs, which the
// router's location does not see.
const setParams = useCallback(
(newParams: Partial<ApiMonitoringParams>, replace = false) => {
setApiMonitoringParams(newParams, location.search, replace);
setApiMonitoringParams(
newParams,
`?${getUnstableCurrentSearchParams().toString()}`,
replace,
);
},
[location.search],
[],
);
return [params, setParams];

View File

@@ -3,7 +3,8 @@ import { Color } from '@signozhq/design-tokens';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Progress } from '@signozhq/ui/progress';
import { Badge } from '@signozhq/ui/badge';
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import {
FiltersType,
IQuickFiltersConfig,
@@ -56,30 +57,28 @@ export const getDisplayValue = (value: unknown): string =>
isEmptyFilterValue(value) ? '-' : String(value);
export const getDomainNameFilterExpression = (domainName: string): string =>
`http_host = '${domainName}'`;
`${SPAN_ATTRIBUTES.SERVER_NAME} = ${formatValueForExpression(domainName)}`;
export const clientKindExpression = `kind_string = 'Client'`;
export const clientKindExpression = "kind_string = 'Client'";
/**
* Converts filters to expression
* @param filters Filters to convert
* @param baseExpression Base expression to combine with filters
* @returns Filter expression string
*/
export const convertFiltersWithUrlHandling = (
filters: IBuilderQuery['filters'],
baseExpression: string,
): string => {
if (!filters) {
return baseExpression;
}
export const getHttpUrlExpression = (endPointName: string): string =>
endPointName
? `${SPAN_ATTRIBUTES.HTTP_URL} = ${formatValueForExpression(endPointName, '=')}`
: '';
const { filter } = convertFiltersToExpressionWithExistingQuery(
filters,
baseExpression,
);
return filter.expression;
};
// The domain (and endpoint) the drawer is locked to, built from the same
// expressions as the requests' filters so the chip shows what runs.
export const getDomainScopeExpression = (
domainName: string,
endPointName = '',
): string =>
[
clientKindExpression,
getDomainNameFilterExpression(domainName),
getHttpUrlExpression(endPointName),
]
.filter(Boolean)
.join(' AND ');
export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
{
@@ -347,7 +346,7 @@ export const getDomainMetricsQueryPayload = (
domainName: string,
start: number,
end: number,
filters: IBuilderQuery['filters'],
filterExpression: string,
): GetQueryResultsProps[] => [
{
selectedTime: 'GLOBAL_TIME',
@@ -368,9 +367,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(domainName)} AND ${urlExpression}`,
filterExpression,
),
},
expression: 'A',
@@ -396,9 +395,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(domainName)}`,
filterExpression,
),
},
expression: 'B',
@@ -424,9 +423,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(domainName)} AND has_error = true`,
filterExpression,
),
},
expression: 'C',
@@ -452,9 +451,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(domainName)}`,
filterExpression,
),
},
expression: 'D',
@@ -824,7 +823,7 @@ export const getEndPointsQueryPayload = (
function buildFilterExpression(
domainName: string,
filters: IBuilderQuery['filters'],
filterExpression: string,
showStatusCodeErrors: boolean,
): string {
const baseFilterParts = [
@@ -836,21 +835,22 @@ function buildFilterExpression(
if (showStatusCodeErrors) {
baseFilterParts.push('status_message EXISTS');
}
const filterExpression = baseFilterParts.join(' AND ');
return convertFiltersWithUrlHandling(filters, filterExpression);
return combineInitialAndUserExpression(
baseFilterParts.join(' AND '),
filterExpression,
);
}
export const getTopErrorsQueryPayload = (
domainName: string,
start: number,
end: number,
filters: IBuilderQuery['filters'],
filterExpression: string,
showStatusCodeErrors = true,
): QueryRangePayloadV5 => {
const filterExpression = buildFilterExpression(
const combinedExpression = buildFilterExpression(
domainName,
filters,
filterExpression,
showStatusCodeErrors,
);
@@ -869,7 +869,7 @@ export const getTopErrorsQueryPayload = (
stepInterval: 60,
disabled: false,
aggregations: [{ expression: 'count()' }],
filter: { expression: filterExpression },
filter: { expression: combinedExpression },
groupBy: [
{
name: SPAN_ATTRIBUTES.HTTP_URL,
@@ -1367,15 +1367,14 @@ export const createFiltersForSelectedRowData = (
// First query payload for endpoint metrics
// Second query payload for endpoint status code table
// Third query payload for endpoint dropdown selection
// Fourth query payload for endpoint dependant services
// Fifth query payload for endpoint response status count bar chart
// Sixth query payload for endpoint response status code latency bar chart
// Third query payload for endpoint dependant services
// Fourth query payload for endpoint response status count bar chart
// Fifth query payload for endpoint response status code latency bar chart
export const getEndPointDetailsQueryPayload = (
domainName: string,
start: number,
end: number,
filters: IBuilderQuery['filters'],
filterExpression: string,
): GetQueryResultsProps[] => [
{
selectedTime: 'GLOBAL_TIME',
@@ -1394,11 +1393,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1424,11 +1423,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'B',
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1454,11 +1453,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: true,
expression: 'C',
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND has_error = true`,
filterExpression,
),
},
functions: [],
@@ -1484,11 +1483,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'D',
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1514,11 +1513,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: true,
expression: 'E',
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1586,11 +1585,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
filterExpression,
),
},
functions: [],
@@ -1622,11 +1621,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'B',
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
filterExpression,
),
},
functions: [],
@@ -1660,11 +1659,11 @@ export const getEndPointDetailsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
filterExpression,
),
},
expression: 'C',
@@ -1713,77 +1712,6 @@ export const getEndPointDetailsQueryPayload = (
end,
step: 60,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TABLE,
query: {
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
queryName: 'A',
aggregateOperator: 'count',
aggregations: [
{
expression: 'count()',
},
],
timeAggregation: 'count',
spaceAggregation: 'sum',
functions: [],
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS`,
),
},
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
legend: '',
limit: null,
orderBy: [],
groupBy: [
{
key: SPAN_ATTRIBUTES.HTTP_URL,
dataType: DataTypes.String,
type: 'attribute',
},
],
reduceTo: ReduceOperators.AVG,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: true,
start,
end,
step: 60,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TABLE,
@@ -1801,11 +1729,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1838,11 +1766,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'B',
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1875,11 +1803,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'C',
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1912,11 +1840,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: true,
expression: 'D',
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND has_error = true`,
filterExpression,
),
},
functions: [],
@@ -1991,11 +1919,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -2062,11 +1990,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -2118,100 +2046,82 @@ export const getEndPointDetailsQueryPayload = (
},
];
export const getEndPointZeroStateQueryPayload = (
export const getEndPointDropDownQueryPayload = (
domainName: string,
start: number,
end: number,
): GetQueryResultsProps[] => [
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TABLE,
query: {
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
queryName: 'A',
aggregateOperator: 'count',
aggregateAttribute: {
dataType: DataTypes.String,
key: '',
type: '',
filterExpression: string,
): GetQueryResultsProps => ({
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TABLE,
query: {
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
queryName: 'A',
aggregateOperator: 'count',
aggregations: [
{
expression: 'count()',
},
timeAggregation: 'count',
spaceAggregation: 'sum',
functions: [],
filters: {
items: [
{
id: '3db61dd6',
key: {
key: SPAN_ATTRIBUTES.SERVER_NAME,
dataType: DataTypes.String,
type: '',
},
op: '=',
value: domainName,
},
{
id: '212678b9',
key: {
key: 'kind_string',
dataType: DataTypes.String,
type: '',
},
op: '=',
value: 'Client',
},
],
op: 'AND',
},
expression: 'A',
disabled: false,
stepInterval: 60,
having: [],
legend: '',
limit: null,
orderBy: [],
groupBy: [
{
key: SPAN_ATTRIBUTES.HTTP_URL,
dataType: DataTypes.String,
type: 'tag',
},
],
reduceTo: ReduceOperators.AVG,
],
timeAggregation: 'count',
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS`,
filterExpression,
),
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
legend: '',
name: 'A',
query: '',
limit: null,
orderBy: [],
groupBy: [
{
key: SPAN_ATTRIBUTES.HTTP_URL,
dataType: DataTypes.String,
type: 'attribute',
},
],
reduceTo: ReduceOperators.AVG,
},
],
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
queryFormulas: [],
queryTraceOperator: [],
},
variables: {},
formatForWeb: true,
start,
end,
step: 60,
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
];
variables: {},
formatForWeb: true,
start,
end,
step: 60,
});
interface EndPointMetricsResponseRow {
data: {
@@ -2369,8 +2279,8 @@ export const statusCodeWidgetInfo = [
interface EndPointDropDownResponseRow {
data: {
[SPAN_ATTRIBUTES.HTTP_URL]: string;
A: number;
[SPAN_ATTRIBUTES.HTTP_URL]?: string;
A?: number;
};
}
@@ -2712,7 +2622,7 @@ export const groupStatusCodes = (
export const getStatusCodeBarChartWidgetData = (
domainName: string,
filters: IBuilderQuery['filters'],
filterExpression: string,
): Widgets => ({
query: {
builder: {
@@ -2728,22 +2638,13 @@ export const getStatusCodeBarChartWidgetData = (
dataSource: DataSource.TRACES,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: 'c6724407',
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: '',
},
op: '=',
value: domainName,
},
...(filters?.items || []),
],
op: 'AND',
filter: {
expression: combineInitialAndUserExpression(
getDomainNameFilterExpression(domainName),
filterExpression,
),
},
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
@@ -2824,18 +2725,15 @@ export const getFormattedEndPointStatusCodeChartData = (
export const END_POINT_DETAILS_QUERY_KEYS_ARRAY = [
REACT_QUERY_KEY.GET_ENDPOINT_METRICS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_RATE_OVER_TIME_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_LATENCY_OVER_TIME_DATA,
];
export const getAllEndpointsWidgetData = (
groupBy: BaseAutocompleteData[],
domainName: string,
filters: IBuilderQuery['filters'],
filterExpression: string,
// eslint-disable-next-line sonarjs/cognitive-complexity
): Widgets => {
const isGroupedByAttribute = groupBy.length > 0;
@@ -2857,11 +2755,11 @@ export const getAllEndpointsWidgetData = (
disabled: false,
expression: 'A',
filter: {
expression: convertFiltersWithUrlHandling(
filters,
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -2889,11 +2787,11 @@ export const getAllEndpointsWidgetData = (
disabled: false,
expression: 'B',
filter: {
expression: convertFiltersWithUrlHandling(
filters,
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -2921,11 +2819,11 @@ export const getAllEndpointsWidgetData = (
disabled: false,
expression: 'C',
filter: {
expression: convertFiltersWithUrlHandling(
filters,
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -2953,11 +2851,11 @@ export const getAllEndpointsWidgetData = (
disabled: true,
expression: 'D',
filter: {
expression: convertFiltersWithUrlHandling(
filters,
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND has_error = true AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -3075,38 +2973,26 @@ export const getAllEndpointsWidgetData = (
return widget;
};
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'A', 'B', 'C', 'F1'];
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'key', 'A', 'B', 'C', 'F1'];
export const getGroupByFiltersFromGroupByValues = (
rowData: any,
groupBy: BaseAutocompleteData[],
): IBuilderQuery['filters'] => {
const items = Object.keys(rowData)
// A group without a value for a key is the set of spans missing that attribute.
export const getGroupByExpressionFromRowData = (
rowData: Record<string, string | number | boolean | null | undefined>,
): string =>
Object.keys(rowData)
.filter((key) => !keysToRemove.includes(key))
.map((key) => {
const groupByAttribute = groupBy.find((gb) => gb.key === key);
return {
id: groupByAttribute?.id || v4(),
key: {
dataType: groupByAttribute?.dataType || DataTypes.String,
key: groupByAttribute?.key || key,
type: groupByAttribute?.type || '',
},
op: '=', // operator for every attribute -> discuss
value: rowData[key],
};
});
return {
items,
op: 'AND',
};
};
const value = rowData[key];
return value === null || value === undefined
? `${key} NOT EXISTS`
: `${key} = ${formatValueForExpression(value)}`;
})
.join(' AND ');
export const getRateOverTimeWidgetData = (
domainName: string,
endPointName: string,
filters: IBuilderQuery['filters'],
filterExpression: string,
): Widgets => {
let legend = domainName;
if (endPointName) {
@@ -3130,9 +3016,9 @@ export const getRateOverTimeWidgetData = (
disabled: false,
expression: 'A',
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`http_host = '${domainName}'`,
filterExpression,
),
},
functions: [],
@@ -3156,7 +3042,7 @@ export const getRateOverTimeWidgetData = (
export const getLatencyOverTimeWidgetData = (
domainName: string,
endPointName: string,
filters: IBuilderQuery['filters'],
filterExpression: string,
): Widgets => {
let legend = domainName;
if (endPointName) {
@@ -3180,9 +3066,9 @@ export const getLatencyOverTimeWidgetData = (
disabled: false,
expression: 'A',
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
expression: combineInitialAndUserExpression(
`http_host = '${domainName}'`,
filterExpression,
),
},
functions: [],

View File

@@ -630,7 +630,6 @@ describe('Footer utils', () => {
expect(props.ruleType).toBe('anomaly_rule');
expect(props.alertType).toBe(AlertTypes.METRICS_BASED_ALERT);
expect(props.schemaVersion).toBe('v2alpha1');
expect(props.condition.algorithm).toBe('standard');
expect(props.condition.seasonality).toBe('daily');
expect(props.condition.thresholds?.spec[0]).toMatchObject({
target: 3,

View File

@@ -324,7 +324,6 @@ export function buildCreateAnomalyAlertRulePayload(
condition: {
...payload.condition,
thresholds: { kind: 'basic', spec: thresholds },
algorithm: thresholdState.algorithm,
seasonality: thresholdState.seasonality,
},
};

View File

@@ -323,7 +323,6 @@ describe('CreateAlert utils', () => {
ruleType: 'anomaly_rule',
condition: {
...defaultPostableAlertRuleV2.condition,
algorithm: 'standard',
seasonality: 'weekly',
thresholds: {
kind: 'basic',
@@ -349,7 +348,6 @@ describe('CreateAlert utils', () => {
expect(getThresholdStateFromAlertDef(anomalyDef)).toMatchObject({
operator: AlertThresholdOperator.IS_BELOW,
thresholds: [{ thresholdValue: 3 }],
algorithm: 'standard',
seasonality: 'weekly',
});
});

View File

@@ -274,8 +274,6 @@ export function getThresholdStateFromAlertDef(
matchType:
alertDef.condition.thresholds?.spec[0].matchType ||
AlertThresholdMatchType.AT_LEAST_ONCE,
algorithm:
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
seasonality:
alertDef.condition.seasonality || INITIAL_ALERT_THRESHOLD_STATE.seasonality,
};

View File

@@ -1,12 +1,10 @@
import { useMemo } from 'react';
import { useListRulesV3 } from 'api/generated/services/rules';
import {
type ListRulesV3200,
type ListRulesV3Params,
RuletypesAlertStateDTO,
type RuletypesListableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
const HOME_ALERT_RULES_LIMIT = 5;
@@ -23,18 +21,7 @@ interface HomeAlertRules {
export function useHomeAlertRules(): HomeAlertRules {
const firing = useListRulesV3(FIRING_RULES_PARAMS, {
query: {
cacheTime: 0,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params: FIRING_RULES_PARAMS,
paramsSerializer: { indexes: null },
signal,
}),
},
query: { cacheTime: 0 },
});
const firingRules = firing.data?.data.rules;
const latest = useListRulesV3(

View File

@@ -104,5 +104,5 @@ describe('AI observability Explorer', () => {
...defaultOptionsQuery,
selectColumns: [],
});
});
}, 15_000);
});

View File

@@ -35,11 +35,11 @@
--tanstack-table-header-cell-bg: var(--l2-background);
--tanstack-table-header-cell-color: var(--l2-foreground);
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-cell-bg: var(--l1-background);
--tanstack-table-cell-color: var(--l2-foreground);
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l2-background-active);
--tanstack-table-resize-handle-bg: var(--l2-background);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
--tanstack-table-row-active-bg: var(--l1-background-active);
--tanstack-table-resize-handle-bg: transparent;
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
--tanstack-table-row-height: 42px;
@@ -48,16 +48,6 @@
--tanstack-cell-padding-left-override: 16px;
--tanstack-cell-padding-right-override: 16px;
--tanstack-table-row-odd-bg: color-mix(
in srgb,
var(--l1-foreground) 2%,
transparent
);
--tanstack-table-row-even-bg: color-mix(
in srgb,
var(--l1-foreground) 1%,
transparent
);
--badge-cursor: pointer;
}

View File

@@ -31,6 +31,8 @@ jest.mock('@floating-ui/dom', () => ({
Promise.resolve({ x: 0, y: 0, placement, strategy, middlewareData: {} }),
}));
jest.setTimeout(15_000);
const VIEW_1_ORDER_BY = JSON.stringify({ columnName: 'name', order: 'asc' });
const VIEW_1_QUERY = "severity = 'critical'";

View File

@@ -240,7 +240,6 @@ function ListAlertRules(): JSX.Element {
showTotalCount: true,
}}
paginationClassname={styles.paginationContainer}
enableAlternatingRowColors
plainTextCellLineClamp={2}
/>
)}

View File

@@ -107,9 +107,7 @@ export function getAlertRuleColumns(
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<LabelColumn labels={tagKeys} value={labels} color="highlight-danger" />
);
return <LabelColumn labels={tagKeys} value={labels} color="secondary" />;
},
},
{

View File

@@ -3,13 +3,11 @@ import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRulesV3 } from 'api/generated/services/rules';
import type {
ListRulesV3200,
ListRulesV3Params,
RenderErrorResponseDTO,
RuletypesLabelPairDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
import { isUndefined } from 'lodash-es';
import type { AlertRule } from './types';
@@ -35,18 +33,7 @@ export function useAlertRulesData(
const hasLoggedEvent = useRef(false);
const rulesResponse = useListRulesV3(params, {
query: {
keepPreviousData: true,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params,
paramsSerializer: { indexes: null },
signal,
}),
},
query: { keepPreviousData: true },
});
const rules = useMemo(

View File

@@ -1,7 +1,9 @@
import React, { Dispatch, SetStateAction, useMemo, useRef } from 'react';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { ENDPOINT_STATS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
import { routeConfig } from 'container/SideNav/config';
import { getQueryString } from 'container/SideNav/helper';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -15,11 +17,7 @@ import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
Query,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { Tags } from 'hooks/useResourceAttribute/types';
import { isModifierKeyPressed } from 'utils/app';
@@ -126,7 +124,7 @@ const generateAPIMonitoringPath = (
domainName: string,
startTime: number,
endTime: number,
filters: IBuilderQuery['filters'],
filterExpression: string,
): string => {
const basePath = ROUTES.API_MONITORING;
return `${basePath}?${createQueryParams({
@@ -138,8 +136,8 @@ const generateAPIMonitoringPath = (
endTime,
},
selectedInterval: 'custom',
endPointDetailsLocalFilters: filters,
}),
[ENDPOINT_STATS_EXPRESSION_KEY]: filterExpression,
})}`;
};
export function onViewAPIMonitoringPopupClick({
@@ -153,41 +151,15 @@ export function onViewAPIMonitoringPopupClick({
return (e?: React.MouseEvent): void => {
const endTime = timestamp + (stepInterval || 60);
const startTime = timestamp - (stepInterval || 60);
const filters = {
items: [
...(isError
? [
{
id: uuid().slice(0, 8),
key: {
key: 'hasError',
dataType: DataTypes.bool,
type: 'tag',
id: 'hasError--bool--tag--true',
},
op: 'in',
value: ['true'],
},
]
: []),
{
id: uuid().slice(0, 8),
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
op: '=',
value: servicename,
},
],
op: 'AND',
};
const filterExpression = [
...(isError ? ["hasError IN ['true']"] : []),
`service.name = ${formatValueForExpression(servicename)}`,
].join(' AND ');
const newPath = generateAPIMonitoringPath(
domainName,
startTime,
endTime,
filters,
filterExpression,
);
safeNavigate(newPath, { newTab: !!e && isModifierKeyPressed(e) });

View File

@@ -153,6 +153,7 @@ import opentelemetryUrl from '@/assets/Logos/opentelemetry.svg';
import phpUrl from '@/assets/Logos/php.svg';
import pinoUrl from '@/assets/Logos/pino.svg';
import pipecatUrl from '@/assets/Logos/pipecat.svg';
import pixeltableUrl from '@/assets/Logos/pixeltable.svg';
import planetscaleUrl from '@/assets/Logos/planetscale.svg';
import pm2Url from '@/assets/Logos/pm2.svg';
import podmanUrl from '@/assets/Logos/podman.svg';
@@ -2579,7 +2580,7 @@ const onboardingConfigWithLinks = [
key: 'aws-ec2-metrics-one-click',
label: 'One Click AWS',
imgUrl: ec2Url,
link: '/integrations?integration=aws-integration&service=ec2',
link: '/integrations/aws?service=ec2',
internalRedirect: true,
},
{
@@ -2637,7 +2638,7 @@ const onboardingConfigWithLinks = [
key: 'aws-ecs-ec2-one-click',
label: 'One Click AWS',
imgUrl: ecsUrl,
link: '/integrations?integration=aws-integration&service=ecs',
link: '/integrations/aws?service=ecs',
internalRedirect: true,
},
{
@@ -2699,7 +2700,7 @@ const onboardingConfigWithLinks = [
key: 'aws-eks-ec2-one-click',
label: 'One Click AWS',
imgUrl: eksUrl,
link: '/integrations?integration=aws-integration&service=eks',
link: '/integrations/aws?service=eks',
internalRedirect: true,
},
{
@@ -2823,21 +2824,21 @@ const onboardingConfigWithLinks = [
key: 'aws-rds-one-click',
label: 'One Click AWS',
imgUrl: rdsUrl,
link: '/integrations?integration=aws-integration&service=rds',
link: '/integrations/aws?service=rds',
internalRedirect: true,
},
{
key: 'aws-rds-mysql-manual',
label: 'MySQL Manual',
imgUrl: rdsUrl,
link: '/integrations?integration=builtin-aws_rds_mysql',
link: '/integrations/builtin-aws_rds_mysql',
internalRedirect: true,
},
{
key: 'aws-rds-postgresql-manual',
label: 'PostgreSQL Manual',
imgUrl: rdsUrl,
link: '/integrations?integration=builtin-aws_rds_postgresql',
link: '/integrations/builtin-aws_rds_postgresql',
internalRedirect: true,
},
],
@@ -2896,7 +2897,7 @@ const onboardingConfigWithLinks = [
key: 'aws-lambda-logs-one-click',
label: 'One Click AWS',
imgUrl: lambdaUrl,
link: '/integrations?integration=aws-integration&service=lambda',
link: '/integrations/aws?service=lambda',
internalRedirect: true,
},
{
@@ -2954,7 +2955,7 @@ const onboardingConfigWithLinks = [
key: 'aws-lambda-metrics-one-click',
label: 'One Click AWS',
imgUrl: lambdaUrl,
link: '/integrations?integration=aws-integration&service=lambda',
link: '/integrations/aws?service=lambda',
internalRedirect: true,
},
{
@@ -4710,6 +4711,7 @@ const onboardingConfigWithLinks = [
'messaging',
'messaging queues',
'messaging-queues-celery',
'python celery',
'queues',
'task queue tracing',
],
@@ -4930,7 +4932,7 @@ const onboardingConfigWithLinks = [
'redis metrics',
],
imgUrl: redisUrl,
link: '/integrations?integration=builtin-redis',
link: '/integrations/builtin-redis',
internalRedirect: true,
},
{
@@ -4946,7 +4948,7 @@ const onboardingConfigWithLinks = [
'postgresql metrics',
],
imgUrl: postgresqlUrl,
link: '/integrations?integration=builtin-postgres',
link: '/integrations/builtin-postgres',
internalRedirect: true,
},
{
@@ -4962,7 +4964,7 @@ const onboardingConfigWithLinks = [
'proxy',
],
imgUrl: nginxUrl,
link: '/integrations?integration=builtin-nginx',
link: '/integrations/builtin-nginx',
internalRedirect: true,
},
{
@@ -4983,7 +4985,7 @@ const onboardingConfigWithLinks = [
'self',
],
imgUrl: mongoUrl,
link: '/integrations?integration=builtin-mongo',
link: '/integrations/builtin-mongo',
internalRedirect: true,
},
{
@@ -5001,7 +5003,7 @@ const onboardingConfigWithLinks = [
'olap database',
],
imgUrl: clickhouseUrl,
link: '/integrations?integration=builtin-clickhouse',
link: '/integrations/builtin-clickhouse',
internalRedirect: true,
},
{
@@ -5056,7 +5058,7 @@ const onboardingConfigWithLinks = [
key: 'aws-alb-one-click',
label: 'One Click AWS',
imgUrl: awsAlbUrl,
link: '/integrations?integration=aws-integration&service=alb',
link: '/integrations/aws?service=alb',
internalRedirect: true,
},
{
@@ -5097,7 +5099,7 @@ const onboardingConfigWithLinks = [
key: 'aws-api-gateway-one-click',
label: 'One Click AWS',
imgUrl: apiGatewayUrl,
link: '/integrations?integration=aws-integration&service=api-gateway',
link: '/integrations/aws?service=api-gateway',
internalRedirect: true,
},
{
@@ -5142,7 +5144,7 @@ const onboardingConfigWithLinks = [
key: 'aws-dynamodb-one-click',
label: 'One Click AWS',
imgUrl: dynamodbUrl,
link: '/integrations?integration=aws-integration&service=dynamodb',
link: '/integrations/aws?service=dynamodb',
internalRedirect: true,
},
{
@@ -5189,14 +5191,14 @@ const onboardingConfigWithLinks = [
key: 'aws-elasticache-one-click',
label: 'One Click AWS',
imgUrl: elasticacheUrl,
link: '/integrations?integration=aws-integration&service=elasticache',
link: '/integrations/aws?service=elasticache',
internalRedirect: true,
},
{
key: 'aws-elasticache-redis-manual',
label: 'Redis Manual',
imgUrl: elasticacheUrl,
link: '/integrations?integration=builtin-aws_elasticache_redis',
link: '/integrations/builtin-aws_elasticache_redis',
internalRedirect: true,
},
],
@@ -5235,7 +5237,7 @@ const onboardingConfigWithLinks = [
key: 'aws-msk-one-click',
label: 'One Click AWS',
imgUrl: amazonMskUrl,
link: '/integrations?integration=aws-integration&service=msk',
link: '/integrations/aws?service=msk',
internalRedirect: true,
},
{
@@ -5278,7 +5280,7 @@ const onboardingConfigWithLinks = [
key: 'aws-s3-one-click',
label: 'One Click AWS',
imgUrl: s3Url,
link: '/integrations?integration=aws-integration&service=s3sync',
link: '/integrations/aws?service=s3sync',
internalRedirect: true,
},
{
@@ -5320,7 +5322,7 @@ const onboardingConfigWithLinks = [
key: 'aws-sns-one-click',
label: 'One Click AWS',
imgUrl: snsUrl,
link: '/integrations?integration=aws-integration&service=sns',
link: '/integrations/aws?service=sns',
internalRedirect: true,
},
{
@@ -5362,7 +5364,7 @@ const onboardingConfigWithLinks = [
key: 'aws-sqs-one-click',
label: 'One Click AWS',
imgUrl: sqsUrl,
link: '/integrations?integration=aws-integration&service=sqs',
link: '/integrations/aws?service=sqs',
internalRedirect: true,
},
{
@@ -7249,12 +7251,14 @@ const onboardingConfigWithLinks = [
'cloud monitoring metrics',
'connect gcp account',
'gcp',
'gcp agent',
'gcp integration',
'gcp logs',
'gcp manual setup',
'gcp metrics',
'gcp monitoring',
'gcp observability',
'gcp one click',
'gcp service account',
'google cloud',
'google cloud integration',
@@ -7263,6 +7267,8 @@ const onboardingConfigWithLinks = [
'metrics',
'monitoring',
'observability',
'one click',
'one click gcp integration',
'opentelemetry collector gcp',
'pub/sub',
],
@@ -8155,29 +8161,50 @@ const onboardingConfigWithLinks = [
link: '/docs/instrumentation/javascript/opentelemetry-pm2/',
},
{
dataSource: 'gcp-one-click-integration',
label: 'GCP One-Click Integration',
imgUrl: gcpUrl,
tags: ['GCP', 'metrics', 'logs'],
module: 'integrations',
dataSource: 'pixeltable',
label: 'Pixeltable',
imgUrl: pixeltableUrl,
tags: ['LLM Monitoring'],
module: 'apm',
relatedSearchKeywords: [
'gcp',
'gcp agent',
'gcp integration',
'gcp logs',
'gcp metrics',
'gcp one click',
'google cloud',
'google cloud integration',
'apm',
'llm',
'llm monitoring',
'multimodal ai',
'observability',
'opentelemetry pixeltable',
'pixeltable',
'pixeltable monitoring',
'pixeltable observability',
'pixeltable traces',
'traces',
'udf',
],
id: 'pixeltable',
link: '/docs/pixeltable-observability/',
},
{
dataSource: 'claude-cowork',
label: 'Claude Cowork',
imgUrl: claudeCodeUrl,
tags: ['LLM Monitoring', 'logs'],
module: 'logs',
relatedSearchKeywords: [
'claude',
'claude cowork',
'claude cowork monitoring',
'claude cowork observability',
'cowork',
'llm',
'llm monitoring',
'logs',
'metrics',
'monitoring',
'observability',
'one click',
'one click gcp integration',
'otel claude cowork',
'telemetry',
],
id: 'gcp-one-click-integration',
link: '/docs/integrations/gcp/one-click-gcp-integrations/',
id: 'claude-cowork',
link: '/docs/claude-cowork-monitoring/',
},
];
export default onboardingConfigWithLinks;

View File

@@ -41,6 +41,8 @@
}
.alert-details {
margin-top: 10px;
&__divider {
--divider-color: var(--l1-border);
--divider-margin: 16px 0;

View File

@@ -56,7 +56,6 @@
}
.alert-rules-container {
margin-top: 10px;
flex: 1;
min-height: 0;
display: flex;
@@ -133,10 +132,6 @@
display: flex;
flex-direction: column;
overflow: hidden;
> .alert-rules-container {
margin-top: 0;
}
}
}
}

View File

@@ -4,6 +4,7 @@
*/
import { rest, type ResponseComposition, type RestContext } from 'msw';
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { screen, userEvent, within } from 'storybook/test';
import {
@@ -12,7 +13,10 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import { accessFor } from '@/storybook/access/access';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { useAlertRuleViewsStore } from 'container/ListAlertRules/useAlertRuleViewsStore';
import { componentPermission } from 'utils/permission';
import { DEFAULT_RULE_LIST_FILTER } from 'container/ListAlertRules/utils';
import {
DEFAULT_RULE_VIEW,
@@ -281,3 +285,37 @@ export const runRowAction = async (
await userEvent.click(actions);
await userEvent.click(await screen.findByRole('menuitem', { name }));
};
export type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
export const listedRules = (
args: AlertRulesArgs,
): RuletypesListableRuleDTO[] => {
const params = new URL(listRoute(args.view), 'http://localhost').searchParams;
if (
args.dataState !== 'loaded' ||
(args.invalidQuery !== 'off' && params.get('query'))
) {
return [];
}
return alertRulesPage(
args.rules,
{ severity: args.ruleSeverity, state: args.ruleState },
params,
).data.rules;
};
export const hasRowMenu = (args: AlertRulesArgs): boolean =>
listedRules(args).length > 0 &&
componentPermission.action.includes(
accessFor(args.access, args.permissions, args.revoked).legacyRole,
);
export const showsNoRulesPage = (args: AlertRulesArgs): boolean =>
args.view === DEFAULT_RULE_VIEW &&
args.dataState === 'loaded' &&
args.rules === 0;
export const listsSavedViews = (args: AlertRulesArgs): boolean =>
args.ruleViews > 0 && args.ruleViewsState === 'success';

View File

@@ -1,9 +1,11 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { alertRulesMocks } from './AlertRules.stories.mocks';
import {
alertRulesMocks,
type AlertRulesArgs,
} from './AlertRules.stories.mocks';
import {
cloneAlert,
copyLabel,
@@ -22,8 +24,6 @@ import {
import AlertList from '../../index';
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
const pageStory = storyMocks(alertRulesMocks, { layout: 'app' });
/**

View File

@@ -3,20 +3,22 @@
* Do not hand-edit: regenerate instead.
*/
import { DEFAULT_RULE_VIEW } from 'container/ListAlertRules/list.config';
import { expect, screen, userEvent, within } from 'storybook/test';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import {
alertRulesMocks,
type AlertRulesArgs,
hasRowMenu,
listedRules,
listsSavedViews,
runRowAction,
showsNoRulesPage,
untilLoaded,
} from '../AlertRules.stories.mocks';
interface PlayContext {
canvasElement: HTMLElement;
args: PageStoryArgs<typeof alertRulesMocks>;
args: AlertRulesArgs;
}
// A row's rename and delete only take the pointer while the row is hovered,
@@ -25,10 +27,15 @@ const hoverActions = userEvent.setup({ pointerEventsCheck: 0 });
export const openQuerySuggestions = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (listedRules(args).length === 0) {
return;
}
const canvas = within(canvasElement);
// The labels come with the rule list, so the box is opened once rows are in.
await canvas.findAllByTestId('alert-actions', undefined, untilLoaded);
await canvas.findAllByTestId(/^alert-row-.+-name$/, undefined, untilLoaded);
const search = canvas.getByTestId('alert-rules-list-search');
await userEvent.click(search.querySelector('.cm-content') ?? search);
await screen.findByText('labels.team');
@@ -49,7 +56,12 @@ export const openSaveViewPopover = async ({
export const openRenameViewPopover = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsSavedViews(args)) {
return;
}
await hoverActions.click(
await within(canvasElement).findByTestId(
'alert-rules-view-rename-view-1',
@@ -57,12 +69,17 @@ export const openRenameViewPopover = async ({
untilLoaded,
),
);
await screen.findByText('Rename view');
await screen.findAllByText('Rename view');
};
export const openDeleteViewConfirm = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsSavedViews(args)) {
return;
}
await hoverActions.click(
await within(canvasElement).findByTestId(
'alert-rules-view-delete-view-1',
@@ -75,7 +92,12 @@ export const openDeleteViewConfirm = async ({
export const openRowActions = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!hasRowMenu(args)) {
return;
}
const [actions] = await within(canvasElement).findAllByTestId(
'alert-actions',
undefined,
@@ -88,7 +110,12 @@ export const openRowActions = async ({
export const openDisabledRuleActions = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!hasRowMenu(args)) {
return;
}
const [actions] = await within(canvasElement).findAllByTestId(
'alert-actions',
undefined,
@@ -96,12 +123,21 @@ export const openDisabledRuleActions = async ({
);
await userEvent.click(actions);
await screen.findByText(/^enable$/i);
await screen.findByText(
listedRules(args)[0].state === RuletypesAlertStateDTO.disabled
? /^enable$/i
: /^disable$/i,
);
};
export const openColumnPicker = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (showsNoRulesPage(args)) {
return;
}
await userEvent.click(
await within(canvasElement).findByTestId(
'alert-columns-button',
@@ -116,11 +152,7 @@ export const openStatesFilter = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (
args.rules === 0 &&
args.view === DEFAULT_RULE_VIEW &&
args.dataState === 'loaded'
) {
if (showsNoRulesPage(args)) {
return;
}
@@ -144,6 +176,10 @@ export const toggleAlert = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!hasRowMenu(args)) {
return;
}
await runRowAction(canvasElement, /^(enable|disable)$/i);
if (args.toggleState === 'success') {
await expect(
@@ -156,6 +192,10 @@ export const cloneAlert = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!hasRowMenu(args)) {
return;
}
await runRowAction(canvasElement, /^clone$/i);
if (args.cloneState === 'success') {
await expect(
@@ -168,6 +208,10 @@ export const deleteAlert = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!hasRowMenu(args)) {
return;
}
await runRowAction(canvasElement, /^delete$/i);
if (args.deleteState === 'success') {
await expect(
@@ -176,7 +220,11 @@ export const deleteAlert = async ({
}
};
export const copyLabel = async (): Promise<void> => {
export const copyLabel = async ({ args }: PlayContext): Promise<void> => {
if (!args.tooltipsOpen || listedRules(args).length === 0) {
return;
}
const [copy] = await screen.findAllByRole(
'button',
{ name: 'Copy to clipboard' },
@@ -184,5 +232,7 @@ export const copyLabel = async (): Promise<void> => {
);
await userEvent.click(copy);
await expect(await screen.findByText(/copied! use in search/i)).toBeVisible();
await waitFor(() =>
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
);
};

View File

@@ -12,6 +12,7 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { VIEWS } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { ENDPOINT_STATS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
import { DEFAULT_PARAMS } from 'container/ApiMonitoring/queryParams';
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { rest } from 'msw';
@@ -123,17 +124,7 @@ const timeRange = (): Partial<AppState> => {
};
};
const serviceFilterItems = {
op: 'AND',
items: [
{
id: 'storybook-service-filter',
key: { key: 'service.name', dataType: 'string', type: 'resource' },
op: '=',
value: 'checkout',
},
],
};
const serviceFilterExpression = "service.name = 'checkout'";
interface RouteValues {
drawer: DrawerState;
@@ -160,12 +151,14 @@ const apiMonitoringRoute = ({
selectedView: VIEW_OF[drawer],
selectedEndPointName:
drawer === 'endpoint-stats' ? endpointUrl(domainName, 0) : '',
...(serviceFilter ? { endPointDetailsLocalFilters: serviceFilterItems } : {}),
};
const filterParam = serviceFilter
? `&${ENDPOINT_STATS_EXPRESSION_KEY}=${encodeURIComponent(serviceFilterExpression)}`
: '';
return `${ROUTES.API_MONITORING}?apiMonitoringParams=${encodeURIComponent(
JSON.stringify(params),
)}`;
)}${filterParam}`;
};
/** The parts of a query spec the handler tells the page's requests apart by. */

View File

@@ -16,6 +16,12 @@ import {
type GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import {
buildDashboardDeletePermission,
buildDashboardReadPermission,
buildDashboardUpdatePermission,
DashboardCreatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import {
expect,
mocked,
@@ -24,7 +30,9 @@ import {
waitFor,
within,
} from 'storybook/test';
import { LicensePlatform } from 'types/api/licensesV3/getActive';
import { grantsAll } from '@/storybook/access/access';
import {
choiceControl,
countControl,
@@ -32,6 +40,8 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import { appShellMocks } from '@/storybook/globals/appShellMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
orgUsersResponse,
@@ -67,6 +77,8 @@ import {
type DashboardViewId,
findBuiltinView,
} from '../list.config';
import { LIST_CHECKS } from '../constants/permissions';
import type { DashboardListItem } from '../utils/helpers';
const LIST = 'Dashboards · list';
const VIEWS = 'Dashboards · views';
@@ -528,6 +540,81 @@ export const dashboardsListMocks = defineStoryMocks({
},
});
export type DashboardsListArgs = PageStoryArgs<typeof dashboardsListMocks>;
const PAGE_SIZE = 20;
export const canList = (args: DashboardsListArgs): boolean =>
grantsAll(args, ...LIST_CHECKS);
/** `AuthZButton` leaves New dashboard enabled when the check itself fails. */
export const canCreate = (args: DashboardsListArgs): boolean =>
args.authzState === 'error' || grantsAll(args, DashboardCreatePermission);
export const canUpdate = (
args: DashboardsListArgs,
row: DashboardListItem,
): boolean =>
grantsAll(
args,
buildDashboardReadPermission(row.id),
buildDashboardUpdatePermission(row.id),
);
export const canDuplicate = (
args: DashboardsListArgs,
row: DashboardListItem,
): boolean =>
grantsAll(
args,
buildDashboardReadPermission(row.id),
DashboardCreatePermission,
);
export const canDelete = (
args: DashboardsListArgs,
row: DashboardListItem,
): boolean =>
!row.locked && grantsAll(args, buildDashboardDeletePermission(row.id));
export const listedDashboards = (
args: DashboardsListArgs,
): DashboardListItem[] => {
if (args.dataState !== 'loaded' || args.invalidQuery || !canList(args)) {
return [];
}
const view = findBuiltinView(args.view);
const rows = dashboardsListResponse({
count: args.dashboards,
offset: 0,
limit: view?.clientFilter ? args.dashboards : PAGE_SIZE,
markers: args.markers,
query:
new URL(listRoute(args.view), 'http://localhost').searchParams.get(
'query',
) ?? '',
}).data.dashboards;
return view?.clientFilter
? view.clientFilter(rows, {
userEmail: STORY_USER_EMAIL,
recent: recentDashboardIds(RECENT_COUNT),
})
: rows;
};
export const showsWorkspaceEmpty = (args: DashboardsListArgs): boolean =>
args.view === DEFAULT_DASHBOARD_VIEW &&
args.dataState === 'loaded' &&
!args.invalidQuery &&
canList(args) &&
args.dashboards === 0;
export const isCloudTenant = (args: DashboardsListArgs): boolean =>
appShellMocks.config?.(appShellMocks.read(args)).appContext?.activeLicense
?.platform === LicensePlatform.CLOUD;
/** Opens the actions menu of the row at `index`. */
export const openRowActions = async (
canvasElement: HTMLElement,
@@ -541,7 +628,7 @@ export const openRowActions = async (
);
await userEvent.click(triggers[index]);
await screen.findByText('Rename');
await screen.findByTestId('dashboard-action-delete');
};
/** Picks a row action, retrying while its permission check still disables it. */
@@ -574,10 +661,11 @@ export const TOAST_TIMEOUT = 10000;
export const openLegacyDialog = async (
canvasElement: HTMLElement,
index: number,
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-title-4',
`dashboard-title-${index}`,
{},
{ timeout: TOAST_TIMEOUT },
),
@@ -587,8 +675,9 @@ export const openLegacyDialog = async (
export const retryMigration = async (
canvasElement: HTMLElement,
index: number,
): Promise<void> => {
await openLegacyDialog(canvasElement);
await openLegacyDialog(canvasElement, index);
const retry = await screen.findByTestId('legacy-dashboard-retry-migration');
await waitFor(

View File

@@ -1,47 +1,41 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
dashboardsListMocks,
type DashboardsListArgs,
longQueryRoute,
overflowingRows,
} from './DashboardsListPage.stories.mocks';
import {
copyLegacyDashboardId,
createDashboardFails,
createDashboard,
deleteDashboard,
deleteViewFails,
deleteView,
duplicateDashboard,
importCreateFails,
importDashboard,
importInvalidImage,
importInvalidJson,
lockDashboard,
migrateDashboard,
migrationFails,
openCreatedByFilter,
openEditTagsDialog,
openFirstRowActions,
openRenameDialog,
openSaveViewPopover,
openTemplateTab,
pinFails,
pinPastLimit,
pinDashboard,
renameDashboard,
renameViewFails,
saveViewFails,
renameView,
saveView,
submitTemplateRequest,
templateRequestFails,
templateRequestRejected,
unlockDashboard,
unpinFails,
unpinDashboard,
updateTags,
} from './__story_mockdata__/dashboardsList.play';
import DashboardsListPage from '../DashboardsListPage';
type DashboardsListArgs = PageStoryArgs<typeof dashboardsListMocks>;
const pageStory = storyMocks(dashboardsListMocks, { layout: 'app' });
/**
@@ -182,21 +176,21 @@ export const DashboardDeletedToast: Story = {
/** Pinning past the limit: the backend refuses with a 409. */
export const PinLimitToast: Story = {
args: { pinWrite: 'limit' },
play: pinPastLimit,
play: pinDashboard,
parameters: { allowConsoleErrors: true },
};
/** A pin the backend failed to write. */
export const PinFailedToast: Story = {
args: { pinWrite: 'error' },
play: pinFails,
play: pinDashboard,
parameters: { allowConsoleErrors: true },
};
/** An unpin the backend failed to write, from the first (pinned) row. */
export const UnpinFailedToast: Story = {
args: { pinWrite: 'error' },
play: unpinFails,
play: unpinDashboard,
parameters: { allowConsoleErrors: true },
};
@@ -213,14 +207,14 @@ export const DashboardMigratedToast: Story = {
/** Retry migration of the legacy dashboard refused by the backend. */
export const DashboardMigrationFailedToast: Story = {
args: { migrationWrite: 'error' },
play: migrationFails,
play: migrateDashboard,
parameters: { allowConsoleErrors: true },
};
/** The Blank tab's create failing: the toast raised beside the error modal. */
export const CreateDashboardFailedToast: Story = {
args: { createWrite: 'error' },
play: createDashboardFails,
play: createDashboard,
parameters: { allowConsoleErrors: true },
};
@@ -232,7 +226,7 @@ export const ImportJsonInvalidImageToast: Story = {
/** A valid dashboard file the create endpoint refuses. */
export const ImportJsonCreateFailedToast: Story = {
args: { createWrite: 'error' },
play: importCreateFails,
play: importDashboard,
parameters: { allowConsoleErrors: true },
};
@@ -244,33 +238,33 @@ export const TemplateRequestSubmittedToast: Story = {
/** A template request the analytics endpoint answered with a failure. */
export const TemplateRequestFailedToast: Story = {
args: { templateRequest: 'error' },
play: templateRequestFails,
play: submitTemplateRequest,
};
/** A template request that threw before an answer came back. */
export const TemplateRequestRejectedToast: Story = {
args: { templateRequest: 'rejected' },
play: templateRequestRejected,
play: submitTemplateRequest,
};
/** Saving the current filters as a view, refused by the backend. */
export const SaveViewFailedToast: Story = {
args: { viewWrite: 'error' },
play: saveViewFails,
play: saveView,
parameters: { allowConsoleErrors: true },
};
/** Renaming a saved view, refused by the backend. */
export const RenameViewFailedToast: Story = {
args: { viewWrite: 'error' },
play: renameViewFails,
play: renameView,
parameters: { allowConsoleErrors: true },
};
/** Deleting a saved view, refused by the backend. */
export const DeleteViewFailedToast: Story = {
args: { viewWrite: 'error' },
play: deleteViewFails,
play: deleteView,
parameters: { allowConsoleErrors: true },
};

View File

@@ -5,30 +5,68 @@
import { expect, screen, userEvent, within } from 'storybook/test';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
canCreate,
canDelete,
canDuplicate,
canList,
canUpdate,
clickRowAction,
dashboardsListMocks,
type DashboardsListArgs,
isCloudTenant,
listedDashboards,
openLegacyDialog,
openRowActions,
pickRowAction,
requestTemplate,
retryMigration,
showsWorkspaceEmpty,
TOAST_TIMEOUT,
uploadDashboardJson,
} from '../DashboardsListPage.stories.mocks';
import type { TemplateRequest } from './dashboardsList';
interface PlayContext {
canvasElement: HTMLElement;
args: PageStoryArgs<typeof dashboardsListMocks>;
args: DashboardsListArgs;
}
const IMPORTED_SPEC = { layouts: [], panels: {}, variables: [] };
const TEMPLATE_REQUEST_TOASTS = {
success: 'Dashboard request submitted',
error: 'Event rejected',
rejected: 'Something went wrong',
} satisfies Record<TemplateRequest, string>;
const openNewDashboard = async (canvasElement: HTMLElement): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
};
const canEditRow = (args: DashboardsListArgs, index: number): boolean => {
const row = listedDashboards(args)[index];
return !!row && !row.legacy && !row.locked && canUpdate(args, row);
};
export const openCreatedByFilter = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (
!canList(args) ||
args.dataState !== 'loaded' ||
showsWorkspaceEmpty(args)
) {
return;
}
const filter = await within(canvasElement).findByTestId(
'dashboards-filter-created-by',
{},
@@ -37,18 +75,18 @@ export const openCreatedByFilter = async ({
await userEvent.click(filter.querySelector('.ant-select-selector') ?? filter);
await userEvent.click(await screen.findByText('Ada (ada@signoz.io)'));
await screen.findByRole('checkbox', { checked: true });
await screen.findAllByRole('checkbox', { checked: true });
};
export const openTemplateTab = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
);
if (!canCreate(args)) {
return;
}
await openNewDashboard(canvasElement);
await expect(
await screen.findByRole('dialog', {}, { timeout: 10000 }),
).toHaveTextContent('New dashboard');
@@ -58,12 +96,13 @@ export const openTemplateTab = async ({
export const importInvalidJson = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
const canvas = within(canvasElement);
if (!canCreate(args)) {
return;
}
await userEvent.click(
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
);
await openNewDashboard(canvasElement);
await userEvent.click(await screen.findByText('Import JSON'));
await userEvent.click(await screen.findByTestId('import-json-submit'));
await screen.findByText(/error loading json/i);
@@ -73,13 +112,23 @@ export const importInvalidJson = async ({
export const openFirstRowActions = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (listedDashboards(args).length === 0) {
return;
}
await openRowActions(canvasElement, 0);
};
export const openRenameDialog = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!canEditRow(args, 1)) {
return;
}
await openRowActions(canvasElement, 1);
await pickRowAction('Rename');
await screen.findByRole('dialog', { name: 'Rename dashboard' });
@@ -87,7 +136,12 @@ export const openRenameDialog = async ({
export const openEditTagsDialog = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!canEditRow(args, 1)) {
return;
}
await openRowActions(canvasElement, 1);
await pickRowAction(/^(Edit|Add) Tags$/);
await screen.findByRole('dialog', { name: /^(Edit|Add) tags$/ });
@@ -95,7 +149,12 @@ export const openEditTagsDialog = async ({
export const openSaveViewPopover = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!canList(args)) {
return;
}
await userEvent.click(
await within(canvasElement).findByRole(
'button',
@@ -110,6 +169,10 @@ export const renameDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!canEditRow(args, 1)) {
return;
}
await openRowActions(canvasElement, 1);
await pickRowAction('Rename');
const input = await screen.findByTestId('rename-dashboard-input');
@@ -126,6 +189,10 @@ export const updateTags = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!canEditRow(args, 1)) {
return;
}
await openRowActions(canvasElement, 1);
await pickRowAction(/^(Edit|Add) Tags$/);
const input = await screen.findByPlaceholderText('key:value (press Enter)');
@@ -141,11 +208,37 @@ export const duplicateDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
const row = listedDashboards(args)[1];
if (!row || row.legacy || !canDuplicate(args, row)) {
return;
}
await openRowActions(canvasElement, 1);
await clickRowAction('dashboard-action-duplicate');
if (args.rowWrite === 'success') {
await screen.findByText(
'Duplicated "API latency and errors"',
`Duplicated "${row.name}"`,
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
const toggleLockAt = async (
canvasElement: HTMLElement,
args: DashboardsListArgs,
index: number,
): Promise<void> => {
const row = listedDashboards(args)[index];
if (!row || row.legacy || !canUpdate(args, row)) {
return;
}
await openRowActions(canvasElement, index);
await clickRowAction('dashboard-action-lock');
if (args.rowWrite === 'success') {
await screen.findByText(
row.locked ? 'Dashboard unlocked' : 'Dashboard locked',
{},
{ timeout: TOAST_TIMEOUT },
);
@@ -155,29 +248,22 @@ export const duplicateDashboard = async ({
export const lockDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await openRowActions(canvasElement, 1);
await clickRowAction('dashboard-action-lock');
if (args.rowWrite === 'success') {
await screen.findByText('Dashboard locked', {}, { timeout: TOAST_TIMEOUT });
}
};
}: PlayContext): Promise<void> => toggleLockAt(canvasElement, args, 1);
export const unlockDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await openRowActions(canvasElement, 0);
await clickRowAction('dashboard-action-lock');
if (args.rowWrite === 'success') {
await screen.findByText('Dashboard unlocked', {}, { timeout: TOAST_TIMEOUT });
}
};
}: PlayContext): Promise<void> => toggleLockAt(canvasElement, args, 0);
export const deleteDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
const row = listedDashboards(args)[1];
if (!row || !canDelete(args, row)) {
return;
}
await openRowActions(canvasElement, 1);
await clickRowAction('dashboard-action-delete');
const dialog = await screen.findByRole('dialog');
@@ -194,61 +280,73 @@ export const deleteDashboard = async ({
}
};
export const pinPastLimit = async ({
export const pinDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
const index = listedDashboards(args).findIndex((row) => !row.pinned);
if (index < 0) {
return;
}
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-pin-3',
`dashboard-pin-${index}`,
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByText(
/You can pin up to 10 dashboards/,
{},
{ timeout: TOAST_TIMEOUT },
);
if (args.pinWrite === 'limit') {
await screen.findByText(
/You can pin up to 10 dashboards/,
{},
{ timeout: TOAST_TIMEOUT },
);
}
if (args.pinWrite === 'error') {
await screen.findByText(
'Failed to pin dashboard.',
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
export const pinFails = async ({
export const unpinDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-pin-3',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByText(
'Failed to pin dashboard.',
{},
{ timeout: TOAST_TIMEOUT },
);
};
const index = listedDashboards(args).findIndex((row) => row.pinned);
if (index < 0) {
return;
}
export const unpinFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-pin-0',
`dashboard-pin-${index}`,
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByText(
'Failed to unpin dashboard.',
{},
{ timeout: TOAST_TIMEOUT },
);
if (args.pinWrite === 'error') {
await screen.findByText(
'Failed to unpin dashboard.',
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
export const copyLegacyDashboardId = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await openLegacyDialog(canvasElement);
const index = listedDashboards(args).findIndex((row) => row.legacy);
if (index < 0) {
return;
}
await openLegacyDialog(canvasElement, index);
await userEvent.click(await screen.findByTestId('legacy-dashboard-copy-id'));
await screen.findByText('Dashboard ID copied', {}, { timeout: TOAST_TIMEOUT });
};
@@ -257,7 +355,13 @@ export const migrateDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await retryMigration(canvasElement);
const rows = listedDashboards(args);
const index = rows.findIndex((row) => row.legacy);
if (index < 0 || !canUpdate(args, rows[index])) {
return;
}
await retryMigration(canvasElement, index);
if (args.migrationWrite === 'success') {
await screen.findByText(
'Dashboard migrated to the new experience',
@@ -265,47 +369,43 @@ export const migrateDashboard = async ({
{ timeout: TOAST_TIMEOUT },
);
}
};
export const migrationFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await retryMigration(canvasElement);
await screen.findByText(
'Something went wrong',
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const createDashboardFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
if (args.migrationWrite === 'error') {
await screen.findByText(
'Something went wrong',
{},
{ timeout: TOAST_TIMEOUT },
),
);
);
}
};
export const createDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!canCreate(args)) {
return;
}
await openNewDashboard(canvasElement);
await userEvent.click(await screen.findByTestId('create-dashboard-submit'));
await screen.findAllByText(
/Something went wrong/,
{},
{ timeout: TOAST_TIMEOUT },
);
if (args.createWrite === 'error') {
await screen.findAllByText(
/Something went wrong/,
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
export const importInvalidImage = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
if (!canCreate(args)) {
return;
}
await openNewDashboard(canvasElement);
await uploadDashboardJson({
schemaVersion: 'v6',
image: 'https://example.com/logo.png',
@@ -318,59 +418,52 @@ export const importInvalidImage = async ({
);
};
export const importCreateFails = async ({
export const importDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
if (!canCreate(args)) {
return;
}
await openNewDashboard(canvasElement);
await uploadDashboardJson({
schemaVersion: 'v6',
spec: { display: { name: 'Imported' }, ...IMPORTED_SPEC },
});
await screen.findAllByText(
/Something went wrong/,
{},
{ timeout: TOAST_TIMEOUT },
);
if (args.createWrite === 'error') {
await screen.findAllByText(
/Something went wrong/,
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
export const submitTemplateRequest = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!canCreate(args) || !isCloudTenant(args)) {
return;
}
await requestTemplate(canvasElement);
await screen.findByText(
'Dashboard request submitted',
TEMPLATE_REQUEST_TOASTS[args.templateRequest],
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const templateRequestFails = async ({
export const saveView = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await requestTemplate(canvasElement);
await screen.findByText('Event rejected', {}, { timeout: TOAST_TIMEOUT });
};
if (!canList(args)) {
return;
}
export const templateRequestRejected = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await requestTemplate(canvasElement);
await screen.findByText(
'Something went wrong',
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const saveViewFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
@@ -380,16 +473,26 @@ export const saveViewFails = async ({
);
await userEvent.type(await screen.findByTestId('save-view-name'), 'On-call');
await userEvent.click(await screen.findByTestId('save-view-confirm'));
await screen.findByText(
'Failed to save view.',
{},
{ timeout: TOAST_TIMEOUT },
);
if (args.viewWrite === 'error') {
await screen.findByText(
'Failed to save view.',
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
export const renameViewFails = async ({
const listsSavedViews = (args: DashboardsListArgs): boolean =>
canList(args) && args.dataState === 'loaded' && args.savedViews > 0;
export const renameView = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsSavedViews(args)) {
return;
}
const [rename] = await within(canvasElement).findAllByRole(
'button',
{ name: 'Rename view' },
@@ -403,16 +506,23 @@ export const renameViewFails = async ({
await userEvent.clear(input);
await userEvent.type(input, 'Renamed view');
await userEvent.click(await screen.findByTestId('rename-view-confirm'));
await screen.findByText(
'Failed to update view.',
{},
{ timeout: TOAST_TIMEOUT },
);
if (args.viewWrite === 'error') {
await screen.findByText(
'Failed to update view.',
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
export const deleteViewFails = async ({
export const deleteView = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsSavedViews(args)) {
return;
}
const [remove] = await within(canvasElement).findAllByRole(
'button',
{ name: 'Delete view' },
@@ -425,9 +535,11 @@ export const deleteViewFails = async ({
await userEvent.click(
await within(dialog).findByRole('button', { name: 'Delete' }),
);
await screen.findByText(
'Failed to delete view.',
{},
{ timeout: TOAST_TIMEOUT },
);
if (args.viewWrite === 'error') {
await screen.findByText(
'Failed to delete view.',
{},
{ timeout: TOAST_TIMEOUT },
);
}
};

View File

@@ -9,6 +9,8 @@ import {
InfraMonitoringEntity,
} from 'container/InfraMonitoringK8sV2/constants';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { infraStoryMocks } from './InfrastructureMonitoring.stories.mocks';
import { infraListResponse } from './__story_mockdata__/infraMonitoring';
@@ -56,6 +58,7 @@ export const podLongNodeName = rest.post(
* what puts `?category=` on the route the story starts on.
*/
export const podsMocks = infraStoryMocks(InfraMonitoringEntity.PODS);
export type PodsArgs = PageStoryArgs<typeof podsMocks>;
export const nodesMocks = infraStoryMocks(InfraMonitoringEntity.NODES);
export const namespacesMocks = infraStoryMocks(
InfraMonitoringEntity.NAMESPACES,

View File

@@ -1,21 +1,24 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
findSuggestion,
openKeySuggestions,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { screen, userEvent, within } from 'storybook/test';
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { podLongNodeName, podsMocks } from './Kubernetes.stories.mocks';
import {
podLongNodeName,
podsMocks,
type PodsArgs,
} from './Kubernetes.stories.mocks';
import {
copyPodMetadata,
copyPodName,
openDrawerLogsTab,
openTableOptions,
suggestFilterKeys,
suggestFilterValues,
} from './__story_mockdata__/pods.play';
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
type PodsArgs = PageStoryArgs<typeof podsMocks>;
const pageStory = storyMocks(podsMocks, { layout: 'app' });
/**
@@ -57,38 +60,19 @@ export const PodDetailsEventsJsonBody: StoryObj<PodsArgs> = {
/** The selected pod's details drawer, switched to its logs tab. */
export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
args: { drawer: true },
play: async () => {
const drawer = within(
await screen.findByRole('dialog', {}, { timeout: 10000 }),
);
await userEvent.click(
await drawer.findByText('Logs', {}, { timeout: 10000 }),
);
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
},
play: openDrawerLogsTab,
};
/** The copy button beside the pod name in the drawer, which toasts the copy. */
export const PodNameCopiedToast: StoryObj<PodsArgs> = {
args: { drawer: true },
play: async () => {
await userEvent.click(
await screen.findByTestId('copy-id-button', {}, { timeout: 10000 }),
);
await screen.findByText('Pod name copied to clipboard');
},
play: copyPodName,
};
/** A metadata value's copy button in the drawer, which toasts the label it copied. */
export const PodMetadataCopiedToast: StoryObj<PodsArgs> = {
args: { drawer: true },
play: async () => {
await userEvent.click(
await screen.findByTestId('copy-metadata-namespace', {}, { timeout: 10000 }),
);
await screen.findByText('NAMESPACE copied to clipboard');
},
play: copyPodMetadata,
};
/**
@@ -122,19 +106,7 @@ export const TooltipsInDetailsDrawer: StoryObj<PodsArgs> = {
*/
export const TooltipsInOptionsPanel: StoryObj<PodsArgs> = {
args: { tooltipsOpen: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId(
'k8s-table-options-button',
{},
{ timeout: 10000 },
),
);
await screen.findByText('Columns');
},
play: openTableOptions,
};
/**
@@ -147,16 +119,11 @@ const heldViewport = { sbshot: { viewport: { height: 1200 } } };
/** The pod filter focused: the Kubernetes keys pods can be narrowed by. */
export const FilterKeySuggestions: StoryObj<PodsArgs> = {
parameters: heldViewport,
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'k8s.node.name');
},
play: suggestFilterKeys,
};
/** The pod filter on a namespace: the namespaces the pods run in. */
export const FilterValueSuggestions: StoryObj<PodsArgs> = {
parameters: heldViewport,
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'k8s.namespace.name = ');
await findSuggestion(canvasElement, 'kube-system');
},
play: suggestFilterValues,
};

View File

@@ -0,0 +1,97 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
findSuggestion,
openKeySuggestions,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { screen, userEvent, within } from 'storybook/test';
import type { PodsArgs } from '../Kubernetes.stories.mocks';
interface PlayContext {
canvasElement: HTMLElement;
args: PodsArgs;
}
export const openDrawerLogsTab = async ({
args,
}: PlayContext): Promise<void> => {
if (!args.drawer || args.dataState !== 'loaded' || args.telemetryRows === 0) {
return;
}
const drawer = within(
await screen.findByRole('dialog', {}, { timeout: 10000 }),
);
await userEvent.click(await drawer.findByText('Logs', {}, { timeout: 10000 }));
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
};
export const copyPodName = async ({ args }: PlayContext): Promise<void> => {
if (!args.drawer) {
return;
}
await userEvent.click(
await screen.findByTestId('copy-id-button', {}, { timeout: 10000 }),
);
await screen.findByText('Pod name copied to clipboard');
};
export const copyPodMetadata = async ({ args }: PlayContext): Promise<void> => {
if (!args.drawer || args.dataState !== 'loaded') {
return;
}
await userEvent.click(
await screen.findByTestId('copy-metadata-namespace', {}, { timeout: 10000 }),
);
await screen.findByText('NAMESPACE copied to clipboard');
};
export const openTableOptions = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
// The drawer covers the list, so its options are out of reach.
if (args.drawer) {
return;
}
await userEvent.click(
await within(canvasElement).findByTestId(
'k8s-table-options-button',
{},
{ timeout: 10000 },
),
);
await screen.findByText('Columns');
};
export const suggestFilterKeys = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (args.dataState !== 'loaded') {
return;
}
await openKeySuggestions(canvasElement, 'k8s.node.name');
};
export const suggestFilterValues = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (args.dataState !== 'loaded') {
return;
}
await typeFilter(canvasElement, 'k8s.namespace.name = ');
await findSuggestion(canvasElement, 'kube-system');
};

View File

@@ -25,6 +25,7 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
RESPONSE_STATES,
type ResponseState,
@@ -312,3 +313,5 @@ export const logsMocks = defineStoryMocks({
);
},
});
export type LogsArgs = PageStoryArgs<typeof logsMocks>;

View File

@@ -1,23 +1,35 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
blurFilter,
openKeySuggestions,
showFilterErrors,
typeFilter,
typeFilterWithCaretBack,
findSuggestion,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { logsMocks } from './LogsModulePage.stories.mocks';
import { type LogsArgs, logsMocks } from './LogsModulePage.stories.mocks';
import {
completeFilter,
copyLog,
copyLogDetailsLink,
copyLogLinkFromRow,
dirtyQuickFiltersSettings,
openLogContext,
openLogDetails,
openLogDetailsMenu,
openQuickFiltersSettings,
showFilterSyntaxError,
stepToNextLog,
suggestConjunctions,
suggestFilterKeys,
suggestFilterValues,
suggestInListValues,
suggestInNestedGroup,
suggestNegatedOperators,
suggestNoValues,
suggestNumberOperators,
suggestOperators,
suggestPartialKey,
suggestRecentSearches,
} from './__story_mockdata__/logs.play';
import LogsModulePage from '../LogsModulePage';
type LogsArgs = PageStoryArgs<typeof logsMocks>;
const pageStory = storyMocks(logsMocks, { layout: 'app' });
/**
@@ -38,42 +50,6 @@ export default meta;
type Story = StoryObj<LogsArgs>;
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
const openQuickFiltersSettings = async (): Promise<void> => {
// The settings control renders disabled while its permission check is in
// flight and is swapped for the enabled one once the check answers, so it is
// looked up again on every attempt; a click on the disabled one is dropped in
// silence.
const control = await waitFor(() => {
const settings = screen.getByTestId('settings-icon-container');
expect(settings).not.toHaveAttribute('aria-disabled', 'true');
return settings;
}, untilLoaded);
await userEvent.click(control);
await screen.findByText('Edit quick filters');
};
/** Opening a log is what the drawer is for, and the first row will do. */
const openFirstLog = async (canvasElement: HTMLElement): Promise<void> => {
const [row] = await within(canvasElement).findAllByTestId(
/^logs-table-row-/,
undefined,
untilLoaded,
);
// The list hangs its click handler off the cells rather than the row, so a
// click on the row itself opens nothing.
const [, timestamp] = within(row).getAllByRole('cell');
await userEvent.click(timestamp);
await screen.findByTestId('log-detail-drawer', undefined, untilLoaded);
};
/**
* The explorer on its list view: quick filters down the left, the frequency
* chart over a page of log lines across every severity, and the saved views
@@ -148,30 +124,17 @@ export const JsonBodyFieldsRaw: Story = {
/** One log opened up: its own fields, then the resource that sent it. */
export const LogDetails: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstLog(canvasElement);
},
play: openLogDetails,
};
/** The logs either side of the one that was opened, on the same filters. */
export const LogDetailsContext: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstLog(canvasElement);
await userEvent.click(
await screen.findByText('Context', undefined, untilLoaded),
);
// The tab queries the logs either side before it can render them.
await screen.findAllByText(/cache lookup for cart/, undefined, untilLoaded);
},
play: openLogContext,
};
/** The log detail drawer with its header actions menu open. */
export const LogDetailsMenuOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstLog(canvasElement);
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
await screen.findByText('Copy link to log');
},
play: openLogDetailsMenu,
};
/** A response warning shown beside the query actions. */
@@ -196,17 +159,6 @@ export const QuickFiltersSettings: Story = {
play: openQuickFiltersSettings,
};
const dirtyQuickFiltersSettings = async (): Promise<void> => {
await openQuickFiltersSettings();
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
};
/** Settings with an unsaved filter removal and the fixed action footer. */
export const QuickFiltersSettingsDirty: Story = {
play: dirtyQuickFiltersSettings,
@@ -232,76 +184,48 @@ export const QuickFiltersSettingsWithBanner: Story = {
*/
export const Tooltips: Story = {
args: { tooltipsOpen: true },
play: async ({ canvasElement }): Promise<void> => {
await openFirstLog(canvasElement);
await userEvent.click(
await screen.findByTestId('log-details-header-next', undefined, untilLoaded),
);
},
play: stepToNextLog,
};
/** The filter focused before anything is typed: every key the logs carry. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'severity_text');
},
play: suggestFilterKeys,
};
/** A partial key, with the keys that still match and the typed part marked. */
export const FilterPartialKey: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'serv');
await findSuggestion(canvasElement, 'service.name');
},
play: suggestPartialKey,
};
/** A string key followed by a space: the operators a string compares with. */
export const FilterOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name ');
await findSuggestion(canvasElement, 'CONTAINS');
},
play: suggestOperators,
};
/** A number key puts the range comparisons first. */
export const FilterNumberOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'http.status_code ');
await findSuggestion(canvasElement, 'BETWEEN');
},
play: suggestNumberOperators,
};
/** `NOT` after a key narrows the list to the operators it can negate. */
export const FilterNegatedOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name NOT ');
await findSuggestion(canvasElement, 'IN');
},
play: suggestNegatedOperators,
};
/** A key and an operator: the values the key holds, fetched for it. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
},
play: suggestFilterValues,
};
/** Values still being fetched for the key. */
export const FilterValuesLoading: Story = {
args: { filterValues: 'loading' },
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'Loading suggestions');
},
play: suggestFilterValues,
};
/** A key the backend holds no values for, such as the free-text body. */
export const FilterNoValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'body = ');
await findSuggestion(canvasElement, 'No suggestions available');
},
play: suggestNoValues,
};
/** The values request failed. */
@@ -309,120 +233,55 @@ export const FilterValuesError: Story = {
args: { filterValues: 'error' },
// The values request deliberately fails.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'Error loading suggestions');
},
play: suggestFilterValues,
};
/** Inside an `IN` list, after the first value: the rest of the values. */
export const FilterInList: Story = {
// The editor logs a TypeError while the list is open, which it survives.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await typeFilterWithCaretBack(
canvasElement,
"service.name IN ['auth', ]",
1,
'checkout',
);
},
play: suggestInListValues,
};
/** A complete condition: the conjunctions that start the next one. */
export const FilterConjunctionSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, "service.name = 'checkout' ");
await findSuggestion(canvasElement, 'OR');
},
play: suggestConjunctions,
};
/** Inside an opened group: keys, another group and `NOT`. */
export const FilterNestedGroup: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilterWithCaretBack(canvasElement, '()', 1, 'NOT');
},
play: suggestInNestedGroup,
};
/** A long valid expression mixing operators, left for the next run. */
export const FilterComplete: Story = {
// The editor logs a TypeError while the `IN` list is typed, which it survives.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(
canvasElement,
"service.name IN ['checkout', 'payments'] AND severity_text = 'ERROR' AND http.status_code >= 500 AND body CONTAINS 'timeout'",
);
await blurFilter(canvasElement);
},
play: completeFilter,
};
/** An incomplete expression after focus left: the marker and its errors. */
export const FilterSyntaxError: Story = {
play: async ({ canvasElement }): Promise<void> => {
await showFilterErrors(canvasElement, 'service.name = ');
},
play: showFilterSyntaxError,
};
/** Filters run before, offered above the key suggestions. */
export const FilterRecentSearches: Story = {
args: { recentFilters: 3 },
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(
canvasElement,
"k8s.namespace.name = 'observability'",
);
},
play: suggestRecentSearches,
};
/** Row actions render on hover, so the first row is hovered before the copy link is pressed. */
export const LogLinkCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
const [row] = await within(canvasElement).findAllByTestId(
/^logs-table-row-/,
undefined,
untilLoaded,
);
const [, timestamp] = within(row).getAllByRole('cell');
await userEvent.hover(timestamp);
await userEvent.click(
await waitFor(() => {
const button = canvasElement.querySelector<HTMLElement>('.copy-log-btn');
if (!button) {
throw new Error('Row actions did not render');
}
return button;
}, untilLoaded),
);
await waitFor(() =>
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
);
},
play: copyLogLinkFromRow,
};
/** "Copy log" in the log detail drawer's actions menu. */
export const LogCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstLog(canvasElement);
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
await userEvent.click(await screen.findByText('Copy log'));
await waitFor(() =>
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
);
},
play: copyLog,
};
/** "Copy link to log" in the log detail drawer's actions menu. */
export const LogDetailsLinkCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstLog(canvasElement);
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
await userEvent.click(await screen.findByText('Copy link to log'));
await waitFor(() =>
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
);
},
play: copyLogDetailsLink,
};

View File

@@ -0,0 +1,319 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
blurFilter,
findSuggestion,
openKeySuggestions,
showFilterErrors,
typeFilter,
typeFilterWithCaretBack,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { QuickFilterManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/quick-filter.permissions';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { grantsAll } from '@/storybook/access/access';
import type { LogsArgs } from '../LogsModulePage.stories.mocks';
interface PlayContext {
canvasElement: HTMLElement;
args: LogsArgs;
}
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
const VALUE_SUGGESTIONS = {
loaded: 'checkout',
loading: 'Loading suggestions',
error: 'Error loading suggestions',
} satisfies Record<LogsArgs['filterValues'], string>;
/** The rows a play opens exist only in the list view's table format. */
const listsLogRows = (args: LogsArgs, count = 1): boolean =>
args.view === 'list' &&
args.format === 'table' &&
args.dataState === 'loaded' &&
args.logs >= count;
/** The settings control stays enabled when the check itself fails. */
const canManageQuickFilters = (args: LogsArgs): boolean =>
args.filtersPanel &&
(args.authzState === 'error' ||
grantsAll(args, ...QuickFilterManagePermissions));
/** Runs `drive` once the filter has its keys, which come with the page's data. */
const onFilter =
(drive: (canvasElement: HTMLElement, args: LogsArgs) => Promise<void>) =>
async ({ canvasElement, args }: PlayContext): Promise<void> => {
if (args.dataState === 'loaded') {
await drive(canvasElement, args);
}
};
/** Opening a log is what the drawer is for, and the first row will do. */
const openFirstLog = async (canvasElement: HTMLElement): Promise<void> => {
const [row] = await within(canvasElement).findAllByTestId(
/^logs-table-row-/,
undefined,
untilLoaded,
);
// The list hangs its click handler off the cells rather than the row, so a
// click on the row itself opens nothing.
const [, timestamp] = within(row).getAllByRole('cell');
await userEvent.click(timestamp);
await screen.findByTestId('log-detail-drawer', undefined, untilLoaded);
};
const openLogMenuItem = async (
canvasElement: HTMLElement,
item: string,
): Promise<void> => {
await openFirstLog(canvasElement);
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
await userEvent.click(await screen.findByText(item));
await waitFor(() =>
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
);
};
export const openLogDetails = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsLogRows(args)) {
return;
}
await openFirstLog(canvasElement);
};
export const openLogContext = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsLogRows(args)) {
return;
}
await openFirstLog(canvasElement);
await userEvent.click(
await screen.findByText('Context', undefined, untilLoaded),
);
// The tab queries the logs either side before it can render them.
await screen.findAllByText(/cache lookup for cart/, undefined, untilLoaded);
};
export const openLogDetailsMenu = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsLogRows(args)) {
return;
}
await openFirstLog(canvasElement);
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
await screen.findByText('Copy link to log');
};
export const openQuickFiltersSettings = async ({
args,
}: PlayContext): Promise<void> => {
if (!canManageQuickFilters(args)) {
return;
}
// The settings control renders disabled while its permission check is in
// flight and is swapped for the enabled one once the check answers, so it is
// looked up again on every attempt; a click on the disabled one is dropped in
// silence.
const control = await waitFor(async () => {
const settings = screen.getByTestId('settings-icon-container');
await expect(settings).not.toHaveAttribute('aria-disabled', 'true');
return settings;
}, untilLoaded);
await userEvent.click(control);
await screen.findByText('Edit quick filters');
};
export const dirtyQuickFiltersSettings = async (
context: PlayContext,
): Promise<void> => {
const { args } = context;
if (
!canManageQuickFilters(args) ||
args.dataState !== 'loaded' ||
args.quickFilters === 0
) {
return;
}
await openQuickFiltersSettings(context);
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
};
export const stepToNextLog = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsLogRows(args, 2)) {
return;
}
await openFirstLog(canvasElement);
await userEvent.click(
await screen.findByTestId('log-details-header-next', undefined, untilLoaded),
);
};
export const suggestFilterKeys = onFilter((canvasElement) =>
openKeySuggestions(canvasElement, 'severity_text'),
);
export const suggestPartialKey = onFilter(async (canvasElement) => {
await typeFilter(canvasElement, 'serv');
await findSuggestion(canvasElement, 'service.name');
});
export const suggestOperators = onFilter(async (canvasElement) => {
await typeFilter(canvasElement, 'service.name ');
await findSuggestion(canvasElement, 'CONTAINS');
});
export const suggestNumberOperators = onFilter(async (canvasElement) => {
await typeFilter(canvasElement, 'http.status_code ');
await findSuggestion(canvasElement, 'BETWEEN');
});
export const suggestNegatedOperators = onFilter(async (canvasElement) => {
await typeFilter(canvasElement, 'service.name NOT ');
await findSuggestion(canvasElement, 'IN');
});
export const suggestFilterValues = onFilter(async (canvasElement, args) => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, VALUE_SUGGESTIONS[args.filterValues]);
});
export const suggestNoValues = onFilter(async (canvasElement, args) => {
if (args.filterValues !== 'loaded') {
return;
}
await typeFilter(canvasElement, 'body = ');
await findSuggestion(canvasElement, 'No suggestions available');
});
export const suggestInListValues = onFilter(async (canvasElement, args) => {
if (args.filterValues !== 'loaded') {
return;
}
await typeFilterWithCaretBack(
canvasElement,
"service.name IN ['auth', ]",
1,
'checkout',
);
});
export const suggestConjunctions = onFilter(async (canvasElement) => {
await typeFilter(canvasElement, "service.name = 'checkout' ");
await findSuggestion(canvasElement, 'OR');
});
export const suggestInNestedGroup = onFilter((canvasElement) =>
typeFilterWithCaretBack(canvasElement, '()', 1, 'NOT'),
);
export const completeFilter = onFilter(async (canvasElement) => {
await typeFilter(
canvasElement,
"service.name IN ['checkout', 'payments'] AND severity_text = 'ERROR' AND http.status_code >= 500 AND body CONTAINS 'timeout'",
);
await blurFilter(canvasElement);
});
export const showFilterSyntaxError = onFilter((canvasElement) =>
showFilterErrors(canvasElement, 'service.name = '),
);
export const suggestRecentSearches = onFilter(async (canvasElement, args) => {
if (args.recentFilters === 0) {
return;
}
await openKeySuggestions(
canvasElement,
"k8s.namespace.name = 'observability'",
);
});
export const copyLogLinkFromRow = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsLogRows(args)) {
return;
}
const [row] = await within(canvasElement).findAllByTestId(
/^logs-table-row-/,
undefined,
untilLoaded,
);
const [, timestamp] = within(row).getAllByRole('cell');
await userEvent.hover(timestamp);
await userEvent.click(
await waitFor(() => {
const button = canvasElement.querySelector<HTMLElement>('.copy-log-btn');
if (!button) {
throw new Error('Row actions did not render');
}
return button;
}, untilLoaded),
);
await waitFor(() =>
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
);
};
export const copyLog = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsLogRows(args)) {
return;
}
await openLogMenuItem(canvasElement, 'Copy log');
};
export const copyLogDetailsLink = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsLogRows(args)) {
return;
}
await openLogMenuItem(canvasElement, 'Copy link to log');
};

View File

@@ -33,6 +33,7 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { dashboardsForUserResponse } from '@/storybook/msw/__story_mockdata__/dashboards';
import {
fieldKeysResponse,
@@ -553,6 +554,8 @@ export const metricsMocks = defineStoryMocks({
},
});
export type MetricsArgs = PageStoryArgs<typeof metricsMocks>;
export const saveUnit = async (
canvasElement: HTMLElement,
expected: string | false,

View File

@@ -1,19 +1,19 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
findSuggestion,
openKeySuggestions,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { screen, userEvent } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { metricsMocks, saveUnit } from './MetricsExplorerPage.stories.mocks';
import {
type MetricsArgs,
metricsMocks,
} from './MetricsExplorerPage.stories.mocks';
import {
openDashboardsMenu,
saveMetricUnit,
suggestFilterKeys,
suggestFilterValues,
} from './__story_mockdata__/metricsExplorer.play';
import MetricsExplorerPage from '../MetricsExplorerPage';
type MetricsArgs = PageStoryArgs<typeof metricsMocks>;
const pageStory = storyMocks(metricsMocks, { layout: 'app' });
/**
@@ -103,31 +103,17 @@ export const Tooltips: Story = {
*/
export const MetricDetailsDashboardsMenu: Story = {
args: { drawer: 'details' },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByTestId(
'metric-dashboards-popover',
{},
{ timeout: 10000 },
),
);
await screen.findByRole('menu');
},
play: openDashboardsMenu,
};
/** The summary's metric search focused: the attributes metrics can be found by. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
},
play: suggestFilterKeys,
};
/** The metric search on an attribute and an operator: the values it holds. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
},
play: suggestFilterValues,
};
/**
@@ -136,14 +122,7 @@ export const FilterValueSuggestions: Story = {
*/
export const UnitSavedToast: Story = {
args: { tab: 'explorer', metricUnit: 'unset' },
play: async ({ canvasElement, args }): Promise<void> => {
await saveUnit(
canvasElement,
args.unitSave === 'success'
? 'Unit saved successfully'
: 'Failed to save unit',
);
},
play: saveMetricUnit,
};
/** The same save with the metadata write failing. */
@@ -151,7 +130,5 @@ export const UnitSaveErrorToast: Story = {
args: { tab: 'explorer', metricUnit: 'unset', unitSave: 'error' },
// The deliberate 500 is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await saveUnit(canvasElement, 'Failed to save unit');
},
play: saveMetricUnit,
};

View File

@@ -0,0 +1,90 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
findSuggestion,
openKeySuggestions,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { screen, userEvent } from 'storybook/test';
import {
type MetricsArgs,
saveUnit,
} from '../MetricsExplorerPage.stories.mocks';
interface PlayContext {
canvasElement: HTMLElement;
args: MetricsArgs;
}
const showsMetricSearch = (args: MetricsArgs): boolean =>
args.dataState === 'loaded' && args.tab !== 'volume-control';
export const openDashboardsMenu = async ({
args,
}: PlayContext): Promise<void> => {
if (
args.tab !== 'summary' ||
args.drawer !== 'details' ||
args.dataState !== 'loaded' ||
args.relatedAssets === 0
) {
return;
}
await userEvent.click(
await screen.findByTestId(
'metric-dashboards-popover',
{},
{ timeout: 10000 },
),
);
await screen.findByRole('menu');
};
export const suggestFilterKeys = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsMetricSearch(args)) {
return;
}
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
};
export const suggestFilterValues = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsMetricSearch(args)) {
return;
}
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
};
export const saveMetricUnit = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (
args.tab !== 'explorer' ||
args.dataState !== 'loaded' ||
args.metricUnit !== 'unset' ||
args.explorerMetrics !== 1
) {
return;
}
await saveUnit(
canvasElement,
args.unitSave === 'success'
? 'Unit saved successfully'
: 'Failed to save unit',
);
};

View File

@@ -18,6 +18,7 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
import {
queryRangeV5RawResponse,
@@ -354,3 +355,5 @@ export const tracesMocks = defineStoryMocks({
],
config: (values) => ({ route: tracesRoute(values) }),
});
export type TracesArgs = PageStoryArgs<typeof tracesMocks>;

View File

@@ -1,21 +1,22 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
findSuggestion,
openKeySuggestions,
showFilterErrors,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { tracesMocks } from './TracesModulePage.stories.mocks';
import { type TracesArgs, tracesMocks } from './TracesModulePage.stories.mocks';
import {
dirtyQuickFiltersSettings,
loadNextListPage,
openQuickFiltersSettings,
showRootSpansNextPage,
suggestBooleanValues,
suggestFilterKeys,
suggestFilterValues,
suggestResourceKeys,
showFilterSyntaxError,
} from './__story_mockdata__/traces.play';
import TracesModulePage from '../TracesModulePage';
type TracesArgs = PageStoryArgs<typeof tracesMocks>;
const pageStory = storyMocks(tracesMocks, { layout: 'app' });
/**
@@ -34,28 +35,8 @@ const meta = {
export default meta;
/** The page fetches before it renders its filters, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
type Story = StoryObj<TracesArgs>;
const openQuickFiltersSettings = async (): Promise<void> => {
// The settings control renders disabled while its permission check is in
// flight and is swapped for the enabled one once the check answers, so it is
// looked up again on every attempt; a click on the disabled one is dropped in
// silence.
const control = await waitFor(() => {
const settings = screen.getByTestId('settings-icon-container');
expect(settings).not.toHaveAttribute('aria-disabled', 'true');
return settings;
}, untilLoaded);
await userEvent.click(control);
await screen.findByText('Edit quick filters');
};
/**
* The Explorer tab on a full window of spans: the org's quick filters down the
* left, the query builder and the view switcher above, and the span list with
@@ -77,35 +58,13 @@ export const RootSpans: Story = {
*/
export const RootSpansNextPage: Story = {
args: { view: ExplorerViews.TRACE, spans: 50 },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('button', { name: 'Go to next page' }, untilLoaded),
);
const pager = screen.getByLabelText('pagination');
await expect(
await within(pager).findByText('3', undefined, untilLoaded),
).toBeVisible();
},
play: showRootSpansNextPage,
};
/** The span list scrolled to its end, with the next 100 spans loaded under the first. */
export const ListNextPage: Story = {
args: { spans: 150 },
play: async ({ canvasElement }): Promise<void> => {
// Scrolls again on every attempt, since each loaded page moves the end.
await waitFor(async (): Promise<void> => {
const scroller = canvasElement.querySelector<HTMLElement>(
'[data-virtuoso-scroller]',
);
await expect(scroller).not.toBeNull();
(scroller as HTMLElement).scrollTop = (scroller as HTMLElement).scrollHeight;
await expect(
canvasElement.querySelector('tr[data-index="120"]'),
).not.toBeNull();
}, untilLoaded);
},
play: loadNextListPage,
};
/** The charted view of the same trace query. */
@@ -154,17 +113,6 @@ export const QuickFiltersSettings: Story = {
play: openQuickFiltersSettings,
};
const dirtyQuickFiltersSettings = async (): Promise<void> => {
await openQuickFiltersSettings();
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
};
/** Settings with an unsaved filter removal and the fixed action footer. */
export const QuickFiltersSettingsDirty: Story = {
play: dirtyQuickFiltersSettings,
@@ -183,38 +131,25 @@ export const QuickFiltersSettingsWithBanner: Story = {
/** The filter focused before anything is typed: span and resource keys together. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'status_code_string');
},
play: suggestFilterKeys,
};
/** A context prefix: only the keys that live on the resource. */
export const FilterResourceKeys: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'resource.');
await findSuggestion(canvasElement, 'resource.service.name');
},
play: suggestResourceKeys,
};
/** A key and an operator: the services the spans came from. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
},
play: suggestFilterValues,
};
/** A boolean key offers its two values. */
export const FilterBooleanValues: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'has_error = ');
await findSuggestion(canvasElement, 'false');
},
play: suggestBooleanValues,
};
/** A dangling conjunction after focus left: the marker and its errors. */
export const FilterSyntaxError: Story = {
play: async ({ canvasElement }): Promise<void> => {
await showFilterErrors(canvasElement, 'has_error = true AND');
},
play: showFilterSyntaxError,
};

View File

@@ -0,0 +1,191 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
findSuggestion,
openKeySuggestions,
showFilterErrors,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { QuickFilterManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/quick-filter.permissions';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { grantsAll } from '@/storybook/access/access';
import type { TracesArgs } from '../TracesModulePage.stories.mocks';
interface PlayContext {
canvasElement: HTMLElement;
args: TracesArgs;
}
/** The page fetches before it renders its filters, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
const LIST_PAGE_SIZE = 100;
const TRACE_PAGE_SIZE = 20;
const showsFilter = (args: TracesArgs): boolean =>
args.dataState === 'loaded' && args.tab !== 'funnels';
const showsView = (args: TracesArgs, view: ExplorerViews): boolean =>
args.tab === 'explorer' && args.view === view && args.dataState === 'loaded';
/** The settings control stays enabled when the check itself fails. */
const canManageQuickFilters = (args: TracesArgs): boolean =>
args.tab === 'explorer' &&
(args.authzState === 'error' ||
grantsAll(args, ...QuickFilterManagePermissions));
export const showRootSpansNextPage = async ({
args,
}: PlayContext): Promise<void> => {
// The pager offers page 3 once page 2 comes back full.
if (
!showsView(args, ExplorerViews.TRACE) ||
args.spans < 2 * TRACE_PAGE_SIZE
) {
return;
}
await userEvent.click(
await screen.findByRole('button', { name: 'Go to next page' }, untilLoaded),
);
const pager = screen.getByLabelText('pagination');
await expect(
await within(pager).findByText('3', undefined, untilLoaded),
).toBeVisible();
};
export const loadNextListPage = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsView(args, ExplorerViews.LIST) || args.spans <= LIST_PAGE_SIZE) {
return;
}
// Scrolls again on every attempt, since each loaded page moves the end.
await waitFor(async (): Promise<void> => {
const scroller = canvasElement.querySelector<HTMLElement>(
'[data-virtuoso-scroller]',
);
await expect(scroller).not.toBeNull();
(scroller as HTMLElement).scrollTop = (scroller as HTMLElement).scrollHeight;
const indexes = Array.from(
canvasElement.querySelectorAll<HTMLElement>('tr[data-index]'),
(row) => Number(row.dataset.index),
);
await expect(Math.max(...indexes)).toBeGreaterThanOrEqual(LIST_PAGE_SIZE);
}, untilLoaded);
};
export const openQuickFiltersSettings = async ({
args,
}: PlayContext): Promise<void> => {
if (!canManageQuickFilters(args)) {
return;
}
// The settings control renders disabled while its permission check is in
// flight and is swapped for the enabled one once the check answers, so it is
// looked up again on every attempt; a click on the disabled one is dropped in
// silence.
const control = await waitFor(async () => {
const settings = screen.getByTestId('settings-icon-container');
await expect(settings).not.toHaveAttribute('aria-disabled', 'true');
return settings;
}, untilLoaded);
await userEvent.click(control);
await screen.findByText('Edit quick filters');
};
export const dirtyQuickFiltersSettings = async (
context: PlayContext,
): Promise<void> => {
const { args } = context;
if (
!canManageQuickFilters(args) ||
args.dataState !== 'loaded' ||
args.quickFilters === 0
) {
return;
}
await openQuickFiltersSettings(context);
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
};
export const suggestFilterKeys = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsFilter(args)) {
return;
}
await openKeySuggestions(canvasElement, 'status_code_string');
};
export const suggestResourceKeys = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsFilter(args)) {
return;
}
await typeFilter(canvasElement, 'resource.');
await findSuggestion(canvasElement, 'resource.service.name');
};
export const suggestFilterValues = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsFilter(args)) {
return;
}
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
};
export const suggestBooleanValues = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsFilter(args)) {
return;
}
await typeFilter(canvasElement, 'has_error = ');
await findSuggestion(canvasElement, 'false');
};
export const showFilterSyntaxError = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsFilter(args)) {
return;
}
await showFilterErrors(canvasElement, 'has_error = true AND');
};

View File

@@ -6,12 +6,16 @@ import {
IsViewerPermission,
} from 'lib/authz/hooks/useAuthZ/legacy';
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import {
formatPermission,
gettableTransactionToPermission,
permissionToTransactionDto,
} from 'lib/authz/hooks/useAuthZ/utils';
import { ROLES, USER_ROLES } from 'types/roles';
import type { ResponseState } from '../runtime/responseState';
/**
* `relation:kind` for every verb the backend allows on a resource, from the
* generated permission catalogue, so a resource added there shows up here
@@ -159,3 +163,23 @@ export const accessFor = (
legacyRole: deriveLegacyRole(permissions),
};
};
export interface AccessArgs {
access: AccessPreset;
permissions: readonly string[];
revoked: readonly string[];
authzState: ResponseState;
}
/** Whether `authz/check`, answered from a story's access controls, passes every one of `checks`. */
export const grantsAll = (
args: AccessArgs,
...checks: BrandedPermission[]
): boolean => {
const grant = accessFor(args.access, args.permissions, args.revoked);
return (
args.authzState === 'loaded' &&
checks.every((check) => grant.allows(permissionToTransactionDto(check)))
);
};

View File

@@ -36,7 +36,6 @@ export interface PostableAlertRuleV2 {
absentFor?: number;
requireMinPoints?: boolean;
requiredNumPoints?: number;
algorithm?: string;
seasonality?: string;
};
evaluation?: {

2
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/SigNoz/clickhouse-go-mock v0.14.0
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
github.com/SigNoz/signoz-otel-collector v0.144.6
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.1
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.2
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/cespare/xxhash/v2 v2.3.0

4
go.sum
View File

@@ -102,8 +102,8 @@ github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkb
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc=
github.com/SigNoz/signoz-otel-collector v0.144.6 h1:AgblCIDlLKGtssS4ywvyEByH9fbJ7hFqZvPvUVJKwp8=
github.com/SigNoz/signoz-otel-collector v0.144.6/go.mod h1:YRQw6ETaq7fm8ts4Itgb6wC0bVhc0+nC6PFI0MaiH9k=
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.1 h1:eKJ4vmSLYIfufgh+HaiJxXZts12Xz4jr81HMYYoQvMU=
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.1/go.mod h1:RSsdmH4/6H+EHeNPyqJ59dGdpKS0hl2p3T5g1NbehOU=
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.2 h1:b0YEbA8sjW73bcDl9PhYrftdZ2uScWMcIu6zKwh4/9I=
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.2/go.mod h1:RSsdmH4/6H+EHeNPyqJ59dGdpKS0hl2p3T5g1NbehOU=
github.com/Yiling-J/theine-go v0.6.2 h1:1GeoXeQ0O0AUkiwj2S9Jc0Mzx+hpqzmqsJ4kIC4M9AY=
github.com/Yiling-J/theine-go v0.6.2/go.mod h1:08QpMa5JZ2pKN+UJCRrCasWYO1IKCdl54Xa836rpmDU=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=

View File

@@ -4,8 +4,8 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -14,124 +14,343 @@ import (
)
func (provider *provider) addRulerRoutes(router *mux.Router) error {
if err := router.Handle("/api/v3/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRulesV3), handler.OpenAPIDef{
ID: "ListRulesV3",
Tags: []string{"rules"},
Summary: "List alert rules (v3)",
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
RequestQuery: new(ruletypes.ListRulesParams),
Response: new(ruletypes.ListableRules),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v3/rules", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListRulesV3, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListRulesV3",
Tags: []string{"rules"},
Summary: "List alert rules (v3)",
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
RequestQuery: new(ruletypes.ListRulesParams),
Response: new(ruletypes.ListableRules),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.GetRuleByID), handler.OpenAPIDef{
ID: "GetRuleByID",
Tags: []string{"rules"},
Summary: "Get alert rule by ID",
Description: "This endpoint returns an alert rule by ID",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.GetRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleByID",
Tags: []string{"rules"},
Summary: "Get alert rule by ID",
Description: "This endpoint returns an alert rule by ID",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CreateRule), handler.OpenAPIDef{
ID: "CreateRule",
Tags: []string{"rules"},
Summary: "Create alert rule",
Description: "This endpoint creates a new alert rule",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rules", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateRule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "CreateRule",
Tags: []string{"rules"},
Summary: "Create alert rule",
Description: "This endpoint creates a new alert rule",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewCreateRuleChannelAttachExtractor(provider.alertmanagerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleBodyTelemetryResources(),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CloneRuleByID), handler.OpenAPIDef{
ID: "CloneRuleByID",
Tags: []string{"rules"},
Summary: "Clone alert rule",
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.CloneRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "CloneRuleByID",
Tags: []string{"rules"},
Summary: "Clone alert rule",
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewCloneRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},
Summary: "Update alert rule",
Description: "This endpoint updates an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbUpdate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDetach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewReplaceRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewReplaceRuleChannelDetachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleBodyTelemetryResources(),
},
),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},
Summary: "Update alert rule",
Description: "This endpoint updates an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "DeleteRuleByID",
Tags: []string{"rules"},
Summary: "Delete alert rule",
Description: "This endpoint deletes an alert rule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.DeleteRuleByID), handler.OpenAPIDef{
ID: "DeleteRuleByID",
Tags: []string{"rules"},
Summary: "Delete alert rule",
Description: "This endpoint deletes an alert rule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.PatchRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "PatchRuleByID",
Tags: []string{"rules"},
Summary: "Patch alert rule",
Description: "This endpoint applies a partial update to an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbUpdate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDetach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewPatchRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewPatchRuleChannelDetachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewOptionalRuleBodyTelemetryResources(),
Optional: true,
},
),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.PatchRuleByID), handler.OpenAPIDef{
ID: "PatchRuleByID",
Tags: []string{"rules"},
Summary: "Patch alert rule",
Description: "This endpoint applies a partial update to an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/test", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.TestRule), handler.OpenAPIDef{
ID: "TestRule",
Tags: []string{"rules"},
Summary: "Test alert rule",
Description: "This endpoint fires a test notification for the given rule definition",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.GettableTestRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/test", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.TestRule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "TestRule",
Tags: []string{"rules"},
Summary: "Test alert rule",
Description: "This endpoint fires a test notification for the given rule definition",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.GettableTestRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewCreateRuleChannelAttachExtractor(provider.alertmanagerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleBodyTelemetryResources(),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}

View File

@@ -251,7 +251,6 @@ func postableRuleExamples() []handler.OpenAPIExample {
},
},
},
"algorithm": "standard",
"seasonality": "daily",
"selectedQueryName": "A",
"requireMinPoints": true,

View File

@@ -5,28 +5,23 @@ import (
"testing"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestPostableRuleExamplesValidate verifies every example payload returned by
// postableRuleExamples() round-trips through PostableRule.UnmarshalJSON and
// postableRuleExamples() round-trips through PostableRule.DecodeStrict and
// passes Validate(). If an example drifts from the runtime contract this
// breaks loudly so the spec doesn't ship invalid payloads to users.
func TestPostableRuleExamplesValidate(t *testing.T) {
for _, example := range postableRuleExamples() {
t.Run(example.Name, func(t *testing.T) {
raw, err := json.Marshal(example.Value)
if err != nil {
t.Fatalf("marshal example: %v", err)
}
require.NoError(t, err)
var rule ruletypes.PostableRule
if err := json.Unmarshal(raw, &rule); err != nil {
t.Fatalf("unmarshal: %v\npayload: %s", err, raw)
}
if err := rule.Validate(); err != nil {
t.Fatalf("Validate: %v\npayload: %s", err, raw)
}
require.NoError(t, rule.DecodeStrict(string(raw)), "payload: %s", raw)
assert.NoError(t, rule.Validate(), "payload: %s", raw)
})
}
}

View File

@@ -109,6 +109,8 @@ type TelemetryResourceDef struct {
Category coretypes.ActionCategory
Selector coretypes.SelectorFunc
Resources coretypes.ResourceExtractor
// Optional skips the check instead of failing when the request carries no telemetry resources.
Optional bool
}
func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
@@ -116,6 +118,9 @@ func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []
if err != nil {
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(def.Verb, def.Category, err)}
}
if len(refs) == 0 && def.Optional {
return nil
}
if len(refs) == 0 {
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(
def.Verb,

View File

@@ -69,3 +69,43 @@ func TestAttachDetachSiblingResourceDefKeepsEmptySingleID(t *testing.T) {
require.True(t, ok)
assert.Equal(t, []string{""}, withTarget.TargetIDs())
}
func TestTelemetryResourceDefWithoutResources(t *testing.T) {
testCases := []struct {
name string
optional bool
wantLen int
wantErr bool
}{
{
name: "Required_Errors",
optional: false,
wantLen: 1,
wantErr: true,
},
{
name: "Optional_ResolvesNothing",
optional: true,
wantLen: 0,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
def := TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
Resources: func(coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) { return nil, nil },
Optional: testCase.optional,
}
resolved := ResolveRequest([]ResourceDef{def}, coretypes.ExtractorContext{})
require.Len(t, resolved, testCase.wantLen)
if testCase.wantErr {
assert.Error(t, resolved[0].Err())
}
})
}
}

View File

@@ -43,15 +43,15 @@ func stripKeyAlias(name string) string {
// unwrapVariant decodes a scan envelope: the chcol.Variant the driver scans a Dynamic column (a JSON
// path such as body_v2.level) into, and the JSONValue a whole JSON column scans into. The attributes
// bag decodes flat so its dotted keys merge with the legacy attribute maps and a key stored at both a
// scalar and an object path stays two keys; the scope decodes flat too, keeping its attributes dotted;
// every other JSON column decodes nested.
// scalar and an object path stays two keys; the scope and resource decode flat too, keeping their
// attributes dotted; every other JSON column decodes nested.
func unwrapVariant(name string, val any) any {
switch v := val.(type) {
case chcol.Variant:
return v.Any()
case telemetrystoretypes.JSONValue:
switch name {
case "attributes", "scope":
case "attributes", "scope", "resource":
return v.Flatten()
}
return v.Nest()

View File

@@ -98,6 +98,10 @@ func TestUnwrapVariant(t *testing.T) {
scopeAttrs := chcol.NewJSON()
scopeAttrs.SetValueAtPath("lib.lang", "go")
scopeCol := jsonColumn(map[string]any{"name": "io.opentelemetry.http", "version": "1.2.0", "attributes": scopeAttrs})
resourceAttrs := chcol.NewJSON()
resourceAttrs.SetValueAtPath("service.name", "signoz")
resourceAttrs.SetValueAtPath("signoz.resource.kind", "role")
resourceCol := jsonColumn(map[string]any{"attributes": resourceAttrs})
testCases := []struct {
name string
@@ -112,6 +116,7 @@ func TestUnwrapVariant(t *testing.T) {
{name: "AttributesColumnFlat", column: "attributes", input: jsonCol, want: map[string]any{"level": "error", "attrs.code": float64(500)}},
{name: "AttributesColumnDropsNullPaths", column: "attributes", input: nullCol, want: map[string]any{"level": "error"}},
{name: "ScopeColumnFlat", column: "scope", input: scopeCol, want: map[string]any{"name": "io.opentelemetry.http", "version": "1.2.0", "attributes": map[string]any{"lib.lang": "go"}}},
{name: "ResourceColumnFlat", column: "resource", input: resourceCol, want: map[string]any{"attributes": map[string]any{"service.name": "signoz", "signoz.resource.kind": "role"}}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {

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