Compare commits

..

6 Commits

Author SHA1 Message Date
Naman Verma
62d477e920 Merge branch 'main' into nv/exec-stats 2026-11-09 12:20:27 +05:30
Naman Verma
fe45b9dcae Merge branch 'main' into nv/exec-stats 2026-11-06 00:51:46 +05:30
Naman Verma
63ee60a40c fix(querier): stop cache refreshes from inflating exec stats 2026-11-06 00:51:03 +05:30
Naman Verma
29a393e47e Merge branch 'main' into nv/exec-stats 2026-11-05 10:51:11 +05:30
Naman Verma
c58f76f998 Merge branch 'main' into nv/exec-stats 2026-11-03 11:34:10 +05:30
Naman Verma
e39ad9e221 test: check how exec stats fail 2026-11-02 22:24:22 +05:30
475 changed files with 11553 additions and 14606 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, 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.
- **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.
- **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,27 +106,6 @@ 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.
@@ -137,7 +116,7 @@ Two habits cover the rest:
- **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. 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).
- **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).
- **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
@@ -164,7 +143,7 @@ Two habits cover the rest:
- [ ] 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, checked against the cases in **Plays**, and the story file holds no play body
- [ ] every play passes with each control value its story exposes, 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

13
.github/CODEOWNERS vendored
View File

@@ -194,8 +194,8 @@ go.mod @therealpandey
/frontend/src/pages/EditRules/ @SigNoz/pulse-frontend
/frontend/src/container/AlertHistory/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertRule/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlert/ @SigNoz/pulse-frontend
/frontend/src/container/EditAlert/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertV2/ @SigNoz/pulse-frontend
/frontend/src/container/EditAlertV2/ @SigNoz/pulse-frontend
/frontend/src/container/FormAlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
@@ -209,10 +209,6 @@ go.mod @therealpandey
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
/frontend/src/container/PlannedDowntime/ @SigNoz/pulse-frontend
/frontend/src/hooks/alerts/ @SigNoz/pulse-frontend
/frontend/src/container/Home/AlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/Home/__tests__/AlertRules.test.tsx @SigNoz/pulse-frontend
## Notification Channels
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
@@ -221,11 +217,6 @@ go.mod @therealpandey
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
## List filter + views (dashboards and alerts lists)
/frontend/src/components/ListFilter/ @SigNoz/pulse-frontend
/frontend/src/components/ListViews/ @SigNoz/pulse-frontend
/frontend/src/hooks/listViews/ @SigNoz/pulse-frontend
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv

View File

@@ -63,7 +63,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
GO_NAME: signoz-community
GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build

View File

@@ -89,7 +89,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./cmd/enterprise

View File

@@ -18,25 +18,25 @@ env:
jobs:
prepare:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' || startsWith(github.event.label.name, 'staging:') }}
if: ${{ contains(github.event.label.name, 'staging:') || github.event.ref == 'refs/heads/main' }}
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 }}
path: ${{ steps.build-info.outputs.path }}
deployment: ${{ steps.build-info.outputs.deployment }}
steps:
- name: self-checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@v4
- id: token
name: github-token-gen
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.PRIMUS_APP_ID }}
private-key: ${{ secrets.PRIMUS_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: primus-checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@v4
with:
repository: signoz/primus
ref: main
@@ -44,29 +44,22 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: build-info
id: build-info
env:
LABEL: ${{ github.event.label.name }}
run: |
# 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
# 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 "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
{
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}"
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"
else
echo "error: not able to determine deployment - please verify the PR label or the branch"
exit 1
fi
echo "deployment=${deployment}" >> $GITHUB_OUTPUT
- name: create-dotenv
run: |
mkdir -p frontend
@@ -78,7 +71,7 @@ jobs:
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
- name: cache-dotenv
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@v4
with:
path: frontend/.env
key: staging-dotenv-${{ github.sha }}
@@ -102,7 +95,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./cmd/enterprise
@@ -122,6 +115,7 @@ 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]
@@ -129,6 +123,6 @@ jobs:
PRIMUS_REF: main
GITHUB_ENVIRONMENT: staging
GITHUB_SILENT: true
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 }}"}'
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 }}"}'

View File

