Compare commits

..

15 Commits

Author SHA1 Message Date
Tushar Vats
ca7daf8ea9 fix(querier): skip offset rows without decoding them (#13564)
Some checks are pending
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
#### Description

- Raw lists sorted by timestamp (logs: `timestamp, id`) fetch `offset +
limit` rows per chunk and drop the first `offset` in Go. Every dropped
row was first decoded into a `RawRow` (body + attribute maps), so deep
pages loaded every skipped row into memory and could OOM signoz-0.
- `readAsRawSkipping` now steps past the skipped rows with `rows.Next()`
without `Scan`, so they are never decoded. Memory stays at about one
page plus one ClickHouse block.
- `executeWindowList` passes the remaining offset to each chunk and
counts the skipped rows against `need` and `offsetLeft`.

#### Issues closed by this PR

Fixes: SigNoz/events-pod#270
Part of: SigNoz/events-pod#269

#### Additional Information

- Skipped rows still cross the wire. Pushing `OFFSET` into ClickHouse is
point 1 of the issue.
- `TestReadAsRawSkipping` uses `clickhouse-go-mock` rows (v0.15.1,
bumped in SigNoz/signoz#13580), whose `Next()` advances like the real
driver. Each skipped row is marked with `RowError`, so the test fails if
a skipped row is ever scanned.
- New integration tests `test_traces_list_offset_across_chunks` and
`test_logs_list_offset_across_chunks` page a 4h list with offsets
landing in, and crossing into, each of its three time chunks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:20:30 +00:00
Swapnil Nakade
d038407475 feat(session): add resource defs to session routes (#13563)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- add resource defs to session routes
- why? - so that auditor can pick up these routes for auditing
- session routes are pre-auth so audit events will be dropped at the
zero org id condition
- rotate and delete session (when session is active) routes are audited

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
contributes to https://github.com/SigNoz/keystone-pod/issues/10
2026-11-13 17:16:26 +00:00
Tushar Vats
da3719ec7e chore(deps): bump clickhouse-go-mock to v0.15.1 (#13580)
#### Description

- Bumps `github.com/SigNoz/clickhouse-go-mock` v0.14.0 → v0.15.1. Its
`Rows.Next()` now advances like the real driver (skipping a row with
`Next()` and no `Scan()` works), and the new `Rows.RowError` fails
`Scan` of a chosen row (SigNoz/clickhouse-go-mock#14).
- v0.15.1 requires clickhouse-go v2.47.0, so this also raises
**production** clickhouse-go 2.44.0 → 2.47.0 and ch-go 0.71.0 → 0.73.0,
with transitive bumps (zap 1.28.0, otelhttp 0.69.0, gopsutil 4.26.5,
lz4, orb, brotli, purego, go-version, go-sysconf, numcpus, plan9stats).
- Kept apart from SigNoz/signoz#13564 so the driver upgrade can be
reverted on its own.

#### Issues closed by this PR

Part of: SigNoz/events-pod#270

#### Additional Information

- clickhouse-go 2.45–2.47: positional binding now skips `?` inside
string literals, quoted identifiers and comments (`--`, `/* */`, `#`)
(ClickHouse/clickhouse-go#1879, #1888). Our builders emit no SQL
comments and no `?` inside quotes, and values inlined by
`pkg/clickhousesql` that contain `?` were previously bound as
placeholders; now they stay literal. `\?` handling is unchanged. Other
fixes: JSON column serialization version (#1850), DateTime/DateTime64
time zones (#1882), connection eviction on wrapped net errors (#1869).
- ch-go 0.73.0 drops the obsolete nested-exception flag
(ClickHouse/ch-go#1159, marked breaking): `proto.Exception.Nested` is
gone. We only use ch-go's error-code constants, and clickhouse-go's own
`Exception` is unchanged between 2.44.0 and 2.47.0.
- Tested: `go build ./...`, `go test ./...` (all pass), `go vet ./...`
(same 38 findings as `main`, none new). Integration suites
`querierlogs`, `queriertraces` and `querier_json_body` against a server
built with these versions: 624 pass (5 first hit ClickHouse memory
limits in the shared local test container while inserting fixtures, and
passed on rerun).
- Follow-up: #13564 switches `TestReadAsRawSkipping` to `cmock` +
`RowError` and drops its mockery driver mocks once this merges.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-11-13 17:13:24 +00:00
Gaurav Tewari
99745b7dba fix(ai-assistant): open explorer chips on the answer's time range (#13363)
#### Description
- Noz's explorer chips carried the query but not its time window, so the
Explorer opened on the current picker value (e.g. Last 6 hours) instead
of the window the answer was computed for.
- Chips now apply the action's `start`/`end` as an absolute range:
off-page via `startTime`/`endTime` on the Explorer URL; on an
already-open Explorer by also setting the global time and dropping the
stale `relativeTime`.
- Without a valid range nothing changes: query and filters hand over,
time stays as is.

#### Issues closed by this PR
Closes SigNoz/nerve-pod#174

#### Screenshots / Screen Recordings
Prev -


https://github.com/user-attachments/assets/9d54125f-ed32-486e-acc2-2d1c59963fa5

 


Now -




https://github.com/user-attachments/assets/1ce75dc0-d62a-430c-b458-ca1036459955




#### Additional Information
- Stacked on #13557, which carries the `redirectWithQueryBuilderData`
change this relies on.
- The Explorers' global time is Redux-only, so `useDispatch` goes behind
the existing `no-restricted-imports` escape hatch.
- Time params are not written with nuqs: the Explorers' time is Redux +
react-router URL writes, and a second nuqs writer adds a history entry
or leaves react-router with a stale `relativeTime` (see
`utils/timeUrlParams.ts`). Once the Explorers move to
`store/globalTime`, the chip can call `setSelectedTime` and drop its URL
handling.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-13 17:10:07 +00:00
Swapnil Nakade
fb60ae1594 refactor: using signoz otel collector image for ch migration (#13517)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- using signoz otel collector image for ch migration
- adding auditquerier in test suite in ci
- added v0.144.16-rc.1 temporarily
2026-11-13 16:53:14 +00:00
Gaurav Tewari
d099467285 feat(query-builder): drop null params and forward navigate options on redirect (#13557)
#### Description
- `redirectWithQueryBuilderData` deletes a search param passed as `null`
instead of writing `"null"`, so one navigation can swap params (e.g.
`relativeTime` for `startTime`/`endTime`).
- Its trailing `newTab` flag becomes `navigateOptions: { newTab, replace
}`, passed straight to `safeNavigate`.

#### Issues closed by this PR

#### Screenshots / Screen Recordings

#### Additional Information
- Split out of #13363. Stacked on #13556 only to keep #13363's diff
clean; it doesn't depend on it.
- The `source: null` reset in `CreateAlertV2/QuerySection` now removes
`source` instead of writing `source=null`; its readers only compare
against `DASHBOARDS`, so behavior is the same.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-13 15:51:53 +00:00
Swapnil Nakade
445e3cb088 feat(user): add resource defs to self-service user routes (#13574)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- add resource defs to the self-service user routes: get and update my
user, change my password, forgot password, reset password, verify reset
token
- why? so that the auditor can pick up these routes for auditing
- update my user and change my password are audited today
- forgot and reset password are pre-auth, so their audit events are
dropped at the zero org id check until the principal is resolvable
- routes stay open access, no API or OpenAPI spec change

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
contributes to https://github.com/SigNoz/keystone-pod/issues/10
2026-11-13 14:58:58 +00:00
Nikhil Mantri
0fbd56058f fix(authz): keep rule validation errors behind the rule FGA check (#13576)
#### Description

- Fixes `ruler/08_rule_validation.py` failing on main after #13253,
since the rule authz check runs before the handler's validation.
- Malformed rule JSON returned 500 from the channel extractor, it now
decodes strictly like the handler and returns 400.
- Channel attach checks skip names that don't exist, so the handler's
`validateChannels` returns its clearer error (e.g. channel referenced by
name instead of display name). Existing channels still need attach.
- A malformed rule id now gets the generic `invalid_selector` 400 like
every other FGA route, test updated.
2026-11-13 14:54:47 +00:00
Nikhil Soni
0f598c49ad test(traces): anchor attribute evolution tests to the migrator's rollout (#13551)
#### Description
- Schema migrator `v0.144.13+` (migration 1002) seeds the traces
`attributes` JSON rollout at install, as it already does for resources.
Tests that wrote their own rollout rows broke on it: `[map]` cases read
JSON, and the seed teardown deleted the migrator's row
(`test_summary_ai_trace[map]` failing on #13517).
- Bumps the schema migrator to `v0.144.15` (CI, tests, devenv). Tests no
longer write `column_evolution_metadata`; rollout-straddle tests anchor
to the migrator's time via `get_attribute_evolution_time`, mirroring
`get_resource_evolution_time`.
- Map/JSON dual run kept: `[map]` anchors spans and query window an hour
before the rollout, where the querier reads only the legacy maps, the
same SQL as with `use_trace_attributes_json` off. `queriertraces` raises
the flux interval so those past windows aren't served from each other's
cached buckets.

#### Additional Information
- Unblocks #13517, which switches migrations to the otel-collector
image.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: swapnil-signoz <swapnil@signoz.io>
2026-11-13 13:04:16 +00:00
Gaurav Tewari
b780551de6 fix(navigation): keep an explicit replace: false in safeNavigate (#13556)
#### Description
- `safeNavigate` replaced the history entry whenever the target added a
param the current URL lacked, even when the caller passed `replace:
false`. An explicit `replace` now wins; without one, behavior is
unchanged.
- No caller passes `replace: false` today, so nothing changes until one
opts in.

#### Issues closed by this PR

#### Screenshots / Screen Recordings

#### Additional Information
- Split out of #13363, whose Noz chips need a history entry Back can
return to when they add `startTime`/`endTime` on an open Explorer.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-13 11:32:14 +00:00
Nikhil Mantri
8bc4c5f79c feat(authz): rule FGA (#13253)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
cacheci / tests (push) Has been cancelled
#### Description

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

#### Issues closed by this PR

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

#### Additional Information

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

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

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

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

#### Issues closed by this PR

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

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

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/719
2026-11-12 16:50:41 +00:00
252 changed files with 10385 additions and 7110 deletions

View File

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

View File

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

View File

@@ -67,7 +67,7 @@ services:
timeout: 5s
retries: 3
telemetrystore-migrator:
image: signoz/signoz-otel-collector:v0.144.6
image: signoz/signoz-otel-collector:v0.144.15
container_name: telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -1,6 +1,6 @@
services:
signoz-otel-collector:
image: signoz/signoz-otel-collector:v0.142.0
image: signoz/signoz-otel-collector:v0.144.15
container_name: signoz-otel-collector-dev
entrypoint:
- /bin/sh

View File

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

View File

@@ -88,7 +88,7 @@ jobs:
postgres:15 \
clickhouse/clickhouse-server:25.12.5 \
clickhouse/clickhouse-keeper:25.12.5 \
signoz/signoz-schema-migrator:v0.144.9 \
signoz/signoz-otel-collector:v0.144.16-rc.1 \
wiremock/wiremock:2.35.1-1 \
| xargs -P 0 -n 1 docker pull -q
- name: python

View File

@@ -40,6 +40,7 @@ jobs:
suite:
- alertmanager
- alertmanagerrotation
- auditquerier
- basepath
- callbackauthn
- cloudintegrations
@@ -83,8 +84,8 @@ jobs:
- wal
clickhouse-version:
- 25.12.5
schema-migrator-version:
- v0.144.6
signoz-otel-collector-version:
- v0.144.16-rc.1
postgres-version:
- 15
if: |
@@ -155,4 +156,4 @@ jobs:
--sqlite-mode ${{matrix.sqlite-mode}} \
--postgres-version ${{matrix.postgres-version}} \
--clickhouse-version ${{matrix.clickhouse-version}} \
--schema-migrator-version ${{matrix.schema-migrator-version}}
--signoz-otel-collector-version ${{matrix.signoz-otel-collector-version}}

View File

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

View File

@@ -256,7 +256,7 @@ Tests can be configured using pytest options:
- `--sqlite-mode` — SQLite journal mode: `delete` or `wal` (default: `delete`). Only relevant when `--sqlstore-provider=sqlite`.
- `--postgres-version` — PostgreSQL version (default: `15`)
- `--clickhouse-version` — ClickHouse version, also used for ClickHouse Keeper (default: `25.12.5`)
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.9`)
- `--signoz-otel-collector-version`: SigNoz OTel collector version, used to run the ClickHouse migrations (default: `v0.144.16-rc.1`)
- `--with-web` — Build the frontend into the SigNoz image (required for e2e)
Example:

View File

@@ -11,7 +11,7 @@ export const ServicesTablePage = Loadable(
export const ServiceMetricsPage = Loadable(
() =>
import(
/* webpackChunkName: "ServiceMetricsPage" */ 'pages/ServiceDetails/ServiceDetails'
/* webpackChunkName: "ServiceMetricsPage" */ 'pages/MetricsApplication/MetricsApplication'
),
);

View File

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

View File

@@ -0,0 +1,11 @@
import { ApiV4Instance } from 'api';
import { AxiosResponse } from 'axios';
import { MetricMetaProps } from 'types/api/metrics/getApDex';
export const getMetricMeta = (
metricName: string,
servicename: string,
): Promise<AxiosResponse<MetricMetaProps>> =>
ApiV4Instance.get(
`/metric/metric_metadata?metricName=${metricName}&serviceName=${servicename}`,
);

View File

@@ -0,0 +1,27 @@
import { ApiV2Instance } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp } from 'types/api';
import { PayloadProps, Props } from 'types/api/metrics/getTopOperations';
const getTopOperations = async (props: Props): Promise<PayloadProps> => {
try {
const endpoint = props.isEntryPoint
? '/service/entry_point_operations'
: '/service/top_operations';
const response = await ApiV2Instance.post(endpoint, {
start: `${props.start}`,
end: `${props.end}`,
service: props.service,
tags: props.selectedTags,
limit: 5000,
});
return response.data.data;
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getTopOperations;

View File

@@ -1,4 +1,6 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch } from 'react-redux';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { matchRoute } from 'lib/router/matchRoute';
import { navigate } from 'lib/router/navigation';
@@ -26,6 +28,7 @@ import {
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { UpdateTimeInterval } from 'store/actions';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
import { openInNewTab } from 'utils/navigation';
import {
@@ -53,6 +56,7 @@ import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { getTimeRangeFromActionQuery } from './utils/getTimeRangeFromActionQuery';
import { isOnResourcePage } from './utils/isOnResourcePage';
import { openSavedViewByKey } from './utils/openSavedView';
import {
@@ -279,6 +283,7 @@ interface ApplyFilterDeps {
typeof useQueryBuilder
>['redirectWithQueryBuilderData'];
handleSetQueryData: ReturnType<typeof useQueryBuilder>['handleSetQueryData'];
setCustomTimeRange: (start: number, end: number) => void;
}
/**
@@ -344,18 +349,35 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
requestTypeFromActionQuery(action.query),
action.signal,
);
const timeRange = getTimeRangeFromActionQuery(action.query);
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
// eslint-disable-next-line no-console
console.log('[apply_filter] normalized', normalized);
if (signalMatchesPathname(action.signal, deps.pathname)) {
// eslint-disable-next-line no-console
console.log('[apply_filter] on-page → handleSetQueryData + redirect');
if (timeRange) {
deps.setCustomTimeRange(timeRange.start, timeRange.end);
}
normalized.builder.queryData.forEach((q, i) => {
deps.handleSetQueryData(i, q);
});
deps.redirectWithQueryBuilderData(normalized, {
[QueryParams.panelTypes]: panelType,
});
deps.redirectWithQueryBuilderData(
normalized,
{
[QueryParams.panelTypes]: panelType,
...(timeRange && {
[QueryParams.startTime]: timeRange.start,
[QueryParams.endTime]: timeRange.end,
// relativeTime outranks startTime/endTime in the time picker, so drop it.
[QueryParams.relativeTime]: null,
}),
},
undefined,
undefined,
// New params like startTime would otherwise make safeNavigate replace the entry.
{ replace: false },
);
return;
}
const base = explorerRouteForSignal(action.signal);
@@ -368,6 +390,10 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
console.log('[apply_filter] off-page → navigate', base);
const url = buildExplorerNavigationUrl(base, normalized, {
[QueryParams.panelTypes]: panelType,
...(timeRange && {
[QueryParams.startTime]: timeRange.start,
[QueryParams.endTime]: timeRange.end,
}),
});
navigate(url);
}
@@ -402,6 +428,13 @@ export default function ActionsSection({
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
const dispatch = useDispatch();
const setCustomTimeRange = useCallback(
(start: number, end: number): void => {
dispatch(UpdateTimeInterval('custom', [start, end]));
},
[dispatch],
);
// Per-chip click state, keyed by chip key (see `key` below). Persists
// loading/success/error so the chip reflects the rollback outcome until
@@ -433,9 +466,16 @@ export default function ActionsSection({
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
setCustomTimeRange,
});
});
}, [actions, pathname, redirectWithQueryBuilderData, handleSetQueryData]);
}, [
actions,
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
setCustomTimeRange,
]);
if (actions.length === 0) {
return null;
@@ -568,6 +608,7 @@ export default function ActionsSection({
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
setCustomTimeRange,
});
break;
}

View File

@@ -9,6 +9,7 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getCurrentLocation } from 'lib/router/navigation';
import { UpdateTimeInterval } from 'store/actions';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
@@ -24,7 +25,17 @@ jest.mock('api/common/logEvent', () => ({
default: jest.fn(),
}));
jest.mock('store/actions', () => {
const actual =
jest.requireActual<typeof import('store/actions')>('store/actions');
return { ...actual, UpdateTimeInterval: jest.fn(actual.UpdateTimeInterval) };
});
const mockUndo = undoExecution as jest.MockedFunction<typeof undoExecution>;
const mockUpdateTimeInterval = jest.mocked(UpdateTimeInterval);
const START_MS = Date.UTC(2026, 6, 26, 10, 34);
const END_MS = Date.UTC(2026, 6, 26, 10, 49);
const UNDO_ACTION = {
kind: MessageActionKindDTO.undo,
@@ -38,13 +49,18 @@ function renderUndoChip(): HTMLElement {
}
// Labels differ per test: auto-applied filters are deduped module-wide by label.
function countByServiceAction(label: string): MessageActionDTO {
function countByServiceAction(
label: string,
{ start, end }: { start?: number; end?: number } = {},
): MessageActionDTO {
return {
kind: MessageActionKindDTO.apply_filter,
label,
signal: ApplyFilterSignalDTO.logs,
query: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
start,
end,
compositeQuery: {
queries: [
{
@@ -62,10 +78,8 @@ function countByServiceAction(label: string): MessageActionDTO {
};
}
function currentPanelTypesParam(): string | null {
return new URLSearchParams(getCurrentLocation().search).get(
QueryParams.panelTypes,
);
function currentParam(param: string): string | null {
return new URLSearchParams(getCurrentLocation().search).get(param);
}
describe('ActionsSection rollback chip', () => {
@@ -139,7 +153,34 @@ describe('ActionsSection apply_filter', () => {
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentPanelTypesParam()).toBe(JSON.stringify(PANEL_TYPES.TABLE));
expect(currentParam(QueryParams.panelTypes)).toBe(
JSON.stringify(PANEL_TYPES.TABLE),
);
});
it('opens the explorer on the time range that produced the answer', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection
actions={[
countByServiceAction('Open with time', {
start: START_MS,
end: END_MS,
}),
]}
messageId="message-with-time"
/>,
undefined,
{ initialRoute: ROUTES.HOME },
);
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentParam(QueryParams.startTime)).toBe(String(START_MS));
expect(currentParam(QueryParams.endTime)).toBe(String(END_MS));
});
it('switches the open explorer to the Table view when applied on it', async () => {
@@ -167,6 +208,54 @@ describe('ActionsSection apply_filter', () => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({ builder: expect.anything() }),
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
undefined,
undefined,
{ replace: false },
);
});
it('switches the open explorer to the answer time range', async () => {
const redirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['redirectWithQueryBuilderData']
>;
const handleSetQueryData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['handleSetQueryData']
>;
render(
<ActionsSection
actions={[
countByServiceAction('Apply with time', {
start: START_MS,
end: END_MS,
}),
]}
messageId="message-on-page-with-time"
/>,
undefined,
{
initialRoute: ROUTES.LOGS_EXPLORER,
queryBuilderOverrides: { redirectWithQueryBuilderData, handleSetQueryData },
},
);
await waitFor(() =>
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
);
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({ builder: expect.anything() }),
{
[QueryParams.panelTypes]: PANEL_TYPES.TABLE,
[QueryParams.startTime]: START_MS,
[QueryParams.endTime]: END_MS,
[QueryParams.relativeTime]: null,
},
undefined,
undefined,
{ replace: false },
);
expect(mockUpdateTimeInterval).toHaveBeenCalledWith('custom', [
START_MS,
END_MS,
]);
});
});

View File

@@ -0,0 +1,25 @@
import type { MessageActionDTOQuery } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { normalizeTimeToMs } from 'utils/timeUtils';
export interface ActionTimeRange {
start: number;
end: number;
}
function toTimestampMs(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return null;
}
return normalizeTimeToMs(Math.trunc(value));
}
export function getTimeRangeFromActionQuery(
query: MessageActionDTOQuery | undefined,
): ActionTimeRange | null {
const start = toTimestampMs(query?.start);
const end = toTimestampMs(query?.end);
if (start === null || end === null || end <= start) {
return null;
}
return { start, end };
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,4 +3,24 @@
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-0) var(--spacing-0);
//TODO: remove this once we have a proper dashboard page.
// The embedded V2 DashboardContainer renders its own page header (dashboard
// title + share/feedback chrome) removed it for now
:global([class*='dashboardPageHeader']),
:global([class*='dashboardInfoWithActions']) {
display: none;
}
// Embedded read-only: the add-variable and panel-action triggers are dead here.
:global([class*='addSlot']),
:global([data-testid^='panel-actions-']) {
display: none;
}
}
.errorState {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-8);
}

View File

@@ -1,24 +1,47 @@
import { useEffect } from 'react';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import SystemDashboard from 'container/SystemDashboard/SystemDashboard';
import { createSystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
import { CHARTS_ONLY } from 'pages/DashboardPage/DashboardContainer/controls/presets';
import Spinner from 'components/Spinner';
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
import { useSystemDashboard } from './hooks/useSystemDashboard';
import styles from './Overview.module.scss';
const AI_OVERVIEW = createSystemDashboardHost(
'ai-o11y-overview',
'llm-observability-overview',
);
function Overview(): JSX.Element {
const { dashboard, isLoading, isError, error, refetch } = useSystemDashboard();
useEffect(() => {
void logEvent('AI Observability Overview: Page visited', {});
}, []);
const renderContent = (): JSX.Element => {
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;
}
if (isError || !dashboard) {
return (
<div className={styles.errorState}>
<Typography.Title>Failed to load dashboard</Typography.Title>
<Typography.Text>
{error?.response?.data?.error?.message ?? error?.message}
</Typography.Text>
</div>
);
}
return (
<DashboardContainer
dashboard={dashboard}
refetch={refetch}
canEditDashboardOverride={false}
/>
);
};
return (
<div className={styles.overview} data-testid="llm-observability-overview">
<SystemDashboard host={AI_OVERVIEW} controls={CHARTS_ONLY} />
{renderContent()}
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import {
getGetDashboardV2QueryKey,
useGetSystemDashboard,
} from 'api/generated/services/dashboard';
import type { GetSystemDashboardQueryError } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
const SYSTEM_DASHBOARD_NAME = 'ai-o11y-overview';
const DASHBOARD_ID = 'llm-observability-overview';
export interface UseSystemDashboardResult {
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
isLoading: boolean;
isError: boolean;
error: GetSystemDashboardQueryError | null;
refetch: () => void;
}
export function useSystemDashboard(): UseSystemDashboardResult {
const queryClient = useQueryClient();
const { data, isLoading, isError, error, refetch } = useGetSystemDashboard(
{ name: SYSTEM_DASHBOARD_NAME },
{ query: { staleTime: Infinity, refetchOnMount: false } },
);
const dashboard = useMemo(
() =>
data
? ({
...data.data,
id: DASHBOARD_ID,
} as DashboardtypesGettableDashboardV2DTO)
: undefined,
[data],
);
if (dashboard) {
queryClient.setQueryData<GetDashboardV2200>(
getGetDashboardV2QueryKey({ id: DASHBOARD_ID }),
{ data: dashboard, status: 'success' },
);
}
const refetchDashboard = useCallback((): void => {
void refetch();
}, [refetch]);
return { dashboard, isLoading, isError, error, refetch: refetchDashboard };
}

View File

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

View File

@@ -2,14 +2,77 @@
display: flex;
flex-direction: column;
.meter-explorer-notices {
.meter-explorer-date-time {
display: flex;
min-height: 30px;
justify-content: end;
border-bottom: 1px solid var(--l1-border);
padding: 10px 16px;
}
.meter-explorer-graphs {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 20px 0;
padding: 20px;
gap: 36px;
&:empty {
display: none;
.info {
display: flex;
flex-direction: column;
gap: 12px;
}
.meter-column-graph {
.row-card {
background-color: var(--l2-background);
padding-left: 10px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
.section-title {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
}
.graph-description {
padding: 10px;
display: flex;
justify-content: center;
align-items: center;
}
.meter-page-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
align-items: flex-start;
gap: 10px;
.meter-graph {
height: 400px;
padding: 10px;
width: 100%;
box-sizing: border-box;
}
}
}
.total {
.meter-column-graph {
.meter-page-grid {
grid-template-columns: repeat(3, 1fr);
.meter-graph {
height: 200px;
}
}
}
}
}
}

View File

@@ -1,30 +1,121 @@
import { useCallback } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useAppLocation } from 'lib/router/useAppLocation';
import { navigate } from 'lib/router/navigation';
import { Alert } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import SystemDashboard from 'container/SystemDashboard/SystemDashboard';
import { createSystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import GridCard from 'container/WidgetCard/Card';
import { Card, CardContainer } from 'container/WidgetCard/styles';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import {
CHARTS_ONLY,
withControls,
} from 'pages/DashboardPage/DashboardContainer/controls/presets';
import useUrlQuery from 'hooks/useUrlQuery';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { Widgets } from 'types/api/widgets/widget';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuid } from 'uuid';
import {
getLogCountWidgetData,
getLogSizeWidgetData,
getMetricCountWidgetData,
getSpanCountWidgetData,
getSpanSizeWidgetData,
getTotalLogSizeWidgetData,
getTotalMetricDatapointCountWidgetData,
getTotalTraceSizeWidgetData,
} from './graphs';
import './BreakDown.styles.scss';
const COST_METER = createSystemDashboardHost(
'cost-meter-breakdown',
'cost-meter-breakdown',
);
type MetricSection = {
id: string;
title: string;
graphs: Widgets[];
};
const CONTROLS = withControls(CHARTS_ONLY, {
toolbar: { autoRefresh: false },
});
const sections: MetricSection[] = [
{
id: uuid(),
title: 'Total',
graphs: [
getTotalLogSizeWidgetData(),
getTotalTraceSizeWidgetData(),
getTotalMetricDatapointCountWidgetData(),
],
},
{
id: uuid(),
title: 'Logs',
graphs: [getLogCountWidgetData(), getLogSizeWidgetData()],
},
{
id: uuid(),
title: 'Traces',
graphs: [getSpanCountWidgetData(), getSpanSizeWidgetData()],
},
{
id: uuid(),
title: 'Metrics',
graphs: [getMetricCountWidgetData()],
},
];
function Section(section: MetricSection): JSX.Element {
const isDarkMode = useIsDarkMode();
const { title, graphs } = section;
const { pathname } = useAppLocation();
const dispatch = useDispatch();
const urlQuery = useUrlQuery();
const onDragSelect = useCallback(
(start: number, end: number) => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
navigate(generatedUrl);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
},
[dispatch, pathname, urlQuery],
);
return (
<div
className="meter-column-graph"
data-testid={`meter-breakdown-section-${title.toLowerCase()}`}
>
<CardContainer className="row-card" isDarkMode={isDarkMode}>
<Typography.Text className="section-title">{title}</Typography.Text>
</CardContainer>
<div className="meter-page-grid">
{graphs.map((widget) => (
<Card
key={widget?.id}
isDarkMode={isDarkMode}
$panelType={PANEL_TYPES.BAR}
className="meter-graph"
>
<GridCard widget={widget} onDragSelect={onDragSelect} version="v5" />
</Card>
))}
</div>
</div>
);
}
function BreakDown(): JSX.Element {
const { isCloudUser } = useGetTenantLicense();
@@ -42,10 +133,12 @@ function BreakDown(): JSX.Element {
return (
<div className="meter-explorer-breakdown">
<section className="meter-explorer-notices">
<section className="meter-explorer-date-time">
<DateTimeSelectionV2 showAutoRefresh={false} />
</section>
<section className="meter-explorer-graphs">
{showInfo && (
<Alert
data-testid="cost-meter-utc-notice"
type="info"
showIcon
closable
@@ -86,8 +179,28 @@ function BreakDown(): JSX.Element {
}
/>
)}
<section className="total">
<Section
id={sections[0].id}
title={sections[0].title}
graphs={sections[0].graphs}
/>
</section>
{sections.map((section, idx) => {
if (idx === 0) {
return;
}
return (
<Section
key={section.id}
id={section.id}
title={section.title}
graphs={section.graphs}
/>
);
})}
</section>
<SystemDashboard host={COST_METER} controls={CONTROLS} />
</div>
);
}

View File

@@ -1,51 +0,0 @@
import type { DashboardControlsInput } from 'pages/DashboardPage/DashboardContainer/controls/types';
import { resolveControls } from 'pages/DashboardPage/DashboardContainer/controls/dashboardControls';
import { render, screen } from 'tests/test-utils';
import type { SystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
import BreakDown from '../BreakDown';
jest.mock('container/SystemDashboard/SystemDashboard', () => ({
__esModule: true,
default: ({
host,
controls,
}: {
host: SystemDashboardHost;
controls: DashboardControlsInput;
}): JSX.Element => (
<div
data-testid="system-dashboard"
data-name={host.name}
data-controls={JSON.stringify(controls)}
/>
),
}));
describe('Cost meter breakdown', () => {
it('mounts the cost meter system dashboard as charts only, without auto-refresh', () => {
render(<BreakDown />);
const dashboard = screen.getByTestId('system-dashboard');
expect(dashboard).toHaveAttribute('data-name', 'cost-meter-breakdown');
const controls = resolveControls(
JSON.parse(dashboard.getAttribute('data-controls') ?? ''),
true,
);
expect(controls.header.breadcrumbs).toBe(false);
expect(controls.panelActions.createAlert).toBe(false);
expect(controls.panelActions.drilldown).toBe(false);
expect(controls.panelActions.zoom).toBe(true);
expect(controls.toolbar.autoRefresh).toBe(false);
expect(controls.toolbar.timeRange).toBe(true);
});
it('keeps the UTC billing notice above the dashboard', () => {
render(<BreakDown />);
expect(screen.getByTestId('cost-meter-utc-notice')).toHaveTextContent(
'Billing is calculated in UTC',
);
});
});

View File

@@ -0,0 +1,374 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetWidgetQueryBuilderProps } from 'container/MetricsApplication/types';
import { Widgets } from 'types/api/widgets/widget';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderFormula,
IBuilderQuery,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
interface GetWidgetQueryProps {
title: string;
description: string;
queryData: IBuilderQuery[];
queryFormulas?: IBuilderFormula[];
panelTypes?: PANEL_TYPES;
yAxisUnit?: string;
columnUnits?: Record<string, string>;
}
interface GetWidgetQueryPropsReturn extends GetWidgetQueryBuilderProps {
description?: string;
nullZeroValues: string;
columnUnits?: Record<string, string>;
}
export const getWidgetQueryBuilder = ({
query,
title = '',
panelTypes,
yAxisUnit = '',
fillSpans = false,
id,
nullZeroValues,
description,
}: GetWidgetQueryPropsReturn): Widgets => ({
description: description || '',
id: id || uuid(),
nullZeroValues: nullZeroValues || '',
opacity: '1',
panelTypes,
query,
timePreferance: 'GLOBAL_TIME',
title,
yAxisUnit,
softMax: null,
softMin: null,
selectedLogFields: [],
selectedTracesFields: [],
fillSpans,
});
export function getWidgetQuery(
props: GetWidgetQueryProps,
): GetWidgetQueryPropsReturn {
const { title, description, panelTypes, yAxisUnit, columnUnits } = props;
return {
title,
yAxisUnit: yAxisUnit || 'none',
panelTypes: panelTypes || PANEL_TYPES.TIME_SERIES,
fillSpans: false,
description,
nullZeroValues: 'zero',
columnUnits,
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: {
queryData: props.queryData,
queryFormulas: (props.queryFormulas as IBuilderFormula[]) || [],
queryTraceOperator: [],
},
clickhouse_sql: [],
id: uuid(),
},
};
}
export const getTotalLogSizeWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.log.size',
id: 'signoz.meter.log.size--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.SUM,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Total size of log records ingested',
description: '',
panelTypes: PANEL_TYPES.VALUE,
yAxisUnit: 'bytes',
}),
);
export const getTotalTraceSizeWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.span.size',
id: 'signoz.meter.span.size--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.SUM,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Total size of spans ingested',
description: '',
panelTypes: PANEL_TYPES.VALUE,
yAxisUnit: 'bytes',
}),
);
export const getTotalMetricDatapointCountWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.metric.datapoint.count',
id: 'signoz.meter.metric.datapoint.count--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.SUM,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Total metric datapoints ingested',
description: '',
panelTypes: PANEL_TYPES.VALUE,
yAxisUnit: 'short',
}),
);
export const getLogCountWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.log.count',
id: 'signoz.meter.log.count--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Count of log records ingested',
description: '',
panelTypes: PANEL_TYPES.BAR,
yAxisUnit: 'short',
}),
);
export const getLogSizeWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.log.size',
id: 'signoz.meter.log.size--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'size',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Size of log records ingested',
description: '',
panelTypes: PANEL_TYPES.BAR,
yAxisUnit: 'bytes',
}),
);
export const getSpanCountWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.span.count',
id: 'signoz.meter.span.count--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Count of spans ingested',
description: '',
panelTypes: PANEL_TYPES.BAR,
yAxisUnit: 'short',
}),
);
export const getSpanSizeWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.span.size',
id: 'signoz.meter.span.size--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'size',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Size of spans ingested',
description: '',
panelTypes: PANEL_TYPES.BAR,
yAxisUnit: 'bytes',
}),
);
export const getMetricCountWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.metric.datapoint.count',
id: 'signoz.meter.metric.datapoint.count--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Count of metric datapoints ingested',
description: '',
panelTypes: PANEL_TYPES.BAR,
yAxisUnit: 'short',
}),
);

View File

@@ -0,0 +1,136 @@
import { OPERATORS } from 'constants/queryBuilder';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import {
DataSource,
MetricAggregateOperator,
QueryBuilderData,
} from 'types/common/queryBuilder';
import { FORMULA, MetricsType, WidgetKeys } from '../constant';
import { DatabaseCallProps, DatabaseCallsRPSProps } from '../types';
import {
getQueryBuilderQueries,
getQueryBuilderQuerieswithFormula,
} from './MetricsPageQueriesFactory';
export const databaseCallsRPS = ({
servicename,
legend,
tagFilterItems,
}: DatabaseCallsRPSProps): QueryBuilderData => {
const autocompleteData: BaseAutocompleteData[] = [
{
key: WidgetKeys.SignozDBLatencyCount,
dataType: DataTypes.Float64,
type: '',
},
];
const groupBy: BaseAutocompleteData[] = [
{
dataType: DataTypes.String,
key: WidgetKeys.DbSystem,
type: 'tag',
},
];
const filterItems: TagFilterItem[][] = [
[
{
id: '',
key: {
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
value: [`${servicename}`],
},
...tagFilterItems,
],
];
const legends = [legend];
const dataSource = DataSource.METRICS;
const timeAggregateOperators = [MetricAggregateOperator.RATE];
const spaceAggregateOperators = [MetricAggregateOperator.SUM];
return getQueryBuilderQueries({
autocompleteData,
groupBy,
legends,
filterItems,
dataSource,
timeAggregateOperators,
spaceAggregateOperators,
});
};
export const databaseCallsAvgDuration = ({
servicename,
tagFilterItems,
}: DatabaseCallProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozDbLatencySum,
dataType: DataTypes.Float64,
type: '',
};
const autocompleteDataB: BaseAutocompleteData = {
key: WidgetKeys.SignozDBLatencyCount,
dataType: DataTypes.Float64,
type: '',
};
const additionalItemsA: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
value: [servicename],
},
...tagFilterItems,
];
const autocompleteData: BaseAutocompleteData[] = [
autocompleteDataA,
autocompleteDataB,
];
const additionalItems: TagFilterItem[][] = [
additionalItemsA,
additionalItemsA,
];
const legends = ['', ''];
const disabled = [true, true];
const legendFormulas = ['Average Duration'];
const expressions = [FORMULA.DATABASE_CALLS_AVG_DURATION];
const timeAggregateOperators = [
MetricAggregateOperator.RATE,
MetricAggregateOperator.RATE,
];
const spaceAggregateOperators = [
MetricAggregateOperator.SUM,
MetricAggregateOperator.SUM,
];
const dataSource = DataSource.METRICS;
return getQueryBuilderQuerieswithFormula({
autocompleteData,
additionalItems,
legends,
disabled,
expressions,
legendFormulas,
timeAggregateOperators,
spaceAggregateOperators,
dataSource,
});
};

View File

@@ -0,0 +1,276 @@
import { OPERATORS } from 'constants/queryBuilder';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import {
DataSource,
MetricAggregateOperator,
QueryBuilderData,
} from 'types/common/queryBuilder';
import { FORMULA, MetricsType, WidgetKeys } from '../constant';
import {
ExternalCallDurationByAddressProps,
ExternalCallProps,
} from '../Tabs/types';
import {
getQueryBuilderQueries,
getQueryBuilderQuerieswithFormula,
} from './MetricsPageQueriesFactory';
const groupBy: BaseAutocompleteData[] = [
{
dataType: DataTypes.String,
key: WidgetKeys.Address,
type: MetricsType.Tag,
},
];
export const externalCallErrorPercent = ({
servicename,
legend,
tagFilterItems,
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozExternalCallLatencyCount,
dataType: DataTypes.Float64,
type: '',
};
const autocompleteDataB: BaseAutocompleteData = {
key: WidgetKeys.SignozExternalCallLatencyCount,
dataType: DataTypes.Float64,
type: '',
};
const additionalItemsA: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
value: [servicename],
},
{
id: '',
key: {
key: WidgetKeys.StatusCode,
dataType: DataTypes.Int64,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: ['STATUS_CODE_ERROR'],
},
...tagFilterItems,
];
const additionalItemsB: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
value: [servicename],
},
...tagFilterItems,
];
const legendFormulas = [legend];
const expressions = [FORMULA.ERROR_PERCENTAGE];
const disabled = [true, true];
const autocompleteData = [autocompleteDataA, autocompleteDataB];
const additionalItems = [additionalItemsA, additionalItemsB];
const timeAggregateOperators = [
MetricAggregateOperator.RATE,
MetricAggregateOperator.RATE,
];
const spaceAggregateOperators = [
MetricAggregateOperator.SUM,
MetricAggregateOperator.SUM,
];
const legends = [legend, legend];
const dataSource = DataSource.METRICS;
return getQueryBuilderQuerieswithFormula({
autocompleteData,
additionalItems,
legends,
groupBy,
disabled,
expressions,
legendFormulas,
timeAggregateOperators,
spaceAggregateOperators,
dataSource,
});
};
export const externalCallDuration = ({
servicename,
tagFilterItems,
}: ExternalCallProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
dataType: DataTypes.Float64,
key: WidgetKeys.SignozExternalCallLatencySum,
type: '',
};
const autocompleteDataB: BaseAutocompleteData = {
dataType: DataTypes.Float64,
key: WidgetKeys.SignozExternalCallLatencyCount,
type: '',
};
const expressions = [FORMULA.DATABASE_CALLS_AVG_DURATION];
const legendFormulas = ['Average Duration'];
const legend = '';
const disabled = [true, true];
const additionalItemsA: TagFilterItem[] = [
{
id: '',
key: {
dataType: DataTypes.String,
key: WidgetKeys.OTelServiceName,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
value: [servicename],
},
...tagFilterItems,
];
const autocompleteData = [autocompleteDataA, autocompleteDataB];
const additionalItems = [additionalItemsA, additionalItemsA];
const legends = [legend, legend];
const timeAggregateOperators = [
MetricAggregateOperator.RATE,
MetricAggregateOperator.RATE,
];
const spaceAggregateOperators = [
MetricAggregateOperator.SUM,
MetricAggregateOperator.SUM,
];
const dataSource = DataSource.METRICS;
return getQueryBuilderQuerieswithFormula({
autocompleteData,
additionalItems,
legends,
disabled,
expressions,
legendFormulas,
timeAggregateOperators,
spaceAggregateOperators,
dataSource,
});
};
export const externalCallRpsByAddress = ({
servicename,
legend,
tagFilterItems,
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
const autocompleteData: BaseAutocompleteData[] = [
{
dataType: DataTypes.Float64,
key: WidgetKeys.SignozExternalCallLatencyCount,
type: '',
},
];
const filterItems: TagFilterItem[][] = [
[
{
id: '',
key: {
dataType: DataTypes.String,
key: WidgetKeys.OTelServiceName,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
value: [servicename],
},
...tagFilterItems,
],
];
const legends = [legend];
const dataSource = DataSource.METRICS;
const timeAggregateOperators = [MetricAggregateOperator.RATE];
const spaceAggregateOperators = [MetricAggregateOperator.SUM];
return getQueryBuilderQueries({
autocompleteData,
groupBy,
legends,
filterItems,
dataSource,
timeAggregateOperators,
spaceAggregateOperators,
});
};
export const externalCallDurationByAddress = ({
servicename,
legend,
tagFilterItems,
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
dataType: DataTypes.Float64,
key: WidgetKeys.SignozExternalCallLatencySum,
type: '',
};
const autocompleteDataB: BaseAutocompleteData = {
dataType: DataTypes.Float64,
key: WidgetKeys.SignozExternalCallLatencyCount,
type: '',
};
const expressions = [FORMULA.DATABASE_CALLS_AVG_DURATION];
const legendFormulas = [legend];
const disabled = [true, true];
const additionalItemsA: TagFilterItem[] = [
{
id: '',
key: {
dataType: DataTypes.String,
key: WidgetKeys.OTelServiceName,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
value: [servicename],
},
...tagFilterItems,
];
const autocompleteData = [autocompleteDataA, autocompleteDataB];
const additionalItems = [additionalItemsA, additionalItemsA];
const legends = [legend, legend];
const timeAggregateOperators = [
MetricAggregateOperator.RATE,
MetricAggregateOperator.RATE,
];
const spaceAggregateOperators = [
MetricAggregateOperator.SUM,
MetricAggregateOperator.SUM,
];
const dataSource = DataSource.METRICS;
return getQueryBuilderQuerieswithFormula({
autocompleteData,
additionalItems,
legends,
groupBy,
disabled,
expressions,
legendFormulas,
timeAggregateOperators,
spaceAggregateOperators,
dataSource,
});
};

View File

@@ -5,13 +5,68 @@ import {
} from 'constants/queryBuilder';
import getStep from 'lib/getStep';
import store from 'store';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import {
MetricAggregateOperator,
QueryBuilderData,
ReduceOperators,
Temporality,
} from 'types/common/queryBuilder';
import { BuilderQuerieswithFormulaProps } from '../Tabs/types';
import {
BuilderQueriesProps,
BuilderQuerieswithFormulaProps,
} from '../Tabs/types';
export const getQueryBuilderQueries = ({
autocompleteData,
groupBy = [],
legends,
filterItems,
aggregateOperator,
dataSource,
queryNameAndExpression,
timeAggregateOperators,
spaceAggregateOperators,
}: BuilderQueriesProps): QueryBuilderData => ({
queryFormulas: [],
queryData: autocompleteData.map((item, index) => {
const newQueryData: IBuilderQuery = {
...initialQueryBuilderFormValuesMap.metrics,
aggregateOperator: ((): string => {
if (aggregateOperator) {
return aggregateOperator[index];
}
return MetricAggregateOperator.SUM_RATE;
})(),
disabled: false,
groupBy,
aggregateAttribute: item,
legend: legends[index],
stepInterval: getStep({
end: store.getState().globalTime.maxTime,
inputFormat: 'ns',
start: store.getState().globalTime.minTime,
}),
filters: {
items: filterItems[index],
op: 'AND',
},
reduceTo: ReduceOperators.AVG,
spaceAggregation: spaceAggregateOperators[index],
timeAggregation: timeAggregateOperators[index],
dataSource,
};
if (queryNameAndExpression) {
newQueryData.queryName = queryNameAndExpression[index];
newQueryData.expression = queryNameAndExpression[index];
}
return newQueryData;
}),
queryTraceOperator: [],
});
export const getQueryBuilderQuerieswithFormula = ({
autocompleteData,

View File

@@ -0,0 +1,612 @@
import { OPERATORS } from 'constants/queryBuilder';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import {
DataSource,
MetricAggregateOperator,
QueryBuilderData,
} from 'types/common/queryBuilder';
import {
FORMULA,
GraphTitle,
LATENCY_AGGREGATEOPERATOR,
LATENCY_AGGREGATEOPERATOR_SPAN_METRICS,
MetricsType,
OPERATION_LEGENDS,
QUERYNAME_AND_EXPRESSION,
WidgetKeys,
} from '../constant';
import {
ApDexMetricsQueryBuilderQueriesProps,
ApDexProps,
LatencyProps,
OperationPerSecProps,
} from '../Tabs/types';
import { convertMilSecToNanoSec, getNearestHighestBucketValue } from '../utils';
import {
getQueryBuilderQueries,
getQueryBuilderQuerieswithFormula,
} from './MetricsPageQueriesFactory';
export const latency = ({
servicename,
tagFilterItems,
isSpanMetricEnable = false,
topLevelOperationsRoute,
}: LatencyProps): QueryBuilderData => {
const signozLatencyBucketMetrics = WidgetKeys.SignozLatencyBucket;
const signozMetricsServiceName = WidgetKeys.OTelServiceName;
const newAutoCompleteData: BaseAutocompleteData = {
key: isSpanMetricEnable
? signozLatencyBucketMetrics
: WidgetKeys.DurationNano,
dataType: DataTypes.Float64,
type: isSpanMetricEnable ? '' : MetricsType.Tag,
};
const autocompleteData = Array(3).fill(newAutoCompleteData);
const filterItem: TagFilterItem[] = [
{
id: '',
key: {
key: isSpanMetricEnable ? signozMetricsServiceName : WidgetKeys.ServiceName,
dataType: DataTypes.String,
type: isSpanMetricEnable ? MetricsType.Resource : MetricsType.Tag,
},
op: isSpanMetricEnable ? OPERATORS.IN : OPERATORS['='],
value: isSpanMetricEnable ? [servicename] : servicename,
},
{
id: '',
key: {
dataType: DataTypes.String,
key: isSpanMetricEnable ? WidgetKeys.Operation : WidgetKeys.Name,
type: MetricsType.Tag,
},
op: OPERATORS.IN.toLowerCase(), // TODO: need to remove toLowerCase() this once backend is changed
value: [...topLevelOperationsRoute],
},
...tagFilterItems,
];
const filterItems = Array(3).fill([...filterItem]);
const legends = LATENCY_AGGREGATEOPERATOR;
const aggregateOperator = isSpanMetricEnable
? LATENCY_AGGREGATEOPERATOR_SPAN_METRICS
: LATENCY_AGGREGATEOPERATOR;
const dataSource = isSpanMetricEnable ? DataSource.METRICS : DataSource.TRACES;
const queryNameAndExpression = QUERYNAME_AND_EXPRESSION;
const timeAggregateOperators = [
MetricAggregateOperator.EMPTY,
MetricAggregateOperator.EMPTY,
MetricAggregateOperator.EMPTY,
];
const spaceAggregateOperators = [
MetricAggregateOperator.P50,
MetricAggregateOperator.P90,
MetricAggregateOperator.P99,
];
return getQueryBuilderQueries({
autocompleteData,
legends,
filterItems,
aggregateOperator,
dataSource,
queryNameAndExpression,
timeAggregateOperators,
spaceAggregateOperators,
});
};
export const apDexTracesQueryBuilderQueries = ({
servicename,
tagFilterItems,
topLevelOperationsRoute,
threashold,
}: ApDexProps): QueryBuilderData => {
const autoCompleteDataA: BaseAutocompleteData = {
dataType: DataTypes.Float64,
key: '',
type: '',
};
const autoCompleteDataB: BaseAutocompleteData = {
dataType: DataTypes.Float64,
key: '',
type: '',
};
const autoCompleteDataC: BaseAutocompleteData = {
dataType: DataTypes.Float64,
key: '',
type: '',
};
const filterItemA: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.ServiceName,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: servicename,
},
{
id: '',
key: {
key: WidgetKeys.Name,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: [...topLevelOperationsRoute],
},
...tagFilterItems,
];
const filterItemB: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.HasError,
dataType: DataTypes.bool,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: false,
},
{
id: '',
key: {
key: WidgetKeys.DurationNano,
dataType: DataTypes.Float64,
type: MetricsType.Tag,
},
op: OPERATORS['<='],
value: convertMilSecToNanoSec(threashold),
},
{
id: '',
key: {
key: WidgetKeys.ServiceName,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: servicename,
},
{
id: '',
key: {
key: WidgetKeys.Name,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: [...topLevelOperationsRoute],
},
];
const filterItemC: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.DurationNano,
dataType: DataTypes.Float64,
type: MetricsType.Tag,
},
op: OPERATORS['<='],
value: convertMilSecToNanoSec(threashold * 4),
},
{
id: '',
key: {
key: WidgetKeys.HasError,
dataType: DataTypes.bool,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: false,
},
{
id: '',
key: {
key: WidgetKeys.ServiceName,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: servicename,
},
{
id: '',
key: {
key: WidgetKeys.Name,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: [...topLevelOperationsRoute],
},
];
const autocompleteData = [
autoCompleteDataA,
autoCompleteDataB,
autoCompleteDataC,
];
const additionalItems = [filterItemA, filterItemB, filterItemC];
const legends = [GraphTitle.APDEX];
const disabled = Array(3).fill(true);
const expressions = [FORMULA.APDEX_TRACES];
const legendFormulas = [GraphTitle.APDEX];
const timeAggregateOperators = [
MetricAggregateOperator.COUNT,
MetricAggregateOperator.COUNT,
MetricAggregateOperator.COUNT,
];
const spaceAggregateOperators = [
MetricAggregateOperator.EMPTY,
MetricAggregateOperator.EMPTY,
MetricAggregateOperator.EMPTY,
];
const dataSource = DataSource.TRACES;
return getQueryBuilderQuerieswithFormula({
autocompleteData,
additionalItems,
legends,
disabled,
expressions,
legendFormulas,
timeAggregateOperators,
spaceAggregateOperators,
dataSource,
});
};
export const apDexMetricsQueryBuilderQueries = ({
servicename,
tagFilterItems,
topLevelOperationsRoute,
threashold,
delta,
metricsBuckets,
}: ApDexMetricsQueryBuilderQueriesProps): QueryBuilderData => {
const autoCompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozLatencyCount,
dataType: DataTypes.Float64,
type: '',
};
const autoCompleteDataB: BaseAutocompleteData = {
key: WidgetKeys.SignozLatencyBucket,
dataType: DataTypes.Float64,
type: '',
};
const autoCompleteDataC: BaseAutocompleteData = {
key: WidgetKeys.SignozLatencyBucket,
dataType: DataTypes.Float64,
type: '',
};
const filterItemA: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: servicename,
},
{
id: '',
key: {
key: WidgetKeys.Operation,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: [...topLevelOperationsRoute],
},
...tagFilterItems,
];
const filterItemB: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.StatusCode,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS['!='],
value: 'STATUS_CODE_ERROR',
},
{
id: '',
key: {
key: WidgetKeys.Le,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: getNearestHighestBucketValue(threashold * 1000, metricsBuckets),
},
{
id: '',
key: {
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: servicename,
},
{
id: '',
key: {
key: WidgetKeys.Operation,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: [...topLevelOperationsRoute],
},
...tagFilterItems,
];
const filterItemC: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.Le,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: getNearestHighestBucketValue(threashold * 1000 * 4, metricsBuckets),
},
{
id: '',
key: {
key: WidgetKeys.StatusCode,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS['!='],
value: 'STATUS_CODE_ERROR',
},
{
id: '',
key: {
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: servicename,
},
{
id: '',
key: {
key: WidgetKeys.Operation,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: [...topLevelOperationsRoute],
},
...tagFilterItems,
];
const autocompleteData = [
autoCompleteDataA,
autoCompleteDataB,
autoCompleteDataC,
];
const additionalItems = [filterItemA, filterItemB, filterItemC];
const legends = [GraphTitle.APDEX];
const disabled = Array(3).fill(true);
const expressions = delta
? [FORMULA.APDEX_DELTA_SPAN_METRICS]
: [FORMULA.APDEX_CUMULATIVE_SPAN_METRICS];
const legendFormulas = [GraphTitle.APDEX];
const timeAggregateOperators = [
MetricAggregateOperator.RATE,
MetricAggregateOperator.RATE,
MetricAggregateOperator.RATE,
];
const spaceAggregateOperators = [
MetricAggregateOperator.SUM,
MetricAggregateOperator.SUM,
MetricAggregateOperator.SUM,
];
const dataSource = DataSource.METRICS;
return getQueryBuilderQuerieswithFormula({
autocompleteData,
additionalItems,
legends,
disabled,
expressions,
legendFormulas,
timeAggregateOperators,
spaceAggregateOperators,
dataSource,
});
};
export const operationPerSec = ({
servicename,
tagFilterItems,
topLevelOperations,
}: OperationPerSecProps): QueryBuilderData => {
const autocompleteData: BaseAutocompleteData[] = [
{
key: WidgetKeys.SignozLatencyCount,
dataType: DataTypes.Float64,
type: '',
},
];
const filterItems: TagFilterItem[][] = [
[
{
id: '',
key: {
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
value: [`${servicename}`],
},
{
id: '',
key: {
key: WidgetKeys.Operation,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: topLevelOperations,
},
...tagFilterItems,
],
];
const legends = OPERATION_LEGENDS;
const dataSource = DataSource.METRICS;
const timeAggregateOperators = [MetricAggregateOperator.RATE];
const spaceAggregateOperators = [MetricAggregateOperator.SUM];
return getQueryBuilderQueries({
autocompleteData,
legends,
filterItems,
dataSource,
timeAggregateOperators,
spaceAggregateOperators,
});
};
export const errorPercentage = ({
servicename,
tagFilterItems,
topLevelOperations,
}: OperationPerSecProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozCallsTotal,
dataType: DataTypes.Float64,
type: '',
};
const autocompleteDataB: BaseAutocompleteData = {
key: WidgetKeys.SignozCallsTotal,
dataType: DataTypes.Float64,
type: '',
};
const autocompleteData = [autocompleteDataA, autocompleteDataB];
const additionalItemsA: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
value: [`${servicename}`],
},
{
id: '',
key: {
key: WidgetKeys.Operation,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: topLevelOperations,
},
{
id: '',
key: {
key: WidgetKeys.StatusCode,
dataType: DataTypes.Int64,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: ['STATUS_CODE_ERROR'],
},
...tagFilterItems,
];
const additionalItemsB: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
value: [`${servicename}`],
},
{
id: '',
key: {
key: WidgetKeys.Operation,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: topLevelOperations,
},
...tagFilterItems,
];
const additionalItems = [additionalItemsA, additionalItemsB];
const legends = [GraphTitle.ERROR_PERCENTAGE];
const disabled = [true, true];
const expressions = [FORMULA.ERROR_PERCENTAGE];
const legendFormulas = [GraphTitle.ERROR_PERCENTAGE];
const timeAggregateOperators = [
MetricAggregateOperator.RATE,
MetricAggregateOperator.RATE,
];
const spaceAggregateOperators = [
MetricAggregateOperator.SUM,
MetricAggregateOperator.SUM,
];
const dataSource = DataSource.METRICS;
return getQueryBuilderQuerieswithFormula({
autocompleteData,
additionalItems,
legends,
disabled,
expressions,
legendFormulas,
timeAggregateOperators,
spaceAggregateOperators,
dataSource,
});
};

View File

@@ -0,0 +1,146 @@
import { OPERATORS } from 'constants/queryBuilder';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import {
DataSource,
MetricAggregateOperator,
QueryBuilderData,
} from 'types/common/queryBuilder';
import {
GraphTitle,
KeyOperationTableHeader,
MetricsType,
WidgetKeys,
} from '../constant';
import { TopOperationQueryFactoryProps } from '../Tabs/types';
import { getQueryBuilderQuerieswithFormula } from './MetricsPageQueriesFactory';
export const topOperationQueries = ({
servicename,
}: TopOperationQueryFactoryProps): QueryBuilderData => {
const latencyAutoCompleteData: BaseAutocompleteData = {
key: WidgetKeys.SignozLatencyBucket,
dataType: DataTypes.Float64,
type: '',
};
const errorRateAutoCompleteData: BaseAutocompleteData = {
key: WidgetKeys.SignozCallsTotal,
dataType: DataTypes.Float64,
type: '',
};
const numOfCallAutoCompleteData: BaseAutocompleteData = {
key: WidgetKeys.SignozLatencyCount,
dataType: DataTypes.Float64,
type: '',
};
const latencyAndNumberOfCallAdditionalItems: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},
value: [servicename],
op: OPERATORS.IN,
},
];
const errorRateAdditionalItemsA: TagFilterItem[] = [
{
id: '',
key: {
dataType: DataTypes.String,
key: WidgetKeys.OTelServiceName,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
value: [servicename],
},
{
id: '',
key: {
dataType: DataTypes.Int64,
key: WidgetKeys.StatusCode,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: ['STATUS_CODE_ERROR'],
},
];
const errorRateAdditionalItemsB = latencyAndNumberOfCallAdditionalItems;
const groupBy: BaseAutocompleteData[] = [
{
dataType: DataTypes.String,
key: WidgetKeys.Operation,
type: MetricsType.Tag,
},
];
const autocompleteData = [
latencyAutoCompleteData,
latencyAutoCompleteData,
latencyAutoCompleteData,
errorRateAutoCompleteData,
errorRateAutoCompleteData,
numOfCallAutoCompleteData,
];
const additionalItems = [
latencyAndNumberOfCallAdditionalItems,
latencyAndNumberOfCallAdditionalItems,
latencyAndNumberOfCallAdditionalItems,
errorRateAdditionalItemsA,
errorRateAdditionalItemsB,
latencyAndNumberOfCallAdditionalItems,
];
const disabled = [false, false, false, true, true, false];
const legends = [
KeyOperationTableHeader.P50,
KeyOperationTableHeader.P90,
KeyOperationTableHeader.P99,
KeyOperationTableHeader.ERROR_RATE,
KeyOperationTableHeader.ERROR_RATE,
KeyOperationTableHeader.NUM_OF_CALLS,
];
const timeAggregateOperators = [
MetricAggregateOperator.EMPTY,
MetricAggregateOperator.EMPTY,
MetricAggregateOperator.EMPTY,
MetricAggregateOperator.RATE,
MetricAggregateOperator.RATE,
MetricAggregateOperator.RATE,
];
const spaceAggregateOperators = [
MetricAggregateOperator.P50,
MetricAggregateOperator.P90,
MetricAggregateOperator.P99,
MetricAggregateOperator.SUM,
MetricAggregateOperator.SUM,
MetricAggregateOperator.SUM,
];
const expressions = ['D*100/E'];
const legendFormulas = [GraphTitle.ERROR_PERCENTAGE];
const dataSource = DataSource.METRICS;
return getQueryBuilderQuerieswithFormula({
autocompleteData,
additionalItems,
disabled,
legends,
timeAggregateOperators,
spaceAggregateOperators,
expressions,
legendFormulas,
dataSource,
groupBy,
});
};

View File

@@ -0,0 +1,253 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch } from 'react-redux';
import { useAppLocation } from 'lib/router/useAppLocation';
import { useAppParams } from 'lib/router/useAppParams';
import { Col } from 'antd';
import logEvent from 'api/common/logEvent';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import Graph from 'container/WidgetCard/Card';
import {
databaseCallsAvgDuration,
databaseCallsRPS,
} from 'container/MetricsApplication/MetricsPageQueries/DBCallQueries';
import useResourceAttribute from 'hooks/useResourceAttribute';
import {
convertRawQueriesToTraceSelectedTags,
getResourceDeploymentKeys,
resourceAttributesToTagFilterItems,
} from 'hooks/useResourceAttribute/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import getStep from 'lib/getStep';
import { navigate } from 'lib/router/navigation';
import store from 'store';
import { UpdateTimeInterval } from 'store/actions';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import {
GraphTitle,
MENU_ITEMS,
SERVICE_CHART_ID,
SERVICE_DETAIL_DRILLDOWN_ENABLED,
} from '../constant';
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
import { Card, GraphContainer, Row } from '../styles';
import { Button } from './styles';
import {
dbSystemTags,
handleNonInQueryRange,
onViewTracePopupClick,
useGetAPMToTracesQueries,
useGraphClickHandler,
} from './util';
function DBCall(): JSX.Element {
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
const servicename = decodeURIComponent(encodedServiceName || '');
const [selectedTimeStamp, setSelectedTimeStamp] = useState<number>(0);
const { queries } = useResourceAttribute();
const urlQuery = useUrlQuery();
const { pathname } = useAppLocation();
const dispatch = useDispatch();
const onDragSelect = useCallback(
(start: number, end: number) => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
navigate(generatedUrl);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
},
[dispatch, pathname, urlQuery],
);
const tagFilterItems: TagFilterItem[] = useMemo(
() =>
handleNonInQueryRange(resourceAttributesToTagFilterItems(queries)) || [],
[queries],
);
const selectedTraceTags: string = useMemo(
() =>
JSON.stringify(
convertRawQueriesToTraceSelectedTags(queries).concat(...dbSystemTags) || [],
),
[queries],
);
const legend = '{{db.system}}';
const databaseCallsRPSWidget = useMemo(
() =>
getWidgetQueryBuilder({
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: databaseCallsRPS({
servicename,
legend,
tagFilterItems,
}),
clickhouse_sql: [],
id: uuid(),
},
title: GraphTitle.DATABASE_CALLS_RPS,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: 'reqps',
id: SERVICE_CHART_ID.dbCallsRPS,
fillSpans: false,
}),
[servicename, tagFilterItems, legend],
);
const databaseCallsAverageDurationWidget = useMemo(
() =>
getWidgetQueryBuilder({
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: databaseCallsAvgDuration({
servicename,
tagFilterItems,
}),
clickhouse_sql: [],
id: uuid(),
},
title: GraphTitle.DATABASE_CALLS_AVG_DURATION,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: 'ms',
id: GraphTitle.DATABASE_CALLS_AVG_DURATION,
fillSpans: true,
}),
[servicename, tagFilterItems],
);
const stepInterval = useMemo(
() =>
getStep({
end: store.getState().globalTime.maxTime,
inputFormat: 'ns',
start: store.getState().globalTime.minTime,
}),
[],
);
const logEventCalledRef = useRef(false);
useEffect(() => {
if (!logEventCalledRef.current) {
const selectedEnvironments = queries.find(
(val) => val.tagKey === getResourceDeploymentKeys(),
)?.tagValue;
logEvent('APM: Service detail page visited', {
selectedEnvironments,
resourceAttributeUsed: !!queries?.length,
section: 'dbMetrics',
});
logEventCalledRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const apmToTraceQuery = useGetAPMToTracesQueries({
servicename,
isDBCall: true,
});
const { safeNavigate } = useSafeNavigate();
const onGraphClickHandler = useGraphClickHandler(setSelectedTimeStamp);
return (
<Row gutter={24}>
<Col span={12}>
<Button
type="default"
size="small"
id="database_call_rps_button"
onClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
>
View Traces
</Button>
<Card data-testid="database_call_rps">
<GraphContainer>
<Graph
widget={databaseCallsRPSWidget}
onClickHandler={(xValue, yValue, mouseX, mouseY): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'database_call_rps',
);
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>
</Col>
<Col span={12}>
<Button
type="default"
size="small"
id="database_call_avg_duration_button"
onClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
>
View Traces
</Button>
<Card data-testid="database_call_avg_duration">
<GraphContainer>
<Graph
widget={databaseCallsAverageDurationWidget}
headerMenuList={MENU_ITEMS}
onClickHandler={(xValue, yValue, mouseX, mouseY): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'database_call_avg_duration',
);
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>
</Col>
</Row>
);
}
export default DBCall;

View File

@@ -0,0 +1,420 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch } from 'react-redux';
import { useAppLocation } from 'lib/router/useAppLocation';
import { useAppParams } from 'lib/router/useAppParams';
import { Col } from 'antd';
import logEvent from 'api/common/logEvent';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import Graph from 'container/WidgetCard/Card';
import {
externalCallDuration,
externalCallDurationByAddress,
externalCallErrorPercent,
externalCallRpsByAddress,
} from 'container/MetricsApplication/MetricsPageQueries/ExternalQueries';
import useResourceAttribute from 'hooks/useResourceAttribute';
import {
convertRawQueriesToTraceSelectedTags,
getResourceDeploymentKeys,
resourceAttributesToTagFilterItems,
} from 'hooks/useResourceAttribute/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import getStep from 'lib/getStep';
import { navigate } from 'lib/router/navigation';
import store from 'store';
import { UpdateTimeInterval } from 'store/actions';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import {
GraphTitle,
legend,
MENU_ITEMS,
SERVICE_DETAIL_DRILLDOWN_ENABLED,
} from '../constant';
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
import { Card, GraphContainer, Row } from '../styles';
import GraphControlsPanel from './Overview/GraphControlsPanel/GraphControlsPanel';
import {
handleNonInQueryRange,
onViewAPIMonitoringPopupClick,
onViewTracePopupClick,
useGetAPMToTracesQueries,
useGraphClickHandler,
} from './util';
function External(): JSX.Element {
const [selectedTimeStamp, setSelectedTimeStamp] = useState<number>(0);
const [selectedData, setSelectedData] = useState<any>(undefined);
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
const servicename = decodeURIComponent(encodedServiceName || '');
const { queries } = useResourceAttribute();
const urlQuery = useUrlQuery();
const { pathname } = useAppLocation();
const dispatch = useDispatch();
const onDragSelect = useCallback(
(start: number, end: number) => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
navigate(generatedUrl);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
},
[dispatch, pathname, urlQuery],
);
const tagFilterItems = useMemo(
() =>
handleNonInQueryRange(resourceAttributesToTagFilterItems(queries)) || [],
[queries],
);
const externalCallErrorWidget = useMemo(
() =>
getWidgetQueryBuilder({
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: externalCallErrorPercent({
servicename,
legend: legend.address,
tagFilterItems,
}),
clickhouse_sql: [],
id: uuid(),
},
title: GraphTitle.EXTERNAL_CALL_ERROR_PERCENTAGE,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: '%',
id: GraphTitle.EXTERNAL_CALL_ERROR_PERCENTAGE,
}),
[servicename, tagFilterItems],
);
const selectedTraceTags = useMemo(
() => JSON.stringify(convertRawQueriesToTraceSelectedTags(queries) || []),
[queries],
);
const externalCallDurationWidget = useMemo(
() =>
getWidgetQueryBuilder({
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: externalCallDuration({
servicename,
tagFilterItems,
}),
clickhouse_sql: [],
id: uuid(),
},
title: GraphTitle.EXTERNAL_CALL_DURATION,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: 'ms',
id: GraphTitle.EXTERNAL_CALL_DURATION,
fillSpans: true,
}),
[servicename, tagFilterItems],
);
const errorApmToTraceQuery = useGetAPMToTracesQueries({
servicename,
isExternalCall: true,
filters: [
{
id: uuid().slice(0, 8),
key: {
key: 'hasError',
dataType: DataTypes.bool,
type: 'tag',
id: 'hasError--bool--tag--true',
},
op: 'in',
value: ['true'],
},
],
});
const stepInterval = useMemo(
() =>
getStep({
end: store.getState().globalTime.maxTime,
inputFormat: 'ns',
start: store.getState().globalTime.minTime,
}),
[],
);
const logEventCalledRef = useRef(false);
useEffect(() => {
if (!logEventCalledRef.current) {
const selectedEnvironments = queries.find(
(val) => val.tagKey === getResourceDeploymentKeys(),
)?.tagValue;
logEvent('APM: Service detail page visited', {
selectedEnvironments,
resourceAttributeUsed: !!queries?.length,
section: 'externalMetrics',
});
logEventCalledRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const externalCallRPSWidget = useMemo(
() =>
getWidgetQueryBuilder({
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: externalCallRpsByAddress({
servicename,
legend: legend.address,
tagFilterItems,
}),
clickhouse_sql: [],
id: uuid(),
},
title: GraphTitle.EXTERNAL_CALL_RPS_BY_ADDRESS,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: 'reqps',
id: GraphTitle.EXTERNAL_CALL_RPS_BY_ADDRESS,
fillSpans: true,
}),
[servicename, tagFilterItems],
);
const externalCallDurationAddressWidget = useMemo(
() =>
getWidgetQueryBuilder({
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: externalCallDurationByAddress({
servicename,
legend: legend.address,
tagFilterItems,
}),
clickhouse_sql: [],
id: uuid(),
},
title: GraphTitle.EXTERNAL_CALL_DURATION_BY_ADDRESS,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: 'ms',
id: GraphTitle.EXTERNAL_CALL_DURATION_BY_ADDRESS,
fillSpans: true,
}),
[servicename, tagFilterItems],
);
const apmToTraceQuery = useGetAPMToTracesQueries({
servicename,
isExternalCall: true,
});
const { safeNavigate } = useSafeNavigate();
const onGraphClickHandler = useGraphClickHandler(
setSelectedTimeStamp,
setSelectedData,
);
return (
<>
<Row gutter={24}>
<Col span={12}>
<GraphControlsPanel
id="external_call_error_percentage_button"
onViewTracesClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery: errorApmToTraceQuery,
stepInterval,
safeNavigate,
})}
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
servicename,
timestamp: selectedTimeStamp,
domainName: selectedData?.address || '',
isError: true,
stepInterval: 300,
safeNavigate,
})}
/>
<Card data-testid="external_call_error_percentage">
<GraphContainer>
<Graph
headerMenuList={MENU_ITEMS}
widget={externalCallErrorWidget}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_error_percentage',
data,
);
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>
</Col>
<Col span={12}>
<GraphControlsPanel
id="external_call_duration_button"
onViewTracesClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
servicename,
timestamp: selectedTimeStamp,
domainName: selectedData?.address,
isError: false,
stepInterval: 300,
safeNavigate,
})}
/>
<Card data-testid="external_call_duration">
<GraphContainer>
<Graph
headerMenuList={MENU_ITEMS}
widget={externalCallDurationWidget}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_duration',
data,
);
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<GraphControlsPanel
id="external_call_rps_by_address_button"
onViewTracesClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
servicename,
timestamp: selectedTimeStamp,
domainName: selectedData?.address,
isError: false,
stepInterval: 300,
safeNavigate,
})}
/>
<Card data-testid="external_call_rps_by_address">
<GraphContainer>
<Graph
widget={externalCallRPSWidget}
headerMenuList={MENU_ITEMS}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): Promise<void> =>
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_rps_by_address',
data,
)
}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>
</Col>
<Col span={12}>
<GraphControlsPanel
id="external_call_duration_by_address_button"
onViewTracesClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
servicename,
timestamp: selectedTimeStamp,
domainName: selectedData?.address,
isError: false,
stepInterval: 300,
safeNavigate,
})}
/>
<Card data-testid="external_call_duration_by_address">
<GraphContainer>
<Graph
widget={externalCallDurationAddressWidget}
headerMenuList={MENU_ITEMS}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_duration_by_address',
data,
);
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>
</Col>
</Row>
</>
);
}
export default External;

View File

@@ -0,0 +1,412 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useAppLocation } from 'lib/router/useAppLocation';
import { useAppParams } from 'lib/router/useAppParams';
import logEvent from 'api/common/logEvent';
import getTopLevelOperations, {
ServiceDataProps,
} from 'api/metrics/getTopLevelOperations';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { routeConfig } from 'container/SideNav/config';
import { getQueryString } from 'container/SideNav/helper';
import useResourceAttribute from 'hooks/useResourceAttribute';
import {
convertRawQueriesToTraceSelectedTags,
getResourceDeploymentKeys,
resourceAttributesToTagFilterItems,
} from 'hooks/useResourceAttribute/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import getStep from 'lib/getStep';
import { navigate } from 'lib/router/navigation';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { defaultTo } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { GlobalReducer } from 'types/reducer/globalTime';
import { isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
import { secondsToMilliseconds } from 'utils/timeUtils';
import { v4 as uuid } from 'uuid';
import { GraphTitle, SERVICE_CHART_ID } from '../constant';
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
import {
errorPercentage,
operationPerSec,
} from '../MetricsPageQueries/OverviewQueries';
import { Col, ColApDexContainer, ColErrorContainer, Row } from '../styles';
import ApDex from './Overview/ApDex';
import GraphControlsPanel from './Overview/GraphControlsPanel/GraphControlsPanel';
import ServiceOverview from './Overview/ServiceOverview';
import TopLevelOperation from './Overview/TopLevelOperations';
import TopOperation from './Overview/TopOperation';
import TopOperationMetrics from './Overview/TopOperationMetrics';
import { Button, Card } from './styles';
import {
generateExplorerPath,
handleNonInQueryRange,
onViewTracePopupClick,
useGetAPMToLogsQueries,
useGetAPMToTracesQueries,
useGraphClickHandler,
} from './util';
function Application(): JSX.Element {
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
const servicename = decodeURIComponent(encodedServiceName || '');
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [selectedTimeStamp, setSelectedTimeStamp] = useState<number>(0);
const { search, pathname } = useAppLocation();
const { queries } = useResourceAttribute();
const urlQuery = useUrlQuery();
const { featureFlags } = useAppContext();
const isSpanMetricEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_SPAN_METRICS)
?.active || false;
const handleSetTimeStamp = useCallback((selectTime: number) => {
setSelectedTimeStamp(selectTime);
}, []);
const onGraphClickHandler = useGraphClickHandler(handleSetTimeStamp);
const dispatch = useDispatch();
const handleGraphClick = useCallback(
(type: string): OnClickPluginOpts['onClick'] =>
(xValue, yValue, mouseX, mouseY): Promise<void> =>
onGraphClickHandler(xValue, yValue, mouseX, mouseY, type),
// eslint-disable-next-line react-hooks/exhaustive-deps
[handleSetTimeStamp],
);
const logEventCalledRef = useRef(false);
useEffect(() => {
if (!logEventCalledRef.current) {
const selectedEnvironments = queries.find(
(val) => val.tagKey === getResourceDeploymentKeys(),
)?.tagValue;
logEvent('APM: Service detail page visited', {
selectedEnvironments,
resourceAttributeUsed: !!queries?.length,
section: 'overview',
});
logEventCalledRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const {
data: topLevelOperations,
error: topLevelOperationsError,
isLoading: topLevelOperationsIsLoading,
isError: topLevelOperationsIsError,
} = useQuery<ServiceDataProps>({
queryKey: [servicename, minTime, maxTime],
queryFn: (): Promise<ServiceDataProps> =>
getTopLevelOperations({
service: servicename || '',
start: minTime,
end: maxTime,
}),
// the time range is part of the key, so without this every window change blanks the
// operations list and the widgets below are rebuilt with an empty `operation in []`
keepPreviousData: true,
});
const selectedTraceTags: string = JSON.stringify(
convertRawQueriesToTraceSelectedTags(queries) || [],
);
const apmToTraceQuery = useGetAPMToTracesQueries({ servicename });
const tagFilterItems = useMemo(
() =>
handleNonInQueryRange(resourceAttributesToTagFilterItems(queries)) || [],
[queries],
);
const topLevelOperationsRoute = useMemo(
() =>
topLevelOperations
? defaultTo(topLevelOperations[servicename || ''], [])
: [],
[servicename, topLevelOperations],
);
const operationPerSecWidget = useMemo(
() =>
getWidgetQueryBuilder({
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: operationPerSec({
servicename,
tagFilterItems,
topLevelOperations: topLevelOperationsRoute,
}),
clickhouse_sql: [],
id: uuid(),
},
title: GraphTitle.RATE_PER_OPS,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: 'ops',
id: SERVICE_CHART_ID.rps,
}),
[servicename, tagFilterItems, topLevelOperationsRoute],
);
const errorPercentageWidget = useMemo(
() =>
getWidgetQueryBuilder({
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: errorPercentage({
servicename,
tagFilterItems,
topLevelOperations: topLevelOperationsRoute,
}),
clickhouse_sql: [],
id: uuid(),
},
title: GraphTitle.ERROR_PERCENTAGE,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: '%',
id: SERVICE_CHART_ID.errorPercentage,
fillSpans: true,
}),
[servicename, tagFilterItems, topLevelOperationsRoute],
);
const stepInterval = useMemo(
() =>
getStep({
end: maxTime,
inputFormat: 'ns',
start: minTime,
}),
[maxTime, minTime],
);
const onDragSelect = useCallback(
(start: number, end: number) => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
navigate(generatedUrl);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
},
[dispatch, pathname, urlQuery],
);
/**
*
* @param timestamp - The timestamp in seconds
* @param apmToTraceQuery - query object
* @param isViewLogsClicked - Whether this is for viewing logs vs traces
* @returns A callback function that handles the navigation when executed
*/
const onErrorTrackHandler = useCallback(
(
timestamp: number,
apmToTraceQuery: Query,
isViewLogsClicked?: boolean,
): ((e: React.MouseEvent) => void) =>
(e: React.MouseEvent): void => {
const endTime = secondsToMilliseconds(timestamp);
const startTime = secondsToMilliseconds(timestamp - stepInterval);
const urlParams = new URLSearchParams(search);
urlParams.set(QueryParams.startTime, startTime.toString());
urlParams.set(QueryParams.endTime, endTime.toString());
urlParams.delete(QueryParams.relativeTime);
const avialableParams = routeConfig[ROUTES.TRACES_EXPLORER];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(
JSON.stringify(apmToTraceQuery),
);
const newPath = generateExplorerPath(
isViewLogsClicked,
urlParams,
servicename,
selectedTraceTags,
JSONCompositeQuery,
queryString,
);
if (isModifierKeyPressed(e)) {
openInNewTab(newPath);
} else {
navigate(newPath);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[stepInterval],
);
const logErrorQuery = useGetAPMToLogsQueries({
servicename,
filters: [
{
id: uuid().slice(0, 8),
key: {
key: 'severity_text',
dataType: DataTypes.String,
type: '',
id: 'severity_text--string----true',
},
op: 'in',
value: ['ERROR', 'FATAL', 'error', 'fatal'],
},
],
});
const errorTrackQuery = useGetAPMToTracesQueries({
servicename,
filters: [
{
id: uuid().slice(0, 8),
key: {
key: 'hasError',
dataType: DataTypes.bool,
type: 'tag',
id: 'hasError--bool--tag--true',
},
op: 'in',
value: ['true'],
},
],
});
const { safeNavigate } = useSafeNavigate();
return (
<>
<Row gutter={24}>
<Col span={12}>
<ServiceOverview
onDragSelect={onDragSelect}
handleGraphClick={handleGraphClick}
selectedTimeStamp={selectedTimeStamp}
selectedTraceTags={selectedTraceTags}
topLevelOperationsRoute={topLevelOperationsRoute}
topLevelOperationsIsLoading={topLevelOperationsIsLoading}
stepInterval={stepInterval}
/>
</Col>
<Col span={12}>
<Button
type="default"
size="small"
id="Rate_button"
onClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
>
View Traces
</Button>
<TopLevelOperation
handleGraphClick={handleGraphClick}
onDragSelect={onDragSelect}
topLevelOperationsError={topLevelOperationsError}
topLevelOperationsIsError={topLevelOperationsIsError}
name="operations_per_sec"
widget={operationPerSecWidget}
opName="Rate"
topLevelOperationsIsLoading={topLevelOperationsIsLoading}
/>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<ColApDexContainer>
<Button
type="default"
size="small"
id="ApDex_button"
onClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
>
View Traces
</Button>
<ApDex
handleGraphClick={handleGraphClick}
onDragSelect={onDragSelect}
topLevelOperationsRoute={topLevelOperationsRoute}
tagFilterItems={tagFilterItems}
/>
</ColApDexContainer>
<ColErrorContainer>
<GraphControlsPanel
id="Error_button"
onViewLogsClick={onErrorTrackHandler(
selectedTimeStamp,
logErrorQuery,
true,
)}
onViewTracesClick={onErrorTrackHandler(
selectedTimeStamp,
errorTrackQuery,
)}
/>
<TopLevelOperation
handleGraphClick={handleGraphClick}
onDragSelect={onDragSelect}
topLevelOperationsError={topLevelOperationsError}
topLevelOperationsIsError={topLevelOperationsIsError}
name="error_percentage_%"
widget={errorPercentageWidget}
opName="Error"
topLevelOperationsIsLoading={topLevelOperationsIsLoading}
/>
</ColErrorContainer>
</Col>
<Col span={12}>
<Card>
{isSpanMetricEnabled ? <TopOperationMetrics /> : <TopOperation />}{' '}
</Card>
</Col>
</Row>
</>
);
}
export type ClickHandlerType = () => void;
export default Application;

View File

@@ -0,0 +1,109 @@
import { ReactNode, useMemo } from 'react';
import { useAppParams } from 'lib/router/useAppParams';
import { Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import TextToolTip from 'components/TextToolTip';
import {
apDexToolTipText,
apDexToolTipUrl,
apDexToolTipUrlText,
} from 'constants/apDex';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import Graph from 'container/WidgetCard/Card';
import DisplayThreshold from 'container/WidgetCard/Header/DisplayThreshold';
import {
GraphTitle,
SERVICE_CHART_ID,
SERVICE_DETAIL_DRILLDOWN_ENABLED,
} from 'container/MetricsApplication/constant';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { apDexMetricsQueryBuilderQueries } from 'container/MetricsApplication/MetricsPageQueries/OverviewQueries';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import { ApDexMetricsProps } from './types';
function ApDexMetrics({
delta,
metricsBuckets,
thresholdValue,
onDragSelect,
tagFilterItems,
topLevelOperationsRoute,
handleGraphClick,
}: ApDexMetricsProps): JSX.Element {
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
const servicename = decodeURIComponent(encodedServiceName || '');
const apDexMetricsWidget = useMemo(
() =>
getWidgetQueryBuilder({
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: apDexMetricsQueryBuilderQueries({
servicename,
tagFilterItems,
topLevelOperationsRoute,
threashold: thresholdValue || 0,
delta: delta || false,
metricsBuckets: metricsBuckets || [],
}),
clickhouse_sql: [],
id: uuid(),
},
title: (
<Space>
<Typography>{GraphTitle.APDEX}</Typography>
<TextToolTip
text={apDexToolTipText}
url={apDexToolTipUrl}
useFilledIcon={false}
urlText={apDexToolTipUrlText}
/>
</Space>
),
panelTypes: PANEL_TYPES.TIME_SERIES,
id: SERVICE_CHART_ID.apdex,
}),
[
delta,
metricsBuckets,
servicename,
tagFilterItems,
thresholdValue,
topLevelOperationsRoute,
],
);
const threshold: ReactNode = useMemo(() => {
if (thresholdValue) {
return <DisplayThreshold threshold={thresholdValue} />;
}
return null;
}, [thresholdValue]);
const isQueryEnabled =
topLevelOperationsRoute.length > 0 &&
!!metricsBuckets &&
metricsBuckets?.length > 0 &&
delta !== undefined;
return (
<Graph
widget={apDexMetricsWidget}
onDragSelect={onDragSelect}
onClickHandler={handleGraphClick('ApDex')}
threshold={threshold}
isQueryEnabled={isQueryEnabled}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
);
}
ApDexMetrics.defaultProps = {
delta: undefined,
le: undefined,
};
export default ApDexMetrics;

View File

@@ -0,0 +1,43 @@
import { useAppParams } from 'lib/router/useAppParams';
import Spinner from 'components/Spinner';
import { useGetMetricMeta } from 'hooks/apDex/useGetMetricMeta';
import useErrorNotification from 'hooks/useErrorNotification';
import { WidgetKeys } from '../../../constant';
import ApDexMetrics from './ApDexMetrics';
import { ApDexDataSwitcherProps } from './types';
function ApDexMetricsApplication({
handleGraphClick,
onDragSelect,
tagFilterItems,
thresholdValue,
topLevelOperationsRoute,
}: ApDexDataSwitcherProps): JSX.Element {
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
const servicename = decodeURIComponent(encodedServiceName || '');
const { data, isLoading, error } = useGetMetricMeta(
WidgetKeys.SignozLatencyBucket,
servicename,
);
useErrorNotification(error);
if (isLoading) {
return <Spinner height="40vh" tip="Loading..." />;
}
return (
<ApDexMetrics
topLevelOperationsRoute={topLevelOperationsRoute}
handleGraphClick={handleGraphClick}
delta={data?.data.delta}
metricsBuckets={data?.data.le || []}
onDragSelect={onDragSelect}
tagFilterItems={tagFilterItems}
thresholdValue={thresholdValue}
/>
);
}
export default ApDexMetricsApplication;

View File

@@ -0,0 +1,55 @@
import { memo, useEffect } from 'react';
import { useAppParams } from 'lib/router/useAppParams';
import Spinner from 'components/Spinner';
import { Card, GraphContainer } from 'container/MetricsApplication/styles';
import { useGetApDexSettings } from 'hooks/apDex/useGetApDexSettings';
import { useNotifications } from 'hooks/useNotifications';
import ApDexMetricsApplication from './ApDexMetricsApplication';
import { ApDexApplicationProps } from './types';
function ApDexApplication({
handleGraphClick,
onDragSelect,
topLevelOperationsRoute,
tagFilterItems,
}: ApDexApplicationProps): JSX.Element {
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
const servicename = decodeURIComponent(encodedServiceName || '');
const { notifications } = useNotifications();
const { data, isLoading, error, isRefetching } =
useGetApDexSettings(servicename);
useEffect(() => {
if (error) {
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
}
}, [error, notifications]);
if (isLoading || isRefetching) {
return (
<Card>
<Spinner height="40vh" tip="Loading..." />
</Card>
);
}
return (
<Card data-testid="apdex">
<GraphContainer>
<ApDexMetricsApplication
handleGraphClick={handleGraphClick}
onDragSelect={onDragSelect}
topLevelOperationsRoute={topLevelOperationsRoute}
tagFilterItems={tagFilterItems}
thresholdValue={data?.data[0].threshold}
/>
</GraphContainer>
</Card>
);
}
export default memo(ApDexApplication);

View File

@@ -0,0 +1,18 @@
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
export interface ApDexApplicationProps {
handleGraphClick: (type: string) => OnClickPluginOpts['onClick'];
onDragSelect: (start: number, end: number) => void;
topLevelOperationsRoute: string[];
tagFilterItems: TagFilterItem[];
}
export interface ApDexDataSwitcherProps extends ApDexApplicationProps {
thresholdValue?: number;
}
export interface ApDexMetricsProps extends ApDexDataSwitcherProps {
delta?: boolean;
metricsBuckets?: number[];
}

View File

@@ -0,0 +1,25 @@
.graph-controls-panel {
position: absolute;
z-index: 999;
display: none;
width: 150px;
padding: 5px;
border-radius: 5px;
background: var(--l2-background);
.ant-btn-link {
padding: 0;
margin: 0;
text-decoration: none;
color: var(--l1-foreground);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 2px;
border: none;
}
.ant-btn-link:hover {
color: var(--l1-foreground);
}
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { Color } from '@signozhq/design-tokens';
import { Button } from 'antd';
import { DraftingCompass, ScrollText, Binoculars } from '@signozhq/icons';
import './GraphControlsPanel.styles.scss';
interface GraphControlsPanelProps {
id: string;
onViewLogsClick?: (e: React.MouseEvent) => void;
onViewTracesClick: (e: React.MouseEvent) => void;
onViewAPIMonitoringClick?: (e: React.MouseEvent) => void;
}
function GraphControlsPanel({
id,
onViewLogsClick,
onViewTracesClick,
onViewAPIMonitoringClick,
}: GraphControlsPanelProps): JSX.Element {
return (
<div id={id} className="graph-controls-panel">
<Button
type="link"
icon={<DraftingCompass size={14} />}
size="small"
onClick={onViewTracesClick}
style={{ color: Color.BG_VANILLA_100 }}
>
View traces
</Button>
{onViewLogsClick && (
<Button
type="link"
icon={<ScrollText size={14} />}
size="small"
onClick={onViewLogsClick}
style={{ color: Color.BG_VANILLA_100 }}
>
View logs
</Button>
)}
{onViewAPIMonitoringClick && (
<Button
type="link"
icon={<Binoculars size={14} />}
size="small"
onClick={onViewAPIMonitoringClick}
style={{ color: Color.BG_VANILLA_100 }}
>
View External APIs
</Button>
)}
</div>
);
}
GraphControlsPanel.defaultProps = {
onViewLogsClick: undefined,
onViewAPIMonitoringClick: undefined,
};
export default GraphControlsPanel;

View File

@@ -0,0 +1,137 @@
import { useMemo } from 'react';
import { useAppParams } from 'lib/router/useAppParams';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { FeatureKeys } from 'constants/features';
import { PANEL_TYPES } from 'constants/queryBuilder';
import Graph from 'container/WidgetCard/Card';
import {
GraphTitle,
SERVICE_CHART_ID,
SERVICE_DETAIL_DRILLDOWN_ENABLED,
} from 'container/MetricsApplication/constant';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { latency } from 'container/MetricsApplication/MetricsPageQueries/OverviewQueries';
import { Card, GraphContainer } from 'container/MetricsApplication/styles';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { resourceAttributesToTagFilterItems } from 'hooks/useResourceAttribute/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { useAppContext } from 'providers/App/App';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import {
handleNonInQueryRange,
onViewTracePopupClick,
useGetAPMToLogsQueries,
useGetAPMToTracesQueries,
} from '../util';
import GraphControlsPanel from './GraphControlsPanel/GraphControlsPanel';
function ServiceOverview({
onDragSelect,
handleGraphClick,
selectedTraceTags,
selectedTimeStamp,
topLevelOperationsRoute,
topLevelOperationsIsLoading,
stepInterval,
}: ServiceOverviewProps): JSX.Element {
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
const servicename = decodeURIComponent(encodedServiceName || '');
const { featureFlags } = useAppContext();
const isSpanMetricEnable =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_SPAN_METRICS)
?.active || false;
const { queries } = useResourceAttribute();
const tagFilterItems = useMemo(
() =>
handleNonInQueryRange(
resourceAttributesToTagFilterItems(queries, !isSpanMetricEnable),
) || [],
[isSpanMetricEnable, queries],
);
const latencyWidget = useMemo(
() =>
getWidgetQueryBuilder({
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: latency({
servicename,
tagFilterItems,
isSpanMetricEnable,
topLevelOperationsRoute,
}),
clickhouse_sql: [],
id: uuid(),
},
title: GraphTitle.LATENCY,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: 'ns',
id: SERVICE_CHART_ID.latency,
}),
[isSpanMetricEnable, servicename, tagFilterItems, topLevelOperationsRoute],
);
const isQueryEnabled =
!topLevelOperationsIsLoading && topLevelOperationsRoute.length > 0;
const apmToTraceQuery = useGetAPMToTracesQueries({ servicename });
const apmToLogQuery = useGetAPMToLogsQueries({ servicename });
const { safeNavigate } = useSafeNavigate();
return (
<>
<GraphControlsPanel
id="Service_button"
onViewLogsClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery: apmToLogQuery,
isViewLogsClicked: true,
stepInterval,
safeNavigate,
})}
onViewTracesClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
/>
<Card data-testid="service_latency">
<GraphContainer>
<Graph
onDragSelect={onDragSelect}
widget={latencyWidget}
onClickHandler={handleGraphClick('Service')}
isQueryEnabled={isQueryEnabled}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>
</>
);
}
interface ServiceOverviewProps {
stepInterval: number;
selectedTimeStamp: number;
selectedTraceTags: string;
onDragSelect: (start: number, end: number) => void;
handleGraphClick: (type: string) => OnClickPluginOpts['onClick'];
topLevelOperationsRoute: string[];
topLevelOperationsIsLoading: boolean;
}
export default ServiceOverview;

View File

@@ -0,0 +1,70 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { navigateToTrace } from 'container/MetricsApplication/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { v4 as uuid } from 'uuid';
import { useGetAPMToTracesQueries } from '../../util';
function ColumnWithLink({
servicename,
minTime,
maxTime,
selectedTraceTags,
record,
}: LinkColumnProps): JSX.Element {
const text = record.toString();
const { safeNavigate } = useSafeNavigate();
const apmToTraceQuery = useGetAPMToTracesQueries({
servicename,
filters: [
{
id: uuid().slice(0, 8),
key: {
key: 'name',
dataType: DataTypes.String,
type: 'tag',
id: 'name--string--tag--true',
},
op: 'in',
value: [text],
},
],
});
const handleOnClick = (operation: string, openInNewTab: boolean): void => {
navigateToTrace({
servicename,
operation,
minTime,
maxTime,
selectedTraceTags,
apmToTraceQuery,
safeNavigate,
openInNewTab,
});
};
return (
<Tooltip placement="topLeft" title={text}>
<Typography.Link
onClick={(e): void => handleOnClick(text, e.metaKey || e.ctrlKey)}
>
{text}
</Typography.Link>
</Tooltip>
);
}
interface LinkColumnProps {
servicename: string;
minTime: number;
maxTime: number;
selectedTraceTags: string;
record: RowData;
}
export default ColumnWithLink;

View File

@@ -0,0 +1,11 @@
import { ReactNode } from 'react';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { TableRendererProps } from '../../types';
export const getTableColumnRenderer = ({
columnName,
renderFunction,
}: TableRendererProps): Record<string, (record: RowData) => ReactNode> => ({
[columnName]: renderFunction,
});

View File

@@ -0,0 +1,56 @@
import { Typography } from '@signozhq/ui/typography';
import axios from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { ENTITY_VERSION_V4 } from 'constants/app';
import Graph from 'container/WidgetCard/Card';
import { SERVICE_DETAIL_DRILLDOWN_ENABLED } from 'container/MetricsApplication/constant';
import { Card, GraphContainer } from 'container/MetricsApplication/styles';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { Widgets } from 'types/api/widgets/widget';
function TopLevelOperation({
name,
opName,
topLevelOperationsIsError,
topLevelOperationsError,
onDragSelect,
handleGraphClick,
widget,
topLevelOperationsIsLoading,
}: TopLevelOperationProps): JSX.Element {
return (
<Card data-testid={name}>
{topLevelOperationsIsError ? (
<Typography>
{axios.isAxiosError(topLevelOperationsError)
? topLevelOperationsError.response?.data
: SOMETHING_WENT_WRONG}
</Typography>
) : (
<GraphContainer>
<Graph
widget={widget}
onClickHandler={handleGraphClick(opName)}
onDragSelect={onDragSelect}
isQueryEnabled={!topLevelOperationsIsLoading}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
)}
</Card>
);
}
interface TopLevelOperationProps {
name: string;
opName: string;
topLevelOperationsIsError: boolean;
topLevelOperationsError: unknown;
onDragSelect: (start: number, end: number) => void;
handleGraphClick: (type: string) => OnClickPluginOpts['onClick'];
widget: Widgets;
topLevelOperationsIsLoading: boolean;
}
export default TopLevelOperation;

View File

@@ -0,0 +1,56 @@
import { useMemo, useState } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useAppParams } from 'lib/router/useAppParams';
import getTopOperations from 'api/metrics/getTopOperations';
import TopOperationsTable from 'container/MetricsApplication/TopOperationsTable';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
import { AppState } from 'store/reducers';
import { PayloadProps } from 'types/api/metrics/getTopOperations';
import { GlobalReducer } from 'types/reducer/globalTime';
import { Tags } from 'hooks/useResourceAttribute/types';
function TopOperation(): JSX.Element {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { servicename: encodedServiceName } = useAppParams<{
servicename?: string;
}>();
const servicename = decodeURIComponent(encodedServiceName || '');
const [isEntryPoint, setIsEntryPoint] = useState<boolean>(false);
const { queries } = useResourceAttribute();
const selectedTags = useMemo(
() => (convertRawQueriesToTraceSelectedTags(queries) as Tags[]) || [],
[queries],
);
const { data, isLoading } = useQuery<PayloadProps>({
queryKey: [minTime, maxTime, servicename, selectedTags, isEntryPoint],
queryFn: (): Promise<PayloadProps> =>
getTopOperations({
service: servicename || '',
start: minTime,
end: maxTime,
selectedTags,
isEntryPoint,
}),
});
const topOperationData = data || [];
return (
<TopOperationsTable
data={topOperationData}
isLoading={isLoading}
isEntryPoint={isEntryPoint}
onEntryPointToggle={setIsEntryPoint}
/>
);
}
export default TopOperation;

View File

@@ -0,0 +1,123 @@
import { ReactNode, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useAppParams } from 'lib/router/useAppParams';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { topOperationMetricsDownloadOptions } from 'container/MetricsApplication/constant';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { topOperationQueries } from 'container/MetricsApplication/MetricsPageQueries/TopOperationQueries';
import { QueryTable } from 'container/QueryTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useNotifications } from 'hooks/useNotifications';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { AppState } from 'store/reducers';
import { EQueryType } from 'types/common/dashboard';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuid } from 'uuid';
import { title } from './config';
import ColumnWithLink from './TableRenderer/ColumnWithLink';
import { getTableColumnRenderer } from './TableRenderer/TableColumnRenderer';
function TopOperationMetrics(): JSX.Element {
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
const servicename = decodeURIComponent(encodedServiceName || '');
const { notifications } = useNotifications();
const {
minTime,
maxTime,
selectedTime: globalSelectedInterval,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { queries } = useResourceAttribute();
const selectedTraceTags = JSON.stringify(
convertRawQueriesToTraceSelectedTags(queries) || [],
);
const keyOperationWidget = useMemo(
() =>
getWidgetQueryBuilder({
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: topOperationQueries({
servicename,
}),
clickhouse_sql: [],
id: uuid(),
},
panelTypes: PANEL_TYPES.TABLE,
}),
[servicename],
);
const updatedQuery = updateStepInterval(keyOperationWidget.query);
const isEmptyWidget = keyOperationWidget.id === PANEL_TYPES.EMPTY_WIDGET;
const { data, isLoading } = useGetQueryRange(
{
selectedTime: keyOperationWidget?.timePreferance,
graphType: keyOperationWidget?.panelTypes,
query: updatedQuery,
globalSelectedInterval,
variables: {},
},
ENTITY_VERSION_V4,
{
queryKey: [
`GetMetricsQueryRange-${keyOperationWidget?.timePreferance}-${globalSelectedInterval}-${keyOperationWidget?.id}`,
keyOperationWidget,
maxTime,
minTime,
globalSelectedInterval,
],
keepPreviousData: true,
enabled: !isEmptyWidget,
refetchOnMount: false,
onError: (error) => {
notifications.error({ message: error.message });
},
},
);
const queryTableData = data?.payload?.data?.newResult?.data?.result || [];
const renderColumnCell = useMemo(
() =>
getTableColumnRenderer({
columnName: 'operation',
renderFunction: (record: RowData): ReactNode => (
<ColumnWithLink
servicename={servicename}
minTime={minTime}
maxTime={maxTime}
selectedTraceTags={selectedTraceTags}
record={record}
/>
),
}),
[servicename, minTime, maxTime, selectedTraceTags],
);
return (
<QueryTable
title={title}
query={updatedQuery}
queryTableData={queryTableData}
loading={isLoading}
renderColumnCell={renderColumnCell}
downloadOption={topOperationMetricsDownloadOptions}
sticky
/>
);
}
export default TopOperationMetrics;

View File

@@ -0,0 +1 @@
export const title = (): string => 'Key Operations';

View File

@@ -1,3 +1,5 @@
import { ReactNode } from 'react';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
@@ -6,6 +8,19 @@ export interface IServiceName {
servicename: string;
}
export interface TopOperationQueryFactoryProps {
servicename: IServiceName['servicename'];
}
export interface ExternalCallDurationByAddressProps extends ExternalCallProps {
legend: string;
}
export interface ExternalCallProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
}
export interface BuilderQueriesProps {
autocompleteData: BaseAutocompleteData[];
groupBy?: BaseAutocompleteData[];
@@ -30,3 +45,33 @@ export interface BuilderQuerieswithFormulaProps {
spaceAggregateOperators: MetricAggregateOperator[];
dataSource: DataSource;
}
export interface OperationPerSecProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
topLevelOperations: string[];
}
export interface LatencyProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
isSpanMetricEnable?: boolean;
topLevelOperationsRoute: string[];
}
export interface ApDexProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
topLevelOperationsRoute: string[];
threashold: number;
}
export interface TableRendererProps {
columnName: string;
renderFunction: (record: RowData) => ReactNode;
}
export interface ApDexMetricsQueryBuilderQueriesProps extends ApDexProps {
delta: boolean;
metricsBuckets: number[];
}

View File

@@ -1,9 +1,171 @@
import { Dispatch, SetStateAction, useRef } from 'react';
import React, { Dispatch, SetStateAction, useMemo, useRef } from 'react';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { ENDPOINT_STATS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
import { routeConfig } from 'container/SideNav/config';
import { getQueryString } from 'container/SideNav/helper';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useClickOutside from 'hooks/useClickOutside';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { resourceAttributesToTracesFilterItems } from 'hooks/useResourceAttribute/utils';
import createQueryParams from 'lib/createQueryParams';
import { prepareQueryWithDefaultTimestamp } from 'pages/LogsExplorer/utils';
import { traceFilterKeys } from 'constants/traceFilterKeys';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { Tags } from 'hooks/useResourceAttribute/types';
import { isModifierKeyPressed } from 'utils/app';
import { secondsToMilliseconds } from 'utils/timeUtils';
import { v4 as uuid } from 'uuid';
export const dbSystemTags: Tags[] = [
{
Key: 'db.system.(string)',
StringValues: [''],
NumberValues: [],
BoolValues: [],
Operator: 'Exists',
},
];
interface OnViewTracePopupClickProps {
servicename: string | undefined;
selectedTraceTags: string;
timestamp: number;
apmToTraceQuery: Query;
isViewLogsClicked?: boolean;
stepInterval?: number;
safeNavigate: (url: string, options?: { newTab?: boolean }) => void;
}
interface OnViewAPIMonitoringPopupClickProps {
servicename: string;
timestamp: number;
stepInterval?: number;
domainName: string;
isError: boolean;
safeNavigate: (url: string, options?: { newTab?: boolean }) => void;
}
export function generateExplorerPath(
isViewLogsClicked: boolean | undefined,
urlParams: URLSearchParams,
servicename: string | undefined,
selectedTraceTags: string,
JSONCompositeQuery: string,
queryString: string[],
): string {
const basePath = isViewLogsClicked
? ROUTES.LOGS_EXPLORER
: ROUTES.TRACES_EXPLORER;
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}&${queryString.join('&')}`;
}
// TODO(@rahul-signoz): update the name of this function once we have view logs button in every panel
/**
* Handles click events for viewing trace/logs popup
* @param selectedTraceTags - Selected trace tags
* @param servicename - Name of the service
* @param timestamp - Timestamp in seconds
* @param apmToTraceQuery - Query object
* @param isViewLogsClicked - Whether this is for viewing logs vs traces
* @param stepInterval - Time interval in seconds
* @param safeNavigate - Navigation function
*/
export function onViewTracePopupClick({
selectedTraceTags,
servicename,
timestamp,
apmToTraceQuery,
isViewLogsClicked,
stepInterval,
safeNavigate,
}: OnViewTracePopupClickProps): (e?: React.MouseEvent) => void {
return (e?: React.MouseEvent): void => {
const endTime = secondsToMilliseconds(timestamp);
const startTime = secondsToMilliseconds(timestamp - (stepInterval || 60));
const urlParams = new URLSearchParams(window.location.search);
urlParams.set(QueryParams.startTime, startTime.toString());
urlParams.set(QueryParams.endTime, endTime.toString());
urlParams.delete(QueryParams.relativeTime);
const avialableParams = routeConfig[ROUTES.TRACES_EXPLORER];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(
JSON.stringify(apmToTraceQuery),
);
const newPath = generateExplorerPath(
isViewLogsClicked,
urlParams,
servicename,
selectedTraceTags,
JSONCompositeQuery,
queryString,
);
safeNavigate(newPath, { newTab: !!e && isModifierKeyPressed(e) });
};
}
const generateAPIMonitoringPath = (
domainName: string,
startTime: number,
endTime: number,
filterExpression: string,
): string => {
const basePath = ROUTES.API_MONITORING;
return `${basePath}?${createQueryParams({
apiMonitoringParams: JSON.stringify({
selectedDomain: domainName,
selectedView: 'endpoint_stats',
modalTimeRange: {
startTime,
endTime,
},
selectedInterval: 'custom',
}),
[ENDPOINT_STATS_EXPRESSION_KEY]: filterExpression,
})}`;
};
export function onViewAPIMonitoringPopupClick({
servicename,
timestamp,
domainName,
isError,
stepInterval,
safeNavigate,
}: OnViewAPIMonitoringPopupClickProps): (e?: React.MouseEvent) => void {
return (e?: React.MouseEvent): void => {
const endTime = timestamp + (stepInterval || 60);
const startTime = timestamp - (stepInterval || 60);
const filterExpression = [
...(isError ? ["hasError IN ['true']"] : []),
`service.name = ${formatValueForExpression(servicename)}`,
].join(' AND ');
const newPath = generateAPIMonitoringPath(
domainName,
startTime,
endTime,
filterExpression,
);
safeNavigate(newPath, { newTab: !!e && isModifierKeyPressed(e) });
};
}
export function useGraphClickHandler(
setSelectedTimeStamp: (n: number) => void | Dispatch<SetStateAction<number>>,
setSelectedData?: (data: any) => void | Dispatch<SetStateAction<any>>,
@@ -55,6 +217,17 @@ export function useGraphClickHandler(
};
}
export const handleNonInQueryRange = (tags: TagFilterItem[]): TagFilterItem[] =>
tags.map((tag) => {
if (tag.op === 'Not IN') {
return {
...tag,
op: 'NIN',
};
}
return tag;
});
export function handleQueryChange(
query: Query,
attributeKeys: BaseAutocompleteData,
@@ -85,3 +258,114 @@ export function handleQueryChange(
},
};
}
export function useGetAPMToLogsQueries({
servicename,
filters,
}: {
servicename: string;
filters?: TagFilterItem[];
}): Query {
const finalFilters: TagFilterItem[] = [];
const { updateAllQueriesOperators } = useQueryBuilder();
let updatedQuery = updateAllQueriesOperators(
initialQueriesMap.logs,
PANEL_TYPES.LIST,
DataSource.LOGS,
);
const serviceName = {
id: 'service.name--string--resource--true',
dataType: DataTypes.String,
key: 'service.name',
type: 'resource',
};
if (filters?.length) {
finalFilters.push(...filters);
}
updatedQuery = prepareQueryWithDefaultTimestamp(updatedQuery);
return handleQueryChange(
updatedQuery,
serviceName,
servicename,
finalFilters,
true,
);
}
export function useGetAPMToTracesQueries({
servicename,
isExternalCall,
isDBCall,
filters,
}: {
servicename: string;
isExternalCall?: boolean;
isDBCall?: boolean;
filters?: TagFilterItem[];
}): Query {
const { updateAllQueriesOperators } = useQueryBuilder();
const { queries } = useResourceAttribute();
const resourceAttributesFilters = useMemo(
() => resourceAttributesToTracesFilterItems(queries),
[queries],
);
const finalFilters: TagFilterItem[] = [];
let spanKindFilter: TagFilterItem;
let dbCallFilter: TagFilterItem;
if (isExternalCall) {
spanKindFilter = {
id: uuid().slice(0, 8),
key: {
key: 'spanKind',
dataType: DataTypes.String,
type: 'tag',
id: 'spanKind--string--tag--true',
},
op: '=',
value: 'Client',
};
finalFilters.push(spanKindFilter);
}
if (isDBCall) {
dbCallFilter = {
id: uuid().slice(0, 8),
key: {
key: 'dbSystem',
dataType: DataTypes.String,
type: 'tag',
id: 'dbSystem--string--tag--true',
},
op: 'exists',
value: '',
};
finalFilters.push(dbCallFilter);
}
if (filters?.length) {
finalFilters.push(...filters);
}
if (resourceAttributesFilters?.length) {
finalFilters.push(...resourceAttributesFilters);
}
return useMemo(() => {
const updatedQuery = updateAllQueriesOperators(
initialQueriesMap.traces,
PANEL_TYPES.TRACE,
DataSource.TRACES,
);
return handleQueryChange(
updatedQuery,
traceFilterKeys.serviceName,
servicename,
finalFilters,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [servicename, queries, updateAllQueriesOperators]);
}

View File

@@ -0,0 +1,24 @@
.top-operation {
position: relative;
&__controls {
display: flex;
justify-content: flex-end;
align-items: center;
position: absolute;
top: 15px;
right: 0px;
z-index: 1;
gap: 8px;
}
&__entry-point {
display: flex;
align-items: center;
gap: 6px;
}
&__download {
.ant-btn-icon {
margin: 0 !important;
}
}
}

View File

@@ -0,0 +1,264 @@
import { useRef } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useAppParams } from 'lib/router/useAppParams';
import { Search } from '@signozhq/icons';
import {
InputRef,
TableColumnsType as ColumnsType,
TableColumnType as ColumnType,
Tooltip,
} from 'antd';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { ResizeTable } from 'components/ResizeTable';
import TextToolTip from 'components/TextToolTip';
import Download from 'container/Download/Download';
import { filterDropdown } from 'container/ServiceApplication/Filter/FilterDropdown';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { AppState } from 'store/reducers';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuid } from 'uuid';
import { useGetAPMToTracesQueries } from './Tabs/util';
import {
convertedTracesToDownloadData,
getErrorRate,
navigateToTrace,
} from './utils';
import './TopOperationsTable.styles.scss';
function TopOperationsTable({
data,
isLoading,
isEntryPoint,
onEntryPointToggle,
}: TopOperationsTableProps): JSX.Element {
const searchInput = useRef<InputRef>(null);
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
const { safeNavigate } = useSafeNavigate();
const servicename = decodeURIComponent(encodedServiceName || '');
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { queries } = useResourceAttribute();
const selectedTraceTags: string = JSON.stringify(
convertRawQueriesToTraceSelectedTags(queries) || [],
);
const apmToTraceQuery = useGetAPMToTracesQueries({ servicename });
const params = useAppParams<'servicename'>();
const handleOnClick = (operation: string, openInNewTab: boolean): void => {
const { servicename: encodedServiceName } = params;
const servicename = decodeURIComponent(encodedServiceName || '');
const opFilters: TagFilterItem[] = [
{
id: uuid().slice(0, 8),
key: {
key: 'name',
dataType: DataTypes.String,
type: 'tag',
id: 'name--string--tag--true',
},
op: 'in',
value: [operation],
},
];
const preparedQuery: Query = {
...apmToTraceQuery,
builder: {
...apmToTraceQuery.builder,
queryData: apmToTraceQuery.builder.queryData?.map((item) => ({
...item,
filters: {
...item.filters,
items: [...(item.filters?.items || []), ...opFilters],
op: item.filters?.op || 'AND',
},
})),
},
};
navigateToTrace({
servicename,
operation,
minTime,
maxTime,
selectedTraceTags,
apmToTraceQuery: preparedQuery,
safeNavigate,
openInNewTab,
});
};
const getSearchOption = (): ColumnType<TopOperationList> => ({
filterDropdown,
filterIcon: <Search size="md" />,
onFilter: (value, record): boolean =>
record.name
.toString()
.toLowerCase()
.includes((value as string).toLowerCase()),
onFilterDropdownOpenChange: (visible): void => {
if (visible) {
setTimeout(() => searchInput.current?.select(), 100);
}
},
render: (text: string): JSX.Element => (
<Tooltip placement="topLeft" title={text}>
<Typography.Link
testId="top-operation-link"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
if (e.metaKey || e.ctrlKey) {
handleOnClick(text, true); // open in new tab
} else {
handleOnClick(text, false); // open in current tab
}
}}
>
{text}
</Typography.Link>
</Tooltip>
),
});
const columns: ColumnsType<TopOperationList> = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 100,
...getSearchOption(),
},
{
title: 'P50 (in ms)',
dataIndex: 'p50',
key: 'p50',
width: 50,
sorter: (a: TopOperationList, b: TopOperationList): number => a.p50 - b.p50,
render: (value: number): string => (value / 1_000_000).toFixed(2),
},
{
title: 'P95 (in ms)',
dataIndex: 'p95',
key: 'p95',
width: 50,
sorter: (a: TopOperationList, b: TopOperationList): number => a.p95 - b.p95,
render: (value: number): string => (value / 1_000_000).toFixed(2),
},
{
title: 'P99 (in ms)',
dataIndex: 'p99',
key: 'p99',
width: 50,
sorter: (a: TopOperationList, b: TopOperationList): number => a.p99 - b.p99,
render: (value: number): string => (value / 1_000_000).toFixed(2),
},
{
title: 'Number of Calls',
dataIndex: 'numCalls',
key: 'numCalls',
width: 50,
sorter: (a: TopOperationList, b: TopOperationList): number =>
a.numCalls - b.numCalls,
},
{
title: 'Error Rate',
dataIndex: 'errorCount',
key: 'errorCount',
width: 50,
sorter: (first: TopOperationList, second: TopOperationList): number =>
getErrorRate(first) - getErrorRate(second),
render: (_, record: TopOperationList): string =>
`${getErrorRate(record).toFixed(2)} %`,
},
];
const downloadableData = convertedTracesToDownloadData(data);
const paginationConfig = {
pageSize: 10,
showSizeChanger: false,
hideOnSinglePage: true,
};
const entryPointSpanInfo = {
text: 'Shows the spans where requests enter new services for the first time',
url: 'https://signoz.io/docs/apm-and-distributed-tracing/application-details/',
urlText: 'Learn more about Entrypoint Spans.',
};
return (
<div className="top-operation" data-testid="top-operations">
<div className="top-operation__controls">
<div className="top-operation__download">
<Download
data={downloadableData}
isLoading={isLoading}
fileName={`top-operations-${servicename}`}
/>
</div>
<div className="top-operation__entry-point">
<Switch
testId="top-operations-entrypoint-toggle"
aria-label="Entrypoint Spans"
color="primary"
textPlacement="right"
value={isEntryPoint}
onChange={onEntryPointToggle}
/>
<span className="top-operation__entry-point-label">Entrypoint Spans</span>
<TextToolTip
text={entryPointSpanInfo.text}
url={entryPointSpanInfo.url}
useFilledIcon={false}
urlText={entryPointSpanInfo.urlText}
/>
</div>
</div>
<ResizeTable
columns={columns}
loading={isLoading}
showHeader
title={(): string =>
isEntryPoint ? 'Key Entrypoint Operations' : 'Key Operations'
}
tableLayout="fixed"
dataSource={data}
rowKey="name"
pagination={paginationConfig}
/>
</div>
);
}
export interface TopOperationList {
p50: number;
p95: number;
p99: number;
numCalls: number;
name: string;
errorCount: number;
}
interface TopOperationsTableProps {
data: TopOperationList[];
isLoading: boolean;
isEntryPoint: boolean;
onEntryPointToggle: (checked: boolean) => void;
}
export default TopOperationsTable;

View File

@@ -0,0 +1,78 @@
import { QueryClient } from 'react-query';
import configureStore from 'redux-mock-store';
import { TopOperationList } from '../TopOperationsTable';
interface TopOperation {
numCalls: number;
errorCount: number;
}
export const getTopOperationList = ({
errorCount,
numCalls,
}: TopOperation): TopOperationList =>
({
p50: 0,
errorCount,
name: 'test',
numCalls,
p95: 0,
p99: 0,
}) as TopOperationList;
export const defaultApiCallExpectation = {
service: 'test-service',
start: 1640995200000,
end: 1641081600000,
selectedTags: [],
isEntryPoint: false,
};
export const mockStore = configureStore([]);
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
});
export const mockTopOperationsData: TopOperationList[] = [
{
name: 'GET /api/users',
p50: 1000000,
p95: 2000000,
p99: 3000000,
numCalls: 100,
errorCount: 5,
},
{
name: 'POST /api/orders',
p50: 1500000,
p95: 2500000,
p99: 3500000,
numCalls: 80,
errorCount: 2,
},
];
export const mockEntryPointData: TopOperationList[] = [
{
name: 'GET /api/health',
p50: 500000,
p95: 1000000,
p99: 1500000,
numCalls: 200,
errorCount: 0,
},
];
export const createMockStore = (): any =>
mockStore({
globalTime: {
minTime: 1640995200000,
maxTime: 1641081600000,
},
});

View File

@@ -0,0 +1,301 @@
import { QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { mockAppParams } from 'tests/router-mocks';
import {
createMockStore,
defaultApiCallExpectation,
mockEntryPointData,
mockTopOperationsData,
queryClient,
} from '../__mocks__/getTopOperation';
import TopOperation from '../Tabs/Overview/TopOperation';
// Mock dependencies
jest.mock('hooks/useResourceAttribute');
jest.mock('hooks/useResourceAttribute/utils', () => ({
convertRawQueriesToTraceSelectedTags: (): any[] => [],
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: jest.fn(),
}),
}));
mockAppParams({ servicename: encodeURIComponent('test-service') });
// Mock the util functions that TopOperationsTable uses
jest.mock('../Tabs/util', () => ({
useGetAPMToTracesQueries: (): any => ({
builder: {
queryData: [
{
filters: {
items: [],
},
},
],
},
}),
}));
// Mock the resourceAttributesToTracesFilterItems function
const mockedUseResourceAttribute = useResourceAttribute as jest.MockedFunction<
typeof useResourceAttribute
>;
// Constants
const KEY_OPERATIONS_TEXT = 'Key Operations';
const KEY_ENTRY_POINT_OPERATIONS_TEXT = 'Key Entrypoint Operations';
const ENTRY_POINT_SPANS_TEXT = 'Entrypoint Spans';
const TOP_OPERATIONS_ENDPOINT = 'top_operations';
const ENTRY_POINT_OPERATIONS_ENDPOINT = 'entry_point_operations';
const renderComponent = (store = createMockStore()): any =>
render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<TopOperation />
</QueryClientProvider>
</Provider>,
);
// Helper function to wait for initial render and verify basic functionality
const waitForInitialRender = async (): Promise<void> => {
await waitFor(() => {
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
});
};
// Helper function to click toggle and wait for data to load
const clickToggleAndWaitForDataLoad = async (): Promise<HTMLElement> => {
const toggleSwitch = screen.getByRole('switch');
act(() => {
fireEvent.click(toggleSwitch);
});
await waitFor(() => {
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
});
return toggleSwitch;
};
describe('TopOperation API Integration', () => {
let apiCalls: { endpoint: string; body: any }[] = [];
beforeEach(() => {
jest.clearAllMocks();
queryClient.clear();
apiCalls = [];
mockedUseResourceAttribute.mockReturnValue({
queries: [],
} as any);
server.use(
rest.post(
'http://localhost/api/v2/service/top_operations',
async (req, res, ctx) => {
const body = await req.json();
apiCalls.push({ endpoint: TOP_OPERATIONS_ENDPOINT, body });
return res(
ctx.status(200),
ctx.json({ status: 'success', data: mockTopOperationsData }),
);
},
),
rest.post(
'http://localhost/api/v2/service/entry_point_operations',
async (req, res, ctx) => {
const body = await req.json();
apiCalls.push({ endpoint: ENTRY_POINT_OPERATIONS_ENDPOINT, body });
return res(
ctx.status(200),
ctx.json({ status: 'success', data: mockEntryPointData }),
);
},
),
);
});
it('renders with default key operations on initial load', async () => {
renderComponent();
await waitForInitialRender();
// Verify the toggle is present and unchecked
const toggleSwitch = screen.getByRole('switch');
expect(toggleSwitch).not.toBeChecked();
expect(screen.getByText(ENTRY_POINT_SPANS_TEXT)).toBeInTheDocument();
});
it('calls top_operations API on initial render', async () => {
renderComponent();
await waitForInitialRender();
// Wait a bit more for API calls to be captured
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(0);
});
// Verify that only the top_operations endpoint was called
expect(apiCalls).toHaveLength(1);
expect(apiCalls[0].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
expect(apiCalls[0].body).toStrictEqual({
start: `${defaultApiCallExpectation.start}`,
end: `${defaultApiCallExpectation.end}`,
service: defaultApiCallExpectation.service,
tags: defaultApiCallExpectation.selectedTags,
limit: 5000,
});
});
it('calls entry_point_operations API when toggle is switched to entry point', async () => {
renderComponent();
// Wait for initial render
await waitForInitialRender();
// Wait for initial API call
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(0);
});
// Clear previous API calls
apiCalls = [];
// Toggle to entry point
await clickToggleAndWaitForDataLoad();
// Wait for the API call to be captured
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(0);
});
// Verify that the entry_point_operations endpoint was called
expect(apiCalls).toHaveLength(1);
expect(apiCalls[0].endpoint).toBe(ENTRY_POINT_OPERATIONS_ENDPOINT);
expect(apiCalls[0].body).toStrictEqual({
start: `${defaultApiCallExpectation.start}`,
end: `${defaultApiCallExpectation.end}`,
service: defaultApiCallExpectation.service,
tags: defaultApiCallExpectation.selectedTags,
limit: 5000,
});
});
it('switches to entry point operations when toggle is clicked', async () => {
renderComponent();
// Wait for initial render
await waitForInitialRender();
// Find and click the toggle switch
const toggleSwitch = screen.getByRole('switch');
expect(toggleSwitch).not.toBeChecked();
await clickToggleAndWaitForDataLoad();
// Check that the switch is now checked and title updates
expect(toggleSwitch).toBeChecked();
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
});
it('calls correct APIs when toggling multiple times', async () => {
renderComponent();
// Wait for initial render
await waitForInitialRender();
// Wait for initial API call
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(0);
});
// Should have called top_operations initially
expect(apiCalls).toHaveLength(1);
expect(apiCalls[0].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
// Toggle to entry point
await clickToggleAndWaitForDataLoad();
// Wait for the second API call
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(1);
});
// Should now have called entry_point_operations
expect(apiCalls).toHaveLength(2);
expect(apiCalls[1].endpoint).toBe(ENTRY_POINT_OPERATIONS_ENDPOINT);
// Toggle back to regular operations
const toggleSwitch = screen.getByRole('switch');
act(() => {
fireEvent.click(toggleSwitch);
});
await waitFor(() => {
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
});
// Wait for the third API call
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(2);
});
// Should have called top_operations again
expect(apiCalls).toHaveLength(3);
expect(apiCalls[2].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
expect(toggleSwitch).not.toBeChecked();
});
it('displays entry point toggle with correct label', async () => {
renderComponent();
await waitFor(() => {
expect(screen.getByText(ENTRY_POINT_SPANS_TEXT)).toBeInTheDocument();
});
const toggleSwitch = screen.getByRole('switch');
expect(toggleSwitch).toBeInTheDocument();
});
it('switches back to key operations when toggle is clicked twice', async () => {
renderComponent();
// Wait for initial render
await waitForInitialRender();
// Toggle on (to entry point)
await clickToggleAndWaitForDataLoad();
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
// Toggle off (back to key operations)
const toggleSwitch = screen.getByRole('switch');
act(() => {
fireEvent.click(toggleSwitch);
});
await waitFor(() => {
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
});
expect(toggleSwitch).not.toBeChecked();
});
});