@@ -22,7 +22,7 @@ jobs:
with:
PRIMUS_REF: main
GO_TEST_CONTEXT: ./...
GO_VERSION: 1.25
GO_VERSION: 1.24
fmt:
if: |
github.event_name == 'merge_group' ||
@@ -32,7 +32,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
lint:
if: |
github.event_name == 'merge_group' ||
@@ -42,7 +42,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
deps:
if: |
github.event_name == 'merge_group' ||
@@ -52,7 +52,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
semconv-generated:
if: |
github.event_name == 'merge_group' ||
@@ -65,7 +65,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: check-semconv-generated-files
run: make semconv-check
build:
@@ -80,7 +80,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: qemu-install
uses: docker/setup-qemu-action@v3
- name: aarch64-install
@@ -116,7 +116,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: generate-openapi
run: |
go run cmd/enterprise/*.go generate openapi
@@ -133,7 +133,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: generate-authz
run: |
go run cmd/enterprise/*.go generate authz
@@ -150,7 +150,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: generate-web-settings
run: |
go run cmd/enterprise/*.go generate config web-settings
@@ -167,7 +167,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: generate-transaction-groups
run: |
go run cmd/enterprise/*.go generate config transaction-groups

View File

@@ -66,7 +66,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: cross-compilation-tools
if: matrix.os == 'ubuntu-latest'
run: |
@@ -130,7 +130,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
# copy the caches from build
- name: get-sha

View File

@@ -75,7 +75,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: cross-compilation-tools
if: matrix.os == 'ubuntu-latest'
run: |
@@ -138,7 +138,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
# copy the caches from build
- name: get-sha

View File

@@ -9,7 +9,6 @@ import (
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/auditor/fileauditor"
"github.com/SigNoz/signoz/ee/auditor/kafkaauditor"
"github.com/SigNoz/signoz/ee/auditor/otlphttpauditor"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
@@ -157,9 +156,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
if err := factories.Add(fileauditor.NewFactory(licensing, version.Info)); err != nil {
panic(err)
}
if err := factories.Add(kafkaauditor.NewFactory(licensing, version.Info)); err != nil {
panic(err)
}
return factories
},
func(ctx context.Context, providerSettings factory.ProviderSettings, flagger pkgflagger.Flagger, licensing licensing.Licensing, telemetryStore telemetrystore.TelemetryStore, retentionGetter retention.Getter, orgGetter organization.Getter, zeus zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string) {

View File

@@ -436,7 +436,6 @@ auditor:
# Specifies the auditor provider to use.
# noop: discards all audit events (community default).
# otlphttp: exports audit events via OTLP HTTP (enterprise).
# kafka: exports audit events to a Kafka topic as OTLP protobuf (enterprise).
provider: noop
# The async channel capacity for audit events. Events are dropped when full (fail-open).
buffer_size: 1000
@@ -462,18 +461,6 @@ auditor:
max_interval: 30s
# The total maximum time spent retrying.
max_elapsed_time: 60s
kafka:
# The seed broker addresses (host:port) used to discover the cluster.
brokers:
- localhost:9092
# The topic audit events are written to. It must already exist.
topic: ""
# The client id the producer reports to the brokers.
client_id: signoz
# The compression codec for produced batches: none, gzip, snappy, lz4 or zstd.
compression: lz4
# The maximum time to drain buffered events on shutdown. Undelivered events are dropped after it.
shutdown_timeout: 30s
##################### Cloud Integration #####################
cloudintegration:

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +0,0 @@
package kafkaauditor
import (
"context"
"log/slog"
"github.com/twmb/franz-go/pkg/kgo"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/audittypes"
)
// Produces the whole batch as a single record so the collector's otlp_proto unmarshaler reads it as one ExportLogsServiceRequest.
func (provider *provider) export(ctx context.Context, events []audittypes.AuditEvent) error {
logs := audittypes.NewPLogsFromAuditEvents(events, "signoz", provider.build.Version(), "signoz.audit")
value, err := provider.marshaler.MarshalLogs(logs)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to marshal audit logs")
}
record := &kgo.Record{Topic: provider.config.Kafka.Topic, Value: value}
if err := provider.client.ProduceSync(ctx, record).FirstErr(); err != nil {
provider.settings.Logger().ErrorContext(ctx, "audit export failed", errors.Attr(err), slog.Int("dropped_log_records", len(events)))
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to produce audit logs to topic %q", provider.config.Kafka.Topic)
}
return nil
}

View File

@@ -1,132 +0,0 @@
package kafkaauditor
import (
"context"
"log/slog"
"github.com/twmb/franz-go/pkg/kgo"
"github.com/twmb/franz-go/plugin/kslog"
"go.opentelemetry.io/collector/pdata/plog"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/auditor/auditorserver"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/version"
)
var _ auditor.Auditor = (*provider)(nil)
type provider struct {
settings factory.ScopedProviderSettings
config auditor.Config
licensing licensing.Licensing
build version.Build
server *auditorserver.Server
marshaler plog.ProtoMarshaler
client *kgo.Client
}
func NewFactory(licensing licensing.Licensing, build version.Build) factory.ProviderFactory[auditor.Auditor, auditor.Config] {
return factory.NewProviderFactory(factory.MustNewName("kafka"), func(ctx context.Context, providerSettings factory.ProviderSettings, config auditor.Config) (auditor.Auditor, error) {
return newProvider(ctx, providerSettings, config, licensing, build)
})
}
func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config auditor.Config, licensing licensing.Licensing, build version.Build) (auditor.Auditor, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/auditor/kafkaauditor")
// Acks from all in-sync replicas, idempotent writes and unbounded record retries are franz-go defaults and are kept as is.
// A delivery timeout or retry limit lets franz-go < v1.22.2 ack a record that was never written (twmb/franz-go#1484).
client, err := kgo.NewClient(
kgo.SeedBrokers(config.Kafka.Brokers...),
kgo.ClientID(config.Kafka.ClientID),
kgo.ProducerBatchCompression(compressionCodec(config.Kafka.Compression)),
kgo.ProducerLinger(0),
kgo.WithLogger(kslog.New(settings.Logger())),
)
if err != nil {
return nil, err
}
provider := &provider{
settings: settings,
config: config,
licensing: licensing,
build: build,
marshaler: plog.ProtoMarshaler{},
client: client,
}
server, err := auditorserver.New(settings,
auditorserver.Config{
BufferSize: config.BufferSize,
BatchSize: config.BatchSize,
FlushInterval: config.FlushInterval,
},
provider.export,
)
if err != nil {
client.Close()
return nil, err
}
provider.server = server
return provider, nil
}
func (provider *provider) Start(ctx context.Context) error {
return provider.server.Start(ctx)
}
func (provider *provider) Audit(ctx context.Context, event audittypes.AuditEvent) {
if event.PrincipalAttributes.PrincipalOrgID.IsZero() {
provider.settings.Logger().WarnContext(ctx, "audit event dropped as org_id is zero")
return
}
if _, err := provider.licensing.GetActive(ctx, event.PrincipalAttributes.PrincipalOrgID); err != nil {
return
}
provider.server.Add(ctx, event)
}
func (provider *provider) Healthy() <-chan struct{} {
return provider.server.Healthy()
}
func (provider *provider) Stop(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, provider.config.Kafka.ShutdownTimeout)
defer cancel()
stoppedC := make(chan error, 1)
go func() { stoppedC <- provider.server.Stop(ctx) }()
select {
case err := <-stoppedC:
provider.client.Close()
return err
case <-ctx.Done():
provider.settings.Logger().WarnContext(ctx, "audit drain timed out, closing kafka client", slog.Duration("shutdown_timeout", provider.config.Kafka.ShutdownTimeout))
// Closing fails buffered and in-flight records, so the blocked export and the rest of the drain return immediately.
provider.client.Close()
return <-stoppedC
}
}
func compressionCodec(name string) kgo.CompressionCodec {
switch name {
case "gzip":
return kgo.GzipCompression()
case "snappy":
return kgo.SnappyCompression()
case "lz4":
return kgo.Lz4Compression()
case "zstd":
return kgo.ZstdCompression()
default:
return kgo.NoCompression()
}
}

View File

@@ -1,227 +0,0 @@
package kafkaauditor
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/twmb/franz-go/pkg/kerr"
"github.com/twmb/franz-go/pkg/kfake"
"github.com/twmb/franz-go/pkg/kgo"
"go.opentelemetry.io/collector/pdata/plog"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/version"
)
func newTestCluster(t *testing.T, topics ...string) *kfake.Cluster {
t.Helper()
cluster, err := kfake.NewCluster(kfake.NumBrokers(1), kfake.SeedTopics(1, topics...))
require.NoError(t, err)
t.Cleanup(cluster.Close)
return cluster
}
func newTestProvider(t *testing.T, brokers []string, topic string, compression string) *provider {
t.Helper()
config := auditor.Config{
Provider: "kafka",
BufferSize: 10,
BatchSize: 5,
FlushInterval: 50 * time.Millisecond,
Kafka: auditor.KafkaConfig{
Brokers: brokers,
Topic: topic,
ClientID: "signoz-test",
Compression: compression,
ShutdownTimeout: 30 * time.Second,
},
}
auditor, err := newProvider(context.Background(), instrumentationtest.New().ToProviderSettings(), config, nil, version.Info)
require.NoError(t, err)
return auditor.(*provider)
}
func newTestEvent(resource coretypes.Resource, action coretypes.Verb, body string) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
Body: body,
EventName: audittypes.NewEventName(action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,
},
ResourceAttributes: audittypes.ResourceAttributes{
Resource: resource,
},
}
}
// Polls the topic until the consumed records hold wantLogRecords audit log records in total.
func consumeRecords(t *testing.T, brokers []string, topic string, wantLogRecords int) []*kgo.Record {
t.Helper()
client, err := kgo.NewClient(
kgo.SeedBrokers(brokers...),
kgo.ConsumeTopics(topic),
kgo.ConsumeResetOffset(kgo.NewOffset().AtStart()),
)
require.NoError(t, err)
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var records []*kgo.Record
gotLogRecords := 0
for gotLogRecords < wantLogRecords {
fetches := client.PollFetches(ctx)
require.NoError(t, ctx.Err(), "timed out waiting for %d log records, got %d", wantLogRecords, gotLogRecords)
require.Empty(t, fetches.Errors())
for _, record := range fetches.Records() {
logs, err := (&plog.ProtoUnmarshaler{}).UnmarshalLogs(record.Value)
require.NoError(t, err)
gotLogRecords += logs.LogRecordCount()
records = append(records, record)
}
}
return records
}
func TestExport(t *testing.T) {
testCases := []struct {
name string
compression string
compressionType uint8
}{
{name: "CompressionNone", compression: "none", compressionType: 0},
{name: "CompressionGzip", compression: "gzip", compressionType: 1},
{name: "CompressionSnappy", compression: "snappy", compressionType: 2},
{name: "CompressionLz4", compression: "lz4", compressionType: 3},
{name: "CompressionZstd", compression: "zstd", compressionType: 4},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
cluster := newTestCluster(t, "tenant_audit")
provider := newTestProvider(t, cluster.ListenAddrs(), "tenant_audit", testCase.compression)
defer provider.client.Close()
events := []audittypes.AuditEvent{
newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"),
newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate, "dashboard created"),
newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbDelete, "dashboard deleted"),
}
require.NoError(t, provider.export(context.Background(), events))
records := consumeRecords(t, cluster.ListenAddrs(), "tenant_audit", len(events))
require.Len(t, records, 1)
assert.Equal(t, "tenant_audit", records[0].Topic)
assert.Equal(t, testCase.compressionType, records[0].Attrs.CompressionType())
logs, err := (&plog.ProtoUnmarshaler{}).UnmarshalLogs(records[0].Value)
require.NoError(t, err)
assert.Equal(t, len(events), logs.LogRecordCount())
serviceName, ok := logs.ResourceLogs().At(0).Resource().Attributes().Get("service.name")
require.True(t, ok)
assert.Equal(t, "signoz", serviceName.Str())
})
}
}
func TestExport_RecordTooLarge(t *testing.T) {
cluster := newTestCluster(t, "large_audit")
provider := newTestProvider(t, cluster.ListenAddrs(), "large_audit", "none")
defer provider.client.Close()
event := newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, strings.Repeat("a", 2<<20))
err := provider.export(context.Background(), []audittypes.AuditEvent{event})
require.Error(t, err)
assert.True(t, errors.Is(err, kerr.MessageTooLarge))
}
func TestExport_UnknownTopic(t *testing.T) {
cluster := newTestCluster(t)
provider := newTestProvider(t, cluster.ListenAddrs(), "missing_audit", "lz4")
defer provider.client.Close()
err := provider.export(context.Background(), []audittypes.AuditEvent{newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, "user created")})
require.Error(t, err)
assert.True(t, errors.Is(err, kerr.UnknownTopicOrPartition))
}
func TestExport_BrokerDown_RetriesUntilContextDone(t *testing.T) {
cluster := newTestCluster(t, "down_audit")
brokers := cluster.ListenAddrs()
cluster.Close()
provider := newTestProvider(t, brokers, "down_audit", "lz4")
defer provider.client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
start := time.Now()
err := provider.export(ctx, []audittypes.AuditEvent{newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, "user created")})
require.Error(t, err)
assert.True(t, errors.Is(err, context.DeadlineExceeded))
assert.GreaterOrEqual(t, time.Since(start), 500*time.Millisecond)
}
func TestStop_DrainsQueuedEvents(t *testing.T) {
cluster := newTestCluster(t, "drain_audit")
provider := newTestProvider(t, cluster.ListenAddrs(), "drain_audit", "lz4")
done := make(chan error, 1)
go func() { done <- provider.Start(context.Background()) }()
<-provider.Healthy()
for range 7 {
provider.server.Add(context.Background(), newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"))
}
require.NoError(t, provider.Stop(context.Background()))
require.NoError(t, <-done)
records := consumeRecords(t, cluster.ListenAddrs(), "drain_audit", 7)
assert.NotEmpty(t, records)
}
func TestStop_BrokerDown_ClosesAfterShutdownTimeout(t *testing.T) {
cluster := newTestCluster(t, "stuck_audit")
brokers := cluster.ListenAddrs()
cluster.Close()
provider := newTestProvider(t, brokers, "stuck_audit", "lz4")
provider.config.Kafka.ShutdownTimeout = 500 * time.Millisecond
done := make(chan error, 1)
go func() { done <- provider.Start(context.Background()) }()
<-provider.Healthy()
for range 7 {
provider.server.Add(context.Background(), newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"))
}
start := time.Now()
require.NoError(t, provider.Stop(context.Background()))
require.NoError(t, <-done)
assert.GreaterOrEqual(t, time.Since(start), 500*time.Millisecond)
assert.Less(t, time.Since(start), 5*time.Second)
}

View File

@@ -34,7 +34,8 @@ type AnomalyRule struct {
provider anomaly.Provider
logger *slog.Logger
version string
logger *slog.Logger
seasonality anomaly.Seasonality
}
@@ -62,6 +63,7 @@ func NewAnomalyRule(
r := AnomalyRule{
BaseRule: baseRule,
querier: querier,
version: p.Version,
logger: logger.With(slog.String("rule.id", id)),
}
@@ -103,15 +105,15 @@ func (r *AnomalyRule) Type() ruletypes.RuleType {
}
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
r.logger.InfoContext(
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval.window_ms", end-start),
slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()),
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
)
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
req := &qbtypes.QueryRangeRequest{
Start: uint64(start),
End: uint64(end),
@@ -275,7 +277,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := r.AlertAnnotations(expand, smpl, value, threshold)
annotations := r.ExpandAnnotations(expand)
if smpl.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")
@@ -418,10 +420,12 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *AnomalyRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.Name()),
RuleCondition: r.Condition(),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
AlertName: valuer.NewDisplayName(r.Name()),
RuleCondition: r.Condition(),
EvalWindow: r.EvalWindow(),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
PreferredChannels: r.PreferredChannels(),
}
byt, err := json.Marshal(ar)

View File

@@ -54,6 +54,9 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
@@ -129,97 +132,10 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
alertsFound, err := rule.Eval(context.Background(), evalTime)
require.NoError(t, err)
assert.Equal(t, c.expectAlerts, alertsFound)
for _, item := range rule.Active {
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationValue))
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationThresholdValue))
}
})
}
}
func TestAnomalyRule_ValueAndThresholdAnnotations(t *testing.T) {
baseTime := time.Unix(1700000000, 0)
evalTime := baseTime.Add(5 * time.Minute)
target := 2.0
postableRule := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName("Test anomaly value"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: valuer.MustParseTextDuration("5m"),
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
},
}},
},
SelectedQuery: "A",
Seasonality: ruletypes.SeasonalityDaily,
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
Name: "Test anomaly value",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
}},
},
},
Annotations: map[string]string{
"summary": "{{$value}}",
"description": "{{$threshold}}",
},
}
response := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
{
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{{
Labels: []*qbtypes.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"}, Value: "checkout"}},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: baseTime.Add(1 * time.Minute).UnixMilli(), Value: 1.5},
{Timestamp: baseTime.Add(2 * time.Minute).UnixMilli(), Value: 3.5},
},
}},
}},
},
},
}
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, nil, instrumentationtest.New().Logger(), mustParseURL(t, "http://localhost:8000"))
require.NoError(t, err)
rule.provider = &mockAnomalyProvider{responses: []*anomaly.AnomaliesResponse{response}}
alertsFound, err := rule.Eval(context.Background(), evalTime)
require.NoError(t, err)
require.Equal(t, 1, alertsFound)
for _, item := range rule.Active {
value := item.Annotations.Get(ruletypes.AnnotationValue)
assert.NotEmpty(t, value)
assert.Equal(t, item.Annotations.Get("summary"), value)
threshold := item.Annotations.Get(ruletypes.AnnotationThresholdValue)
assert.NotEmpty(t, threshold)
assert.Equal(t, item.Annotations.Get("description"), threshold)
}
}
func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
// Test missing data alert with AbsentFor grace period
// 1. Call Eval with data at time t1, to populate lastTimestampWithDatapoints
@@ -241,7 +157,10 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
AlertOnAbsent: true,
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{

View File

@@ -1,9 +1,7 @@
import type { ErrorEvent, StackFrame } from '@sentry/react';
import { AxiosError, AxiosHeaders } from 'axios';
import {
hasNoAppFrames,
isExpectedRequestFailure,
isExternalIntegrationError,
SENTRY_IGNORE_ERRORS,
} from '../sentryFilters';
@@ -140,36 +138,3 @@ describe('SENTRY_IGNORE_ERRORS', () => {
).toBe(true);
});
});
describe('isExpectedRequestFailure', () => {
function axiosErrorWithStatus(status: number): AxiosError {
const config = { headers: new AxiosHeaders() };
return new AxiosError(
`Request failed with status code ${status}`,
AxiosError.ERR_BAD_REQUEST,
config,
undefined,
{ status, statusText: '', headers: {}, config, data: {} },
);
}
it.each([401, 403])('drops a %i response', (status) => {
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(true);
});
it('drops a network error', () => {
expect(
isExpectedRequestFailure(
new AxiosError('Network Error', AxiosError.ERR_NETWORK),
),
).toBe(true);
});
it.each([400, 404, 500, 503])('keeps a %i response', (status) => {
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(false);
});
it('keeps an error that is not from axios', () => {
expect(isExpectedRequestFailure(new Error('Network Error'))).toBe(false);
});
});

View File

@@ -40,7 +40,6 @@ import defaultRoutes, {
} from './routes';
import {
hasNoAppFrames,
isExpectedRequestFailure,
isExternalIntegrationError,
SENTRY_IGNORE_ERRORS,
} from './sentryFilters';
@@ -342,9 +341,6 @@ function App(): JSX.Element {
if (error?.name === 'AbortError') {
return null;
}
if (isExpectedRequestFailure(hint?.originalException)) {
return null;
}
// Ignore benign Monaco cancellation errors (name 'Canceled').
if (error?.name === 'Canceled') {
return null;

View File

@@ -1,5 +1,4 @@
import type { ErrorEvent } from '@sentry/react';
import { AxiosError, isAxiosError } from 'axios';
export const SENTRY_IGNORE_ERRORS = [
// A tab that outlived a deploy requests hashed assets the new build no longer
@@ -15,18 +14,6 @@ export const SENTRY_IGNORE_ERRORS = [
/Called on script loaded before session recording is available/,
];
// Not UI bugs: 401/403 come from the session or role, ERR_NETWORK from a
// dropped connection.
export function isExpectedRequestFailure(error: unknown): boolean {
if (!isAxiosError(error)) {
return false;
}
const status = error.response?.status;
return (
error.code === AxiosError.ERR_NETWORK || status === 401 || status === 403
);
}
const APP_ASSETS_PATH = '/assets/';
// Frames without a position are `[native code]`, which says nothing about the

View File

@@ -45,6 +45,7 @@ import type {
GetRuleHistoryTopContributorsParams,
GetRuleHistoryTopContributorsPathParameters,
ListRuleViews200,
ListRules200,
ListRulesV3200,
ListRulesV3Params,
PatchRuleByID200,
@@ -426,6 +427,85 @@ export const useUpdateRuleView = <
> => {
return useMutation(getUpdateRuleViewMutationOptions(options));
};
/**
* This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.
* @deprecated
* @summary List alert rules
*/
export const listRules = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListRules200>({
url: `/api/v2/rules`,
method: 'GET',
signal,
});
};
export const getListRulesQueryKey = () => {
return [`/api/v2/rules`] as const;
};
export const getListRulesQueryOptions = <
TData = Awaited<ReturnType<typeof listRules>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof listRules>>, TError, TData>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListRulesQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRules>>> = ({
signal,
}) => listRules(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listRules>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListRulesQueryResult = NonNullable<
Awaited<ReturnType<typeof listRules>>
>;
export type ListRulesQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary List alert rules
*/
export function useListRules<
TData = Awaited<ReturnType<typeof listRules>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof listRules>>, TError, TData>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListRulesQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @deprecated
* @summary List alert rules
*/
export const invalidateListRules = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListRulesQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoint creates a new alert rule
* @summary Create alert rule

View File

@@ -829,26 +829,33 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
jira = 'jira',
}
export interface AlertmanagertypesChannelJiraAuthDTO {
export interface AlertmanagertypesChannelJiraCloudAuthDTO {
/**
* @type string
* @format password
* @description The Atlassian API token paired with email. Required when deployment is cloud. A secret; returned by create, update and fetch by ID, never by list.
* @description The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list.
*/
apiToken?: string;
apiToken: string;
/**
* @type string
* @description The Atlassian account email used for basic auth. Required when deployment is cloud.
* @description The Atlassian account email used for basic auth.
*/
email?: string;
/**
* @type string
* @format password
* @description The Jira personal access token, sent as a Bearer header. Required when deployment is selfhosted. A secret; returned by create, update and fetch by ID, never by list.
*/
pat?: string;
email: string;
}
export interface AlertmanagertypesChannelJiraSelfHostedAuthDTO {
/**
* @type string
* @format password
* @description The Jira personal access token, sent as a Bearer header. A secret; returned by create, update and fetch by ID, never by list.
*/
pat: string;
}
export type AlertmanagertypesChannelJiraAuthDTO =
| AlertmanagertypesChannelJiraCloudAuthDTO
| AlertmanagertypesChannelJiraSelfHostedAuthDTO;
export enum AlertmanagertypesJiraDeploymentDTO {
cloud = 'cloud',
selfhosted = 'selfhosted',
@@ -3604,7 +3611,6 @@ export enum CoretypesKindDTO {
'logs-field' = 'logs-field',
'traces-field' = 'traces-field',
'metrics-field' = 'metrics-field',
'llm-pricing-rule' = 'llm-pricing-rule',
}
export enum CoretypesTypeDTO {
user = 'user',
@@ -7073,7 +7079,7 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
*/
functions?: Querybuildertypesv5FunctionDTO[] | null;
/**
* @description A filter on the formula values, referencing the formula by its name or as __result, for example F1 > 0. A time series keeps only the points that match and drops a series with none left; a scalar result keeps only the rows that match.
* @description Accepted but currently ignored; the computed values are returned unfiltered.
*/
having?: Querybuildertypesv5HavingDTO;
/**
@@ -12960,10 +12966,23 @@ export interface RuletypesRuleConditionDTO {
* @description Whether to fire a nodata alert when the query returns no data. Defaults to false.
*/
alertOnAbsent?: boolean;
/**
* @type string
* @description anomaly_rule only: the anomaly detection algorithm. The supported value is standard.
*/
algorithm?: string;
/**
* @description The queries whose result is compared against the thresholds.
*/
compositeQuery: RuletypesAlertCompositeQueryDTO;
/**
* @description schemaVersion v1 only: which points in the window must satisfy op. Accepted as at_least_once, all_the_times, on_average, in_total or last, their aliases avg and sum, or the legacy codes as strings, '1' to '5'. Required for v1; v2alpha1 sets matchType on each threshold.
*/
matchType?: RuletypesMatchTypeDTO;
/**
* @description schemaVersion v1 only: how the value is compared with target. Accepted as above, below, equal, not_equal, above_or_equal, below_or_equal or outside_bounds, their symbols, or the legacy codes as strings, '1' to '7'. Required for v1; v2alpha1 sets op on each threshold.
*/
op?: RuletypesCompareOperatorDTO;
/**
* @type boolean
* @description Whether a series is skipped until it has at least requiredNumPoints points in the window. Defaults to false.
@@ -12983,6 +13002,17 @@ export interface RuletypesRuleConditionDTO {
* @description The name of the query whose result is evaluated, such as A or F1. Must match a query in compositeQuery.
*/
selectedQueryName: string;
/**
* @type number,null
* @format double
* @description schemaVersion v1 only: the value compared against, in targetUnit. Required for v1; v2alpha1 sets target on each threshold.
*/
target?: number | null;
/**
* @type string
* @description schemaVersion v1 only: the unit of target, such as percent or ms. Converted to compositeQuery.unit before comparison when the two differ.
*/
targetUnit?: string;
/**
* @description The thresholds to evaluate, as a kind and spec.
*/
@@ -13018,10 +13048,20 @@ export interface RuletypesPostableRuleDTO {
* @description Whether the rule is paused. A disabled rule is neither evaluated nor notifies. Defaults to false.
*/
disabled?: boolean;
/**
* @type string
* @description The lookback window as a Go duration such as 5m. Only read when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec instead.
*/
evalWindow?: string;
/**
* @description The evaluation window, as a kind and spec.
*/
evaluation: RuletypesEvaluationEnvelopeDTO;
/**
* @type string
* @description How often the rule is evaluated, as a Go duration such as 1m. Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set it in evaluation.spec instead.
*/
frequency?: string;
/**
* @type object
* @description Labels attached to every alert the rule fires. Keys match [a-zA-Z_.][a-zA-Z0-9_.]*; values may use Go templates such as {{$labels.service_name}}. severity is the conventional routing label.
@@ -13031,6 +13071,11 @@ export interface RuletypesPostableRuleDTO {
* @description Grouping, re-notification and routing behaviour.
*/
notificationSettings: RuletypesNotificationSettingsDTO;
/**
* @type array
* @description The displayName of each notification channel to notify, for schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries its own channels.
*/
preferredChannels?: string[];
/**
* @description The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql queries and is forced to promql_rule for promql queries.
*/
@@ -13041,6 +13086,16 @@ export interface RuletypesPostableRuleDTO {
* @description The rule payload schema.
*/
schemaVersion: RuletypesPostableRuleDTOSchemaVersion;
/**
* @type string
* @description The URL of the page the rule was created from. Informational; it is not used for evaluation.
*/
source?: string;
/**
* @type string
* @description The query payload version. Nothing reads it.
*/
version?: string;
}
export interface RuletypesPostableRuleViewDTO {
@@ -13105,10 +13160,20 @@ export interface RuletypesRuleDTO {
* @description Whether the rule is paused. A disabled rule is neither evaluated nor notifies. Defaults to false.
*/
disabled?: boolean;
/**
* @type string
* @description The lookback window as a Go duration such as 5m. Only read when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec instead.
*/
evalWindow?: string;
/**
* @description The evaluation window, as a kind and spec.
*/
evaluation: RuletypesEvaluationEnvelopeDTO;
/**
* @type string
* @description How often the rule is evaluated, as a Go duration such as 1m. Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set it in evaluation.spec instead.
*/
frequency?: string;
/**
* @type string
* @description The unique identifier of the rule.
@@ -13123,6 +13188,11 @@ export interface RuletypesRuleDTO {
* @description Grouping, re-notification and routing behaviour.
*/
notificationSettings: RuletypesNotificationSettingsDTO;
/**
* @type array
* @description The displayName of each notification channel to notify, for schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries its own channels.
*/
preferredChannels?: string[];
/**
* @description The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql queries and is forced to promql_rule for promql queries.
*/
@@ -13133,6 +13203,11 @@ export interface RuletypesRuleDTO {
* @description The rule payload schema.
*/
schemaVersion: RuletypesRuleDTOSchemaVersion;
/**
* @type string
* @description The URL of the page the rule was created from. Informational; it is not used for evaluation.
*/
source?: string;
/**
* @description The current evaluation state: inactive, pending, recovering, firing, nodata or disabled.
*/
@@ -13146,6 +13221,11 @@ export interface RuletypesRuleDTO {
* @type string
*/
updatedBy?: string;
/**
* @type string
* @description The query payload version. Nothing reads it.
*/
version?: string;
}
export enum RuletypesThresholdKindDTO {
@@ -16703,6 +16783,17 @@ export type UpdateRuleView200 = {
status: string;
};
export type ListRules200 = {
/**
* @type array
*/
data: RuletypesRuleDTO[];
/**
* @type string
*/
status: string;
};
export type CreateRule201 = {
data: RuletypesRuleDTO;
/**

View File

@@ -11,8 +11,6 @@ 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

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 726 B

View File

@@ -1,13 +1,18 @@
import { Select } from 'antd';
import { ConfigProvider, Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import cx from 'classnames';
import type { UpdatedWindow } from '../dsl/types';
import type { CreatorOption } from '../types';
import { UPDATED_OPTIONS } from './constants';
import MultiSelectFilter from './MultiSelectFilter';
import styles from './FilterZone.module.scss';
// The option checkbox already marks selection, so selected rows stay regular weight.
const CREATOR_SELECT_THEME = {
components: { Select: { optionSelectedFontWeight: 400 } },
};
interface Props {
createdBy: string[];
updated: UpdatedWindow;
@@ -47,16 +52,36 @@ function FilterChips({
return (
<div className={styles.chips}>
<MultiSelectFilter
value={createdBy}
options={creatorOptionsData}
placeholder="Created by"
testId={`${testIdPrefix}-filter-created-by`}
disabled={disabled}
onClear={onClearCreatedBy}
onChange={onCreatedByChange}
onDropdownVisibleChange={runOnClose}
/>
<ConfigProvider theme={CREATOR_SELECT_THEME}>
<Select
mode="multiple"
showSearch
allowClear
className={cx(styles.select, styles.selectWide)}
placeholder="Created by"
value={createdBy}
options={creatorOptionsData}
optionFilterProp="label"
maxTagCount={1}
menuItemSelectedIcon={null}
data-testid={`${testIdPrefix}-filter-created-by`}
disabled={disabled}
onClear={onClearCreatedBy}
optionRender={(option): JSX.Element => (
<div className={styles.creatorOption}>
<div className={styles.creatorCheck}>
<Checkbox
color="primary"
value={createdBy.includes(option.value as string)}
/>
</div>
<span className={styles.creatorLabel}>{option.label}</span>
</div>
)}
onChange={(value): void => onCreatedByChange(value as string[])}
onDropdownVisibleChange={runOnClose}
/>
</ConfigProvider>
<Select
showSearch
className={cx(styles.select, styles.selectNarrow)}

View File

@@ -86,21 +86,21 @@
}
}
// Multi-select option: a presentational checkbox + label (antd handles the toggle).
.checkOption {
// Created-by option: a presentational checkbox + label (antd handles the toggle).
.creatorOption {
display: flex;
align-items: center;
gap: 8px;
}
.checkOptionBox {
.creatorCheck {
// Flex, not block: a line box would shift with the check mark's baseline.
display: flex;
flex-shrink: 0;
pointer-events: none;
}
.checkOptionLabel {
.creatorLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View File

@@ -1,72 +0,0 @@
import { ConfigProvider, Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import cx from 'classnames';
import styles from './FilterZone.module.scss';
// The option checkbox already marks selection, so selected rows stay regular weight.
const CHECKBOX_SELECT_THEME = {
components: { Select: { optionSelectedFontWeight: 400 } },
};
export interface MultiSelectFilterOption {
value: string;
label: string;
}
interface Props {
value: string[];
options: MultiSelectFilterOption[];
placeholder: string;
testId: string;
onChange: (values: string[]) => void;
onClear?: () => void;
onDropdownVisibleChange?: (open: boolean) => void;
disabled?: boolean;
}
function MultiSelectFilter({
value,
options,
placeholder,
testId,
onChange,
onClear,
onDropdownVisibleChange,
disabled = false,
}: Props): JSX.Element {
return (
<ConfigProvider theme={CHECKBOX_SELECT_THEME}>
<Select
mode="multiple"
showSearch
allowClear
className={cx(styles.select, styles.selectWide)}
placeholder={placeholder}
value={value}
options={options}
optionFilterProp="label"
maxTagCount={1}
menuItemSelectedIcon={null}
data-testid={testId}
disabled={disabled}
onClear={onClear}
optionRender={(option): JSX.Element => (
<div className={styles.checkOption}>
<div className={styles.checkOptionBox}>
<Checkbox
color="primary"
value={value.includes(option.value as string)}
/>
</div>
<span className={styles.checkOptionLabel}>{option.label}</span>
</div>
)}
onChange={(next): void => onChange(next as string[])}
onDropdownVisibleChange={onDropdownVisibleChange}
/>
</ConfigProvider>
);
}
export default MultiSelectFilter;

View File

@@ -48,59 +48,22 @@ export function QuerySearchV2Provider({
}, [initialExpression, store]);
const isInitialized = useRef(false);
const lastCommittedRef = useRef(committedExpression);
useEffect(() => {
if (isInitialized.current || !urlExpression) {
return;
}
isInitialized.current = true;
store
.getState()
.initializeFromUrl(
getUserExpressionFromCombined(initialExpression, urlExpression),
if (!isInitialized.current && urlExpression) {
const cleanedExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
store.getState().initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [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;
if (isInitialized.current || !urlExpression) {
setUrlExpression(committedExpression || null);
}
lastCommittedRef.current = committedExpression;
isInitialized.current = true;
const next = committedExpression || null;
if (next === urlExpression) {
return;
}
void setUrlExpression(next);
}, [committedExpression, urlExpression, setUrlExpression]);
}, [committedExpression, setUrlExpression, urlExpression]);
useEffect(() => {
return (): void => {

View File

@@ -1,10 +1,9 @@
import { ReactNode } from 'react';
import { act, renderHook } from 'tests/test-utils';
import { act, renderHook } from '@testing-library/react';
import {
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
@@ -35,22 +34,10 @@ 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;
@@ -59,7 +46,6 @@ function useTestHooks(): {
expression: useExpression(),
userExpression: useUserExpression(),
initialExpression: useInitialExpression(),
inputExpression: useInputExpression(),
querySearchInitialExpressionProp: useQuerySearchInitialExpressionProp(),
onChange: useQuerySearchOnChange(),
onRun: useQuerySearchOnRun(),
@@ -70,7 +56,6 @@ describe('QuerySearchExpressionProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUrlValue = null;
window.history.replaceState(null, '', '/');
});
it('should provide initial context values', () => {
@@ -125,96 +110,6 @@ 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,41 +15,11 @@ $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,12 +1,6 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CircleCheck,
Info,
TriangleAlert,
Filter,
Play,
} from '@signozhq/icons';
import { CircleCheck, Info, TriangleAlert, Filter } from '@signozhq/icons';
import {
autocompletion,
closeCompletion,
@@ -22,7 +16,6 @@ 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,
@@ -66,7 +59,6 @@ import {
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import { ScopeChip } from './ScopeChip/ScopeChip';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -113,14 +105,9 @@ 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,
@@ -138,13 +125,10 @@ function QuerySearch({
queryData,
dataSource,
onRun,
showRunButton,
isLoadingQueries,
signalSource,
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
initialExpression,
showScopeChip: showScopeChipProp,
metricNamespace,
valueSuggestionsOverride,
}: QuerySearchProps): JSX.Element {
@@ -164,7 +148,6 @@ function QuerySearch({
const editorRef = useRef<EditorView | null>(null);
const isScopedFilter = initialExpression !== undefined;
const showScopeChip = !!showScopeChipProp && !!initialExpression;
const validateExpressionForEditor = useCallback(
(editorDoc: string): void => {
@@ -190,18 +173,6 @@ 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;
@@ -1586,182 +1557,169 @@ function QuerySearch({
</div>
)}
<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
<div className="query-where-clause-editor-container">
{isScopedFilter ? (
<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,
}}
/>
</a>
</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,
}}
/>
</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,
<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', {
hasErrors: validation.errors.length > 0,
hasInitialExpression: isScopedFilter && !showScopeChip,
})}
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>
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>
}
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>
}
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>
@@ -1855,15 +1813,12 @@ 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

@@ -1,57 +0,0 @@
.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

@@ -1,41 +0,0 @@
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,134 +224,6 @@ 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

@@ -53,6 +53,7 @@ export enum QueryParams {
variables = 'variables',
version = 'version',
source = 'source',
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
isTestAlert = 'isTestAlert',
yAxisUnit = 'yAxisUnit',
ruleName = 'ruleName',

View File

@@ -26,6 +26,7 @@ export const REACT_QUERY_KEY = {
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
CLONE_ALERT_RULE: 'CLONE_ALERT_RULE',
GET_HOST_LIST: 'GET_HOST_LIST',
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
@@ -69,6 +70,8 @@ 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

@@ -21,15 +21,14 @@ import {
useListDashboardsForUserV2,
} from 'api/generated/services/dashboard';
import {
getListRulesV3QueryKey,
useListRulesV3,
getListRulesQueryKey,
useListRules,
} from 'api/generated/services/rules';
import type {
DashboardtypesListedDashboardForUserV2DTO,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
ListRulesV3200,
ListRulesV3Params,
ListRules200,
} from 'api/generated/services/sigNoz.schemas';
import logEvent from 'api/common/logEvent';
import { useQueryService } from 'hooks/useQueryService';
@@ -180,9 +179,6 @@ const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
*/
const DASHBOARD_LIST_PARAMS: ListDashboardsForUserV2Params = { limit: 1000 };
/** Same as dashboards. 200 is the most the rules list returns per call. */
const ALERT_RULE_LIST_PARAMS: ListRulesV3Params = { limit: 200 };
function dashboardTitle(
dashboard: DashboardtypesListedDashboardForUserV2DTO,
): string {
@@ -759,7 +755,7 @@ export default function ChatInput({
data: alertsResponse,
isLoading: isAlertsLoading,
isError: isAlertsError,
} = useListRulesV3(ALERT_RULE_LIST_PARAMS, {
} = useListRules({
query: {
enabled: isContextPickerOpen && activeContextCategory === 'Alerts',
staleTime: Infinity,
@@ -808,10 +804,10 @@ export default function ChatInput({
}
}
if (ctx.type === 'alert' && ctx.resourceId) {
const cached = queryClient.getQueryData<ListRulesV3200>(
getListRulesV3QueryKey(ALERT_RULE_LIST_PARAMS),
const cached = queryClient.getQueryData<ListRules200>(
getListRulesQueryKey(),
);
const rule = cached?.data.rules.find((r) => r.id === ctx.resourceId);
const rule = cached?.data?.find((r) => r.id === ctx.resourceId);
if (rule?.alert) {
return rule.alert;
}
@@ -839,8 +835,8 @@ export default function ChatInput({
value: dashboardTitle(dashboard),
})) ?? [],
Alerts:
alertsResponse?.data.rules
.filter((alertRule) => Boolean(alertRule.alert))
alertsResponse?.data
?.filter((alertRule) => Boolean(alertRule.alert))
.map((alertRule) => ({
id: alertRule.id,
value: alertRule.alert,
@@ -1063,7 +1059,6 @@ export default function ChatInput({
color="secondary"
size="sm"
id={`ai-context-tab-${category}`}
testId={`ai-context-tab-${category}`}
// Single stable panel id shared by every tab: only the
// active category's tabpanel is rendered, so per-category
// `aria-controls` ids would point at nonexistent nodes

View File

@@ -1,70 +0,0 @@
import {
alertRulesFixture,
listRulesV3Response,
} from 'mocks-server/__mockdata__/alert_rules';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
jest.mock('api/generated/services/dashboard', () => ({
useListDashboardsForUserV2: (): unknown => ({
data: undefined,
isLoading: false,
isError: false,
}),
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
}));
jest.mock('hooks/useQueryService', () => ({
useQueryService: (): unknown => ({
data: [],
isLoading: false,
isFetching: false,
isError: false,
}),
}));
jest.mock('../../../hooks/useSpeechRecognition', () => ({
useSpeechRecognition: (): unknown => ({
isListening: false,
isSupported: false,
permission: 'prompt',
start: jest.fn(),
discard: jest.fn(),
}),
}));
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
useAIAssistantAnalyticsContext: (): unknown => ({
threadId: undefined,
page: '/',
mode: 'sidepane',
}),
}));
// eslint-disable-next-line import/first
import ChatInput from '../ChatInput';
describe('ChatInput — alerts context picker', () => {
it('lists the rules from one v3 rules request', async () => {
const [first, second] = alertRulesFixture;
const requests: string[] = [];
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
requests.push(req.url.search);
return res(
ctx.status(200),
ctx.json(listRulesV3Response([first, second], req.url.searchParams)),
);
}),
);
render(<ChatInput onSend={jest.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
await userEvent.click(await screen.findByTestId('ai-context-tab-Alerts'));
await expect(screen.findByText(first.alert)).resolves.toBeInTheDocument();
expect(screen.getByText(second.alert)).toBeInTheDocument();
expect(requests).toStrictEqual(['?limit=200']);
});
});

View File

@@ -12,12 +12,8 @@ jest.mock('api/generated/services/dashboard', () => ({
}));
jest.mock('api/generated/services/rules', () => ({
useListRulesV3: (): unknown => ({
data: undefined,
isLoading: false,
isError: false,
}),
getListRulesV3QueryKey: (): string[] => ['rules'],
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
getListRulesQueryKey: (): string[] => ['rules'],
}));
jest.mock('hooks/useQueryService', () => ({

View File

@@ -1,24 +1,20 @@
import { useCallback, useEffect, useMemo } from 'react';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Select } from 'antd';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap } from 'constants/queryBuilder';
import {
getAllEndpointsWidgetData,
getGroupByExpressionFromRowData,
getGroupByFiltersFromGroupByValues,
} from 'container/ApiMonitoring/utils';
import GridCard from 'container/WidgetCard/Card';
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
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,
@@ -27,6 +23,8 @@ function AllEndPoints({
groupBy,
setGroupBy,
timeRange,
initialFilters,
setInitialFiltersEndPointStats,
}: {
domainName: string;
setSelectedEndPointName: (name: string) => void;
@@ -37,50 +35,113 @@ function AllEndPoints({
startTime: number;
endTime: number;
};
initialFilters: IBuilderQuery['filters'];
setInitialFiltersEndPointStats: (filters: IBuilderQuery['filters']) => void;
}): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const [, setEndPointStatsExpression] = useQueryState(
ENDPOINT_STATS_EXPRESSION_KEY,
parseAsString,
);
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 }[]
>([]);
// --- FILTERS STATE SYNC ---
const {
expression: filterExpression,
initialExpression: scopeExpression,
queryData,
onDraftChange,
onRun,
onBlur: handleFilterBlur,
} = useFilterExpression({
groupBy,
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 };
});
// 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, 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],
() => getAllEndpointsWidgetData(groupBy, domainName, filters),
[groupBy, domainName, filters],
);
// --- GROUP BY STATE SYNC (existing) ---
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
setGroupBy(value);
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('');
// Update params
setParams({ groupBy: value.map((g) => g.key) });
setParams({ groupBy: newGroupBy.map((g) => g.key) });
},
[setGroupBy, setParams],
[groupByFiltersData, setGroupBy, allAvailableGroupByOptions, setParams],
);
// Sync params to local groupBy state on param change
@@ -93,31 +154,77 @@ function AllEndPoints({
groupBy.map((g) => g.key),
)
) {
setGroupBy(
params.groupBy.map((key) => chooseAutocompleteFromCustomValue([], 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, groupBy, setGroupBy]);
}, [params.groupBy, allAvailableGroupByOptions, groupBy, setGroupBy]);
useEffect(() => {
if (groupByFiltersData?.payload) {
// Update dropdown options
setGroupByOptions(
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.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]);
const onRowClick = useCallback(
(props: any): void => {
setSelectedEndPointName(props[SPAN_ATTRIBUTES.HTTP_URL] as string);
setSelectedView(VIEWS.ENDPOINT_STATS);
const statsExpression = combineInitialAndUserExpression(
getGroupByExpressionFromRowData(props),
filterExpression,
);
void setEndPointStatsExpression(statsExpression || null);
const initialItems = [
...(filters?.items || []),
...(getGroupByFiltersFromGroupByValues(props, groupBy)?.items || []),
];
setInitialFiltersEndPointStats({
items: initialItems,
op: 'AND',
});
setParams({
selectedEndPointName: props[SPAN_ATTRIBUTES.HTTP_URL] as string,
selectedView: VIEWS.ENDPOINT_STATS,
endPointDetailsLocalFilters: {
items: initialItems,
op: 'AND',
},
});
},
[
filterExpression,
setEndPointStatsExpression,
filters,
setInitialFiltersEndPointStats,
setSelectedEndPointName,
setSelectedView,
groupBy,
setParams,
],
);
@@ -125,28 +232,28 @@ function AllEndPoints({
return (
<div className="all-endpoints-container">
<div className="all-endpoints-header">
<div className="filter-container" onBlur={handleFilterBlur}>
<QuerySearch
queryData={queryData}
onChange={onDraftChange}
onRun={handleRun}
showRunButton
isLoadingQueries={isFetchingTable}
initialExpression={scopeExpression}
showScopeChip
dataSource={DataSource.TRACES}
<div className="filter-container">
<QueryBuilderSearchV2
query={query}
onChange={handleFilterChange}
placeholder="Search for filters..."
/>
</div>
</div>
<div className="group-by-container">
<div className="group-by-label"> Group by </div>
<GroupByFilter
query={queryData}
onChange={handleGroupByChange}
disabled={false}
<Select
className="group-by-select"
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}
allowClear
maxTagCount="responsive"
placeholder="Search for attribute"
/>
options={groupByOptions}
onChange={handleGroupByChange}
onSearch={(value: string): void => setGroupBySearchValue(value)}
/>{' '}
</div>
<div className="endpoints-table-container">
<GridCard

View File

@@ -195,6 +195,16 @@
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 {
@@ -355,9 +365,16 @@
.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 {
@@ -399,8 +416,29 @@
.endpoint-details-filters-container {
display: flex;
flex-direction: column;
gap: 8px;
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;
}
}
.ant-select-item,

View File

@@ -6,7 +6,6 @@ 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,
@@ -18,12 +17,7 @@ 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';
@@ -40,14 +34,14 @@ function DomainDetails({
selectedDomainIndex,
setSelectedDomainIndex,
domainListLength,
domainListFilterExpression,
domainListFilters,
}: {
domainData: any;
handleClose: () => void;
selectedDomainIndex: number;
setSelectedDomainIndex: (index: number) => void;
domainListLength: number;
domainListFilterExpression: string;
domainListFilters: IBuilderQuery['filters'];
}): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const [selectedView, setSelectedView] = useState<VIEWS>(
@@ -59,16 +53,8 @@ function DomainDetails({
const [endPointsGroupBy, setEndPointsGroupBy] = useState<
IBuilderQuery['groupBy']
>([]);
const domainName = domainData?.domainName ?? '';
const allEndpointsScope = useMemo(
() => getDomainScopeExpression(domainName),
[domainName],
);
const endPointStatsScope = useMemo(
() => getDomainScopeExpression(domainName, selectedEndPointName),
[domainName, selectedEndPointName],
);
const [initialFiltersEndPointStats, setInitialFiltersEndPointStats] =
useState<IBuilderQuery['filters']>(domainListFilters);
const handleTabChange = (value: string): void => {
setSelectedView(value as VIEWS);
@@ -236,7 +222,7 @@ function DomainDetails({
<>
<DomainMetrics
domainName={domainData.domainName}
domainListFilterExpression={domainListFilterExpression}
domainListFilters={domainListFilters}
timeRange={modalTimeRange}
/>
<div className="views-tabs-container">
@@ -267,43 +253,34 @@ function DomainDetails({
/>
</div>
{selectedView === VIEW_TYPES.ALL_ENDPOINTS && (
<QuerySearchV2Provider
queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}
initialExpression={allEndpointsScope}
persistOnUnmount
>
<AllEndPoints
domainName={domainData.domainName}
setSelectedEndPointName={handleEndPointChange}
setSelectedView={setSelectedView}
groupBy={endPointsGroupBy}
setGroupBy={setEndPointsGroupBy}
timeRange={modalTimeRange}
/>
</QuerySearchV2Provider>
<AllEndPoints
domainName={domainData.domainName}
setSelectedEndPointName={handleEndPointChange}
setSelectedView={setSelectedView}
groupBy={endPointsGroupBy}
setGroupBy={setEndPointsGroupBy}
timeRange={modalTimeRange}
initialFilters={domainListFilters}
setInitialFiltersEndPointStats={setInitialFiltersEndPointStats}
/>
)}
{selectedView === VIEW_TYPES.ENDPOINT_STATS && (
<QuerySearchV2Provider
queryParamKey={ENDPOINT_STATS_EXPRESSION_KEY}
initialExpression={endPointStatsScope}
persistOnUnmount
>
<EndPointDetails
domainName={domainData.domainName}
endPointName={selectedEndPointName}
setSelectedEndPointName={handleEndPointChange}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
/>
</QuerySearchV2Provider>
<EndPointDetails
domainName={domainData.domainName}
endPointName={selectedEndPointName}
setSelectedEndPointName={handleEndPointChange}
initialFilters={initialFiltersEndPointStats}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
/>
)}
{selectedView === VIEW_TYPES.TOP_ERRORS && (
<TopErrors
domainName={domainData.domainName}
timeRange={modalTimeRange}
initialFilterExpression={domainListFilterExpression}
initialFilters={domainListFilters}
/>
)}
</>

View File

@@ -1,17 +1,17 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueries } from 'react-query';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap } from 'constants/queryBuilder';
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,28 +19,36 @@ 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 EndPointPicker from './components/EndPointPicker';
import EndPointsDropDown from './components/EndPointsDropDown';
import MetricOverTimeGraph from './components/MetricOverTimeGraph';
import StatusCodeBarCharts from './components/StatusCodeBarCharts';
import StatusCodeTable from './components/StatusCodeTable';
import { useFilterExpression } from './hooks/useFilterExpression';
import { useWidgetQueries } from './hooks/useWidgetQueries';
import { SPAN_ATTRIBUTES } from './constants';
const httpUrlKey = {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.HTTP_URL,
type: 'tag',
};
function EndPointDetails({
domainName,
endPointName,
setSelectedEndPointName,
initialFilters,
timeRange,
handleTimeChange,
}: {
domainName: string;
endPointName: string;
setSelectedEndPointName: (value: string) => void;
initialFilters: IBuilderQuery['filters'];
timeRange: {
startTime: number;
endTime: number;
@@ -51,66 +59,159 @@ function EndPointDetails({
) => void;
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
const [params, setParams] = useApiMonitoringParams();
// Search filters; the endpoint filter is combined in at query time
const {
expression: filterExpression,
initialExpression: scopeExpression,
queryData,
onDraftChange,
onRun,
onBlur: handleFilterBlur,
} = useFilterExpression();
const currentQuery = initialQueriesMap[DataSource.TRACES];
const httpUrlExpression = useMemo(
() => getHttpUrlExpression(endPointName),
[endPointName],
// 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 scopedExpression = useMemo(
() => combineInitialAndUserExpression(httpUrlExpression, filterExpression),
[httpUrlExpression, filterExpression],
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 isServicesFilterApplied = useMemo(
() =>
extractQueryPairs(filterExpression).some(
(pair) => pair.key === 'service.name',
),
[filterExpression],
() => filters?.items?.some((item) => item.key?.key === 'service.name'),
[filters],
);
const endPointDetailsQueryPayload = useMemo(
() =>
getEndPointDetailsQueryPayload(
domainName,
minTime,
maxTime,
scopedExpression,
),
[domainName, scopedExpression, minTime, maxTime],
() => getEndPointDetailsQueryPayload(domainName, minTime, maxTime, filters),
[domainName, filters, 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) => ({
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,
})),
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,
};
}),
);
const [
endPointMetricsDataQuery,
endPointStatusCodeDataQuery,
endPointDropDownDataQuery,
endPointDependentServicesDataQuery,
endPointStatusCodeBarChartsDataQuery,
endPointStatusCodeLatencyBarChartsDataQuery,
@@ -121,6 +222,7 @@ function EndPointDetails({
endPointDetailsDataQueries[2],
endPointDetailsDataQueries[3],
endPointDetailsDataQueries[4],
endPointDetailsDataQueries[5],
],
[endPointDetailsDataQueries],
);
@@ -132,32 +234,10 @@ function EndPointDetails({
const [rateOverTimeWidget, latencyOverTimeWidget] = useMemo(
() => [
getRateOverTimeWidgetData(domainName, endPointName, scopedExpression),
getLatencyOverTimeWidgetData(domainName, endPointName, scopedExpression),
getRateOverTimeWidgetData(domainName, endPointName, filters),
getLatencyOverTimeWidgetData(domainName, endPointName, filters),
],
[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],
[domainName, endPointName, filters], // Use combinedFilters
);
// // [TODO] Fix this later
@@ -177,23 +257,19 @@ function EndPointDetails({
return (
<div className="endpoint-details-container">
<div className="endpoint-details-filters-container">
<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}
<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}
placeholder="Search for filters..."
/>
</div>
@@ -223,7 +299,7 @@ function EndPointDetails({
endPointStatusCodeLatencyBarChartsDataQuery
}
domainName={domainName}
filterExpression={scopedExpression}
filters={filters}
timeRange={timeRange}
onDragSelect={onDragSelect}
/>

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { QueryFunctionContext, useQuery } from 'react-query';
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
import { Spin, Table, Tooltip } from 'antd';
import { Info, Loader } from '@signozhq/icons';
import { Switch } from '@signozhq/ui/switch';
@@ -7,36 +7,41 @@ 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,
getHttpUrlExpression,
getEndPointDetailsQueryPayload,
getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters,
getTopErrorsQueryPayload,
} from 'container/ApiMonitoring/utils';
import { SuccessResponseV2 } from 'types/api';
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 { DataSource } from 'types/common/queryBuilder';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import EndPointPicker from './components/EndPointPicker';
import EndPointsDropDown from './components/EndPointsDropDown';
import ErrorState from './components/ErrorState';
import { SPAN_ATTRIBUTES } from './constants';
function TopErrors({
domainName,
timeRange,
initialFilterExpression,
initialFilters,
}: {
domainName: string;
timeRange: {
startTime: number;
endTime: number;
};
initialFilterExpression: string;
initialFilters: IBuilderQuery['filters'];
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
@@ -50,10 +55,27 @@ function TopErrors({
domainName,
minTime,
maxTime,
combineInitialAndUserExpression(
getHttpUrlExpression(endPointName),
initialFilterExpression,
),
{
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',
},
showStatusCodeErrors,
),
[
@@ -61,7 +83,7 @@ function TopErrors({
endPointName,
minTime,
maxTime,
initialFilterExpression,
initialFilters,
showStatusCodeErrors,
],
);
@@ -98,6 +120,35 @@ 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) {
@@ -111,13 +162,14 @@ function TopErrors({
return (
<div className="all-endpoints-container">
<div className="top-errors-dropdown-container">
<EndPointPicker
domainName={domainName}
timeRange={timeRange}
filterExpression=""
value={endPointName}
onChange={setSelectedEndPointName}
/>
<div className="endpoint-details-filters-container-dropdown">
<EndPointsDropDown
selectedEndPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointDropDownDataQuery}
parentContainerDiv=".endpoint-details-filters-container"
/>
</div>
<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 { Table, TablePaginationConfig } from 'antd';
import { Skeleton, Table, TablePaginationConfig } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { QueryParams } from 'constants/query';
import {
@@ -54,6 +54,10 @@ function DependentServices({
[isExpanded, dependentServicesData.length],
);
if (isLoading || isRefetching) {
return <Skeleton />;
}
if (isError) {
return <ErrorState refetch={refetch} />;
}

View File

@@ -3,6 +3,7 @@ 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';
@@ -33,7 +34,10 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
startTime: 1758259531000,
endTime: 1758261331000,
},
domainListFilterExpression: '',
domainListFilters: {
items: [],
op: 'AND' as const,
} as IBuilderQuery['filters'],
};
const mockSuccessResponse = {
@@ -170,12 +174,35 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
it('includes custom filters in filter expressions', async () => {
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
const customFilterExpression =
"service.name = 'my-service' AND deployment.environment = 'production'";
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,
};
renderComponent({
...mockProps,
domainListFilterExpression: customFilterExpression,
domainListFilters: customFilters,
});
await waitFor(() => {

View File

@@ -14,17 +14,18 @@ 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,
domainListFilterExpression,
domainListFilters,
}: {
domainName: string;
timeRange: { startTime: number; endTime: number };
domainListFilterExpression: string;
domainListFilters: IBuilderQuery['filters'];
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
@@ -34,9 +35,9 @@ function DomainMetrics({
domainName,
minTime,
maxTime,
domainListFilterExpression,
domainListFilters,
),
[domainName, minTime, maxTime, domainListFilterExpression],
[domainName, minTime, maxTime, domainListFilters],
);
// Since only one query here

View File

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

View File

@@ -1,18 +0,0 @@
.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

@@ -1,92 +0,0 @@
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

@@ -0,0 +1,65 @@
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,6 +24,7 @@ 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 {
@@ -36,7 +37,7 @@ function StatusCodeBarCharts({
endPointStatusCodeBarChartsDataQuery,
endPointStatusCodeLatencyBarChartsDataQuery,
domainName,
filterExpression,
filters,
timeRange,
onDragSelect,
}: {
@@ -49,7 +50,7 @@ function StatusCodeBarCharts({
unknown
>;
domainName: string;
filterExpression: string;
filters: IBuilderQuery['filters'];
timeRange: {
startTime: number;
endTime: number;
@@ -129,8 +130,12 @@ function StatusCodeBarCharts({
);
const widget = useMemo<Widgets>(
() => getStatusCodeBarChartWidgetData(domainName, filterExpression),
[domainName, filterExpression],
() =>
getStatusCodeBarChartWidgetData(domainName, {
items: [...(filters?.items || [])],
op: filters?.op || 'AND',
}),
[domainName, filters],
);
const activeApiResponse = useMemo(

View File

@@ -1,157 +0,0 @@
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

@@ -1,82 +0,0 @@
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

@@ -1,93 +0,0 @@
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

@@ -1,45 +0,0 @@
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,7 +19,6 @@ 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';
@@ -28,26 +27,16 @@ 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,
);
@@ -66,8 +55,6 @@ function DomainList(): JSX.Element {
});
const compositeData = useGetCompositeQueryParam();
const listFilterExpression =
compositeData?.builder?.queryData[0]?.filter?.expression ?? '';
const [isCancelled, setIsCancelled] = useState(false);
@@ -168,7 +155,6 @@ 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}
/>
@@ -239,11 +225,6 @@ 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', {});
}
},
@@ -264,12 +245,8 @@ function DomainList(): JSX.Element {
handleClose={(): void => {
setSelectedDomainIndex(-1);
setParams(DEFAULT_PARAMS);
void setDrawerFilters({
[ALL_ENDPOINTS_EXPRESSION_KEY]: null,
[ENDPOINT_STATS_EXPRESSION_KEY]: null,
});
}}
domainListFilterExpression={listFilterExpression}
domainListFilters={query?.filters}
/>
)}
</section>

View File

@@ -1,122 +0,0 @@
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

@@ -1,42 +0,0 @@
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,4 +1,6 @@
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 {
@@ -9,14 +11,20 @@ import {
getFormattedEndPointDropDownData,
getFormattedEndPointStatusCodeChartData,
getFormattedEndPointStatusCodeData,
getGroupByExpressionFromRowData,
getHttpUrlExpression,
getGroupByFiltersFromGroupByValues,
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
@@ -82,39 +90,140 @@ describe('API Monitoring Utils', () => {
expect(result[0].lastUsed).toBe('-');
});
});
describe('getGroupByExpressionFromRowData', () => {
it('builds an AND expression from the row values, skipping the url and metric columns', () => {
expect(
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");
});
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',
};
it('escapes quotes in values', () => {
expect(getGroupByExpressionFromRowData({ 'user.name': "o'brien" })).toBe(
"user.name = 'o\\'brien'",
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[],
);
});
it('matches spans without the attribute when the group has no value', () => {
// 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
expect(
getGroupByExpressionFromRowData({
'service.name': 'cart',
'ai.embeddings': null,
}),
).toBe("service.name = 'cart' AND ai.embeddings NOT EXISTS");
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);
});
it('returns an empty expression for a row without group columns', () => {
expect(getGroupByExpressionFromRowData({ http_url: '/x', A: 1 })).toBe('');
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[],
);
// 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[] = [];
// Act
const result = getGroupByFiltersFromGroupByValues(rowData, groupBy);
// Assert
expect(result).toBeDefined();
expect(result?.op).toBe('AND');
expect(result?.items).toHaveLength(0);
});
});
@@ -594,9 +703,13 @@ 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, '');
const result = getStatusCodeBarChartWidgetData(
domainName,
filters as IBuilderQuery['filters'],
);
// Assert
expect(result).toBeDefined();
@@ -610,29 +723,47 @@ describe('API Monitoring Utils', () => {
const queryData = result.query.builder.queryData[0];
// Should have domain filter
expect(queryData.filter?.expression).toBe(
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}'`,
const domainFilter = queryData.filters?.items?.find(
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
);
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 customFilterExpression = "custom.key = 'custom-value'";
const customFilter = {
id: 'custom-filter',
key: {
dataType: 'string',
key: 'custom.key',
type: '',
},
op: '=',
value: 'custom-value',
};
const filters = { items: [customFilter], op: 'AND' };
// Act
const result = getStatusCodeBarChartWidgetData(
domainName,
customFilterExpression,
filters as IBuilderQuery['filters'],
);
// Assert
const queryData = result.query.builder.queryData[0];
// Should include our custom filter after the domain filter
expect(queryData.filter?.expression).toBe(
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}' AND (${customFilterExpression})`,
// Should include our custom filter
const includedFilter = queryData.filters?.items?.find(
(item) => item.id === 'custom-filter',
);
expect(includedFilter).toBeDefined();
if (includedFilter) {
expect(includedFilter.value).toBe('custom-value');
}
});
});
@@ -796,17 +927,4 @@ 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,17 +1,11 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import {
getAllEndpointsWidgetData,
getGroupByExpressionFromRowData,
getGroupByFiltersFromGroupByValues,
} from 'container/ApiMonitoring/utils';
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 { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import AllEndPoints from '../Explorer/Domains/DomainDetails/AllEndPoints';
import { FilterProviders } from './FilterProviders';
import {
SPAN_ATTRIBUTES,
VIEWS,
@@ -20,7 +14,7 @@ import {
// Mock the dependencies
jest.mock('container/ApiMonitoring/utils', () => ({
getAllEndpointsWidgetData: jest.fn(),
getGroupByExpressionFromRowData: jest.fn(),
getGroupByFiltersFromGroupByValues: jest.fn(),
}));
jest.mock('container/WidgetCard/Card', () => ({
@@ -40,47 +34,53 @@ jest.mock('container/WidgetCard/Card', () => ({
)),
}));
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(
'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('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>
)),
}));
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>
)),
};
});
// Mock useApiMonitoringParams hook
jest.mock('container/ApiMonitoring/queryParams', () => ({
...jest.requireActual('container/ApiMonitoring/queryParams'),
useApiMonitoringParams: jest.fn().mockReturnValue([
{
showIP: true,
@@ -88,6 +88,8 @@ jest.mock('container/ApiMonitoring/queryParams', () => ({
selectedView: 'all_endpoints',
selectedEndPointName: '',
groupBy: [],
allEndpointsLocalFilters: undefined,
endPointDetailsLocalFilters: undefined,
modalTimeRange: undefined,
selectedInterval: undefined,
},
@@ -106,21 +108,29 @@ describe('AllEndPoints', () => {
startTime: 1609459200000,
endTime: 1609545600000,
},
};
const renderAllEndPoints = (): void => {
render(
<FilterProviders queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}>
<AllEndPoints {...mockProps} />
</FilterProviders>,
);
initialFilters: { op: 'AND', items: [] },
setInitialFiltersEndPointStats: jest.fn(),
};
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',
@@ -129,9 +139,10 @@ describe('AllEndPoints', () => {
queryData: [],
});
(getGroupByExpressionFromRowData as jest.Mock).mockReturnValue(
"status = '200'",
);
(getGroupByFiltersFromGroupByValues as jest.Mock).mockReturnValue({
items: [{ id: 'group-filter', key: 'status', op: '=', value: '200' }],
op: 'AND',
});
});
// Add cleanup after each test
@@ -140,7 +151,7 @@ describe('AllEndPoints', () => {
});
it('renders component correctly', () => {
renderAllEndPoints();
render(<AllEndPoints {...mockProps} />);
// Verify basic component rendering
expect(screen.getByText('Group by')).toBeInTheDocument();
@@ -150,7 +161,7 @@ describe('AllEndPoints', () => {
});
it('handles filter changes', () => {
renderAllEndPoints();
render(<AllEndPoints {...mockProps} />);
// Trigger filter change
fireEvent.click(screen.getByTestId('filter-change-button'));
@@ -159,24 +170,25 @@ describe('AllEndPoints', () => {
expect(getAllEndpointsWidgetData).toHaveBeenCalledWith(
expect.anything(),
'test-domain',
"test = 'test'",
expect.objectContaining({
items: expect.arrayContaining([expect.objectContaining({ id: 'test' })]),
op: 'AND',
}),
);
});
it('handles group by changes', () => {
renderAllEndPoints();
render(<AllEndPoints {...mockProps} />);
// Trigger group by change
fireEvent.click(screen.getByTestId('select-change-button'));
// 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' }),
]);
// Check if setGroupBy was called with updated group by value
expect(mockProps.setGroupBy).toHaveBeenCalled();
});
it('handles row click in grid card', async () => {
renderAllEndPoints();
render(<AllEndPoints {...mockProps} />);
// Trigger row click
fireEvent.click(screen.getByTestId('row-click-button'));
@@ -184,11 +196,7 @@ describe('AllEndPoints', () => {
// Check if proper functions were called
expect(mockProps.setSelectedEndPointName).toHaveBeenCalledWith('/api/test');
expect(mockProps.setSelectedView).toHaveBeenCalledWith(VIEWS.ENDPOINT_STATS);
expect(getGroupByExpressionFromRowData).toHaveBeenCalled();
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
"status = '200'",
),
);
expect(mockProps.setInitialFiltersEndPointStats).toHaveBeenCalled();
expect(getGroupByFiltersFromGroupByValues).toHaveBeenCalled();
});
});

View File

@@ -14,12 +14,16 @@ 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 = '';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
const emptyGroupBy: BaseAutocompleteData[] = [];
describe('1. V5 Format Migration - All Four Queries', () => {

View File

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

View File

@@ -1,60 +0,0 @@
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

@@ -1,84 +0,0 @@
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,7 +1,5 @@
import { useQueries } from 'react-query';
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 { fireEvent, render, screen } from '@testing-library/react';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
extractPortAndEndpoint,
@@ -13,13 +11,14 @@ import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
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', () => ({
@@ -27,32 +26,72 @@ 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: jest.requireActual(
'container/ApiMonitoring/utils',
).END_POINT_DETAILS_QUERY_KEYS_ARRAY,
END_POINT_DETAILS_QUERY_KEYS_ARRAY: [
'endPointMetricsData',
'endPointStatusCodeData',
'endPointDropDownData',
'endPointDependentServicesData',
'endPointStatusCodeBarChartsData',
'endPointStatusCodeLatencyBarChartsData',
],
extractPortAndEndpoint: jest.fn(),
getHttpUrlExpression: jest.requireActual('container/ApiMonitoring/utils')
.getHttpUrlExpression,
getEndPointDetailsQueryPayload: jest.fn(),
getLatencyOverTimeWidgetData: jest.fn(),
getRateOverTimeWidgetData: jest.fn(),
}));
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>
)),
}));
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>
)),
}),
);
// Mock all child components to simplify testing
jest.mock(
@@ -68,15 +107,15 @@ jest.mock(
);
jest.mock(
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onChange }) => (
default: jest.fn().mockImplementation(({ setSelectedEndPointName }) => (
<div data-testid="endpoints-dropdown">
<button
type="button"
data-testid="select-endpoint-button"
onClick={(): void => onChange('/api/new-endpoint')}
onClick={(): void => setSelectedEndPointName('/api/new-endpoint')}
>
Select Endpoint
</button>
@@ -133,23 +172,8 @@ 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(5).fill({
const mockQueryResults = Array(6).fill({
data: { data: [] },
isLoading: false,
isError: false,
@@ -160,6 +184,7 @@ describe('EndPointDetails Component', () => {
domainName: 'test-domain',
endPointName: '/api/test',
setSelectedEndPointName: jest.fn(),
initialFilters: { items: [], op: 'AND' } as TagFilter,
timeRange: {
startTime: 1609459200000,
endTime: 1609545600000,
@@ -172,7 +197,6 @@ describe('EndPointDetails Component', () => {
beforeEach(() => {
jest.clearAllMocks();
resetNuqsState();
(extractPortAndEndpoint as jest.Mock).mockReturnValue({
port: '8080',
@@ -185,6 +209,7 @@ 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({
@@ -201,7 +226,7 @@ describe('EndPointDetails Component', () => {
});
it('renders the component correctly', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
// Check all major components are rendered
expect(screen.getByTestId('query-builder-search')).toBeInTheDocument();
@@ -221,29 +246,44 @@ describe('EndPointDetails Component', () => {
});
it('calls getEndPointDetailsQueryPayload with correct parameters', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
'test-domain',
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`,
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
op: 'AND',
}),
);
});
it('adds endpoint filter to initial filters', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
}),
);
});
it('updates filters when the search expression is run', async () => {
renderStats(<EndPointDetails {...mockProps} />);
it('updates filters when QueryBuilderSearch changes', () => {
render(<EndPointDetails {...mockProps} />);
// Trigger filter change
fireEvent.click(screen.getByTestId('filter-change-button'));
@@ -254,37 +294,19 @@ 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({
queryData: expect.objectContaining({
filter: { expression: "service.name = 'checkout'" },
}),
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'test.key' }),
value: 'test-value',
}),
]),
}),
expect.anything(),
);
expect(EndPointPicker).toHaveBeenLastCalledWith(
expect.objectContaining({
filterExpression: "service.name = 'checkout'",
value: '/api/test',
}),
expect.anything(),
);
});
it('handles endpoint dropdown selection', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
// Trigger endpoint selection
fireEvent.click(screen.getByTestId('select-endpoint-button'));
@@ -296,50 +318,63 @@ describe('EndPointDetails Component', () => {
});
it('does not display dependent services when service filter is applied', () => {
renderStats(
<EndPointDetails {...mockProps} />,
"service.name = 'test-service'",
);
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} />);
// Dependent services should not be displayed
expect(screen.queryByTestId('dependent-services')).not.toBeInTheDocument();
});
it('passes the correct parameters to widget data generators', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
expect(getRateOverTimeWidgetData).toHaveBeenCalledWith(
'test-domain',
'/api/test',
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
}),
);
expect(getLatencyOverTimeWidgetData).toHaveBeenCalledWith(
'test-domain',
'/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,
]),
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
}),
);
});
it('generates correct query parameters for useQueries', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
// Check if useQueries was called with correct parameters
expect(useQueries).toHaveBeenCalledWith(

View File

@@ -1,115 +0,0 @@
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

@@ -0,0 +1,210 @@
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
* getEndPointDropDownQueryPayload (endpoint picker options):
* These tests validate the migration from V4 to V5 format for the third payload
* in getEndPointDetailsQueryPayload (endpoint dropdown data):
* - Filter format change: filters.items[] → filter.expression
* - Domain handling: http_host = '${domainName}'
* - Kind filter: kind_string = 'Client'
@@ -10,8 +10,9 @@
* - Aggregation: count() expression
* - GroupBy: http_url with type 'attribute'
*/
import { getEndPointDropDownQueryPayload } from 'container/ApiMonitoring/utils';
import { getEndPointDetailsQueryPayload } 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';
@@ -19,16 +20,22 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const mockStartTime = 1000;
const mockEndTime = 2000;
const emptyFilters = '';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
describe('1. V5 Format Migration - Structure and Base Filters', () => {
it('migrates to V5 format with correct filter expression structure, aggregations, and groupBy', () => {
const dropdownQuery = getEndPointDropDownQueryPayload(
const payload = getEndPointDetailsQueryPayload(
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)
@@ -70,21 +77,46 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
describe('2. Custom Filters Integration', () => {
it('merges custom filters into filter expression with AND logic', () => {
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
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 dropdownQuery = getEndPointDropDownQueryPayload(
const payload = getEndPointDetailsQueryPayload(
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 AND (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 service.name = 'user-service' AND deployment.environment = 'production'`,
);
});
});

View File

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

View File

@@ -5,6 +5,7 @@ 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';
@@ -155,6 +156,7 @@ jest.mock('antd', () => {
describe('StatusCodeBarCharts', () => {
// Default props for tests
const mockFilters: IBuilderQuery['filters'] = { items: [], op: 'AND' };
const mockTimeRange = {
startTime: 1609459200000,
endTime: 1609545600000,
@@ -249,7 +251,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression=""
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -284,7 +286,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression=""
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -326,7 +328,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression=""
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -370,7 +372,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression=""
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -417,7 +419,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression=""
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -431,13 +433,26 @@ 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 mockCustomFilterExpression = "test-key = 'test-value'";
const mockCustomFilters = {
items: [
{
id: 'custom-filter',
key: { key: 'test-key' },
op: '=',
value: 'test-value',
},
],
op: 'AND',
};
const mockData = {
payload: mockFormattedData,
@@ -465,7 +480,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression={mockCustomFilterExpression}
filters={mockCustomFilters as IBuilderQuery['filters']}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -474,7 +489,12 @@ describe('StatusCodeBarCharts', () => {
// Assert widget creation was called with the correct parameters
expect(getStatusCodeBarChartWidgetData).toHaveBeenCalledWith(
mockDomainName,
mockCustomFilterExpression,
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({ id: 'custom-filter' }),
]),
op: 'AND',
}),
);
});
});

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 (4th and 5th payloads):
* in getEndPointDetailsQueryPayload (5th and 6th payloads):
* - Number of Calls Chart (count aggregation)
* - Latency Chart (p99 aggregation)
*
@@ -15,12 +15,16 @@
*/
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 = '';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
describe('1. Number of Calls Chart - V5 Payload Structure', () => {
it('generates correct V5 payload for count aggregation grouped by status code', () => {
@@ -31,8 +35,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
emptyFilters,
);
// 4th payload (index 3) is the number of calls bar chart
const callsChartQuery = payload[3];
// 5th payload (index 4) is the number of calls bar chart
const callsChartQuery = payload[4];
const queryA = callsChartQuery.query.builder.queryData[0];
// V5 format: filter.expression (not filters.items)
@@ -80,8 +84,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
emptyFilters,
);
// 5th payload (index 4) is the latency bar chart
const latencyChartQuery = payload[4];
// 6th payload (index 5) is the latency bar chart
const latencyChartQuery = payload[5];
const queryA = latencyChartQuery.query.builder.queryData[0];
// V5 format: filter.expression (not filters.items)
@@ -126,8 +130,31 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
describe('3. Custom Filters Integration', () => {
it('merges custom filters into filter expression for both charts', () => {
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
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 payload = getEndPointDetailsQueryPayload(
mockDomainName,
@@ -136,8 +163,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
customFilters,
);
const callsChartQuery = payload[3];
const latencyChartQuery = payload[4];
const callsChartQuery = payload[4];
const latencyChartQuery = payload[5];
const callsExpression =
callsChartQuery.query.builder.queryData[0].filter?.expression;

View File

@@ -13,12 +13,16 @@
*/
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 = '';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
describe('1. V5 Format Migration with Base Filters', () => {
it('migrates to V5 format with correct filter expression structure and base filters', () => {
@@ -105,8 +109,31 @@ describe('StatusCodeTable - V5 Migration Validation', () => {
describe('3. Custom Filters Integration', () => {
it('merges custom filters into filter expression with AND logic', () => {
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
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 payload = getEndPointDetailsQueryPayload(
mockDomainName,

View File

@@ -1,40 +1,31 @@
import { BuilderQuery } from 'api/v5/v5';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { rest, server } from 'mocks-server/server';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
within,
} from 'tests/test-utils';
import { fireEvent, render, screen, 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';
interface QueryRangeRequestBody {
compositeQuery: { queries: { spec: { filter: { expression: string } } }[] };
}
// Mock the EndPointPicker component to avoid issues
// Mock the EndPointsDropDown component to avoid issues
jest.mock(
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
() => ({
__esModule: true,
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>
)),
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>
)),
}),
);
@@ -52,7 +43,10 @@ describe('TopErrors', () => {
startTime: 1000000000,
endTime: 1000010000,
},
initialFilterExpression: '',
initialFilters: {
items: [],
op: 'AND',
},
};
// Helper function to wait for table data to load
@@ -233,30 +227,22 @@ describe('TopErrors', () => {
});
});
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);
}),
);
it('updates endpoint filter when dropdown value changes', async () => {
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(expressions[expressions.length - 1]).toContain(
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/new-endpoint'`,
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
// 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 () => {
@@ -320,7 +306,7 @@ describe('TopErrors', () => {
'test-domain',
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
'',
{ items: [], op: 'AND' },
false,
);

View File

@@ -2,7 +2,6 @@ import {
ApiMonitoringParams,
DEFAULT_PARAMS,
getApiMonitoringParams,
getLegacyFilterExpressions,
setApiMonitoringParams,
} from 'container/ApiMonitoring/queryParams';
import { mockNavigate } from 'tests/router-mocks';
@@ -53,50 +52,6 @@ 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

@@ -1,65 +0,0 @@
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

@@ -1,5 +0,0 @@
// 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,10 +1,6 @@
import { useCallback } from 'react';
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';
import { navigate } from 'lib/router/navigation';
// --- Types for all API Monitoring query params ---
export interface ApiMonitoringParams {
@@ -13,6 +9,8 @@ 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
@@ -24,6 +22,8 @@ export const DEFAULT_PARAMS: ApiMonitoringParams = {
selectedView: 'all_endpoints',
selectedEndPointName: '',
groupBy: [],
allEndpointsLocalFilters: undefined,
endPointDetailsLocalFilters: undefined,
modalTimeRange: undefined,
selectedInterval: undefined,
};
@@ -35,50 +35,15 @@ function encodeParams(params: ApiMonitoringParams): string {
return encodeURIComponent(JSON.stringify(params));
}
// Older shared links carry the filters as TagFilter items.
type LegacyApiMonitoringParams = ApiMonitoringParams & {
allEndpointsLocalFilters?: TagFilter;
endPointDetailsLocalFilters?: TagFilter;
};
function parseParams(value: string | null): LegacyApiMonitoringParams | null {
function decodeParams(value: string | null): ApiMonitoringParams {
if (!value) {
return null;
return DEFAULT_PARAMS;
}
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 ---
@@ -109,18 +74,11 @@ 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,
`?${getUnstableCurrentSearchParams().toString()}`,
replace,
);
setApiMonitoringParams(newParams, location.search, replace);
},
[],
[location.search],
);
return [params, setParams];

View File

@@ -3,8 +3,7 @@ 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 { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
import {
FiltersType,
IQuickFiltersConfig,
@@ -57,28 +56,30 @@ export const getDisplayValue = (value: unknown): string =>
isEmptyFilterValue(value) ? '-' : String(value);
export const getDomainNameFilterExpression = (domainName: string): string =>
`${SPAN_ATTRIBUTES.SERVER_NAME} = ${formatValueForExpression(domainName)}`;
`http_host = '${domainName}'`;
export const clientKindExpression = "kind_string = 'Client'";
export const clientKindExpression = `kind_string = 'Client'`;
export const getHttpUrlExpression = (endPointName: string): string =>
endPointName
? `${SPAN_ATTRIBUTES.HTTP_URL} = ${formatValueForExpression(endPointName, '=')}`
: '';
/**
* 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;
}
// 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 ');
const { filter } = convertFiltersToExpressionWithExistingQuery(
filters,
baseExpression,
);
return filter.expression;
};
export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
{
@@ -346,7 +347,7 @@ export const getDomainMetricsQueryPayload = (
domainName: string,
start: number,
end: number,
filterExpression: string,
filters: IBuilderQuery['filters'],
): GetQueryResultsProps[] => [
{
selectedTime: 'GLOBAL_TIME',
@@ -367,9 +368,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(domainName)} AND ${urlExpression}`,
filterExpression,
),
},
expression: 'A',
@@ -395,9 +396,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(domainName)}`,
filterExpression,
),
},
expression: 'B',
@@ -423,9 +424,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(domainName)} AND has_error = true`,
filterExpression,
),
},
expression: 'C',
@@ -451,9 +452,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(domainName)}`,
filterExpression,
),
},
expression: 'D',
@@ -823,7 +824,7 @@ export const getEndPointsQueryPayload = (
function buildFilterExpression(
domainName: string,
filterExpression: string,
filters: IBuilderQuery['filters'],
showStatusCodeErrors: boolean,
): string {
const baseFilterParts = [
@@ -835,22 +836,21 @@ function buildFilterExpression(
if (showStatusCodeErrors) {
baseFilterParts.push('status_message EXISTS');
}
return combineInitialAndUserExpression(
baseFilterParts.join(' AND '),
filterExpression,
);
const filterExpression = baseFilterParts.join(' AND ');
return convertFiltersWithUrlHandling(filters, filterExpression);
}
export const getTopErrorsQueryPayload = (
domainName: string,
start: number,
end: number,
filterExpression: string,
filters: IBuilderQuery['filters'],
showStatusCodeErrors = true,
): QueryRangePayloadV5 => {
const combinedExpression = buildFilterExpression(
const filterExpression = buildFilterExpression(
domainName,
filterExpression,
filters,
showStatusCodeErrors,
);
@@ -869,7 +869,7 @@ export const getTopErrorsQueryPayload = (
stepInterval: 60,
disabled: false,
aggregations: [{ expression: 'count()' }],
filter: { expression: combinedExpression },
filter: { expression: filterExpression },
groupBy: [
{
name: SPAN_ATTRIBUTES.HTTP_URL,
@@ -1367,14 +1367,15 @@ export const createFiltersForSelectedRowData = (
// First query payload for endpoint metrics
// Second query payload for endpoint status code table
// 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
// 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
export const getEndPointDetailsQueryPayload = (
domainName: string,
start: number,
end: number,
filterExpression: string,
filters: IBuilderQuery['filters'],
): GetQueryResultsProps[] => [
{
selectedTime: 'GLOBAL_TIME',
@@ -1393,11 +1394,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1423,11 +1424,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'B',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1453,11 +1454,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: true,
expression: 'C',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND has_error = true`,
filterExpression,
),
},
functions: [],
@@ -1483,11 +1484,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'D',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1513,11 +1514,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: true,
expression: 'E',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1585,11 +1586,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
filterExpression,
),
},
functions: [],
@@ -1621,11 +1622,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'B',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
filterExpression,
),
},
functions: [],
@@ -1659,11 +1660,11 @@ export const getEndPointDetailsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
filterExpression,
),
},
expression: 'C',
@@ -1712,6 +1713,77 @@ 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,
@@ -1729,11 +1801,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1766,11 +1838,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'B',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1803,11 +1875,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'C',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1840,11 +1912,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: true,
expression: 'D',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND has_error = true`,
filterExpression,
),
},
functions: [],
@@ -1919,11 +1991,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1990,11 +2062,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -2046,82 +2118,100 @@ export const getEndPointDetailsQueryPayload = (
},
];
export const getEndPointDropDownQueryPayload = (
export const getEndPointZeroStateQueryPayload = (
domainName: string,
start: number,
end: number,
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: [],
filter: {
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS`,
filterExpression,
),
},
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
legend: '',
limit: null,
orderBy: [],
groupBy: [
{
key: SPAN_ATTRIBUTES.HTTP_URL,
): GetQueryResultsProps[] => [
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TABLE,
query: {
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
queryName: 'A',
aggregateOperator: 'count',
aggregateAttribute: {
dataType: DataTypes.String,
type: 'attribute',
key: '',
type: '',
},
],
reduceTo: ReduceOperators.AVG,
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,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryFormulas: [],
queryTraceOperator: [],
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
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,
},
variables: {},
formatForWeb: true,
start,
end,
step: 60,
});
];
interface EndPointMetricsResponseRow {
data: {
@@ -2279,8 +2369,8 @@ export const statusCodeWidgetInfo = [
interface EndPointDropDownResponseRow {
data: {
[SPAN_ATTRIBUTES.HTTP_URL]?: string;
A?: number;
[SPAN_ATTRIBUTES.HTTP_URL]: string;
A: number;
};
}
@@ -2622,7 +2712,7 @@ export const groupStatusCodes = (
export const getStatusCodeBarChartWidgetData = (
domainName: string,
filterExpression: string,
filters: IBuilderQuery['filters'],
): Widgets => ({
query: {
builder: {
@@ -2638,13 +2728,22 @@ export const getStatusCodeBarChartWidgetData = (
dataSource: DataSource.TRACES,
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
getDomainNameFilterExpression(domainName),
filterExpression,
),
filters: {
items: [
{
id: 'c6724407',
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: '',
},
op: '=',
value: domainName,
},
...(filters?.items || []),
],
op: 'AND',
},
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
@@ -2725,15 +2824,18 @@ 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,
filterExpression: string,
filters: IBuilderQuery['filters'],
// eslint-disable-next-line sonarjs/cognitive-complexity
): Widgets => {
const isGroupedByAttribute = groupBy.length > 0;
@@ -2755,11 +2857,11 @@ export const getAllEndpointsWidgetData = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters,
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -2787,11 +2889,11 @@ export const getAllEndpointsWidgetData = (
disabled: false,
expression: 'B',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters,
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -2819,11 +2921,11 @@ export const getAllEndpointsWidgetData = (
disabled: false,
expression: 'C',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters,
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -2851,11 +2953,11 @@ export const getAllEndpointsWidgetData = (
disabled: true,
expression: 'D',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters,
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND has_error = true AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -2973,26 +3075,38 @@ export const getAllEndpointsWidgetData = (
return widget;
};
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'key', 'A', 'B', 'C', 'F1'];
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'A', 'B', 'C', 'F1'];
// 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)
export const getGroupByFiltersFromGroupByValues = (
rowData: any,
groupBy: BaseAutocompleteData[],
): IBuilderQuery['filters'] => {
const items = Object.keys(rowData)
.filter((key) => !keysToRemove.includes(key))
.map((key) => {
const value = rowData[key];
return value === null || value === undefined
? `${key} NOT EXISTS`
: `${key} = ${formatValueForExpression(value)}`;
})
.join(' AND ');
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',
};
};
export const getRateOverTimeWidgetData = (
domainName: string,
endPointName: string,
filterExpression: string,
filters: IBuilderQuery['filters'],
): Widgets => {
let legend = domainName;
if (endPointName) {
@@ -3016,9 +3130,9 @@ export const getRateOverTimeWidgetData = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`http_host = '${domainName}'`,
filterExpression,
),
},
functions: [],
@@ -3042,7 +3156,7 @@ export const getRateOverTimeWidgetData = (
export const getLatencyOverTimeWidgetData = (
domainName: string,
endPointName: string,
filterExpression: string,
filters: IBuilderQuery['filters'],
): Widgets => {
let legend = domainName;
if (endPointName) {
@@ -3066,9 +3180,9 @@ export const getLatencyOverTimeWidgetData = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`http_host = '${domainName}'`,
filterExpression,
),
},
functions: [],

View File

@@ -1,3 +0,0 @@
import CreateAlert from './CreateAlert';
export default CreateAlert;

View File

@@ -23,11 +23,20 @@ export type JiraDeployment = AlertmanagertypesJiraDeploymentDTO;
export type KeysOfUnion<T> = T extends unknown ? keyof T : never;
/** Every key of every member, optional, so a form can hold any member's fields. */
export type FlattenUnion<T> = {
[K in KeysOfUnion<T>]?: T extends unknown
? K extends keyof T
? T[K]
: never
: never;
};
export type JiraFormSpec = Omit<
AlertmanagertypesChannelJiraConfigDTO,
'auth'
> & {
auth?: AlertmanagertypesChannelJiraAuthDTO;
auth?: FlattenUnion<AlertmanagertypesChannelJiraAuthDTO>;
};
/** Merged so switching kind keeps the fields the kinds share. */

View File

@@ -0,0 +1,136 @@
import ROUTES from 'constants/routes';
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
import CreateAlertPage from 'pages/CreateAlert';
import { act, fireEvent, render } from 'tests/test-utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_TO_TITLE, ALERT_TYPE_URL_MAP } from './constants';
jest
.spyOn(usePrefillAlertConditions, 'usePrefillAlertConditions')
.mockReturnValue({
matchType: '3',
op: '1',
target: 100,
targetUnit: 'rpm',
});
let mockWindowOpen: jest.Mock;
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
function findLinkForAlertType(
links: HTMLElement[],
alertType: AlertTypes,
): HTMLElement {
const link = links.find(
(el) =>
el.closest('[data-testid]')?.getAttribute('data-testid') ===
`alert-type-card-${alertType}`,
);
expect(link).toBeTruthy();
return link as HTMLElement;
}
function clickLinkAndVerifyRedirect(
link: HTMLElement,
expectedUrl: string,
): void {
fireEvent.click(link);
expect(mockWindowOpen).toHaveBeenCalledWith(expectedUrl, '_blank');
}
describe('Alert rule documentation redirection', () => {
let renderResult: ReturnType<typeof render>;
beforeAll(() => {
mockWindowOpen = jest.fn();
window.open = mockWindowOpen;
});
beforeEach(() => {
act(() => {
renderResult = render(
<CreateAlertPage />,
{},
{
initialRoute: ROUTES.ALERTS_NEW,
},
);
});
});
it('should render alert type cards', () => {
const { getByText, getAllByText } = renderResult;
// Check for the heading
expect(getByText('choose_alert_type')).toBeInTheDocument();
// Check for alert type titles and descriptions
Object.values(AlertTypes).forEach((alertType) => {
const title = ALERT_TYPE_TO_TITLE[alertType];
expect(getByText(title)).toBeInTheDocument();
expect(getByText(`${title}_desc`)).toBeInTheDocument();
});
const clickHereLinks = getAllByText(
'Click here to see how to create a sample alert.',
);
expect(clickHereLinks).toHaveLength(5);
});
it('should redirect to correct documentation for each alert type', () => {
const { getAllByText } = renderResult;
const clickHereLinks = getAllByText(
'Click here to see how to create a sample alert.',
);
const alertTypeCount = Object.keys(AlertTypes).length;
expect(clickHereLinks).toHaveLength(alertTypeCount);
Object.values(AlertTypes).forEach((alertType) => {
const linkForAlertType = findLinkForAlertType(clickHereLinks, alertType);
const expectedUrl = ALERT_TYPE_URL_MAP[alertType];
clickLinkAndVerifyRedirect(linkForAlertType, expectedUrl.selection);
});
expect(mockWindowOpen).toHaveBeenCalledTimes(alertTypeCount);
});
});
describe('Create alert page redirection', () => {
Object.values(AlertTypes)
.filter((type) => type !== AlertTypes.ANOMALY_BASED_ALERT)
.forEach((alertType) => {
it(`should redirect to create alert page for ${alertType} and "Check an example alert" should redirect to the correct documentation`, () => {
const { getByRole } = render(
<CreateAlertPage />,
{},
{
initialRoute: `${ROUTES.ALERTS_NEW}?alertType=${alertType}&showClassicCreateAlertsPage=true`,
},
);
act(() => {
fireEvent.click(
getByRole('button', {
name: /alert setup guide/i,
}),
);
});
expect(mockWindowOpen).toHaveBeenCalledWith(
ALERT_TYPE_URL_MAP[alertType].creation,
'_blank',
);
});
});
});

View File

@@ -0,0 +1,60 @@
import ROUTES from 'constants/routes';
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
import CreateAlertPage from 'pages/CreateAlert';
import { act, fireEvent, render } from 'tests/test-utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_URL_MAP } from './constants';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
jest
.spyOn(usePrefillAlertConditions, 'usePrefillAlertConditions')
.mockReturnValue({
matchType: '3',
op: '1',
target: 100,
targetUnit: 'rpm',
});
describe('Anomaly Alert Documentation Redirection', () => {
let mockWindowOpen: jest.Mock;
beforeAll(() => {
mockWindowOpen = jest.fn();
window.open = mockWindowOpen;
});
it('should handle anomaly alert documentation redirection correctly', () => {
// The setup guide button only exists in the classic form.
const { getByRole } = render(<CreateAlertPage />, undefined, {
initialRoute: `${ROUTES.ALERTS_NEW}?ruleType=anomaly_rule&showClassicCreateAlertsPage=true`,
});
const alertType = AlertTypes.ANOMALY_BASED_ALERT;
act(() => {
fireEvent.click(
getByRole('button', {
name: /alert setup guide/i,
}),
);
});
expect(mockWindowOpen).toHaveBeenCalledWith(
ALERT_TYPE_URL_MAP[alertType].creation,
'_blank',
);
});
});

View File

@@ -5,7 +5,7 @@ import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import { useIsAnomalyDetectionAvailable } from 'container/CreateAlert/useIsAnomalyDetectionAvailable';
import { useIsAnomalyDetectionAvailable } from 'container/CreateAlertV2/useIsAnomalyDetectionAvailable';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';

View File

@@ -23,9 +23,28 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
},
}));
jest.mock('container/CreateAlert', () => ({
jest.mock('container/FormAlertRules', () => ({
__esModule: true,
default: function MockCreateAlert({
default: function MockFormAlertRules({
alertType,
}: {
alertType: AlertTypes;
}): JSX.Element {
return (
<div>
<h1>Form Alert Rules</h1>
<p>{alertType}</p>
</div>
);
},
AlertDetectionTypes: {
THRESHOLD_ALERT: 'threshold_rule',
ANOMALY_DETECTION_ALERT: 'anomaly_rule',
},
}));
jest.mock('container/CreateAlertV2', () => ({
__esModule: true,
default: function MockCreateAlertV2({
alertType,
}: {
alertType: AlertTypes;
@@ -57,6 +76,7 @@ const mockGetUrlQuery = jest.fn();
const mockSafeNavigate = jest.fn();
const mockDeleteUrlQuery = jest.fn();
const FORM_ALERT_RULES_TEXT = 'Form Alert Rules';
const CREATE_ALERT_V2_TEXT = 'Create Alert V2';
describe('CreateAlertRule', () => {
@@ -75,6 +95,20 @@ describe('CreateAlertRule', () => {
useAppContextSpy.mockReturnValue(getAppContextMockState());
});
it('should render classic flow when showClassicCreateAlertsPage is true', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'true';
}
if (key === QueryParams.alertType) {
return AlertTypes.METRICS_BASED_ALERT;
}
return null;
});
render(<CreateAlertRule />);
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
});
it('should render new flow when alertType is provided', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.alertType) {
@@ -90,6 +124,7 @@ describe('CreateAlertRule', () => {
mockGetUrlQuery.mockReturnValue(null);
useCompositeQueryParamSpy.mockReturnValue(null);
render(<CreateAlertRule />);
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
});
@@ -114,6 +149,9 @@ describe('CreateAlertRule', () => {
it('should render new flow with the anomaly alert type when ruleType is anomaly_rule', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'false';
}
if (key === QueryParams.ruleType) {
return 'anomaly_rule';
}
@@ -122,6 +160,7 @@ describe('CreateAlertRule', () => {
render(<CreateAlertRule />);
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.getByText(AlertTypes.ANOMALY_BASED_ALERT)).toBeInTheDocument();
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
});
it('should use alertType from URL when provided', () => {
@@ -245,5 +284,27 @@ describe('CreateAlertRule', () => {
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate even when showClassicCreateAlertsPage flag is present', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'true';
}
return null;
});
render(<CreateAlertRule />);
fireEvent.click(screen.getByText('metric_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
'threshold_rule',
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.METRICS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
});
});

View File

@@ -1,9 +1,10 @@
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import {
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
AlertDef,
@@ -27,6 +28,7 @@ const defaultAnnotations = {
export const alertDefaults: AlertDef = {
alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V5,
condition: {
compositeQuery: {
builderQueries: {
@@ -60,7 +62,8 @@ export const alertDefaults: AlertDef = {
export const anamolyAlertDefaults: AlertDef = {
alertType: AlertTypes.METRICS_BASED_ALERT,
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
version: ENTITY_VERSION_V5,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
condition: {
compositeQuery: {
builderQueries: {
@@ -104,6 +107,7 @@ export const anamolyAlertDefaults: AlertDef = {
export const logAlertDefaults: AlertDef = {
alertType: AlertTypes.LOGS_BASED_ALERT,
version: ENTITY_VERSION_V5,
condition: {
compositeQuery: {
builderQueries: {
@@ -135,6 +139,7 @@ export const logAlertDefaults: AlertDef = {
export const traceAlertDefaults: AlertDef = {
alertType: AlertTypes.TRACES_BASED_ALERT,
version: ENTITY_VERSION_V5,
condition: {
compositeQuery: {
builderQueries: {
@@ -166,6 +171,7 @@ export const traceAlertDefaults: AlertDef = {
export const exceptionAlertDefaults: AlertDef = {
alertType: AlertTypes.EXCEPTIONS_BASED_ALERT,
version: ENTITY_VERSION_V5,
condition: {
compositeQuery: {
builderQueries: {
@@ -195,6 +201,14 @@ export const exceptionAlertDefaults: AlertDef = {
alert: '',
};
export const ALERTS_VALUES_MAP: Record<AlertTypes, AlertDef> = {
[AlertTypes.ANOMALY_BASED_ALERT]: anamolyAlertDefaults,
[AlertTypes.METRICS_BASED_ALERT]: alertDefaults,
[AlertTypes.LOGS_BASED_ALERT]: logAlertDefaults,
[AlertTypes.TRACES_BASED_ALERT]: traceAlertDefaults,
[AlertTypes.EXCEPTIONS_BASED_ALERT]: exceptionAlertDefaults,
};
export const ALERT_TYPE_BREADCRUMB_TITLE: Record<AlertTypes, string> = {
[AlertTypes.ANOMALY_BASED_ALERT]: 'Anomaly-Based Alert',
[AlertTypes.METRICS_BASED_ALERT]: 'Metric-Based Alert',

View File

@@ -1,13 +1,14 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Tabs, TabsProps } from 'antd';
import { Form, Tabs, TabsProps } from 'antd';
import logEvent from 'api/common/logEvent';
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import CreateAlert from 'container/CreateAlert';
import CreateAlertV2 from 'container/CreateAlertV2';
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@@ -15,20 +16,25 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { AlertListTabs } from 'pages/AlertList/types';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
import { ALERT_TYPE_VS_SOURCE_MAPPING } from './config';
import { ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
import { ALERTS_VALUES_MAP, ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
import SelectAlertType from './SelectAlertType';
import './CreateAlertRule.styles.scss';
function CreateRules(): JSX.Element {
const [formInstance] = Form.useForm();
const compositeQuery = useGetCompositeQueryParam();
const queryParams = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const ruleTypeFromURL = queryParams.get(QueryParams.ruleType);
const alertTypeFromURL = queryParams.get(QueryParams.alertType);
const version = queryParams.get(QueryParams.version);
const showClassicCreateAlertsPageFlag =
queryParams.get(QueryParams.showClassicCreateAlertsPage) === 'true';
const isTypeSelectionMode =
!alertTypeFromURL && !ruleTypeFromURL && !compositeQuery;
@@ -40,7 +46,7 @@ function CreateRules(): JSX.Element {
}, [isTypeSelectionMode]);
const alertType = useMemo(() => {
if (ruleTypeFromURL === RuletypesRuleTypeDTO.anomaly_rule) {
if (ruleTypeFromURL === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
return AlertTypes.ANOMALY_BASED_ALERT;
}
if (!alertTypeFromURL) {
@@ -53,6 +59,14 @@ function CreateRules(): JSX.Element {
return alertTypeFromURL as AlertTypes;
}, [alertTypeFromURL, ruleTypeFromURL, compositeQuery?.builder.queryData]);
const initialAlertValue: AlertDef = useMemo(
() => ({
...ALERTS_VALUES_MAP[alertType],
version: version || ENTITY_VERSION_V5,
}),
[alertType, version],
);
const handleTabChange = useCallback(
(tab: string): void => {
queryParams.set('tab', tab);
@@ -66,10 +80,13 @@ function CreateRules(): JSX.Element {
const handleSelectType = useCallback(
(type: AlertTypes, newTab?: boolean): void => {
if (type === AlertTypes.ANOMALY_BASED_ALERT) {
queryParams.set(QueryParams.ruleType, RuletypesRuleTypeDTO.anomaly_rule);
queryParams.set(
QueryParams.ruleType,
AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
);
queryParams.set(QueryParams.alertType, AlertTypes.METRICS_BASED_ALERT);
} else {
queryParams.set(QueryParams.ruleType, RuletypesRuleTypeDTO.threshold_rule);
queryParams.set(QueryParams.ruleType, AlertDetectionTypes.THRESHOLD_ALERT);
queryParams.set(QueryParams.alertType, type);
}
@@ -82,8 +99,26 @@ function CreateRules(): JSX.Element {
if (isTypeSelectionMode) {
return <SelectAlertType onSelect={handleSelectType} />;
}
return <CreateAlert alertType={alertType} />;
}, [isTypeSelectionMode, handleSelectType, alertType]);
if (showClassicCreateAlertsPageFlag) {
return (
<FormAlertRules
alertType={alertType}
formInstance={formInstance}
initialValue={initialAlertValue}
ruleId=""
/>
);
}
return <CreateAlertV2 alertType={alertType} />;
}, [
isTypeSelectionMode,
handleSelectType,
showClassicCreateAlertsPageFlag,
alertType,
formInstance,
initialAlertValue,
]);
const items: TabsProps['items'] = [
{

View File

@@ -111,8 +111,8 @@ jest.mock('constants/formats/alertFormatCategories', () => ({
]),
}));
jest.mock('container/CreateAlert/utils', () => ({
...jest.requireActual('container/CreateAlert/utils'),
jest.mock('container/CreateAlertV2/utils', () => ({
...jest.requireActual('container/CreateAlertV2/utils'),
}));
const TEST_STRINGS = {

View File

@@ -1,4 +1,4 @@
import { createMockAlertContextState } from 'container/CreateAlert/EvaluationSettings/__tests__/testUtils';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { render, screen, userEvent, within } from 'tests/test-utils';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { DefaultOptionType } from 'antd/es/select';
import { createMockAlertContextState } from 'container/CreateAlert/EvaluationSettings/__tests__/testUtils';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';

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