View File

@@ -1,7 +1,69 @@
import { DownloadOptions } from 'container/Download/Download.types';
import { MenuItemKeys } from 'container/WidgetCard/Header/contants';
import {
MetricAggregateOperator,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
export const legend = {
address: '{{address}}',
};
export const QUERYNAME_AND_EXPRESSION = ['A', 'B', 'C'];
export const LATENCY_AGGREGATEOPERATOR = [
TracesAggregatorOperator.P50,
TracesAggregatorOperator.P90,
TracesAggregatorOperator.P99,
];
export const LATENCY_AGGREGATEOPERATOR_SPAN_METRICS = [
MetricAggregateOperator.P50,
MetricAggregateOperator.P90,
MetricAggregateOperator.P99,
];
export const OPERATION_LEGENDS = ['Operations'];
export const MENU_ITEMS = [MenuItemKeys.View, MenuItemKeys.CreateAlerts];
export const SERVICE_DETAIL_DRILLDOWN_ENABLED = true;
export enum FORMULA {
ERROR_PERCENTAGE = 'A*100/B',
DATABASE_CALLS_AVG_DURATION = 'A/B',
// The apdex formula is (satisfied_count + 0.5 * tolerating_count + 0 * frustating_count) / total_count
// The satisfied_count is B, tolerating_count is C, total_count is A
// But why do we have (B+C)/2 instead of B + C/2?
// The way we issue the query is latency <= threshold, which means we over count i.e
// query B => durationNano <= 500ms
// query C => durationNano <= 2000ms
// Since <= 2000ms includes <= 500ms, we over count, to correct we subtract B/2
// so the full expression would be (B + C/2) - B/2 = (B+C)/2
// However, if you add a filter on durationNano > 500ms, (filterItemC in overviewQueries) the query would be
// B + C/2
APDEX_TRACES = '((B + C)/2)/A',
// The delta span metrics store delta compared to previous reporting interval
// but not the counts for the current interval. The bucket counts are cumulative
APDEX_DELTA_SPAN_METRICS = '((B + C)/2)/A',
// Cumulative span metrics store the counts for all buckets
// so we need to subtract B/2 to correct the over counting
APDEX_CUMULATIVE_SPAN_METRICS = '((B + C)/2)/A',
}
export const TOP_LEVEL_OPERATIONS = ['{{.top_level_operations}}'];
export enum GraphTitle {
APDEX = 'Apdex',
LATENCY = 'Latency',
RATE_PER_OPS = 'Rate (ops/s)',
ERROR_PERCENTAGE = 'Error Percentage',
DATABASE_CALLS_RPS = 'Database Calls RPS',
DATABASE_CALLS_AVG_DURATION = 'Database Calls Avg Duration',
EXTERNAL_CALL_ERROR_PERCENTAGE = 'External Call Error Percentage',
EXTERNAL_CALL_DURATION = 'External Call duration',
EXTERNAL_CALL_RPS_BY_ADDRESS = 'External Call RPS(by Address)',
EXTERNAL_CALL_DURATION_BY_ADDRESS = 'External Call duration(by Address)',
}
export enum KeyOperationTableHeader {
P50 = 'P50',
P90 = 'P90',
@@ -39,3 +101,22 @@ export enum WidgetKeys {
SignozLatencyBucket = 'signoz_latency.bucket',
DbSystem = 'db.system',
}
export const topOperationMetricsDownloadOptions: DownloadOptions = {
isDownloadEnabled: true,
fileName: 'top-operation',
} as const;
export const SERVICE_CHART_ID = {
latency: 'SERVICE_OVERVIEW_LATENCY',
error: 'SERVICE_OVERVIEW_ERROR',
rps: 'SERVICE_OVERVIEW_RPS',
apdex: 'SERVICE_OVERVIEW_APDEX',
errorPercentage: 'SERVICE_OVERVIEW_ERROR_PERCENTAGE',
dbCallsRPS: 'SERVICE_DATABASE_CALLS_RPS',
dbCallsAvgDuration: 'SERVICE_DATABASE_CALLS_AVG_DURATION',
externalCallDurationByAddress: 'SERVICE_EXTERNAL_CALLS_DURATION_BY_ADDRESS',
externalCallErrorPercentage: 'SERVICE_EXTERNAL_CALLS_ERROR_PERCENTAGE',
externalCallDuration: 'SERVICE_EXTERNAL_CALLS_DURATION',
externalCallRPSByAddress: 'SERVICE_EXTERNAL_CALLS_RPS_BY_ADDRESS',
};

View File

@@ -0,0 +1,50 @@
import {
Card as CardComponent,
Col as ColComponent,
Row as RowComponent,
} from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styled from 'styled-components';
export const Card = styled(CardComponent)`
&&& {
height: 40vh;
overflow: hidden;
}
.ant-card-body {
height: 100%;
padding: 0;
}
`;
export const Row = styled(RowComponent)`
&&& {
padding: 1rem;
}
`;
export const Col = styled(ColComponent)`
display &&& {
position: relative;
}
`;
export const ColApDexContainer = styled(ColComponent)`
padding: 0 !important;
`;
export const ColErrorContainer = styled(ColComponent)`
padding: 2rem 0 !important;
`;
export const GraphContainer = styled.div`
min-height: calc(40vh - 40px);
height: 100%;
`;
export const GraphTitle = styled(Typography)`
&&& {
text-align: center;
}
`;

View File

@@ -1,5 +1,8 @@
import { ReactNode } from 'react';
import { Widgets } from 'types/api/widgets/widget';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { IServiceName } from './Tabs/types';
export interface GetWidgetQueryBuilderProps {
query: Widgets['query'];
@@ -10,3 +13,23 @@ export interface GetWidgetQueryBuilderProps {
fillSpans?: Widgets['fillSpans'];
columnUnits?: Widgets['columnUnits'];
}
export interface NavigateToTraceProps {
servicename: string;
operation: string;
minTime: number;
maxTime: number;
selectedTraceTags: string;
apmToTraceQuery: Query;
safeNavigate: (path: string) => void;
openInNewTab: boolean;
}
export interface DatabaseCallsRPSProps extends DatabaseCallProps {
legend: string;
}
export interface DatabaseCallProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
}

View File

@@ -0,0 +1,70 @@
import { getTopOperationList } from './__mocks__/getTopOperation';
import { TopOperationList } from './TopOperationsTable';
import {
convertedTracesToDownloadData,
getErrorRate,
getNearestHighestBucketValue,
} from './utils';
describe('Error Rate', () => {
it('should return correct error rate', () => {
const list: TopOperationList = getTopOperationList({
errorCount: 10,
numCalls: 100,
});
expect(getErrorRate(list)).toBe(10);
});
it('should handle no errors gracefully', () => {
const list = getTopOperationList({ errorCount: 0, numCalls: 100 });
expect(getErrorRate(list)).toBe(0);
});
it('should handle zero calls', () => {
const list = getTopOperationList({ errorCount: 0, numCalls: 0 });
expect(getErrorRate(list)).toBe(0);
});
});
describe('getNearestHighestBucketValue', () => {
it('should return nearest higher bucket value', () => {
expect(getNearestHighestBucketValue(50, [10, 20, 30, 40, 60, 70])).toBe('60');
});
it('should return +Inf for value higher than any bucket', () => {
expect(getNearestHighestBucketValue(80, [10, 20, 30, 40, 60, 70])).toBe(
'+Inf',
);
});
it('should return the first bucket for value lower than all buckets', () => {
expect(getNearestHighestBucketValue(5, [10, 20, 30, 40, 60, 70])).toBe('10');
});
});
describe('convertedTracesToDownloadData', () => {
it('should convert trace data correctly', () => {
const data = [
{
name: 'op1',
p50: 50000000,
p95: 95000000,
p99: 99000000,
numCalls: 100,
errorCount: 10,
},
];
expect(convertedTracesToDownloadData(data)).toStrictEqual([
{
Name: 'op1',
'P50 (in ms)': '50.00',
'P95 (in ms)': '95.00',
'P99 (in ms)': '99.00',
'Number of calls': '100',
'Error Rate (%)': '10.00',
},
]);
});
});

View File

@@ -0,0 +1,74 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { withBasePath } from 'utils/basePath';
import { TopOperationList } from './TopOperationsTable';
import { NavigateToTraceProps } from './types';
export const getErrorRate = (list: TopOperationList): number => {
if (list.errorCount === 0 && list.numCalls === 0) {
return 0;
}
return (list.errorCount / list.numCalls) * 100;
};
export const navigateToTrace = ({
servicename,
operation,
minTime,
maxTime,
selectedTraceTags,
apmToTraceQuery,
safeNavigate,
openInNewTab = false,
}: NavigateToTraceProps): void => {
const urlParams = new URLSearchParams();
urlParams.set(
QueryParams.startTime,
Math.floor(minTime / 1_000_000).toString(),
);
urlParams.set(QueryParams.endTime, Math.floor(maxTime / 1_000_000).toString());
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(apmToTraceQuery));
const newTraceExplorerPath = `${
ROUTES.TRACES_EXPLORER
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
if (openInNewTab) {
window.open(withBasePath(newTraceExplorerPath), '_blank');
} else {
safeNavigate(newTraceExplorerPath);
}
};
export const getNearestHighestBucketValue = (
value: number,
buckets: number[],
): string => {
// sort the buckets
buckets.sort((a, b) => a - b);
const nearestBucket = buckets.find((bucket) => bucket >= value);
return nearestBucket?.toString() || '+Inf';
};
export const convertMilSecToNanoSec = (value: number): number =>
value * 1000000000;
export const convertedTracesToDownloadData = (
originalData: TopOperationList[],
): Record<string, string>[] =>
originalData.map((item) => {
const newObj: Record<string, string> = {
Name: item.name,
'P50 (in ms)': (item.p50 / 1000000).toFixed(2),
'P95 (in ms)': (item.p95 / 1000000).toFixed(2),
'P99 (in ms)': (item.p99 / 1000000).toFixed(2),
'Number of calls': item.numCalls.toString(),
'Error Rate (%)': getErrorRate(item).toFixed(2),
};
return newObj;
});

View File

@@ -1,13 +0,0 @@
.errorState {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-8);
}
.loading {
display: flex;
justify-content: center;
padding: var(--spacing-16);
}

View File

@@ -1,65 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { Spinner } from '@signozhq/ui/spinner';
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
import { buildDashboardReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
import type { DashboardControlsInput } from 'pages/DashboardPage/DashboardContainer/controls/types';
import type { SystemDashboardHost } from './systemDashboardHost';
import { useSystemDashboard } from './useSystemDashboard';
import styles from './SystemDashboard.module.scss';
interface SystemDashboardProps {
host: SystemDashboardHost;
controls?: DashboardControlsInput;
}
/** A shipped, read-only system dashboard, embedded in a feature page. */
function SystemDashboard({
host,
controls,
}: SystemDashboardProps): JSX.Element {
const { dashboard, isLoading, isError, error, refetch } =
useSystemDashboard(host);
if (isLoading) {
return (
<div className={styles.loading}>
<Spinner size={24} aria-label="Loading dashboard" />
</div>
);
}
if (error?.response?.status === 403) {
return (
<div className={styles.errorState} data-testid="system-dashboard-forbidden">
<PermissionDeniedCallout
deniedPermissions={[buildDashboardReadPermission('*')]}
/>
</div>
);
}
if (isError || !dashboard) {
return (
<div className={styles.errorState} data-testid="system-dashboard-error">
<Typography.Title>Failed to load dashboard</Typography.Title>
<Typography.Text>
{error?.response?.data?.error?.message ?? error?.message}
</Typography.Text>
</div>
);
}
return (
<DashboardContainer
dashboard={dashboard}
refetch={refetch}
source={host.source}
readOnly
controls={controls}
/>
);
}
export default SystemDashboard;

View File

@@ -1,104 +0,0 @@
import {
DashboardtypesSourceDTO,
type GetSystemDashboard200,
} from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { render, screen } from 'tests/test-utils';
import SystemDashboard from '../SystemDashboard';
import { createSystemDashboardHost } from '../systemDashboardHost';
const HOST = createSystemDashboardHost('test-overview', 'test-overview-id');
const ENDPOINT = '*/api/v2/dashboards/system/test-overview';
jest.mock('pages/DashboardPage/DashboardContainer', () => ({
__esModule: true,
default: ({
dashboard,
readOnly,
}: {
dashboard: { id: string; spec: { display: { name: string } } };
readOnly: boolean;
}): JSX.Element => (
<div
data-testid="dashboard-container"
data-dashboard-id={dashboard.id}
data-read-only={String(readOnly)}
>
{dashboard.spec.display.name}
</div>
),
}));
const response: GetSystemDashboard200 = {
status: 'success',
data: {
orgId: 'org',
locked: true,
name: 'signoz---test-overview',
schemaVersion: 'v6',
source: DashboardtypesSourceDTO.system,
tags: [],
spec: {
display: { name: 'Test overview' },
variables: [],
panels: {},
layouts: [],
},
},
};
describe('SystemDashboard', () => {
afterEach(() => server.resetHandlers());
it('mounts the named system dashboard read-only under the host id', async () => {
server.use(rest.get(ENDPOINT, (_req, res, ctx) => res(ctx.json(response))));
render(<SystemDashboard host={HOST} />);
const container = await screen.findByTestId('dashboard-container');
expect(container).toHaveTextContent('Test overview');
expect(container).toHaveAttribute('data-dashboard-id', 'test-overview-id');
expect(container).toHaveAttribute('data-read-only', 'true');
});
it('tells a user without dashboard read access why it is missing', async () => {
server.use(
rest.get(ENDPOINT, (_req, res, ctx) =>
res(
ctx.status(403),
ctx.json({
status: 'error',
error: { code: 'forbidden', message: 'forbidden' },
}),
),
),
);
render(<SystemDashboard host={HOST} />);
await expect(
screen.findByTestId('system-dashboard-forbidden'),
).resolves.toHaveTextContent('is not authorized to perform read:dashboard:*');
});
it('shows why the dashboard failed to load', async () => {
server.use(
rest.get(ENDPOINT, (_req, res, ctx) =>
res(
ctx.status(404),
ctx.json({
status: 'error',
error: { code: 'not_found', message: 'not provisioned' },
}),
),
),
);
render(<SystemDashboard host={HOST} />);
await expect(
screen.findByTestId('system-dashboard-error'),
).resolves.toHaveTextContent('not provisioned');
});
});

View File

@@ -1,36 +0,0 @@
import { getGetSystemDashboardQueryKey } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
GetSystemDashboard200,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardQuerySource } from 'pages/DashboardPage/DashboardContainer/store/slices/editContextSlice';
export interface SystemDashboardHost {
/** Definition name without the `signoz---` prefix. */
name: string;
select: (
response: GetSystemDashboard200,
) => DashboardtypesGettableDashboardV2DTO;
source: DashboardQuerySource;
}
/**
* Call at module level: `select` and `source` must stay referentially stable.
* System dashboards carry no `id`; `id` keys the store's per-dashboard state
* (saved variable values), so keep it stable across releases.
*/
export function createSystemDashboardHost(
name: string,
id: string,
): SystemDashboardHost {
const select = (
response: GetSystemDashboard200,
): DashboardtypesGettableDashboardV2DTO =>
({ ...response.data, id }) as DashboardtypesGettableDashboardV2DTO;
return {
name,
select,
source: { queryKey: getGetSystemDashboardQueryKey({ name }), select },
};
}

View File

@@ -1,41 +0,0 @@
import { useCallback } from 'react';
import { useGetSystemDashboard } from 'api/generated/services/dashboard';
import type { GetSystemDashboardQueryError } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import type { SystemDashboardHost } from './systemDashboardHost';
export interface UseSystemDashboardResult {
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
isLoading: boolean;
isError: boolean;
error: GetSystemDashboardQueryError | null;
refetch: () => void;
}
export function useSystemDashboard(
host: SystemDashboardHost,
): UseSystemDashboardResult {
const {
data: dashboard,
isLoading,
isError,
error,
refetch,
} = useGetSystemDashboard(
{ name: host.name },
{
query: {
select: host.select,
staleTime: Infinity,
refetchOnMount: false,
},
},
);
const refetchDashboard = useCallback((): void => {
void refetch();
}, [refetch]);
return { dashboard, isLoading, isError, error, refetch: refetchDashboard };
}

View File

@@ -195,7 +195,6 @@ export const routesToSkip = [
ROUTES.NOT_FOUND,
ROUTES.METER_EXPLORER,
ROUTES.METER,
ROUTES.SERVICE_METRICS,
ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
ROUTES.SOMETHING_WENT_WRONG,
ROUTES.AI_OBSERVABILITY_OVERVIEW,

View File

@@ -0,0 +1,19 @@
import { SolidInfoCircle } from '@signozhq/icons';
import {
DisplayThresholdContainer,
TypographHeading,
Typography,
} from 'container/WidgetCard/Header/styles';
import { DisplayThresholdProps } from 'container/WidgetCard/Header/types';
function DisplayThreshold({ threshold }: DisplayThresholdProps): JSX.Element {
return (
<DisplayThresholdContainer>
<TypographHeading>Threshold </TypographHeading>
<Typography>{threshold || <SolidInfoCircle size="md" />}</Typography>
</DisplayThresholdContainer>
);
}
export default DisplayThreshold;

View File

@@ -0,0 +1,58 @@
import { grey } from '@ant-design/colors';
import { Typography as TypographyComponent } from '@signozhq/ui/typography';
import styled from 'styled-components';
export const HeaderContainer = styled.div<{ hover: boolean }>`
width: 100%;
text-align: center;
padding: 0.25rem 0;
font-size: 0.8rem;
position: absolute;
top: 0;
left: 0;
`;
export const HeaderContentContainer = styled.span`
cursor: pointer;
position: relative;
text-align: center;
`;
export const ThesholdContainer = styled.span`
margin-top: -0.3rem;
`;
export const DisplayThresholdContainer = styled.div`
display: flex;
align-items: center;
width: auto;
justify-content: space-between;
`;
export const WidgetHeaderContainer = styled.div`
display: flex;
align-items: center;
justify-content: flex-end;
align-items: center;
height: 30px;
width: 100%;
left: 0;
`;
export const ArrowContainer = styled.span<{ hover: boolean }>`
visibility: ${({ hover }): string => (hover ? 'visible' : 'hidden')};
`;
export const Typography = styled(TypographyComponent)`
&&& {
width: auto;
margin-left: 0.2rem;
color: ${grey[2]};
}
`;
export const TypographHeading = styled(TypographyComponent)`
&&& {
color: ${grey[2]};
}
`;

View File

@@ -1,3 +1,4 @@
import { ReactNode } from 'react';
import type { DropdownActionItemType } from '@signozhq/ui/dropdown';
import { MenuItemKeys } from 'container/WidgetCard/Header/contants';
@@ -6,3 +7,7 @@ export type MenuItem = DropdownActionItemType & {
value: MenuItemKeys;
isVisible: boolean;
};
export interface DisplayThresholdProps {
threshold: ReactNode;
}

View File

@@ -0,0 +1,32 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { back, getCurrentLocation } from 'lib/router/navigation';
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
// jest.config.ts maps `hooks/useSafeNavigate` to a mock; the `.ts` path loads the real hook.
const { useSafeNavigate } = jest.requireActual<
typeof import('hooks/useSafeNavigate')
>('hooks/useSafeNavigate.ts');
describe('useSafeNavigate', () => {
it('adds a history entry for replace: false even when the target adds params', async () => {
const initialSearch = `?${QueryParams.relativeTime}=15m`;
const { result } = renderHook(() => useSafeNavigate(), {
wrapper: ({ children }): JSX.Element => (
<AllTheProviders initialRoute={`${ROUTES.LOGS_EXPLORER}${initialSearch}`}>
{children}
</AllTheProviders>
),
});
act(() => {
result.current.safeNavigate(
`${ROUTES.LOGS_EXPLORER}?${QueryParams.startTime}=1000`,
{ replace: false },
);
});
act(() => back());
await waitFor(() => expect(getCurrentLocation().search).toBe(initialSearch));
});
});

View File

@@ -0,0 +1,13 @@
import { useQuery, UseQueryResult } from 'react-query';
import { getMetricMeta } from 'api/metrics/ApDex/getMetricMeta';
import { AxiosError, AxiosResponse } from 'axios';
import { MetricMetaProps } from 'types/api/metrics/getApDex';
export const useGetMetricMeta = (
metricName: string,
servicename: string,
): UseQueryResult<AxiosResponse<MetricMetaProps>, AxiosError> =>
useQuery<AxiosResponse<MetricMetaProps>, AxiosError>({
queryKey: [{ metricName, servicename }],
queryFn: async () => getMetricMeta(metricName, servicename),
});

View File

@@ -90,7 +90,7 @@ export const useHandleExplorerTabChange = (): {
},
redirectToUrl,
undefined,
newTab,
{ newTab },
);
},
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewKey],

View File

@@ -140,7 +140,7 @@ export const useSafeNavigate = (
const navigationOptions = {
...options,
replace: isDefaultParamsNavigation || options?.replace,
replace: options?.replace ?? isDefaultParamsNavigation,
};
if (typeof to === 'string') {

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { buildRoutePath } from 'lib/router/buildRoutePath';
import {
@@ -26,21 +26,19 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import AuthZDropdown from 'lib/authz/components/AuthZDropdown/AuthZDropdown';
import type {
AuthZDropdownItemType,
AuthZGroupChildType,
} from 'lib/authz/components/AuthZDropdown/types';
import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/types';
import { blockedBy } from 'lib/authz/components/AuthZDropdown/utils';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { DashboardCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import DashboardSettings from '../../DashboardSettings';
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
import SettingsDrawer from '../SettingsDrawer';
import styles from './DashboardActions.module.scss';
import { useDeleteDashboardAction } from './useDeleteDashboardAction';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
interface DashboardActionsProps {
title: string;
@@ -68,14 +66,13 @@ function DashboardActions({
deleteChecks,
deleteDisabledTooltip,
} = useDashboardEditContext();
const show = useDashboardControlGroup('dashboardActions');
const requestSettings = useDashboardStore((s) => s.requestSettings);
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const { disabledTooltip: lockDisabledTooltip } = useDashboardLockPermission({
dashboardId: dashboard.id,
source: dashboard.source,
enabled: show.lock,
});
// Cloning creates a new dashboard, so it needs `create` too, and no lock gate.
@@ -84,6 +81,8 @@ function DashboardActions({
[editChecks],
);
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState<boolean>(false);
const [isCloning, setIsCloning] = useState<boolean>(false);
const [isNewSectionOpen, setIsNewSectionOpen] = useState<boolean>(false);
@@ -95,6 +94,14 @@ function DashboardActions({
panelCount: Object.keys(dashboard.spec.panels).length,
});
// Open the settings drawer when something in the tree requests it (e.g. the
// variables bar's "Add variable" button).
useEffect(() => {
if (settingsRequest) {
setIsSettingsDrawerOpen(true);
}
}, [settingsRequest]);
const { addSection, isSaving: isAddingSection } = useAddSection({
layouts: dashboard.spec.layouts,
});
@@ -136,8 +143,8 @@ function DashboardActions({
void logEvent(DashboardDetailEvents.SettingsOpened, {
dashboardId: dashboard.id,
});
requestSettings({ tab: 'Overview' });
}, [dashboard.id, requestSettings]);
setIsSettingsDrawerOpen(true);
}, [dashboard.id]);
const handleOpenJsonEditor = useCallback((): void => {
void logEvent(DashboardDetailEvents.JsonEditorOpened, {
@@ -155,68 +162,61 @@ function DashboardActions({
void handle.enter();
}, [dashboard.id, handle]);
// Unavailable items stay in the menu, carrying the reason; hidden ones are dropped.
const menuItems = useMemo<AuthZDropdownItemType[]>(() => {
const dashboardItems: AuthZGroupChildType[] = [
...(show.rename
? [
{
type: 'item' as const,
value: 'rename',
testId: 'dashboard-action-rename',
label: 'Rename',
prefix: <PenLine size={14} />,
checks: editChecks,
...blockedBy(editDisabledTooltip),
onClick: onOpenRename,
},
]
: []),
// Clone creates a new dashboard, so it's not lock-gated.
...(show.clone
? [
{
type: 'item' as const,
value: 'clone',
testId: 'dashboard-action-clone',
label: 'Clone dashboard',
prefix: <Copy size={14} />,
checks: cloneChecks,
loading: isCloning,
loadingTooltip: 'Cloning dashboard',
onClick: (): void => void handleClone(),
},
]
: []),
...(show.lock
? [
{
type: 'item' as const,
value: 'lock',
testId: 'dashboard-action-lock',
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
prefix: <LockKeyhole size={14} />,
checks: editChecks,
...blockedBy(lockDisabledTooltip),
onClick: onLockToggle,
},
]
: []),
...(show.fullscreen
? [
{
type: 'item' as const,
value: 'fullscreen',
testId: 'dashboard-action-fullscreen',
label: 'Full screen',
prefix: <Fullscreen size={14} />,
onClick: handleEnterFullScreen,
},
]
: []),
];
const layoutItems: AuthZGroupChildType[] = show.newSection
? [
// Unavailable items stay in the menu, carrying the reason.
const menuItems = useMemo<AuthZDropdownItemType[]>(
() => [
{
type: 'group',
value: 'group-dashboard',
label: 'Dashboard',
items: [
{
type: 'item',
value: 'rename',
testId: 'dashboard-action-rename',
label: 'Rename',
prefix: <PenLine size={14} />,
checks: editChecks,
...blockedBy(editDisabledTooltip),
onClick: onOpenRename,
},
// Clone creates a new dashboard, so it's not lock-gated.
{
type: 'item',
value: 'clone',
testId: 'dashboard-action-clone',
label: 'Clone dashboard',
prefix: <Copy size={14} />,
checks: cloneChecks,
loading: isCloning,
loadingTooltip: 'Cloning dashboard',
onClick: (): void => void handleClone(),
},
{
type: 'item',
value: 'lock',
testId: 'dashboard-action-lock',
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
prefix: <LockKeyhole size={14} />,
checks: editChecks,
...blockedBy(lockDisabledTooltip),
onClick: onLockToggle,
},
{
type: 'item',
value: 'fullscreen',
testId: 'dashboard-action-fullscreen',
label: 'Full screen',
prefix: <Fullscreen size={14} />,
onClick: handleEnterFullScreen,
},
],
},
{
type: 'group',
value: 'group-layout',
label: 'Layout',
items: [
{
type: 'item',
value: 'new-section',
@@ -227,124 +227,101 @@ function DashboardActions({
...blockedBy(editDisabledTooltip),
onClick: (): void => setIsNewSectionOpen(true),
},
]
: [];
return [
...(dashboardItems.length
? [
{
type: 'group' as const,
value: 'group-dashboard',
label: 'Dashboard',
items: dashboardItems,
},
]
: []),
...(layoutItems.length
? [
{
type: 'group' as const,
value: 'group-layout',
label: 'Layout',
items: layoutItems,
},
]
: []),
...(show.delete
? [
{ type: 'separator' as const, value: 'divider-danger' },
{
type: 'item' as const,
value: 'delete',
testId: 'dashboard-action-delete',
label: 'Delete dashboard',
prefix: <Trash2 size={14} />,
danger: true,
checks: deleteChecks,
// Independent of read/update, but a locked dashboard can't be removed.
...blockedBy(deleteDisabledTooltip),
onClick: confirmDeleteDashboard,
},
]
: []),
];
}, [
show,
editChecks,
editDisabledTooltip,
deleteChecks,
deleteDisabledTooltip,
cloneChecks,
isCloning,
lockDisabledTooltip,
isDashboardLocked,
onOpenRename,
handleClone,
onLockToggle,
handleEnterFullScreen,
confirmDeleteDashboard,
]);
],
},
{ type: 'separator', value: 'divider-danger' },
{
type: 'item',
value: 'delete',
testId: 'dashboard-action-delete',
label: 'Delete dashboard',
prefix: <Trash2 size={14} />,
danger: true,
checks: deleteChecks,
// Independent of read/update, but a locked dashboard can't be removed.
...blockedBy(deleteDisabledTooltip),
onClick: confirmDeleteDashboard,
},
],
[
editChecks,
editDisabledTooltip,
deleteChecks,
deleteDisabledTooltip,
cloneChecks,
isCloning,
lockDisabledTooltip,
isDashboardLocked,
onOpenRename,
handleClone,
onLockToggle,
handleEnterFullScreen,
confirmDeleteDashboard,
],
);
return (
<div className={styles.dashboardActionsContainer}>
{menuItems.length > 0 && (
<AuthZDropdown items={menuItems} nativeButton align="end" side="bottom">
<Button
variant="solid"
color="secondary"
size="md"
prefix={<Grid3X3 size="md" />}
testId="options"
>
Actions
</Button>
</AuthZDropdown>
)}
{show.configure && (
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
disabled={!isEditable}
onClick={handleOpenSettings}
size="md"
>
Configure
</Button>
</AuthZTooltip>
)}
{show.json && (
<AuthZDropdown items={menuItems} nativeButton align="end" side="bottom">
<Button
variant="solid"
color="secondary"
prefix={<Braces size="md" />}
testId="edit-json"
onClick={handleOpenJsonEditor}
size="md"
prefix={<Grid3X3 size="md" />}
testId="options"
>
Actions
</Button>
</AuthZDropdown>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
disabled={!isEditable}
onClick={handleOpenSettings}
size="md"
>
JSON
Configure
</Button>
)}
{show.newPanel && (
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
disabled={!isEditable}
size="md"
>
New Panel
</Button>
</AuthZTooltip>
)}
</AuthZTooltip>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
destroyOnClose
onClose={(): void => {
setIsSettingsDrawerOpen(false);
clearSettingsRequest();
}}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
<Button
variant="solid"
color="secondary"
prefix={<Braces size="md" />}
testId="edit-json"
onClick={handleOpenJsonEditor}
size="md"
>
JSON
</Button>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
disabled={!isEditable}
size="md"
>
New Panel
</Button>
</AuthZTooltip>
<JsonEditorDrawer
dashboard={dashboard}
isOpen={isJsonEditorOpen}

View File

@@ -24,11 +24,9 @@ import { openInNewTab } from 'utils/navigation';
import styles from './DashboardInfo.module.scss';
import TagsOverflowTooltip from './TagsOverflowTooltip';
import { getLockTooltip } from './utils';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
// The tag cluster keeps a fixed footprint so a long title ellipsizes around it
// instead of collapsing the tags: show up to two tags, then a `+N` overflow badge.
@@ -77,19 +75,27 @@ function DashboardInfo({
}: DashboardInfoProps): JSX.Element {
const { isEditable: canEdit } = useDashboardEditContext();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const show = useDashboardControlGroup('title');
const hasTags = show.tags && tags.length > 0;
const hasDescription = show.description && !isEmpty(description);
const hasTags = tags.length > 0;
const hasDescription = !isEmpty(description);
const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS);
const remainingTags = tags.slice(MAX_VISIBLE_TAGS);
const lockTooltip = getLockTooltip({
isLocked: isDashboardLocked,
canToggle: !!onToggleLock,
disabledReason: lockDisabledTooltip,
});
// Without a toggle, say why it can't be toggled rather than only restating the
// lock state.
let lockTooltip: string;
if (onToggleLock) {
lockTooltip = isDashboardLocked
? 'Locked — click to unlock'
: 'Unlocked — click to lock';
} else if (lockDisabledTooltip) {
lockTooltip = lockDisabledTooltip;
} else {
lockTooltip = isDashboardLocked
? 'This dashboard is locked'
: 'This dashboard is unlocked';
}
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
@@ -122,68 +128,65 @@ function DashboardInfo({
return (
<div className={styles.dashboardInfo}>
{show.name && (
<img src={image} alt={title} className={styles.dashboardImage} />
)}
<img src={image} alt={title} className={styles.dashboardImage} />
{show.name &&
(isEditing ? (
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={DASHBOARD_NAME_MAX_LENGTH}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="solid"
color="primary"
size="sm"
icon
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
{isEditing ? (
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={DASHBOARD_NAME_MAX_LENGTH}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="solid"
color="primary"
size="sm"
icon
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
size="sm"
icon
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<>
<Tooltip title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
size="sm"
icon
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<>
<Tooltip title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</Tooltip>
<CopyButton
value={dashboardId}
ariaLabel="Copy dashboard ID"
testId="dashboard-copy-id"
onCopy={handleCopyId}
/>
</>
))}
{title}
</Typography.Text>
</Tooltip>
<CopyButton
value={dashboardId}
ariaLabel="Copy dashboard ID"
testId="dashboard-copy-id"
onCopy={handleCopyId}
/>
</>
)}
{hasDescription && (
<Tooltip
@@ -202,7 +205,7 @@ function DashboardInfo({
</Tooltip>
)}
{show.publicBadge && isPublicDashboard && (
{isPublicDashboard && (
<Tooltip title="This dashboard is publicly accessible. Click to open the public page.">
<Button
type="button"
@@ -219,7 +222,7 @@ function DashboardInfo({
</Tooltip>
)}
{show.lock && showLockToggle && (
{showLockToggle && (
<Tooltip title={onToggleLock ? lockTooltip : undefined}>
<Button
disabledTooltip={lockTooltip}

View File

@@ -1,19 +0,0 @@
// Without a toggle, say why it can't be toggled rather than only restating the
// lock state.
export function getLockTooltip({
isLocked,
canToggle,
disabledReason,
}: {
isLocked: boolean;
canToggle: boolean;
disabledReason?: string;
}): string {
if (canToggle) {
return isLocked ? 'Locked — click to unlock' : 'Unlocked — click to lock';
}
if (disabledReason) {
return disabledReason;
}
return isLocked ? 'This dashboard is locked' : 'This dashboard is unlocked';
}

View File

@@ -1,144 +0,0 @@
import type { FullScreenHandle } from 'react-full-screen';
import { useDashboardLockPermission } from 'hooks/dashboards/useDashboardLockPermission';
import { render, screen } from 'tests/test-utils';
import { dashboardResponse } from '../../../stories/__story_mockdata__/dashboard';
import type { DashboardControlsInput } from '../../controls/types';
import { usePublicDashboardMeta } from '../../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
import { useDashboardStore } from '../../store/useDashboardStore';
import DashboardPageToolbar from '../index';
const { data: dashboard } = dashboardResponse({
panels: 0,
sectioned: false,
variables: [],
locked: true,
});
const handle: FullScreenHandle = {
active: false,
enter: jest.fn(),
exit: jest.fn(),
node: { current: null },
};
jest.mock('../DashboardActions/DashboardActions', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="dashboard-actions" />,
}));
jest.mock('../DashboardInfo/DashboardInfo', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="dashboard-info" />,
}));
jest.mock('../../VariablesBar/VariablesBar', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="variables-bar" />,
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="time-selection" />,
}));
jest.mock(
'../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal',
() => ({ __esModule: true, default: (): null => null }),
);
jest.mock('../../hooks/useCreatePanel', () => ({
useCreatePanel: (): unknown => ({
isPickerOpen: false,
openPicker: jest.fn(),
closePicker: jest.fn(),
createPanel: jest.fn(),
targetLayoutIndex: 0,
}),
}));
jest.mock('../../hooks/useOptimisticPatch', () => ({
useOptimisticPatch: (): unknown => ({ patchAsync: jest.fn() }),
}));
jest.mock('hooks/dashboards/useToggleDashboardLock', () => ({
useToggleDashboardLock: (): unknown => ({ toggleLock: jest.fn() }),
}));
jest.mock('hooks/dashboards/useDashboardLockPermission', () => ({
useDashboardLockPermission: jest.fn(() => ({
canToggleLock: false,
isLoading: false,
disabledTooltip: '',
})),
}));
jest.mock(
'../../DashboardSettings/PublicDashboard/usePublicDashboardMeta',
() => ({
usePublicDashboardMeta: jest.fn(() => ({
isPublic: false,
publicMeta: undefined,
})),
}),
);
function renderToolbar(
ctx: { readOnly?: boolean; controls?: DashboardControlsInput } = {},
): void {
useDashboardStore
.getState()
.setEditContext({ dashboardId: dashboard.id, refetch: jest.fn(), ...ctx });
render(<DashboardPageToolbar dashboard={dashboard} handle={handle} />);
}
describe('DashboardPageToolbar - controls', () => {
afterEach(() => {
useDashboardStore
.getState()
.setEditContext({ dashboardId: '', refetch: jest.fn() });
jest.clearAllMocks();
});
it('renders every row and checks lock + share by default', () => {
renderToolbar();
expect(screen.getByTestId('dashboard-info')).toBeInTheDocument();
expect(screen.getByTestId('dashboard-actions')).toBeInTheDocument();
expect(screen.getByTestId('time-selection')).toBeInTheDocument();
expect(jest.mocked(useDashboardLockPermission)).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true }),
);
expect(jest.mocked(usePublicDashboardMeta)).toHaveBeenCalledWith(
dashboard.id,
{
enabled: true,
},
);
});
it('skips the lock permission check on a read-only mount', () => {
renderToolbar({ readOnly: true });
expect(jest.mocked(useDashboardLockPermission)).toHaveBeenCalledWith(
expect.objectContaining({ enabled: false }),
);
});
it('drops the title row and actions the host hides, and their requests', () => {
renderToolbar({
readOnly: true,
controls: { title: false, dashboardActions: false },
});
expect(screen.queryByTestId('dashboard-info')).not.toBeInTheDocument();
expect(screen.queryByTestId('dashboard-actions')).not.toBeInTheDocument();
expect(jest.mocked(usePublicDashboardMeta)).toHaveBeenCalledWith(
dashboard.id,
{
enabled: false,
},
);
});
it('hides the time range and keeps the variables bar mounted but hidden', () => {
renderToolbar({
controls: { toolbar: { timeRange: false, variables: false } },
});
expect(screen.queryByTestId('time-selection')).not.toBeInTheDocument();
expect(screen.getByTestId('variables-bar')).not.toBeVisible();
});
});

View File

@@ -17,7 +17,6 @@ import { getAbsoluteUrl } from 'utils/basePath';
import { useCreatePanel } from '../hooks/useCreatePanel';
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
import { useDashboardControlGroup } from '../controls/useDashboardControlGroup';
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import DashboardActions from './DashboardActions/DashboardActions';
@@ -57,11 +56,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
[dashboard.tags],
);
const showTitle = useDashboardControlGroup('title');
const showActions = useDashboardControlGroup('dashboardActions');
const showToolbar = useDashboardControlGroup('toolbar');
const hasTitleRow = Object.values(showTitle).some(Boolean);
const hasActions = Object.values(showActions).some(Boolean);
const { showErrorModal } = useErrorModal();
const { patchAsync } = useOptimisticPatch();
const {
@@ -77,13 +71,10 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
useDashboardLockPermission({
dashboardId: id,
source: dashboard.source,
enabled: showTitle.lock || showActions.lock,
});
// Public-sharing meta (deduped react-query read); drives the header globe.
const { isPublic, publicMeta } = usePublicDashboardMeta(id, {
enabled: showTitle.publicBadge,
});
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
// Shared with the list's row menu — it owns the API call, the toast and the
@@ -164,66 +155,48 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
return (
<section className={styles.dashboardPageToolbarContainer}>
{(hasTitleRow || hasActions) && (
<div className={styles.dashboardInfoWithActions}>
{hasTitleRow && (
<DashboardInfo
title={title}
image={image}
tags={tags}
description={description}
isPublicDashboard={isPublic}
publicUrl={publicUrl}
isDashboardLocked={isDashboardLocked}
showLockToggle={showLockToggle}
onToggleLock={
canToggleLock
? (): void => handleLockDashboardToggle('header')
: undefined
}
lockDisabledTooltip={lockDisabledTooltip}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}
onStartEdit={startEdit}
onCommit={commit}
onCancel={cancel}
/>
)}
{hasActions && (
<DashboardActions
title={title}
dashboard={dashboard}
handle={handle}
isDashboardLocked={isDashboardLocked}
onAddPanel={onAddPanel}
onLockToggle={(): void => handleLockDashboardToggle('menu')}
onOpenRename={startEdit}
/>
)}
</div>
)}
<div className={styles.dashboardInfoWithActions}>
<DashboardInfo
title={title}
image={image}
tags={tags}
description={description}
isPublicDashboard={isPublic}
publicUrl={publicUrl}
isDashboardLocked={isDashboardLocked}
showLockToggle={showLockToggle}
onToggleLock={
canToggleLock
? (): void => handleLockDashboardToggle('header')
: undefined
}
lockDisabledTooltip={lockDisabledTooltip}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}
onStartEdit={startEdit}
onCommit={commit}
onCancel={cancel}
/>
<DashboardActions
title={title}
dashboard={dashboard}
handle={handle}
isDashboardLocked={isDashboardLocked}
onAddPanel={onAddPanel}
onLockToggle={(): void => handleLockDashboardToggle('menu')}
onOpenRename={startEdit}
/>
</div>
{/* Row 2: the time selector floats top-right (declared first so the
variables bar's content wraps around it); the variables bar
collapses to one line and, when expanded, wraps full-width under it. */}
<div className={styles.toolbarRow}>
{showToolbar.timeRange && (
<div className={styles.timeCluster}>
<DateTimeSelectionV2
showAutoRefresh={showToolbar.autoRefresh}
hideShareModal
/>
</div>
)}
{showToolbar.variables ? (
<VariablesBar dashboard={dashboard} />
) : (
// Still mounted: the selectors seed and auto-select the values panels query with.
<div hidden>
<VariablesBar dashboard={dashboard} />
</div>
)}
<div className={styles.timeCluster}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<VariablesBar dashboard={dashboard} />
</div>
<PanelTypeSelectionModal
open={isPickerOpen}

View File

@@ -26,13 +26,9 @@ const PUBLIC_META_STALE_TIME = 5 * 60 * 1000;
*/
export function usePublicDashboardMeta(
dashboardId: string,
options?: { enabled?: boolean },
): UsePublicDashboardMetaReturn {
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const enabled =
(options?.enabled ?? true) &&
!!dashboardId &&
(isCloudUser || isEnterpriseSelfHostedUser);
const enabled = !!dashboardId && (isCloudUser || isEnterpriseSelfHostedUser);
const { data, isLoading, isFetching, error, refetch } = useGetPublicDashboard(
{ id: dashboardId },

View File

@@ -2,17 +2,33 @@ import { renderHook } from '@testing-library/react';
import { useContextLinkVariables } from '../useContextLinkVariables';
const mockUseDashboardVariableNames = jest.fn();
const mockUseGetDashboardV2 = jest.fn();
const mockUseQueryBuilder = jest.fn();
jest.mock(
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (
selector: (s: { dashboardId: string }) => unknown,
): unknown => selector({ dashboardId: 'dash-1' }),
}),
);
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => mockUseQueryBuilder(),
}));
jest.mock('api/generated/services/dashboard', () => ({
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
}));
// dtoToFormModel is exercised by its own suite; here we only need it to surface a name.
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters',
() => ({
useDashboardVariableNames: (): unknown => mockUseDashboardVariableNames(),
dtoToFormModel: (dto: { name: string }): { name: string } => ({
name: dto.name,
}),
}),
);
@@ -29,7 +45,11 @@ describe('useContextLinkVariables', () => {
},
},
});
mockUseDashboardVariableNames.mockReturnValue(['region', 'tier']);
mockUseGetDashboardV2.mockReturnValue({
data: {
data: { spec: { variables: [{ name: 'region' }, { name: 'tier' }] } },
},
});
});
it('orders globals, then _prefixed query fields (deduped), then dashboard vars', () => {
@@ -49,7 +69,9 @@ describe('useContextLinkVariables', () => {
mockUseQueryBuilder.mockReturnValue({
currentQuery: { builder: { queryData: [] } },
});
mockUseDashboardVariableNames.mockReturnValue([]);
// Loaded dashboard with no variables — useDashboardFetchRequired guarantees the
// dashboard is present, so the empty case is an empty spec, not `undefined`.
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
const { result } = renderHook(() => useContextLinkVariables());

View File

@@ -11,7 +11,6 @@ import { useDashboardStore } from '../../store/useDashboardStore';
import PanelTypeSelectionModal from '../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import styles from './DashboardEmptyState.module.scss';
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
import { useDashboardControl } from '../../controls/useDashboardControl';
interface DashboardEmptyStateProps {
canAddPanel: boolean;
@@ -25,11 +24,9 @@ function DashboardEmptyState({
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const requestSettings = useDashboardStore((s) => s.requestSettings);
const showConfigure = useDashboardControl('dashboardActions.configure');
const showNewPanel = useDashboardControl('dashboardActions.newPanel');
return (
<section className={styles.emptyState} data-testid="dashboard-empty-state">
<section className={styles.emptyState}>
<div className={styles.content}>
<div className={styles.heading}>
<img src={dashboardEmojiUrl} alt="" className={styles.emoji} />
@@ -42,66 +39,62 @@ function DashboardEmptyState({
</div>
<div className={styles.steps}>
{showConfigure && (
<div className={styles.step}>
<div className={styles.stepText}>
<Configure size={14} className={styles.stepIcon} />
<div className={styles.stepCopy}>
<Typography.Text className={styles.stepTitle}>
Configure your new dashboard
</Typography.Text>
<Typography.Text className={styles.stepInfo}>
Give it a name, add description, tags and variables
</Typography.Text>
</div>
<div className={styles.step}>
<div className={styles.stepText}>
<Configure size={14} className={styles.stepIcon} />
<div className={styles.stepCopy}>
<Typography.Text className={styles.stepTitle}>
Configure your new dashboard
</Typography.Text>
<Typography.Text className={styles.stepInfo}>
Give it a name, add description, tags and variables
</Typography.Text>
</div>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
size="md"
variant="solid"
color="secondary"
width="140px"
prefix={<Configure size="md" />}
disabled={!isEditable}
onClick={(): void => requestSettings({ tab: 'Overview' })}
testId="empty-configure"
>
Configure
</Button>
</AuthZTooltip>
</div>
)}
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
size="md"
variant="solid"
color="secondary"
width="140px"
prefix={<Configure size="md" />}
disabled={!isEditable}
onClick={(): void => requestSettings({ tab: 'Overview' })}
testId="empty-configure"
>
Configure
</Button>
</AuthZTooltip>
</div>
{showNewPanel && (
<div className={styles.step}>
<div className={styles.stepText}>
<img src={landscapeUrl} alt="" className={styles.stepIcon} />
<div className={styles.stepCopy}>
<Typography.Text className={styles.stepTitle}>
Add panels
</Typography.Text>
<Typography.Text className={styles.stepInfo}>
Add panels to visualize your data
</Typography.Text>
</div>
<div className={styles.step}>
<div className={styles.stepText}>
<img src={landscapeUrl} alt="" className={styles.stepIcon} />
<div className={styles.stepCopy}>
<Typography.Text className={styles.stepTitle}>
Add panels
</Typography.Text>
<Typography.Text className={styles.stepInfo}>
Add panels to visualize your data
</Typography.Text>
</div>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
size="md"
variant="solid"
color="primary"
prefix={<Plus size="md" />}
disabled={!canAddPanel}
onClick={(): void => openPicker()}
testId="add-panel"
>
New Panel
</Button>
</AuthZTooltip>
</div>
)}
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
size="md"
variant="solid"
color="primary"
prefix={<Plus size="md" />}
disabled={!canAddPanel}
onClick={(): void => openPicker()}
testId="add-panel"
>
New Panel
</Button>
</AuthZTooltip>
</div>
</div>
</div>
<PanelTypeSelectionModal

View File

@@ -10,7 +10,6 @@ import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import type { DashboardSection } from '../../../../utils';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelActionItems } from '../usePanelActionItems';
/** Keys of the disabled items, in order. */
@@ -495,43 +494,4 @@ describe('usePanelActionItems', () => {
click(actionRow(result.current.items, 'create-alert'));
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
});
describe('host controls', () => {
afterEach(() => {
useDashboardStore
.getState()
.setEditContext({ dashboardId: '', refetch: jest.fn() });
});
it('drops every writing action on a read-only mount', () => {
useDashboardStore.getState().setEditContext({
dashboardId: 'dash-1',
refetch: jest.fn(),
readOnly: true,
});
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'separator',
'download',
'create-alert',
]);
});
it('drops the actions the host hides', () => {
useDashboardStore.getState().setEditContext({
dashboardId: 'dash-1',
refetch: jest.fn(),
controls: { panelActions: { createAlert: false, delete: false } },
});
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).not.toContain('create-alert');
expect(itemKeys(result.current)).not.toContain('delete-panel');
expect(itemKeys(result.current)).toContain('edit-panel');
});
});
});

View File

@@ -29,7 +29,6 @@ import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/t
import { blockedBy } from 'lib/authz/components/AuthZDropdown/utils';
import { getBlockedPanelActions } from '../utils/getBlockedPanelActions';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
import { useDashboardControlGroup } from '../../../controls/useDashboardControlGroup';
// Stable fallback so renders without layout context don't churn the mutation
// hooks' deps (a fresh [] each render would re-create their callbacks).
@@ -67,7 +66,6 @@ export function usePanelActionItems({
const blocked = useMemo(() => getBlockedPanelActions(panel), [panel]);
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
const show = useDashboardControlGroup('panelActions');
const openPanelEditor = useOpenPanelEditor();
const createAlert = useCreateAlertFromPanel();
const { openView } = useViewPanel();
@@ -105,7 +103,7 @@ export function usePanelActionItems({
const editGate = { checks: editChecks, ...blockedBy(editDisabledTooltip) };
const panelGroup: AuthZDropdownItemType[] = [];
if (show.view && panelCapabilities.view) {
if (panelCapabilities.view) {
panelGroup.push({
type: 'item',
value: 'view-panel',
@@ -114,7 +112,7 @@ export function usePanelActionItems({
onClick: (): void => openView(panelId, panel),
});
}
if (show.edit && panelCapabilities.edit) {
if (panelCapabilities.edit) {
panelGroup.push({
type: 'item',
value: 'edit-panel',
@@ -124,7 +122,7 @@ export function usePanelActionItems({
onClick: (): void => openPanelEditor(panelId, { panel }),
});
}
if (show.clone && panelCapabilities.clone) {
if (panelCapabilities.clone) {
// Needs section context to place the copy; disabled without it.
panelGroup.push({
type: 'item',
@@ -144,13 +142,13 @@ export function usePanelActionItems({
}
const dataGroup: AuthZDropdownItemType[] = [];
if (show.download && downloadItem) {
if (downloadItem) {
dataGroup.push(downloadItem);
}
// Create Alerts opens a new tab and never mutates the dashboard, so —
// unlike edit/clone — it isn't gated on editability (V1 parity).
if (show.createAlert && panelCapabilities.createAlert) {
if (panelCapabilities.createAlert) {
dataGroup.push({
type: 'item',
value: 'create-alert',
@@ -161,9 +159,8 @@ export function usePanelActionItems({
});
}
const moveGroup: AuthZDropdownItemType[] = !show.move
? []
: isEditable && panelActions
const moveGroup: AuthZDropdownItemType[] =
isEditable && panelActions
? buildMoveItems({
sections,
currentLayoutIndex: panelActions.currentLayoutIndex,
@@ -180,19 +177,17 @@ export function usePanelActionItems({
},
];
const deleteGroup: AuthZDropdownItemType[] = !show.delete
? []
: [
{
type: 'item',
value: 'delete-panel',
label: 'Delete panel',
prefix: <Trash2 size={14} />,
danger: true,
...editGate,
onClick: (): void => requestDelete(),
},
];
const deleteGroup: AuthZDropdownItemType[] = [
{
type: 'item',
value: 'delete-panel',
label: 'Delete panel',
prefix: <Trash2 size={14} />,
danger: true,
...editGate,
onClick: (): void => requestDelete(),
},
];
return [panelGroup, dataGroup, moveGroup, deleteGroup]
.filter((group) => group.length > 0)
@@ -202,7 +197,6 @@ export function usePanelActionItems({
: [{ type: 'separator', value: `separator-${index}` }, ...group],
);
}, [
show,
isEditable,
editChecks,
editDisabledTooltip,

View File

@@ -14,7 +14,6 @@ import { EQueryType } from 'types/common/dashboard';
import styles from './ViewPanelModal.module.scss';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
import { useDashboardControl } from '../../../controls/useDashboardControl';
interface ViewPanelModalHeaderBaseProps {
onSwitchToEdit: () => void;
@@ -71,7 +70,6 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
editChecks,
editDisabledTooltip,
} = useDashboardEditContext();
const showSwitchToEdit = useDashboardControl('viewModal.switchToEdit');
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
const panelTypeItems = usePanelTypeSelectItems({
@@ -89,22 +87,20 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
onChange={onChangePanelKind}
/>
</div>
{showSwitchToEdit && (
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
size="md"
variant="outlined"
color="secondary"
prefix={<PenLine />}
disabled={!canSwitchToEdit}
onClick={onSwitchToEdit}
testId="view-panel-switch-to-edit"
>
Switch to Edit Mode
</Button>
</AuthZTooltip>
)}
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
size="md"
variant="outlined"
color="secondary"
prefix={<PenLine />}
disabled={!canSwitchToEdit}
onClick={onSwitchToEdit}
testId="view-panel-switch-to-edit"
>
Switch to Edit Mode
</Button>
</AuthZTooltip>
{query && (
<Button
size="md"

View File

@@ -1,71 +0,0 @@
import { render, screen } from 'tests/test-utils';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import ViewPanelModalHeader from '../ViewPanelModalHeader';
import type { DashboardControlsInput } from '../../../../controls/types';
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
() => ({
useDashboardEditContext: (): unknown => ({
isEditable: true,
editChecks: [],
editDisabledTooltip: '',
}),
}),
);
jest.mock(
'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems',
() => ({ usePanelTypeSelectItems: (): unknown[] => [] }),
);
function renderHeader(
readOnly: boolean,
controls?: DashboardControlsInput,
): void {
useDashboardStore.getState().setEditContext({
dashboardId: 'dash-1',
refetch: jest.fn(),
readOnly,
controls,
});
render(
<ViewPanelModalHeader
mode="static"
panelKind="signoz/TimeSeriesPanel"
onChangePanelKind={jest.fn()}
onSwitchToEdit={jest.fn()}
/>,
);
}
describe('ViewPanelModalHeader - controls', () => {
afterEach(() => {
useDashboardStore
.getState()
.setEditContext({ dashboardId: '', refetch: jest.fn() });
});
it('offers Switch to Edit Mode on an editable mount', () => {
renderHeader(false);
expect(screen.getByTestId('view-panel-switch-to-edit')).toBeInTheDocument();
});
it('hides Switch to Edit Mode on a read-only mount', () => {
renderHeader(true);
expect(screen.getByTestId('view-panel-type-selector')).toBeInTheDocument();
expect(
screen.queryByTestId('view-panel-switch-to-edit'),
).not.toBeInTheDocument();
});
it('hides Switch to Edit Mode when the host hides it', () => {
renderHeader(false, { viewModal: false });
expect(
screen.queryByTestId('view-panel-switch-to-edit'),
).not.toBeInTheDocument();
});
});

View File

@@ -163,15 +163,6 @@ describe('useDrilldown', () => {
expect(result.current.enableDrillDown).toBe(false);
});
it('is false when every builder query is disabled, leaving only formulas to draw', () => {
mockGetBuilderQueries.mockReturnValue([
{ name: 'A', disabled: true },
{ name: 'B', disabled: true },
]);
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
expect(result.current.enableDrillDown).toBe(false);
});
it.each([EQueryType.PROM, EQueryType.CLICKHOUSE, undefined])(
'is false when the query type is %s (not the builder)',
(queryType) => {

View File

@@ -18,29 +18,17 @@ let mockVariables: Array<{
let mockSelectionMap: Record<string, { value: unknown; allSelected: boolean }> =
{};
let mockIsEditable = true;
const mockSetVariableValue = jest.fn();
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
const DYNAMIC_KIND = 'signoz/DynamicVariable';
const QUERY_KIND = 'signoz/QueryVariable';
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired',
() => ({
useDashboardFetchRequired: (): unknown => ({
dashboard: { spec: { variables: mockVariables } },
variables: mockVariables,
refetch: jest.fn(),
}),
jest.mock('api/generated/services/dashboard', () => ({
useGetDashboardV2: (): unknown => ({
data: { data: { spec: { variables: mockVariables } } },
}),
);
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
() => ({
useDashboardEditContext: (): unknown => ({ isEditable: mockIsEditable }),
}),
);
}));
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useOptimisticPatch',
() => ({
@@ -109,7 +97,6 @@ function renderItems(): void {
describe('useDrilldownDashboardVariables', () => {
beforeEach(() => {
mockIsEditable = true;
jest.clearAllMocks();
mockVariables = [];
mockSelectionMap = {};
@@ -184,17 +171,6 @@ describe('useDrilldownDashboardVariables', () => {
});
});
it('offers nothing to create on a dashboard the viewer cannot edit', () => {
mockVariables = [];
mockIsEditable = false;
const { result } = renderHook(() =>
useDrilldownDashboardVariables({ filters, onClose: jest.fn() }),
);
expect(result.current.actions).toStrictEqual([]);
expect(result.current.hasFieldVariables).toBe(false);
});
it('offers Create and persists a new dynamic variable when none matches', async () => {
mockVariables = [];
renderItems();

View File

@@ -1,51 +0,0 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { AllTheProviders, renderHook } from 'tests/test-utils';
import { CHARTS_ONLY } from '../../../../controls/presets';
import type { DashboardControlsInput } from '../../../../controls/types';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelInteractions } from '../usePanelInteractions';
function dragSelect(controls?: DashboardControlsInput): void {
useDashboardStore
.getState()
.setEditContext({ dashboardId: 'd1', refetch: jest.fn(), controls });
const { result } = renderHook(() => usePanelInteractions(), {
wrapper: AllTheProviders,
});
result.current.onDragSelect(1_000, 2_000);
}
describe('usePanelInteractions drag-to-zoom', () => {
afterEach(() => {
useDashboardStore
.getState()
.setEditContext({ dashboardId: '', refetch: jest.fn() });
});
it('writes the dragged range to the URL', () => {
dragSelect();
expect(safeNavigateMock).toHaveBeenCalledWith(
expect.stringContaining('startTime=1000&endTime=2000'),
);
});
it('does nothing when the host turns zoom off', () => {
dragSelect({ panelActions: { zoom: false } });
expect(safeNavigateMock).not.toHaveBeenCalled();
});
it('still zooms when the host hides the toolbar but leaves zoom on', () => {
dragSelect({ toolbar: false });
expect(safeNavigateMock).toHaveBeenCalled();
});
it('still zooms on a charts-only mount', () => {
dragSelect(CHARTS_ONLY);
expect(safeNavigateMock).toHaveBeenCalled();
});
});

View File

@@ -33,7 +33,6 @@ import { useDrilldownCoordinates } from './useDrilldownCoordinates';
import { useDrilldownDashboardVariables } from './useDrilldownDashboardVariables';
import { useDrilldownFilter } from './useDrilldownFilter';
import { useResolvedDrilldownQuery } from './useResolvedDrilldownQuery';
import { useDashboardControl } from 'pages/DashboardPage/DashboardContainer/controls/useDashboardControl';
import { useViewPanel } from './useViewPanel';
/** Which menu the popover shows; extend as submenus are added (e.g. dashboard variables). */
@@ -84,16 +83,13 @@ export function useDrilldown(
const queries = panel.spec.queries;
// Drilldown only for builder-authored panels with a query to drill into (PromQL /
// ClickHouse have no builder context to refine). A panel whose builder queries
// are all disabled draws only formulas, which have no series to drill into.
const showDrilldown = useDashboardControl('panelActions.drilldown');
// ClickHouse have no builder context to refine).
const enableDrillDown = useMemo(
() =>
showDrilldown &&
getPanelDefinition(kind).actions.drilldown &&
getPanelQueryType(panel) === EQueryType.QUERY_BUILDER &&
getBuilderQueries(queries).some((query) => !query.disabled),
[showDrilldown, kind, panel, queries],
getBuilderQueries(queries).length > 0,
[kind, panel, queries],
);
const v1Query = useMemo(

View File

@@ -12,7 +12,6 @@ import {
type VariableFormModel,
} from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableFormModel';
import { buildVariablesPatch } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/utils/variablePatchOps';
import { useDashboardEditContext } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext';
import { useDashboardFetchRequired } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired';
import { useOptimisticPatch } from 'pages/DashboardPage/DashboardContainer/hooks/useOptimisticPatch';
import { selectVariableValues } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
@@ -45,7 +44,7 @@ export interface DrilldownVariableAction {
}
export interface UseDrilldownDashboardVariablesApi {
/** Whether the submenu has any entry to offer (gates the base-menu entry). */
/** Whether the clicked point exposes any field to bind to a variable (gates the base-menu entry). */
hasFieldVariables: boolean;
/** Resolved menu entries, one per group-by field on the clicked point. */
actions: DrilldownVariableAction[];
@@ -75,8 +74,6 @@ export function useDrilldownDashboardVariables({
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValue = useDashboardStore((state) => state.setVariableValue);
const { patchAsync } = useOptimisticPatch();
// Create writes the dashboard spec; Set/Unset only change this view's selection.
const { isEditable } = useDashboardEditContext();
const fieldVariables = useMemo<[string, string | number][]>(
() =>
@@ -140,57 +137,51 @@ export function useDrilldownDashboardVariables({
const actions = useMemo<DrilldownVariableAction[]>(
() =>
fieldVariables
.map(([fieldName, fieldValue]) => {
const existing = dynamicVariables.find(
(v) => v.dynamicAttribute === fieldName,
);
if (!existing) {
if (!isEditable) {
return null;
}
return {
fieldName,
fieldValue,
kind: DrilldownVariableActionKind.Create,
onClick: (): void => {
void handleCreate(fieldName, fieldValue);
},
};
}
const current = selection[existing.name]?.value;
const isSame = Array.isArray(current)
? current.length === 1 && current[0] === fieldValue
: current === fieldValue;
// Multi-select values must be arrays (a scalar renders as empty in the selector).
const cleared = existing.multiSelect ? [] : '';
const assigned = existing.multiSelect ? [fieldValue] : fieldValue;
fieldVariables.map(([fieldName, fieldValue]) => {
const existing = dynamicVariables.find(
(v) => v.dynamicAttribute === fieldName,
);
if (!existing) {
return {
fieldName,
fieldValue,
kind: isSame
? DrilldownVariableActionKind.Unset
: DrilldownVariableActionKind.Set,
kind: DrilldownVariableActionKind.Create,
onClick: (): void => {
void logEvent(DashboardDetailEvents.DrilldownAction, {
action: 'setVariable',
dashboardId,
});
setSelection(existing.name, {
value: isSame ? cleared : assigned,
allSelected: false,
});
onClose();
void handleCreate(fieldName, fieldValue);
},
};
})
.filter((action): action is DrilldownVariableAction => action !== null),
}
const current = selection[existing.name]?.value;
const isSame = Array.isArray(current)
? current.length === 1 && current[0] === fieldValue
: current === fieldValue;
// Multi-select values must be arrays (a scalar renders as empty in the selector).
const cleared = existing.multiSelect ? [] : '';
const assigned = existing.multiSelect ? [fieldValue] : fieldValue;
return {
fieldName,
fieldValue,
kind: isSame
? DrilldownVariableActionKind.Unset
: DrilldownVariableActionKind.Set,
onClick: (): void => {
void logEvent(DashboardDetailEvents.DrilldownAction, {
action: 'setVariable',
dashboardId,
});
setSelection(existing.name, {
value: isSame ? cleared : assigned,
allSelected: false,
});
onClose();
},
};
}),
[
fieldVariables,
isEditable,
dynamicVariables,
selection,
setSelection,
@@ -200,5 +191,5 @@ export function useDrilldownDashboardVariables({
],
);
return { hasFieldVariables: actions.length > 0, actions };
return { hasFieldVariables: fieldVariables.length > 0, actions };
}

View File

@@ -13,7 +13,6 @@ import useUrlQuery from 'hooks/useUrlQuery';
import logEvent from 'api/common/logEvent';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { UpdateTimeInterval } from 'store/actions';
import { useDashboardControl } from 'pages/DashboardPage/DashboardContainer/controls/useDashboardControl';
export interface PanelInteractions {
/** Drag-select a chart range → write it to the URL + global time so every panel re-fetches the same range. */
@@ -44,12 +43,8 @@ export function usePanelInteractions(): PanelInteractions {
[syncMode, syncFilterMode, dashboardId],
);
const canZoom = useDashboardControl('panelActions.zoom');
const onDragSelect = useCallback(
(start: number, end: number): void => {
if (!canZoom) {
return;
}
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
@@ -62,7 +57,7 @@ export function usePanelInteractions(): PanelInteractions {
void logEvent(DashboardDetailEvents.PanelZoomed, { context: 'panel' });
}
},
[canZoom, dispatch, pathname, safeNavigate, urlQuery],
[dispatch, pathname, safeNavigate, urlQuery],
);
return { onDragSelect, dashboardPreference };

View File

@@ -8,12 +8,12 @@ import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { useCreatePanel } from '../../../hooks/useCreatePanel';
import type { DashboardSection } from '../../../utils';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useCloneSection } from '../hooks/useCloneSection';
import { useDeleteSection } from '../hooks/useDeleteSection';
import { usePanelPickerHighlight } from '../hooks/usePanelPickerHighlight';
import { usePanelPickerReveal } from '../hooks/usePanelPickerReveal';
import { useRenameSection } from '../hooks/useRenameSection';
import { useScrollIntoView } from '../hooks/useScrollIntoView';
import { useSectionHeaderActions } from '../hooks/useSectionHeaderActions';
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
import SectionTitleModal from '../SectionTitleModal';
import SectionGrid from '../SectionGrid/SectionGrid';
@@ -66,14 +66,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
setIsDeleteOpen(false);
}, [deleteSection]);
const openRename = useCallback((): void => setIsRenaming(true), []);
const openDelete = useCallback((): void => setIsDeleteOpen(true), []);
const actions = useSectionHeaderActions({
section,
openPicker,
openRename,
openDelete,
});
const cloneSection = useCloneSection();
const sectionRef = useRef<HTMLDivElement>(null);
useScrollIntoView(section.id, sectionRef);
@@ -88,7 +81,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
layoutIndex={section.layoutIndex}
sections={sections}
placeholderKind={pickerTarget?.panelKind}
flush={!section.title}
/>
);
@@ -124,30 +116,35 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
dragHandle={dragHandle}
disabledChecks={editChecks}
disabledTooltip={editDisabledTooltip}
actions={actions}
actions={{
onRename: (): void => setIsRenaming(true),
onAddPanel: (): void => openPicker(section.layoutIndex),
onCloneSection: (): void => void cloneSection(section),
onDeleteSection: (): void => setIsDeleteOpen(true),
}}
/>
{open &&
(section.items.length > 0 || pickerTarget
? grid
: actions.onAddPanel && (
<div className={styles.emptySection}>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
size="md"
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size="md" />}
onClick={
isEditable ? (): void => openPicker(section.layoutIndex) : undefined
}
testId={`section-add-panel-${section.id}`}
>
New Panel
</Button>
</AuthZTooltip>
</div>
))}
(section.items.length > 0 || pickerTarget ? (
grid
) : (
<div className={styles.emptySection}>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
size="md"
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size="md" />}
onClick={
isEditable ? (): void => openPicker(section.layoutIndex) : undefined
}
testId={`section-add-panel-${section.id}`}
>
New Panel
</Button>
</AuthZTooltip>
</div>
))}
<SectionTitleModal
open={isRenaming}
heading="Rename section"

View File

@@ -25,7 +25,7 @@ function SectionActionsMenu({
onRename,
onCloneSection,
onDeleteSection,
}: SectionActionsMenuProps): JSX.Element | null {
}: SectionActionsMenuProps): JSX.Element {
const items = useMemo<AuthZDropdownItemType[]>(() => {
const gate = { checks: disabledChecks, ...blockedBy(disabledTooltip) };
const result: AuthZDropdownItemType[] = [];
@@ -83,10 +83,6 @@ function SectionActionsMenu({
onDeleteSection,
]);
if (items.length === 0) {
return null;
}
return (
<AuthZDropdown
items={items}

View File

@@ -23,8 +23,6 @@ interface SectionGridProps {
sections?: DashboardSection[];
/** Shows where a new panel of this kind will land. */
placeholderKind?: PanelKind;
/** No side padding: panels share the toolbar's edges. For a grid without section chrome. */
flush?: boolean;
}
function SectionGrid({
@@ -32,7 +30,6 @@ function SectionGrid({
layoutIndex,
sections,
placeholderKind,
flush = false,
}: SectionGridProps): JSX.Element {
const { isEditable } = useDashboardEditContext();
@@ -68,7 +65,6 @@ function SectionGrid({
onDragStop={handleLayoutChange}
onResizeStop={handleLayoutChange}
margin={[GRID_MARGIN, GRID_MARGIN]}
containerPadding={flush ? [0, GRID_MARGIN] : [GRID_MARGIN, GRID_MARGIN]}
>
{items.map((item) => (
// A layout item can reference a panel id that no longer exists in the

View File

@@ -16,12 +16,12 @@ export interface SectionDragHandle {
setActivatorNodeRef: (element: HTMLElement | null) => void;
}
/** Section actions; an absent one isn't offered. */
/** Editable-mode section actions — present together or not at all. */
export interface SectionHeaderActions {
onRename?: () => void;
onAddPanel?: () => void;
onCloneSection?: () => void;
onDeleteSection?: () => void;
onRename: () => void;
onAddPanel: () => void;
onCloneSection: () => void;
onDeleteSection: () => void;
}
interface SectionHeaderProps {

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