Compare commits

..

4 Commits

Author SHA1 Message Date
Ashwin Bhatkal
b3f7970b96 ci(e2e): save main's server binary in cacheci 2026-11-03 22:58:22 +05:30
Ashwin Bhatkal
47a2ce14a6 feat(tests): add --prebuilt-signoz to run an existing signoz image 2026-11-03 22:58:22 +05:30
Ashwin Bhatkal
3be463e06a ci(e2e): pin Node 22 2026-11-03 22:58:22 +05:30
Ashwin Bhatkal
e11224105c ci(e2e): run the suite on 10 shards 2026-11-03 22:58:21 +05:30
509 changed files with 6087 additions and 20145 deletions

View File

@@ -4,9 +4,9 @@ Applies to everything in the repo — code, config, workflows.
- **No unnecessary comments.** Do not comment where the code is self-explanatory; never restate what the code already says.
- **Document only** non-obvious behavior, constraints, formats, and edge cases.
- **Rationale goes in prose, not source.** Why a job exists, how a subsystem fits together: that belongs in the README or the PR. Exception: a config entry that would look like a mistake without its reason (a pinned version, a trust or lint exclusion) keeps a short reason next to it.
- **Never remove pre-existing comments** when editing code. The bar above applies to comments you write, not comments already there. When the code a comment describes changes, update the comment; when that code is deleted, delete the comment with it.
- **Never talk to the reviewer.** No comments about where a change came from, what was changed, or why the change is correct — that belongs in the PR description and is noise the moment it merges. A banner a skill requires (the AI-owned banner on story mocks and `tests/e2e/drivers/`) tells maintainers how the file is made; keep it.
- **Rationale goes in prose, not source.** Why a version is pinned, why a job exists, how a subsystem fits together — that belongs in the README or the PR.
- **Never remove pre-existing comments** when editing code. The bar above applies to comments you write, not comments already there.
- **Never talk to the reviewer.** No comments about where a change came from, what was changed, or why the change is correct — that belongs in the PR description and is noise the moment it merges.
- **Less is more.** When writing something intended for human consumption, (comment, commit message, reply to prompt) use as few words as possible. Pick every word meticulously to reduce the volume to a strict minimum. Be down to the point. Less is more.
Language rules build on this one: [`go-comments`](go-comments.md), [`py-comments`](py-comments.md).

View File

@@ -1,40 +0,0 @@
---
paths:
- "frontend/src/**/*.ts"
- "frontend/src/**/*.tsx"
---
# Frontend state
Pick the store by what the state is. Never add Redux or React Context for shared state; to move existing Redux or Context state, use the `migrate-state-management` skill ([`frontend/.claude/skills/migrate-state-management/SKILL.md`](../../frontend/.claude/skills/migrate-state-management/SKILL.md)).
| State | Use | Never |
|---|---|---|
| Server / API data | React Query, generated hooks from `src/api/generated` | a copy in Redux, Context or Zustand |
| Shareable: filters, time range, selection, page | nuqs (`useQueryState` with a parser) | Redux, Context, hand-parsed `useSearchParams` |
| Global client state | a small Zustand store per domain | Redux, Context |
| One component's UI state | `useState` / `useReducer` | any store |
## Server state
- Expose a query through a hook that returns its result plus memoized derived values. Never dispatch a response into another store.
- **The query key holds every value the request reads.** A field change that alters the request (name, context, data type) must change the key; a lossy signature serves stale rows.
## URL state
- Keep the URL small (Chrome trims near 2k characters). No large datasets, no sensitive data.
- **A rewrite touches only the params it owns.** Repairing or normalising one param keeps the rest (`activeLogId`, pagination) as they were.
- **Links carry the current time range.** A row link, "open in new tab" and "copy link" land on the same period as an in-app click.
- **A preset read from the URL applies once.** A later user change or reset must not re-apply it.
## Zustand
- One `create()` per module; split a large store into slice factories combined in that one `create()`.
- Always read through a selector: `useDashboardStore((s) => s.isDashboardLocked)`, never `useDashboardStore()`.
- Update only through `set`; never mutate state. State fields before actions in the store object.
- Hooks never call `useStore.getState()` (`signoz/no-zustand-getstate-in-hooks`); export standalone actions instead.
## Local and form state
- **Editable state starts from saved data once.** Initialise the form from the saved value, then let the user own it; never re-derive it from saved data on each render.
- **A mode switch owns its in-flight work.** While a request from one mode runs, disable the switch or drop the result when the mode changes, so it can't close or overwrite the new mode.

View File

@@ -1,20 +0,0 @@
---
paths:
- "frontend/src/**/*.test.ts"
- "frontend/src/**/*.test.tsx"
---
# Frontend tests
Examples and the harness API live in [`frontend/src/tests/README.md`](../../frontend/src/tests/README.md).
- **Import from the harness.** `render`, `screen`, `userEvent`, `waitFor` and the rest come from `tests/test-utils`, never from `@testing-library/react`. The harness mounts the router, React Query, Redux, app context and preferences. When it can't do what a test needs, extend `tests/test-utils` instead of importing around it.
- **Find elements by test ID.** Query with `getByTestId`/`findByTestId` on the `data-testid` or `testId` the component exposes, and add one to the component when it has none. Fall back to role or text only for markup the app doesn't own. A test ID matches exactly one element and survives copy and markup changes; `getByRole` computes the accessibility tree on every call and is the slowest query.
- **Interact like a user.** `const user = userEvent.setup({ pointerEventsCheck: 0 })`, then an awaited `user.click`, `hover`, `keyboard` or `type`. `fireEvent` only for events `userEvent` has no equivalent for: scroll, resize, setting `scrollTop`.
- **Fake timers per test only**, for debounce or throttle, with `userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) })`. Never global.
- **Network through MSW.** The global server runs for every test; override per test with `server.use(...)` from `mocks-server/server`. Large fixtures go in `mocks-server/__mockdata__`.
- **Router through the harness.** Pass `initialRoute`/`routePath` to `render` and assert with `getCurrentLocation()` from `lib/router/navigation`. Never mock `react-router`, `react-router-dom` or `lib/history`; when a test can't render at a route, use the `lib/router` spies in `tests/router-mocks`.
- **Mock only business boundaries.** Local `jest.mock` for API modules, custom hooks and domain components. Never re-mock what the harness provides or what `jest.config.ts` maps (`uplot`, `@signozhq/icons`, `useSafeNavigate`, `logEvent`).
- **Type mocks and fixtures.** `jest.mocked(fn)` for module mocks, `jest.fn() as jest.MockedFunction<T>` for a standalone mock you configure or assert on. Fixtures carry their real type, from `src/api/generated` where it exists. No `any`, no `as unknown as`. An inline `onX={jest.fn()}` on a typed prop that is never asserted needs no annotation.
- **A test fails without its change.** Revert the change, or break the line under test, and run the test before you finish: it must fail. Assert the specific outcome (exact text, request payload, call arguments), never only that an element exists.
- **No limit on tests per file.** Split a file when it mixes unrelated behaviour, not to hit a count.

View File

@@ -1,15 +0,0 @@
---
paths:
- "frontend/src/**/*.ts"
- "frontend/src/**/*.tsx"
---
# Log body
`ILog.body` is `string | ILogBody`. On `use_json_body` tenants the API sends an object, and the collector wraps a plain-string body into `{ message }`. Every read goes through `frontend/src/lib/logs/logBody.ts`.
- **Read with a getter, never directly.** `getBodyDisplay` for a list cell or a log line, `getBodyMessage` for the drawer header. Both accept `undefined` and collapse a `{message}`-only object to its message.
- **Never cast or stringify a body inline.** No `as string`, no `String(log.body)`, no `JSON.stringify(log.body)`, no `typeof body === 'string' ? … : …` outside the module. The v5 converter collapses `{ message }` once with `extractOnlyMessageBody`; live logs and the dashboard list panel skip the converter, so their rows carry the raw object and only a getter renders them right.
- **Resolve a selected field with `getLogFieldValue`** from `lib/logs/flatLogData.ts`, so a `body.<key>` column works on both string and object bodies. An object value renders as JSON and is not offered as an add-to-query filter.
- **The drawer tree and field lookup are the only places that use the raw value.** Everything else renders a string from a getter.
- **Review check.** A raw body value used outside a getter call, `extractOnlyMessageBody` in the converter, `flatLogData.ts` and `LogDetailedView/utils.tsx` is a finding.

View File

@@ -9,6 +9,3 @@
- **Keep the commit body short and human readable** focused on decision made if any. Commit body must not re-iterate the changes done, skip if title is sufficient in conveying the change.
- **Use convensional commit format** for commits and PR title.
- **Do not amend the commits once pushed.** Always create a new commit once changes are pushed to remote.
- **Review fixes are new commits too.** A force push hides what changed from human reviewers and makes Greptile post its findings again. Bring in `main` with a merge; only a stacked branch (`gh stack`) rebases, since the stack moves as a unit.
- **Answer every Greptile thread.** Reply with the commit that fixes it (`Fixed in 4df6f3198d: <what changed>`) or the reason it stays, then resolve. Never resolve one with neither: Greptile withdraws a finding, and remembers it, only when told why.
- **Leave human review threads to the author.** Fix the code they ask for, but never reply to or resolve a human reviewer's thread.

View File

@@ -10,9 +10,6 @@ Page stories are written with the `signoz-page-story` skill ([`.claude/skills/si
- **A UI change lands with its story.** Any change that alters what a page renders — a new page, a new state, a new branch, a changed layout — updates the story of every page that renders it, or creates one where the page has none. Go through the `signoz-page-story` skill; never hand-write a page story outside it.
- **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.
- **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.
- **Prove it with a visual diff when asked.** Before/after screenshots and "did this touch other screens" go through `sbshot-visual-diff`, which can only show what a story renders.

View File

@@ -115,8 +115,8 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
- **A variant earns a story only when it is worth linking to**: an empty workspace, a viewer, a page mid-load. Everything else stays a control.
- **Endpoints the page owns go through `response.json`**, so the Data control covers loaded, loading and failed in one declaration. Endpoints the page cannot render without (ingestion detection, preferences, feature payloads) take a plain resolver so the shell survives the loading and error states.
- **Query-param state starts from `route`** (`/logs?tab=explorer`). In-page param navigation works inside a story; a different pathname is blocked and reported by the overlay. A control for a param is worth it only when the param is a page mode someone would want to flip.
- **File layout**: 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).
- **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`. 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. 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,8 +143,6 @@ 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 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
- [ ] `pnpm tsgo --noEmit`, `pnpm exec oxlint <files>`, `pnpm exec oxfmt --check <files>` all clean. The repo has no prettier: `pnpm exec prettier` prints a pass while exiting 254

9
.github/CODEOWNERS vendored
View File

@@ -24,7 +24,7 @@ go.mod @therealpandey
/pkg/config/ @therealpandey
/pkg/errors/ @therealpandey
/pkg/factory/ @therealpandey
/pkg/types/ @therealpandey @srikanthccv
/pkg/types/ @therealpandey
/pkg/valuer/ @therealpandey
/cmd/ @therealpandey
.golangci.yml @therealpandey
@@ -136,7 +136,7 @@ go.mod @therealpandey
# Integration tests
/tests/integration/ @therealpandey @srikanthccv
/tests/integration/ @therealpandey
# e2e tests
/tests/e2e/ @AshwinBhatkal
@@ -302,8 +302,3 @@ go.mod @therealpandey
/frontend/.storybook/ @H4ad
/frontend/src/storybook/ @H4ad
/.claude/skills/signoz-page-story/ @H4ad
## AI Assistant
/frontend/src/container/AIAssistant/ @SigNoz/nerve @SigNoz/frontend-maintainers
/frontend/src/hooks/aiAssistant/ @SigNoz/nerve @SigNoz/frontend-maintainers
/frontend/src/pages/AIAssistantPage/ @SigNoz/nerve @SigNoz/frontend-maintainers

View File

@@ -46,8 +46,34 @@ jobs:
docker build -f "$RUNNER_TEMP/inject.Dockerfile" "$RUNNER_TEMP/cacheci"
- name: build
run: |
docker build -f cmd/enterprise/Dockerfile.integration --build-arg TARGETARCH=amd64 --build-arg ZEUSURL=http://zeus:8080 .
docker build -f cmd/enterprise/Dockerfile.integration --tag signoz:integration --build-arg TARGETARCH=amd64 --build-arg ZEUSURL=http://zeus:8080 .
docker build -f cmd/enterprise/Dockerfile.with-web.integration --build-arg TARGETARCH=amd64 --build-arg ZEUSURL=http://zeus:8080 .
# e2eci runs this server binary for changes that leave the server as it
# is here, and builds only the web.
- name: e2e-signoz-key
id: e2e-signoz-key
run: |
echo "key=$(tests/e2e/scripts/signoz-key.sh)" >> "$GITHUB_OUTPUT"
- name: e2e-signoz
run: |
mkdir -p "$RUNNER_TEMP/e2e-signoz"
id=$(docker create signoz:integration)
docker cp "$id":/root/signoz "$RUNNER_TEMP/e2e-signoz/signoz"
docker rm "$id"
- name: save-e2e-signoz
uses: actions/cache/save@v4
with:
path: ${{ runner.temp }}/e2e-signoz
key: ${{ steps.e2e-signoz-key.outputs.key }}
# Older keys only match commits main has moved past.
- name: prune-e2e-signoz
env:
GH_TOKEN: ${{ github.token }}
KEY: ${{ steps.e2e-signoz-key.outputs.key }}
run: |
gh cache list --repo "$GITHUB_REPOSITORY" --key e2e-signoz- --json key --jq '.[].key' \
| grep -vxF "$KEY" \
| xargs -r -n 1 gh cache delete --repo "$GITHUB_REPOSITORY"
# docker cp instead of --output type=local (the local exporter stalls on
# multi-GB outputs); tarballs instead of raw trees so the host never hits
# the permission and symlink semantics that broke docker cp.

View File

@@ -27,7 +27,7 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
FORCE: ${{ contains(github.event.pull_request.labels.*.name, 'safe-to-e2e') }}
run: |
CHANGED_FILES=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename | select((startswith("tests/integration/") or startswith("frontend/src/api/generated/")) | not)')
CHANGED_FILES=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename | select(startswith("tests/integration/") | not)')
PATTERN='^(frontend/|tests/|\.github/workflows/e2eci\.yaml$|cmd/enterprise/Dockerfile\.with-web\.integration$)'
if [ "$FORCE" = "true" ]; then

View File

@@ -552,47 +552,26 @@ components:
required:
- apiKey
type: object
AlertmanagertypesChannelJiraAuth:
oneOf:
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraCloudAuth'
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraSelfHostedAuth'
type: object
AlertmanagertypesChannelJiraCloudAuth:
AlertmanagertypesChannelJiraConfig:
properties:
apiToken:
description: The Atlassian API token paired with email. A secret; returned
by create, update and fetch by ID, never by list.
format: password
type: string
email:
description: The Atlassian account email used for basic auth.
type: string
required:
- email
- apiToken
type: object
AlertmanagertypesChannelJiraConfig:
properties:
auth:
allOf:
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraAuth'
description: 'The credentials: email and apiToken when deployment is cloud,
pat when deployment is selfhosted.'
customFields:
additionalProperties: {}
description: Extra issue fields keyed by Jira field ID. Stored, but not
currently included in the issue Jira receives.
nullable: true
type: object
deployment:
allOf:
- $ref: '#/components/schemas/AlertmanagertypesJiraDeployment'
description: 'The Jira edition: cloud for an Atlassian-hosted site, selfhosted
for Jira Server or Data Center.'
description:
description: Alertmanager template for the issue description, rendered as
markdown; defaults to the SigNoz Jira default.
type: string
email:
description: The Atlassian account email used for basic auth.
type: string
issueType:
description: The issue type name, such as Bug.
type: string
@@ -625,8 +604,8 @@ components:
nullable: true
type: boolean
site:
description: The base URL of the Jira site, for example https://example.atlassian.net
or https://jira.example.com.
description: The base URL of the Jira Cloud site, for example https://example.atlassian.net.
Only Jira Cloud is supported.
type: string
summary:
description: Alertmanager template for the issue summary; defaults to the
@@ -636,21 +615,11 @@ components:
description: The resolution name that stops an issue from being reopened.
type: string
required:
- deployment
- site
- project
- issueType
- auth
type: object
AlertmanagertypesChannelJiraSelfHostedAuth:
properties:
pat:
description: The Jira personal access token, sent as a Bearer header. A
secret; returned by create, update and fetch by ID, never by list.
format: password
type: string
required:
- pat
- email
- apiToken
type: object
AlertmanagertypesChannelKind:
enum:
@@ -1186,11 +1155,6 @@ components:
description: Comma-separated tags attached to the alert.
type: string
type: object
AlertmanagertypesJiraDeployment:
enum:
- cloud
- selfhosted
type: string
AlertmanagertypesJiraReceiverConfig:
properties:
custom_fields:
@@ -1198,10 +1162,6 @@ components:
description: Extra issue fields keyed by Jira field ID. Stored, but not
currently included in the issue Jira receives.
type: object
deployment:
allOf:
- $ref: '#/components/schemas/AlertmanagertypesJiraDeployment'
description: The Jira edition, cloud or selfhosted; defaults to cloud.
description:
description: Alertmanager template for the issue description.
type: string
@@ -1209,8 +1169,7 @@ components:
allOf:
- $ref: '#/components/schemas/ConfigHTTPClientConfig'
description: HTTP client settings; basic auth carries the account email
and API token on cloud, authorization carries the Bearer personal access
token on self-hosted.
and API token.
issue_type:
description: The issue type name.
type: string
@@ -1240,7 +1199,7 @@ components:
send_resolved:
type: boolean
site:
description: The Jira base URL.
description: The Jira Cloud base URL.
type: string
summary:
description: Alertmanager template for the issue summary.
@@ -4068,9 +4027,7 @@ components:
the query they draw.
cursor:
description: The opaque nextCursor value from a previous raw result, used
to fetch the next page. Honored only by raw requests whose order starts
with timestamp, for logs followed by id in the same direction; every other
request ignores it.
to fetch the next page.
type: string
disabled:
description: When true, the result is excluded from the response; the query
@@ -4107,11 +4064,10 @@ components:
are replaced by group-by label values.
type: string
limit:
description: The maximum number of rows for raw and trace requests, or of
series or rows for grouped scalar and time_series requests, keeping the
top entries by order. When 0, scalar and time_series requests are unlimited,
raw requests ordered by timestamp return a page of 10000 rows to continue
with nextCursor, and other raw and trace requests return 100 rows.
description: The maximum number of rows for raw requests, or of series or
rows for grouped aggregations, keeping the top entries by order. When
0, aggregations are unlimited while raw requests ordered by timestamp
return a page of 10000 rows; follow nextCursor for the rest.
type: integer
limitBy:
allOf:
@@ -4123,11 +4079,8 @@ components:
operators and selectedQueryName refer to it.
type: string
offset:
description: The number of rows for raw and trace requests, or of ordered
series or rows for scalar and time_series requests, to skip before the
page starts. Requires limit. On raw requests the skip counts from where
a cursor resumes, and the skipped rows are still read, so prefer cursor
for paging.
description: The number of rows to skip before returning results. Prefer
cursor for raw requests.
type: integer
order:
description: The sort order of the result. Order by a group-by field, an
@@ -9609,10 +9562,9 @@ components:
Querybuildertypesv5AggregationMeta:
properties:
buckets:
description: The ascending upper bounds of the value buckets, set for heatmap
and distribution results; each point then carries one count per bound
plus a trailing overflow count in values. The first bucket starts at -Inf
when its bound is at or under zero, else at zero.
description: The ascending upper bounds of the value bands, set only for
heatmap results; each point then carries one count per bound plus a trailing
overflow count in values.
items:
format: double
type: number
@@ -9767,101 +9719,6 @@ components:
nullable: true
type: array
type: object
Querybuildertypesv5Distribution:
properties:
counts:
description: One count per bound in meta.buckets plus a trailing overflow
count, summed over every step of the requested time range.
items:
format: double
type: number
nullable: true
type: array
labels:
description: The group-by field values that identify this distribution.
items:
$ref: '#/components/schemas/Querybuildertypesv5Label'
type: array
required:
- counts
type: object
Querybuildertypesv5DistributionAggregation:
properties:
alias:
description: The alias of the aggregation, or the internal name __result_N,
with N its index, when no alias was given.
type: string
distributions:
description: One distribution per group-by combination.
items:
$ref: '#/components/schemas/Querybuildertypesv5Distribution'
nullable: true
type: array
index:
description: The zero-based position of the aggregation in the query.
type: integer
meta:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
description: Display metadata for the aggregation, including the bucket
upper bounds the counts are positional against.
percentiles:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5DistributionPercentiles'
description: Percentiles over the counts of every distribution added together,
estimated the way histogram_quantile does by interpolating linearly within
the bucket each rank falls in. Omitted when nothing was counted or meta.buckets
is empty.
required:
- index
- alias
- distributions
type: object
Querybuildertypesv5DistributionData:
properties:
aggregations:
description: One entry per aggregation of the query. The order is not guaranteed;
match entries to aggregations by index.
items:
$ref: '#/components/schemas/Querybuildertypesv5DistributionAggregation'
nullable: true
type: array
queryName:
description: The name of the query that produced this result.
type: string
required:
- queryName
- aggregations
type: object
Querybuildertypesv5DistributionPercentiles:
properties:
p50:
description: The 50th percentile.
format: double
type: number
p75:
description: The 75th percentile.
format: double
type: number
p90:
description: The 90th percentile.
format: double
type: number
p95:
description: The 95th percentile.
format: double
type: number
p99:
description: The 99th percentile.
format: double
type: number
required:
- p50
- p75
- p90
- p95
- p99
type: object
Querybuildertypesv5ExecStats:
description: 'Execution cost of the request: rows and bytes scanned and execution
time. A measure of cost, not of whether data exists.'
@@ -10245,10 +10102,6 @@ components:
description: The unique name of the formula, such as F1, used by selectedQueryName
and by panels. Referencing a formula from another formula is not reliable.
type: string
offset:
description: The number of ordered series or rows to skip before the page
starts. Requires limit.
type: integer
order:
description: The sort order of the formula result.
items:
@@ -10274,9 +10127,7 @@ components:
the query they draw.
cursor:
description: The opaque nextCursor value from a previous raw result, used
to fetch the next page. Honored only by raw requests whose order starts
with timestamp, for logs followed by id in the same direction; every other
request ignores it.
to fetch the next page.
type: string
disabled:
description: When true, the result is excluded from the response; the query
@@ -10312,11 +10163,10 @@ components:
are replaced by group-by label values.
type: string
limit:
description: The maximum number of rows for raw and trace requests, or of
series or rows for grouped scalar and time_series requests, keeping the
top entries by order. When 0, scalar and time_series requests are unlimited,
raw requests ordered by timestamp return a page of 10000 rows to continue
with nextCursor, and other raw and trace requests return 100 rows.
description: The maximum number of rows for raw requests, or of series or
rows for grouped aggregations, keeping the top entries by order. When
0, aggregations are unlimited while raw requests ordered by timestamp
return a page of 10000 rows; follow nextCursor for the rest.
type: integer
limitBy:
allOf:
@@ -10328,11 +10178,8 @@ components:
operators and selectedQueryName refer to it.
type: string
offset:
description: The number of rows for raw and trace requests, or of ordered
series or rows for scalar and time_series requests, to skip before the
page starts. Requires limit. On raw requests the skip counts from where
a cursor resumes, and the skipped rows are still read, so prefer cursor
for paging.
description: The number of rows to skip before returning results. Prefer
cursor for raw requests.
type: integer
order:
description: The sort order of the result. Order by a group-by field, an
@@ -10394,9 +10241,7 @@ components:
the query they draw.
cursor:
description: The opaque nextCursor value from a previous raw result, used
to fetch the next page. Honored only by raw requests whose order starts
with timestamp, for logs followed by id in the same direction; every other
request ignores it.
to fetch the next page.
type: string
disabled:
description: When true, the result is excluded from the response; the query
@@ -10433,11 +10278,10 @@ components:
are replaced by group-by label values.
type: string
limit:
description: The maximum number of rows for raw and trace requests, or of
series or rows for grouped scalar and time_series requests, keeping the
top entries by order. When 0, scalar and time_series requests are unlimited,
raw requests ordered by timestamp return a page of 10000 rows to continue
with nextCursor, and other raw and trace requests return 100 rows.
description: The maximum number of rows for raw requests, or of series or
rows for grouped aggregations, keeping the top entries by order. When
0, aggregations are unlimited while raw requests ordered by timestamp
return a page of 10000 rows; follow nextCursor for the rest.
type: integer
limitBy:
allOf:
@@ -10449,11 +10293,8 @@ components:
operators and selectedQueryName refer to it.
type: string
offset:
description: The number of rows for raw and trace requests, or of ordered
series or rows for scalar and time_series requests, to skip before the
page starts. Requires limit. On raw requests the skip counts from where
a cursor resumes, and the skipped rows are still read, so prefer cursor
for paging.
description: The number of rows to skip before returning results. Prefer
cursor for raw requests.
type: integer
order:
description: The sort order of the result. Order by a group-by field, an
@@ -10515,9 +10356,7 @@ components:
the query they draw.
cursor:
description: The opaque nextCursor value from a previous raw result, used
to fetch the next page. Honored only by raw requests whose order starts
with timestamp, for logs followed by id in the same direction; every other
request ignores it.
to fetch the next page.
type: string
disabled:
description: When true, the result is excluded from the response; the query
@@ -10554,11 +10393,10 @@ components:
are replaced by group-by label values.
type: string
limit:
description: The maximum number of rows for raw and trace requests, or of
series or rows for grouped scalar and time_series requests, keeping the
top entries by order. When 0, scalar and time_series requests are unlimited,
raw requests ordered by timestamp return a page of 10000 rows to continue
with nextCursor, and other raw and trace requests return 100 rows.
description: The maximum number of rows for raw requests, or of series or
rows for grouped aggregations, keeping the top entries by order. When
0, aggregations are unlimited while raw requests ordered by timestamp
return a page of 10000 rows; follow nextCursor for the rest.
type: integer
limitBy:
allOf:
@@ -10570,11 +10408,8 @@ components:
operators and selectedQueryName refer to it.
type: string
offset:
description: The number of rows for raw and trace requests, or of ordered
series or rows for scalar and time_series requests, to skip before the
page starts. Requires limit. On raw requests the skip counts from where
a cursor resumes, and the skipped rows are still read, so prefer cursor
for paging.
description: The number of rows to skip before returning results. Prefer
cursor for raw requests.
type: integer
order:
description: The sort order of the result. Order by a group-by field, an
@@ -10675,9 +10510,8 @@ components:
description: The unique name of the trace operator query.
type: string
offset:
description: The number of raw or trace rows, or of ordered series or rows
for time_series and scalar requests, to skip before the page starts. Requires
limit.
description: The number of raw results to skip before returning. Aggregation
requests ignore it.
type: integer
order:
description: The sort order of the matched traces; only span_count and trace_duration
@@ -10708,12 +10542,10 @@ components:
- $ref: '#/components/schemas/Querybuildertypesv5TimeSeriesData'
- $ref: '#/components/schemas/Querybuildertypesv5ScalarData'
- $ref: '#/components/schemas/Querybuildertypesv5RawData'
- $ref: '#/components/schemas/Querybuildertypesv5DistributionData'
properties:
results:
description: 'One result per enabled query, typed by the request type: TimeSeriesData
for time_series and heatmap, ScalarData for scalar, RawData for raw and
DistributionData for distribution requests.'
for time_series, ScalarData for scalar and RawData for raw requests.'
items: {}
nullable: true
type: array
@@ -10903,9 +10735,8 @@ components:
type: object
Querybuildertypesv5QueryRangeResponse:
description: 'Response from the v5 query range endpoint. The data.results array
contains typed results depending on the requestType: TimeSeriesData for time_series
and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for
distribution requests.'
contains typed results depending on the requestType: TimeSeriesData for time_series,
ScalarData for scalar, or RawData for raw requests.'
properties:
data:
allOf:
@@ -11002,7 +10833,6 @@ components:
- raw_stream
- trace
- heatmap
- distribution
type: string
Querybuildertypesv5ScalarData:
properties:
@@ -11114,8 +10944,8 @@ components:
format: double
type: number
values:
description: The per-bucket counts for heatmap results, in the order of
meta.buckets plus a trailing overflow count.
description: The per-band counts for heatmap results, in the order of meta.buckets
plus a trailing overflow count.
items:
format: double
type: number
@@ -24220,16 +24050,14 @@ paths:
jira:
description: Creates one Jira Cloud issue per alert group and reopens
it if the alert fires again within `reopenDuration`. Authenticates
with an Atlassian account email and API token under `auth`.
with an Atlassian account email and API token.
summary: Jira Cloud issues
value:
config:
kind: jira
spec:
auth:
apiToken: XXXXXXXXXXXXXXXXXXXXXXXX
email: oncall@example.com
deployment: cloud
apiToken: XXXXXXXXXXXXXXXXXXXXXXXX
email: oncall@example.com
issueType: Bug
labels:
- signoz
@@ -24239,23 +24067,6 @@ paths:
site: https://example.atlassian.net
displayName: Jira platform
name: jira-platform
jira-selfhosted:
description: Creates issues on a Jira Data Center or Server site.
Authenticates with a personal access token under `auth`, sent as
a Bearer header.
summary: Self-hosted Jira issues
value:
config:
kind: jira
spec:
auth:
pat: XXXXXXXXXXXXXXXXXXXXXXXX
deployment: selfhosted
issueType: Bug
project: OPS
site: https://jira.example.com
displayName: Jira Data Center
name: jira-datacenter
jsmops:
description: Creates JSM Ops alerts through an integration API key.
JSM Ops speaks the Opsgenie alert API, so `priority` takes P1 to
@@ -28476,12 +28287,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28490,15 +28295,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:read
- logs-field:list
- traces-field:list
- metrics-field:list
- VIEWER
- tokenizer:
- rule:read
- logs-field:list
- traces-field:list
- metrics-field:list
- VIEWER
summary: Get rule history filter keys
tags:
- rules
@@ -28666,12 +28465,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28680,15 +28473,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:read
- logs-field:read
- traces-field:read
- metrics-field:read
- VIEWER
- tokenizer:
- rule:read
- logs-field:read
- traces-field:read
- metrics-field:read
- VIEWER
summary: Get rule history filter values
tags:
- rules
@@ -28761,9 +28548,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:read
- VIEWER
- tokenizer:
- rule:read
- VIEWER
summary: Get rule overall status timeline
tags:
- rules
@@ -28833,9 +28620,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:read
- VIEWER
- tokenizer:
- rule:read
- VIEWER
summary: Get rule history stats
tags:
- rules
@@ -28917,12 +28704,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28931,19 +28712,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- VIEWER
- tokenizer:
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- VIEWER
summary: Get rule history timeline
tags:
- rules
@@ -29008,12 +28779,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -29022,19 +28787,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- VIEWER
- tokenizer:
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- VIEWER
summary: Get top contributors to rule firing
tags:
- rules

View File

@@ -352,6 +352,7 @@ The same pytest flags integration tests expose work here, since E2E reuses the s
- `--teardown` — tear everything down.
- `--clean` — prune the docker build caches, forcing the next image build to start cold.
- `--with-web` — build the frontend into the SigNoz container. **Required for E2E**; integration tests don't need it.
- `--prebuilt-signoz` — run the `signoz:integration` image already in the local image store instead of building it. The image must be built with `ZEUSURL=http://zeus:8080`. CI uses it when main's server binary matches the change.
- `--sqlstore-provider`, `--postgres-version`, `--clickhouse-version`, etc. — see `docs/contributing/tests/integration.md`.
## What should I remember?

View File

@@ -0,0 +1,97 @@
---
globs: **/*.store.ts
alwaysApply: false
---
# State Management: React Query, nuqs, Zustand
Use the following stack. Do **not** introduce or recommend Redux or React Context for shared/global state.
## Server state → React Query
- **Use for:** API responses, time-series data, caching, background refetch, retries, stale/refresh.
- **Do not use Redux/Context** to store or mirror data that comes from React Query (e.g. do not dispatch API results into Redux).
- Prefer generated React Query hooks from `frontend/src/api/generated` when available.
- Keep server state in React Query; expose it via hooks that return the query result (and optionally memoized derived values). Do not duplicate it in Redux or Context.
```tsx
// ✅ GOOD: single source of truth from React Query
export function useAppStateHook() {
const { data, isError } = useQuery(...)
const memoizedConfigs = useMemo(() => ({ ... }), [data?.configs])
return { configs: memoizedConfigs, isError, ... }
}
// ❌ BAD: copying React Query result into Redux
dispatch({ type: UPDATE_LATEST_VERSION, payload: queryResponse.data })
```
## URL state → nuqs
- **Use for:** shareable state, filters, time range, selected values, pagination, view state that belongs in the URL.
- **Do not use Redux/Context** for state that should be shareable or reflected in the URL.
- Use [nuqs](https://nuqs.dev/docs/basic-usage) for typed, type-safe URL search params. Avoid ad-hoc `useSearchParams` encoding/decoding.
- Keep URL payload small; respect browser URL length limits (e.g. Chrome ~2k chars). Do not put large datasets or sensitive data in query params.
```tsx
// ✅ GOOD: nuqs for filters / time range / selection
const [timeRange, setTimeRange] = useQueryState('timeRange', parseAsString.withDefault('1h'))
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
// ❌ BAD: Redux/Context for shareable or URL-synced state
const { timeRange } = useContext(SomeContext)
```
## Client state → Zustand
- **Use for:** global/client state, cross-component state, feature flags, complex or large client objects (e.g. dashboard state, query builder state).
- **Do not use Redux or React Context** for global or feature-level client state.
- Prefer small, domain-scoped stores (e.g. DashboardStore, QueryBuilderStore).
### Zustand best practices (align with eslint-plugin-zustand-rules)
- **One store per module.** Do not define multiple `create()` calls in the same file; use one store per module (or compose slices into one store).
- **Always use selectors.** Call the store hook with a selector so only the used slice triggers re-renders. Never use `useStore()` with no selector.
```tsx
// ✅ GOOD: selector — re-renders only when isDashboardLocked changes
const isLocked = useDashboardStore(state => state.isDashboardLocked)
// ❌ BAD: no selector — re-renders on any store change
const state = useDashboardStore()
```
- **Never mutate state directly.** Update only via `set` or `setState` (or `getState()` + `set` for reads). No `state.foo = x` or `state.bears += 1` inside actions.
```tsx
// ✅ GOOD: use set
increment: () => set(state => ({ bears: state.bears + 1 }))
// ❌ BAD: direct mutation
increment: () => { state.bears += 1 }
```
- **State properties before actions.** In the store object, list all state fields first, then action functions.
- **Split into slices when state is large.** If a store has many top-level properties (e.g. more than 5–10), split into slice factories and combine with one `create()`.
```tsx
// ✅ GOOD: slices for large state
const createBearSlice = set => ({ bears: 0, addBear: () => set(s => ({ bears: s.bears + 1 })) })
const createFishSlice = set => ({ fish: 0, addFish: () => set(s => ({ fish: s.fish + 1 })) })
const useStore = create(set => ({ ...createBearSlice(set), ...createFishSlice(set) }))
```
- **In projects using Zustand:** add `eslint-plugin-zustand-rules` and extend `plugin:zustand-rules/recommended` to enforce these rules automatically.
## Local state → React state only
- **Use useState/useReducer** for: component-local UI state, form inputs, toggles, hover state, data that never leaves the component.
- Do not use Zustand, Redux, or Context for state that is purely local to one component or a small subtree.
## Summary
| State type | Use | Avoid |
|-------------------|------------------|--------------------|
| Server / API | React Query | Redux, Context |
| URL / shareable | nuqs | Redux, Context |
| Global client | Zustand | Redux, Context |
| Local UI | useState/useReducer | Zustand, Redux, Context |

View File

@@ -0,0 +1,74 @@
---
description: Core testing conventions - imports, rendering, MSW, interactions, queries
globs: **/*.test.{ts,tsx}
alwaysApply: false
---
# Testing Conventions
## Imports
Always import from the test harness, never directly from `@testing-library/react`:
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
```
## Router
Use the built-in router in `render`:
```ts
render(<Page />, undefined, { initialRoute: '/traces-explorer' });
```
Only mock `useLocation` / `useParams` if the test depends on their values.
## MSW
Global MSW server runs automatically. Override per-test:
```ts
server.use(
rest.get('*/api/v1/foo', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ ok: true })))
);
```
Keep large response fixtures in `mocks-server/__mockdata_`.
## Interactions
- Prefer `userEvent` for real user interactions (click, type, select, tab).
- Use `fireEvent` only for low-level events not covered by `userEvent` (e.g., scroll, resize). Wrap in `act(...)` if needed.
- Always `await` interactions:
```ts
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /save/i }));
```
## Timers
No global fake timers. Per-test only, for debounce/throttle:
```ts
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) });
await user.type(screen.getByRole('textbox'), 'query');
jest.advanceTimersByTime(400);
jest.useRealTimers();
```
## Queries
Prefer accessible queries: `getByRole` > `findByRole` > `getByLabelText` > visible text > `data-testid` (last resort).
## Anti-patterns
- Never import from `@testing-library/react` directly
- Never use global fake timers
- Never wrap `render` in `act(...)`
- Never mock infra dependencies locally (router, react-query)
- Limit to 3-5 focused tests per file

View File

@@ -0,0 +1,54 @@
---
description: When to use global vs local mocks in tests
globs: **/*.test.{ts,tsx}
alwaysApply: false
---
# Mock Strategy
## Use Global Mocks For
High-frequency dependencies (20+ test files):
- Core infrastructure: react-router-dom, react-query, antd
- Browser APIs: ResizeObserver, matchMedia, localStorage
- Utility libraries: date-fns, lodash
Available global mock files (from jest.config.ts):
- `uplot` -> `__mocks__/uplotMock.ts`
## Use Local Mocks For
- Business logic dependencies (API endpoints, custom hooks, domain components)
- Test-specific behavior (different data per test, error scenarios, loading states)
## Decision Tree
```
Used in 20+ test files?
YES -> Global mock
NO -> Business logic or test-specific?
YES -> Local mock
NO -> Consider global if usage grows
```
## Correct Usage
```ts
// Global mocks are already available - just import
import { useLocation } from 'react-router-dom';
// Local mocks for business logic
jest.mock('../api/tracesService', () => ({
getTraces: jest.fn(() => mockTracesData),
}));
```
## Anti-patterns
```ts
// Never re-mock globally mocked dependencies locally
jest.mock('react-router-dom', () => ({ ... }));
// Never put test-specific data in global mocks
jest.mock('../api/tracesService', () => ({ getTraces: jest.fn(() => specificTestData) }));
```

View File

@@ -0,0 +1,54 @@
---
description: TypeScript type safety requirements for Jest tests
globs: **/*.test.{ts,tsx}
alwaysApply: false
---
# TypeScript Type Safety in Tests
All Jest tests must be fully type-safe. Never use `any`.
## Mock Function Typing
```ts
// Use jest.mocked for module mocks
import useFoo from 'hooks/useFoo';
jest.mock('hooks/useFoo');
const mockUseFoo = jest.mocked(useFoo);
// Use jest.MockedFunction for standalone mocks
const mockFetch = jest.fn() as jest.MockedFunction<(id: number) => Promise<User>>;
```
## Mock Data
Define interfaces for all mock data:
```ts
const mockUser: User = { id: 1, name: 'John', email: 'john@example.com' };
const mockProps: ComponentProps = {
title: 'Test',
data: [mockUser],
onSelect: jest.fn() as jest.MockedFunction<(user: User) => void>,
};
```
## Hook Mocking Pattern
```ts
import useFoo from 'hooks/useFoo';
jest.mock('hooks/useFoo');
const mockUseFoo = jest.mocked(useFoo);
mockUseFoo.mockReturnValue(/* minimal shape */);
```
Prefer helpers (`rqSuccess`, `rqLoading`, `rqError`) for React Query results.
## Checklist
- All mock functions use `jest.MockedFunction<T>` or `jest.mocked()`
- All mock data has proper interfaces
- No `any` types in test files
- Component props are typed
- API response types are defined

View File

@@ -0,0 +1,19 @@
---
description: Prefer SigNoz UI and icons across frontend code
globs: **/*.{ts,tsx,js,jsx}
alwaysApply: true
---
# UI Components and Icons Source of Truth
For all frontend implementation work in this repository:
- Always use UI primitives/components from `@signozhq/ui`.
- Always use icons from `@signozhq/icons`.
- Do not introduce new usage of icon libraries directly (for example `lucide-react`) in app code.
- Do not mix multiple component systems for the same UI surface when an equivalent exists in `@signozhq/ui`.
## Migration guidance
- If touching a file that already uses non-`@signozhq/icons` icons, prefer migrating that file to `@signozhq/icons` as part of the same change when practical.
- If a required component or icon is missing from SigNoz packages, call this out explicitly in the PR/summary before introducing alternatives.

View File

@@ -146,5 +146,5 @@ Do not use Zustand, Redux, or Context for purely local UI state.
## Additional resources
- Project rule: [.claude/rules/frontend-state.md](../../../../.claude/rules/frontend-state.md)
- Project rule: [.cursor/rules/state-management.mdc](../../rules/state-management.mdc)
- Detailed patterns and rationale: [reference.md](reference.md)

View File

@@ -143,12 +143,7 @@ const preview: Preview = {
'Logs',
['Explorer', 'Live Tail', 'Pipelines', 'Settings'],
'Traces',
[
'Explorer',
['Overview', 'Span Drawer'],
'Trace Details',
'Funnel Details',
],
['Explorer', 'Trace Details', 'Funnel Details'],
'Metrics',
['Explorer'],
'Infrastructure',

View File

@@ -25,24 +25,15 @@ You are operating within a constrained context window and strict system prompts.
- Never create barrel files.
- When writing new css, prefer CSS Modules
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
- UI primitives come from `@signozhq/ui` (subpath imports) and icons from `@signozhq/icons`. No new `antd` component or direct icon library (`lucide-react`) where `@signozhq/ui` has an equivalent; `signoz/no-antd-components` enforces part of this list.
- Exception: a control added beside `antd` siblings of the same kind (a tab in an `antd` tab strip) matches them until that surface migrates. Say so in the PR.
- When `@signozhq/ui` or `@signozhq/icons` lacks what you need, say so in the PR before reaching for another library.
- Before styling a `@signozhq/ui` component, check its props in `node_modules/@signozhq/ui/dist/<component>/*.d.ts` for `className`/`style` support; some omit them. Avoid overriding the component's CSS vars unless explicitly needed.
- When writing code that could need authorization checks, read ./src/lib/authz/README.md
- A flow that gains a request also gates on that request's permission: a rename that now reads the view first needs `read` as well as `update`.
- Identity keys (legend groups, color groups, CSV headers, React keys) come from structured values, never from joined display text: `['us, east', 'prod']` and `['us', 'east, prod']` both join to `us, east, prod`.
- A payload saved under another kind or schema copies only the fields that schema accepts, and drops bindings to columns the user removed. The server rejects unknown or stale fields, so the preview works and the save fails.
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. Run these checks once all changes of a task are done, never after each edit. You are FORBIDDEN from reporting a task as complete until you have:
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
- Run `pnpm tsgo --noEmit`
- Run `pnpm lint:js --quiet` to find critical errors
- Run `pnpm oxlint <file1> <file2>` and fix all warnings
- Run `pnpm build`
- Find if the file has tests for it, or if there's `__test__` folder or the parent folder has tests, and run. Run them before coding the change or after all changes, never in between.
- For e2e, run only the specs you added or changed: the stack is heavy. Leave the full jest and e2e suites to CI unless the user asks.
- For every test you added or changed, revert the change it covers and confirm the test fails
- On a stacked branch (`gh stack`), run `pnpm tsgo --noEmit` on every branch above it before pushing: a renamed prop or moved export breaks them
- Find if the file has tests for it, or if there's `__test__` folder or the parent folder has tests, and run.
- Fixed ALL resulting errors
4. BEHAVIOR CHANGE DETECTION: When modifying existing behavior:
@@ -50,14 +41,6 @@ You are operating within a constrained context window and strict system prompts.
- Update test assertions to match new behavior
- If no tests exist, add them BEFORE changing behavior
## Repo Rules
Claude Code loads `../.claude/rules/` on its own. Other agents read the matching file before editing:
- Every file: [comments](../.claude/rules/comments.md), [pull requests](../.claude/rules/pull-requests.md)
- `src/**/*.ts(x)` that holds state, a query, URL params or a form: [frontend state](../.claude/rules/frontend-state.md)
- `*.test.ts(x)`: [frontend tests](../.claude/rules/frontend-tests.md)
- `*.tsx` that changes what a page renders: [storybook](../.claude/rules/storybook.md)
## Context Management
1. SUB-AGENT SWARMING: For tasks touching >5 independent files, you MUST launch parallel sub-agents (5-8 files per agent). Each agent gets its own context window. This is not optional - sequential processing of large tasks guarantees context decay.

View File

@@ -80,7 +80,7 @@
"crypto-js": "4.2.0",
"d3-hierarchy": "3.1.2",
"dayjs": "^1.10.7",
"dompurify": "3.4.17",
"dompurify": "3.4.15",
"event-source-polyfill": "1.0.31",
"eventemitter3": "5.0.1",
"history": "4.10.1",
@@ -200,17 +200,17 @@
"oxfmt": "0.73.0",
"oxlint": "1.88.0",
"oxlint-tsgolint": "7.0.2004",
"postcss": "8.5.32",
"postcss": "8.5.26",
"postcss-scss": "4.0.9",
"react-resizable": "3.0.4",
"redux-mock-store": "1.5.4",
"sass": "1.97.3",
"sharp": "0.35.5",
"sharp": "0.35.4",
"storybook": "10.6.3",
"stylelint": "17.15.0",
"svgo": "4.1.0",
"ts-jest": "29.4.15",
"typescript-plugin-css-modules": "5.3.0",
"typescript-plugin-css-modules": "5.2.0",
"use-sync-external-store": "1.6.0",
"vite-plugin-checker": "0.12.0",
"vite-plugin-compression": "0.5.1",

465
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,9 +37,9 @@ overrides:
cookie@<0.7.0: '>=0.7.1 <1'
# via: @grafana/data@11.6.15 (3.4.0/3.2.4 exact);
# @monaco-editor/react > monaco-editor@0.55.1 (3.2.7 exact)
# remove: blocked. The direct dep is already on 3.4.17, but @grafana/data
# remove: blocked. The direct dep is already on 3.4.15, but @grafana/data
# (latest 13.1.0) and monaco-editor (latest 0.55.1) still pin vulnerable versions
dompurify@<=3.4.16: '>=3.4.17 <4'
dompurify@<=3.4.12: '>=3.4.13 <4'
# via: rolldown-vite@7.3.1 (esbuild ^0.27.0); ts-jest@29.4.9 (~0.27.4)
# remove: blocked on rolldown-vite (7.3.1 is latest, still ^0.27.0);
# orval >=8.20.0 and ts-jest >=29.4.11 already fixed on their side
@@ -60,10 +60,17 @@ overrides:
# @commitlint/cli > @commitlint/load > cosmiconfig@9 (^4.1.0)
# remove: blocked, both consumers cap js-yaml at ^4 and only 4.3.2 carries the fix
'js-yaml@>=4.0.0 <4.3.2': '>=4.3.2 <5'
# via: typescript-plugin-css-modules@5.2.0 (less ^4.2.0) > less@4.4.0 (image-size ~0.5.0)
# remove: bump typescript-plugin-css-modules once it floors less itself; image-size has
# no patched release at all, so dropping the dep is the only fix — less@4.5.0 did
less@<4.5.0: '>=4.5.0 <5'
# via: direct dep postcss (nanoid ^3.3.17)
# remove: blocked — postcss 8.5.26 (latest) caps nanoid at ^3, fix landed in 3.3.18
nanoid@<3.3.18: '>=3.3.18 <4'
# via: react-syntax-highlighter@15.5.0 (prismjs ^1.27.0 + refractor@3 ~1.27.0 tilde-pinned)
# remove: bump react-syntax-highlighter to >=16.1.1 (prismjs ^1.30.0, refractor@5)
prismjs@<1.30.0: '>=1.30.0 <2'
# via: typescript-plugin-css-modules@5.3.0 > postcss-modules-local-by-default@4.2.0
# via: typescript-plugin-css-modules@5.2.0 > postcss-modules-local-by-default@4.2.0
# (postcss-selector-parser ^7.0.0)
# remove: blocked, 4.2.0 is latest and the range is open, so the floor is what pulls the fix
postcss-selector-parser@>=7.1.0 <7.1.3: '>=7.1.3 <8'

View File

@@ -18,25 +18,15 @@
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
"field_jira_deployment": "Jira deployment",
"option_jira_deployment_cloud": "Jira Cloud",
"option_jira_deployment_selfhosted": "Self-hosted Jira",
"jira_selfhosted_tip": "Works with Jira Data Center 8.14 and newer. Create the personal access token from a dedicated service user so issues aren't reported under a personal name.",
"jira_selfhosted_tip_link": "Learn how",
"field_jira_site": "Site URL",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
"tooltip_jira_site_selfhosted": "Your self-hosted Jira base URL, e.g. https://jira.your-company.com, or a proxy in front of it.",
"jira_required_fields": "Site URL, project and issue type are required",
"jira_cloud_credentials_required": "Email and API token are required for Jira Cloud",
"jira_pat_required": "A personal access token is required for self-hosted Jira",
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
"jira_service_account_tip_link": "Learn how",
"field_jira_email": "Email",
"help_jira_email": "The Atlassian account email used for authentication.",
"field_jira_api_token": "API token",
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
"field_jira_pat": "Personal access token",
"help_jira_pat": "Create one in Jira under your profile, Personal Access Tokens. Sent as a Bearer header.",
"field_jira_project": "Project key",
"field_jira_issue_type": "Issue type",
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
@@ -44,7 +34,6 @@
"help_jira_summary": "Template for the Jira issue title.",
"field_jira_description": "Description",
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
"help_jira_description_selfhosted": "Template for the issue description. Rendered as Jira wiki markup with a status panel and links back to SigNoz.",
"jira_advanced_section": "Advanced Options",
"field_jira_priority": "Priority",
"placeholder_jira_priority": "Leave empty to use the project default",

View File

@@ -26,25 +26,15 @@
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
"field_jira_deployment": "Jira deployment",
"option_jira_deployment_cloud": "Jira Cloud",
"option_jira_deployment_selfhosted": "Self-hosted Jira",
"jira_selfhosted_tip": "Works with Jira Data Center 8.14 and newer. Create the personal access token from a dedicated service user so issues aren't reported under a personal name.",
"jira_selfhosted_tip_link": "Learn how",
"field_jira_site": "Site URL",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
"tooltip_jira_site_selfhosted": "Your self-hosted Jira base URL, e.g. https://jira.your-company.com, or a proxy in front of it.",
"jira_required_fields": "Site URL, project and issue type are required",
"jira_cloud_credentials_required": "Email and API token are required for Jira Cloud",
"jira_pat_required": "A personal access token is required for self-hosted Jira",
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
"jira_service_account_tip_link": "Learn how",
"field_jira_email": "Email",
"help_jira_email": "The Atlassian account email used for authentication.",
"field_jira_api_token": "API token",
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
"field_jira_pat": "Personal access token",
"help_jira_pat": "Create one in Jira under your profile, Personal Access Tokens. Sent as a Bearer header.",
"field_jira_project": "Project key",
"field_jira_issue_type": "Issue type",
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
@@ -52,7 +42,6 @@
"help_jira_summary": "Template for the Jira issue title.",
"field_jira_description": "Description",
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
"help_jira_description_selfhosted": "Template for the issue description. Rendered as Jira wiki markup with a status panel and links back to SigNoz.",
"jira_advanced_section": "Advanced Options",
"field_jira_priority": "Priority",
"placeholder_jira_priority": "Leave empty to use the project default",

View File

@@ -829,37 +829,6 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
jira = 'jira',
}
export interface AlertmanagertypesChannelJiraCloudAuthDTO {
/**
* @type string
* @format password
* @description The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list.
*/
apiToken: string;
/**
* @type string
* @description The Atlassian account email used for basic auth.
*/
email: string;
}
export interface AlertmanagertypesChannelJiraSelfHostedAuthDTO {
/**
* @type string
* @format password
* @description The Jira personal access token, sent as a Bearer header. A secret; returned by create, update and fetch by ID, never by list.
*/
pat: string;
}
export type AlertmanagertypesChannelJiraAuthDTO =
| AlertmanagertypesChannelJiraCloudAuthDTO
| AlertmanagertypesChannelJiraSelfHostedAuthDTO;
export enum AlertmanagertypesJiraDeploymentDTO {
cloud = 'cloud',
selfhosted = 'selfhosted',
}
/**
* Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives.
*/
@@ -875,23 +844,26 @@ export type AlertmanagertypesChannelJiraConfigDTOCustomFields = Alertmanagertype
export interface AlertmanagertypesChannelJiraConfigDTO {
/**
* @description The credentials: email and apiToken when deployment is cloud, pat when deployment is selfhosted.
* @type string
* @format password
* @description The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list.
*/
auth: AlertmanagertypesChannelJiraAuthDTO;
apiToken: string;
/**
* @type object,null
* @description Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives.
*/
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
/**
* @description The Jira edition: cloud for an Atlassian-hosted site, selfhosted for Jira Server or Data Center.
*/
deployment: AlertmanagertypesJiraDeploymentDTO;
/**
* @type string
* @description Alertmanager template for the issue description, rendered as markdown; defaults to the SigNoz Jira default.
*/
description?: string;
/**
* @type string
* @description The Atlassian account email used for basic auth.
*/
email: string;
/**
* @type string
* @description The issue type name, such as Bug.
@@ -934,7 +906,7 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
sendResolved?: boolean | null;
/**
* @type string
* @description The base URL of the Jira site, for example https://example.atlassian.net or https://jira.example.com.
* @description The base URL of the Jira Cloud site, for example https://example.atlassian.net. Only Jira Cloud is supported.
*/
site: string;
/**
@@ -1744,17 +1716,13 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
* @description Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives.
*/
custom_fields?: AlertmanagertypesJiraReceiverConfigDTOCustomFields;
/**
* @description The Jira edition, cloud or selfhosted; defaults to cloud.
*/
deployment?: AlertmanagertypesJiraDeploymentDTO;
/**
* @type string
* @description Alertmanager template for the issue description.
*/
description?: string;
/**
* @description HTTP client settings; basic auth carries the account email and API token on cloud, authorization carries the Bearer personal access token on self-hosted.
* @description HTTP client settings; basic auth carries the account email and API token.
*/
http_config?: ConfigHTTPClientConfigDTO;
/**
@@ -1797,7 +1765,7 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
send_resolved?: boolean;
/**
* @type string
* @description The Jira base URL.
* @description The Jira Cloud base URL.
*/
site?: string;
/**
@@ -5305,7 +5273,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
*/
cursor?: string;
/**
@@ -5338,7 +5306,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
legend?: string;
/**
* @type integer
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
*/
limit?: number;
/**
@@ -5352,7 +5320,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
name?: string;
/**
* @type integer
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
*/
offset?: number;
/**
@@ -5697,7 +5665,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
*/
cursor?: string;
/**
@@ -5730,7 +5698,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
legend?: string;
/**
* @type integer
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
*/
limit?: number;
/**
@@ -5744,7 +5712,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
name?: string;
/**
* @type integer
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
*/
offset?: number;
/**
@@ -5877,7 +5845,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
*/
cursor?: string;
/**
@@ -5910,7 +5878,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
legend?: string;
/**
* @type integer
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
*/
limit?: number;
/**
@@ -5924,7 +5892,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
name?: string;
/**
* @type integer
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
*/
offset?: number;
/**
@@ -5976,7 +5944,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
*/
cursor?: string;
/**
@@ -6009,7 +5977,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
legend?: string;
/**
* @type integer
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
*/
limit?: number;
/**
@@ -6023,7 +5991,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
name?: string;
/**
* @type integer
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
*/
offset?: number;
/**
@@ -6969,7 +6937,6 @@ export enum Querybuildertypesv5RequestTypeDTO {
raw_stream = 'raw_stream',
trace = 'trace',
heatmap = 'heatmap',
distribution = 'distribution',
}
/**
* The query kind.
@@ -7097,11 +7064,6 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
* @description The unique name of the formula, such as F1, used by selectedQueryName and by panels. Referencing a formula from another formula is not reliable.
*/
name?: string;
/**
* @type integer
* @description The number of ordered series or rows to skip before the page starts. Requires limit.
*/
offset?: number;
/**
* @type array,null
* @description The sort order of the formula result.
@@ -7184,7 +7146,7 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
name?: string;
/**
* @type integer
* @description The number of raw or trace rows, or of ordered series or rows for time_series and scalar requests, to skip before the page starts. Requires limit.
* @description The number of raw results to skip before returning. Aggregation requests ignore it.
*/
offset?: number;
/**
@@ -11011,7 +10973,7 @@ export interface Querybuildertypesv5TimeSeriesValueDTO {
value?: number;
/**
* @type array
* @description The per-bucket counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
* @description The per-band counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
*/
values?: number[];
}
@@ -11632,7 +11594,7 @@ export interface PromotetypesPostableIndexMaterializedPathsDTO {
export interface Querybuildertypesv5AggregationMetaDTO {
/**
* @type array
* @description The ascending upper bounds of the value buckets, set for heatmap and distribution results; each point then carries one count per bound plus a trailing overflow count in values. The first bucket starts at -Inf when its bound is at or under zero, else at zero.
* @description The ascending upper bounds of the value bands, set only for heatmap results; each point then carries one count per bound plus a trailing overflow count in values.
*/
buckets?: number[];
/**
@@ -11753,91 +11715,6 @@ export interface Querybuildertypesv5ColumnDescriptorDTO {
unit?: string;
}
export interface Querybuildertypesv5DistributionDTO {
/**
* @type array,null
* @description One count per bound in meta.buckets plus a trailing overflow count, summed over every step of the requested time range.
*/
counts: number[] | null;
/**
* @type array
* @description The group-by field values that identify this distribution.
*/
labels?: Querybuildertypesv5LabelDTO[];
}
export interface Querybuildertypesv5DistributionPercentilesDTO {
/**
* @type number
* @format double
* @description The 50th percentile.
*/
p50: number;
/**
* @type number
* @format double
* @description The 75th percentile.
*/
p75: number;
/**
* @type number
* @format double
* @description The 90th percentile.
*/
p90: number;
/**
* @type number
* @format double
* @description The 95th percentile.
*/
p95: number;
/**
* @type number
* @format double
* @description The 99th percentile.
*/
p99: number;
}
export interface Querybuildertypesv5DistributionAggregationDTO {
/**
* @type string
* @description The alias of the aggregation, or the internal name __result_N, with N its index, when no alias was given.
*/
alias: string;
/**
* @type array,null
* @description One distribution per group-by combination.
*/
distributions: Querybuildertypesv5DistributionDTO[] | null;
/**
* @type integer
* @description The zero-based position of the aggregation in the query.
*/
index: number;
/**
* @description Display metadata for the aggregation, including the bucket upper bounds the counts are positional against.
*/
meta?: Querybuildertypesv5AggregationMetaDTO;
/**
* @description Percentiles over the counts of every distribution added together, estimated the way histogram_quantile does by interpolating linearly within the bucket each rank falls in. Omitted when nothing was counted or meta.buckets is empty.
*/
percentiles?: Querybuildertypesv5DistributionPercentilesDTO;
}
export interface Querybuildertypesv5DistributionDataDTO {
/**
* @type array,null
* @description One entry per aggregation of the query. The order is not guaranteed; match entries to aggregations by index.
*/
aggregations: Querybuildertypesv5DistributionAggregationDTO[] | null;
/**
* @type string
* @description The name of the query that produced this result.
*/
queryName: string;
}
/**
* The step interval in seconds that was used for each query, keyed by query name.
*/
@@ -12093,28 +11970,21 @@ export type Querybuildertypesv5QueryDataDTO =
| (Querybuildertypesv5TimeSeriesDataDTO & {
/**
* @type array,null
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
*/
results?: unknown[] | null;
})
| (Querybuildertypesv5ScalarDataDTO & {
/**
* @type array,null
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
*/
results?: unknown[] | null;
})
| (Querybuildertypesv5RawDataDTO & {
/**
* @type array,null
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
*/
results?: unknown[] | null;
})
| (Querybuildertypesv5DistributionDataDTO & {
/**
* @type array,null
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
*/
results?: unknown[] | null;
});
@@ -12248,7 +12118,7 @@ export interface Querybuildertypesv5QueryRangeRequestDTO {
}
/**
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for distribution requests.
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series, ScalarData for scalar, or RawData for raw requests.
*/
export interface Querybuildertypesv5QueryRangeResponseDTO {
/**

View File

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

View File

@@ -1,4 +1,3 @@
import { extractOnlyMessageBody } from 'lib/logs/logBody';
import { cloneDeep, isEmpty } from 'lodash-es';
import { SuccessResponse, Warning } from 'types/api';
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
@@ -282,6 +281,19 @@ function convertScalarWithFormatForWeb(
});
}
function extractOnlyMessageBody(body: unknown): unknown {
const isJsonBody = body && typeof body === 'object' && !Array.isArray(body);
if (isJsonBody) {
const keys = Object.keys(body);
const hasOnlyMessageKey = keys.length === 1 && keys[0] === 'message';
if (hasOnlyMessageKey) {
const { message } = body as { message: unknown };
return typeof message === 'string' ? message : JSON.stringify(message);
}
}
return body;
}
/**
* Converts V5 RawData to legacy format
*/

View File

@@ -1,17 +0,0 @@
.drawer {
overflow: hidden;
border-color: var(--l2-border);
background: var(--l2-background);
box-shadow: -4px 10px 16px 2px var(--l1-shadow);
&:focus,
&:focus-visible {
outline: none;
}
}
.body {
display: flex;
flex-direction: column;
height: 100%;
}

View File

@@ -0,0 +1,7 @@
.details-panel-drawer {
&__body {
display: flex;
flex-direction: column;
height: 100%;
}
}

View File

@@ -1,15 +1,12 @@
import { Drawer, DrawerContent } from '@signozhq/ui/drawer';
import cx from 'classnames';
import { AnimatePresence } from 'motion/react';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import styles from './DetailsPanelDrawer.module.scss';
import './DetailsPanelDrawer.styles.scss';
interface DetailsPanelDrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
testId?: string;
}
function DetailsPanelDrawer({
@@ -17,41 +14,22 @@ function DetailsPanelDrawer({
onClose,
children,
className,
testId,
}: DetailsPanelDrawerProps): JSX.Element {
return (
<Drawer
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
modal={false}
direction="right"
showOverlay={false}
className={`details-panel-drawer ${className || ''}`}
>
<AnimatePresence>
{isOpen && (
<DrawerContent
forceMount
direction="right"
width="extra-wide"
showOverlay={false}
// Outside presses are left to the page, which knows what counts as inside.
onInteractOutside={(e): void => e.preventDefault()}
className={cx(styles.drawer, className)}
testId={testId}
>
<div className={styles.body}>{children}</div>
</DrawerContent>
)}
</AnimatePresence>
</Drawer>
<div className="details-panel-drawer__body">{children}</div>
</DrawerWrapper>
);
}
DetailsPanelDrawer.defaultProps = {
className: undefined,
testId: undefined,
};
export default DetailsPanelDrawer;

View File

@@ -29,8 +29,8 @@ export default function WaitlistFragment({
setIsSubmitting(true);
logEvent('Infra Monitoring: Get early access clicked', {
entityType,
logEvent('Infra Monitoring: Get Early Access Clicked', {
entity_type: entityType,
userEmail: user.email,
})
.then(() => {

View File

@@ -18,7 +18,6 @@ function InputWithLabel({
className,
closeIcon,
disabled,
sanitize,
}: {
label: string;
initialValue?: string | number | null;
@@ -30,17 +29,14 @@ function InputWithLabel({
className?: string;
closeIcon?: React.ReactNode;
disabled?: boolean;
/** Rewrites the typed value before it is shown and reported. */
sanitize?: (value: string) => string;
}): JSX.Element {
const [inputValue, setInputValue] = useState<string>(
initialValue ? initialValue.toString() : '',
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const value = sanitize ? sanitize(e.target.value) : e.target.value;
setInputValue(value);
onChange?.(value);
setInputValue(e.target.value);
onChange?.(e.target.value);
};
return (
@@ -82,7 +78,6 @@ InputWithLabel.defaultProps = {
initialValue: undefined,
className: undefined,
closeIcon: undefined,
sanitize: undefined,
};
export default InputWithLabel;

View File

@@ -1,100 +0,0 @@
import { ConfigProvider, Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import cx from 'classnames';
import type { UpdatedWindow } from '../dsl/types';
import type { CreatorOption } from '../types';
import { UPDATED_OPTIONS } from './constants';
import styles from './FilterZone.module.scss';
// The option checkbox already marks selection, so selected rows stay regular weight.
const CREATOR_SELECT_THEME = {
components: { Select: { optionSelectedFontWeight: 400 } },
};
interface Props {
createdBy: string[];
updated: UpdatedWindow;
creatorOptions: CreatorOption[];
onCreatedByChange: (emails: string[]) => void;
onUpdatedChange: (window: UpdatedWindow) => void;
// Run the staged draft — fired when a dropdown closes.
onApply: () => void;
// Clear all Created-by selections and run immediately.
onClearCreatedBy: () => void;
// Ids: `${testIdPrefix}-filter-created-by`, `-filter-updated`.
testIdPrefix: string;
disabled?: boolean;
}
function FilterChips({
createdBy,
updated,
creatorOptions,
onCreatedByChange,
onUpdatedChange,
onApply,
onClearCreatedBy,
testIdPrefix,
disabled = false,
}: Props): JSX.Element {
const creatorOptionsData = creatorOptions.map((o) => ({
value: o.email,
label: o.label,
}));
const runOnClose = (open: boolean): void => {
if (!open) {
onApply();
}
};
return (
<div className={styles.chips}>
<ConfigProvider theme={CREATOR_SELECT_THEME}>
<Select
mode="multiple"
showSearch
allowClear
className={cx(styles.select, styles.selectWide)}
placeholder="Created by"
value={createdBy}
options={creatorOptionsData}
optionFilterProp="label"
maxTagCount={1}
menuItemSelectedIcon={null}
data-testid={`${testIdPrefix}-filter-created-by`}
disabled={disabled}
onClear={onClearCreatedBy}
optionRender={(option): JSX.Element => (
<div className={styles.creatorOption}>
<div className={styles.creatorCheck}>
<Checkbox
color="primary"
value={createdBy.includes(option.value as string)}
/>
</div>
<span className={styles.creatorLabel}>{option.label}</span>
</div>
)}
onChange={(value): void => onCreatedByChange(value as string[])}
onDropdownVisibleChange={runOnClose}
/>
</ConfigProvider>
<Select
showSearch
className={cx(styles.select, styles.selectNarrow)}
placeholder="Updated"
value={updated}
options={UPDATED_OPTIONS}
optionFilterProp="label"
data-testid={`${testIdPrefix}-filter-updated`}
disabled={disabled}
onChange={(value): void => onUpdatedChange(value as UpdatedWindow)}
/>
</div>
);
}
export default FilterChips;

View File

@@ -1,166 +0,0 @@
import type { ReactElement, ReactNode } from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import type { DslGrammar, SuggestionSource } from '../dsl/types';
import SearchBar from '../SearchBar/SearchBar';
import type {
CreatorOption,
FilterAppliedPayload,
FilterAppliedType,
} from '../types';
import FilterChips from './FilterChips';
import { useFilterZoneDraft } from './useFilterZoneDraft';
import styles from './FilterZone.module.scss';
interface Props {
// The last-run query (source of truth for fetching + the dirty baseline).
query: string;
grammar: DslGrammar;
// Ids: `${testIdPrefix}-search-zone`, `-list-search*`, `-filter-zone`,
// `-filter-clear`, `-filter-created-by`, `-filter-updated`, `-query-error`.
testIdPrefix: string;
creatorOptions: CreatorOption[];
source: SuggestionSource;
// Commit (run) the draft — the only path that triggers a fetch.
onQueryChange: (value: string) => void;
placeholder?: string;
// Rendered at the end of the search row (e.g. the New Dashboard action).
rightSlot?: ReactNode;
// Rendered in the filters row after the chips.
extraFilters?: ReactNode;
// Shows Clear even when the query is empty.
hasExtraFilters?: boolean;
// Called by Clear after it empties the query.
onClearAll?: () => void;
// Shown under the search row.
error?: string;
// Wraps the search box and the filter controls (e.g. in an authz tooltip).
wrapControl?: (node: ReactElement) => ReactElement;
onSubmitted?: (hasQuery: boolean) => void;
onFilterApplied?: (
filterType: FilterAppliedType,
payload: FilterAppliedPayload,
) => void;
onCleared?: () => void;
/** Filtering needs `list`: the box and chips edit a query that cannot be run. */
disabled?: boolean;
}
const identity = (node: ReactElement): ReactElement => node;
// The filter command zone: a draft DSL query box (run with Cmd/Ctrl+Enter or the
// Run button; a dirty dot signals unrun changes) plus Created-by / Updated chips.
function FilterZone({
query,
grammar,
testIdPrefix,
creatorOptions,
source,
onQueryChange,
placeholder,
rightSlot,
extraFilters,
hasExtraFilters = false,
onClearAll,
error,
wrapControl = identity,
onSubmitted,
onFilterApplied,
onCleared,
disabled = false,
}: Props): JSX.Element {
const {
draft,
setDraft,
reflected,
dirty,
isEmpty,
run,
handleCreatedByChange,
handleUpdatedChange,
handleClearCreatedBy,
handleClear,
} = useFilterZoneDraft({
query,
onQueryChange,
onFilterApplied,
onCleared,
onClearAll,
});
return (
<div className={styles.filterZone}>
<div className={styles.searchRow}>
{wrapControl(
<div
className={styles.searchInput}
data-testid={`${testIdPrefix}-search-zone`}
>
<SearchBar
value={draft}
grammar={grammar}
testIdPrefix={`${testIdPrefix}-list-search`}
placeholder={placeholder}
source={source}
dirty={dirty}
disabled={disabled}
onChange={setDraft}
onSubmit={run}
onSubmitted={onSubmitted}
/>
</div>,
)}
{rightSlot}
</div>
{error && (
<Typography.Text
className={styles.error}
testId={`${testIdPrefix}-query-error`}
>
{error}
</Typography.Text>
)}
<div className={styles.filtersRow}>
<Typography.Text className={styles.filtersLabel}>Filters</Typography.Text>
{wrapControl(
<div
className={styles.filterControls}
data-testid={`${testIdPrefix}-filter-zone`}
>
<FilterChips
createdBy={reflected.createdBy}
updated={reflected.updated}
creatorOptions={creatorOptions}
testIdPrefix={testIdPrefix}
onCreatedByChange={handleCreatedByChange}
onUpdatedChange={handleUpdatedChange}
onApply={run}
onClearCreatedBy={handleClearCreatedBy}
disabled={disabled}
/>
{extraFilters}
{(!isEmpty || hasExtraFilters) && (
<Button
disabledTooltip={undefined}
variant="outlined"
color="secondary"
size="md"
prefix={<X size={12} />}
disabled={disabled}
onClick={handleClear}
testId={`${testIdPrefix}-filter-clear`}
>
Clear
</Button>
)}
</div>,
)}
</div>
</div>
);
}
export default FilterZone;

View File

@@ -1,135 +0,0 @@
import { act, renderHook } from 'tests/test-utils';
import { useFilterZoneDraft } from '../useFilterZoneDraft';
const BASE_QUERY = "name = 'x'";
type Args = Required<Parameters<typeof useFilterZoneDraft>[0]>;
function setup(query = BASE_QUERY): {
result: { current: ReturnType<typeof useFilterZoneDraft> };
rerender: (props: { query: string }) => void;
onQueryChange: jest.MockedFunction<Args['onQueryChange']>;
onFilterApplied: jest.MockedFunction<Args['onFilterApplied']>;
onCleared: jest.MockedFunction<Args['onCleared']>;
onClearAll: jest.MockedFunction<Args['onClearAll']>;
} {
const onQueryChange = jest.fn() as jest.MockedFunction<Args['onQueryChange']>;
const onFilterApplied = jest.fn() as jest.MockedFunction<
Args['onFilterApplied']
>;
const onCleared = jest.fn() as jest.MockedFunction<Args['onCleared']>;
const onClearAll = jest.fn() as jest.MockedFunction<Args['onClearAll']>;
const { result, rerender } = renderHook(
({ query: q }: { query: string }) =>
useFilterZoneDraft({
query: q,
onQueryChange,
onFilterApplied,
onCleared,
onClearAll,
}),
{ initialProps: { query } },
);
return {
result,
rerender,
onQueryChange,
onFilterApplied,
onCleared,
onClearAll,
};
}
describe('useFilterZoneDraft', () => {
it('edits a draft without running it until run is called', () => {
const { result, onQueryChange } = setup();
act(() => result.current.setDraft("name = 'y' "));
expect(result.current.dirty).toBe(true);
expect(onQueryChange).not.toHaveBeenCalled();
act(() => result.current.run());
expect(onQueryChange).toHaveBeenCalledWith("name = 'y'");
});
it('does not run a draft that matches the last-run query', () => {
const { result, onQueryChange } = setup();
act(() => result.current.run());
expect(result.current.dirty).toBe(false);
expect(onQueryChange).not.toHaveBeenCalled();
});
it('resyncs the draft when the query changes from outside', () => {
const { result, rerender } = setup();
act(() => result.current.setDraft("name = 'typing'"));
rerender({ query: "name = 'from-view'" });
expect(result.current.draft).toBe("name = 'from-view'");
expect(result.current.dirty).toBe(false);
});
it('stages a created-by pick into the draft without running it', () => {
const { result, onQueryChange, onFilterApplied } = setup();
act(() => result.current.handleCreatedByChange(['a@x.io', 'b@x.io']));
expect(result.current.draft).toBe(
"name = 'x' AND created_by IN ['a@x.io', 'b@x.io']",
);
expect(result.current.reflected.createdBy).toStrictEqual([
'a@x.io',
'b@x.io',
]);
expect(onFilterApplied).toHaveBeenCalledWith('createdBy', { valueCount: 2 });
expect(onQueryChange).not.toHaveBeenCalled();
});
it('runs an updated-window pick immediately', () => {
const { result, onQueryChange, onFilterApplied } = setup();
act(() => result.current.handleUpdatedChange('7d'));
expect(onFilterApplied).toHaveBeenCalledWith('updatedWindow', {});
expect(onQueryChange).toHaveBeenCalledTimes(1);
expect(onQueryChange.mock.calls[0][0]).toContain("name = 'x' AND updated_at");
expect(result.current.reflected.updated).toBe('7d');
});
it('drops the created-by clause and runs when created-by is cleared', () => {
const { result, onQueryChange } = setup(
"created_by = 'a@x.io' AND name = 'x'",
);
act(() => result.current.handleClearCreatedBy());
expect(result.current.draft).toBe("name = 'x'");
expect(onQueryChange).toHaveBeenCalledWith("name = 'x'");
});
it('empties the draft and runs the empty query on clear', () => {
const { result, onQueryChange, onCleared, onClearAll } = setup();
act(() => result.current.handleClear());
expect(result.current.draft).toBe('');
expect(result.current.isEmpty).toBe(true);
expect(onCleared).toHaveBeenCalledTimes(1);
expect(onQueryChange).toHaveBeenCalledWith('');
expect(onClearAll).toHaveBeenCalledTimes(1);
});
it('skips re-running an already-empty query on clear', () => {
const { result, onQueryChange, onClearAll } = setup('');
act(() => result.current.handleClear());
expect(onQueryChange).not.toHaveBeenCalled();
expect(onClearAll).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,15 +0,0 @@
import type { UpdatedWindow } from '../dsl/types';
const UPDATED_LABELS: Record<UpdatedWindow, string> = {
any: 'Any time',
today: 'Today',
'7d': 'Last 7 days',
'30d': 'Last 30 days',
};
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
export const UPDATED_OPTIONS = UPDATED_WINDOWS.map((w) => ({
value: w,
label: UPDATED_LABELS[w],
}));

View File

@@ -1,121 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
createdByClause,
parseReflectedClauses,
type ReflectedClauses,
spliceClause,
updatedClause,
} from '../dsl/filterQuery';
import type { UpdatedWindow } from '../dsl/types';
import type { FilterAppliedPayload, FilterAppliedType } from '../types';
interface Args {
query: string;
onQueryChange: (value: string) => void;
onFilterApplied?: (
filterType: FilterAppliedType,
payload: FilterAppliedPayload,
) => void;
onCleared?: () => void;
onClearAll?: () => void;
}
interface FilterZoneDraft {
draft: string;
setDraft: (value: string) => void;
reflected: ReflectedClauses;
dirty: boolean;
isEmpty: boolean;
run: () => void;
handleCreatedByChange: (emails: string[]) => void;
handleUpdatedChange: (window: UpdatedWindow) => void;
handleClearCreatedBy: () => void;
handleClear: () => void;
}
// The query box is a DRAFT: typing and the Created-by / Updated dropdowns all edit
// the draft (dropdowns splice their clause in); nothing fetches until the draft is
// run. The draft re-syncs when the last-run query changes externally (view
// select, back/forward).
export function useFilterZoneDraft({
query,
onQueryChange,
onFilterApplied,
onCleared,
onClearAll,
}: Args): FilterZoneDraft {
const [draft, setDraft] = useState(query);
useEffect(() => {
setDraft(query);
}, [query]);
const reflected = useMemo(() => parseReflectedClauses(draft), [draft]);
const dirty = draft.trim() !== query.trim();
const isEmpty = !draft.trim();
const commit = useCallback(
(next: string): void => {
const trimmed = next.trim();
if (trimmed !== query) {
onQueryChange(trimmed);
}
},
[query, onQueryChange],
);
const run = useCallback((): void => commit(draft), [commit, draft]);
// Created-by (multi-select) only STAGES its clause into the draft; the query runs
// when the dropdown closes (FilterChips fires onApply), not on each pick.
const handleCreatedByChange = useCallback(
(emails: string[]): void => {
onFilterApplied?.('createdBy', { valueCount: emails.length });
setDraft((d) => spliceClause(d, 'created_by', createdByClause(emails)));
},
[onFilterApplied],
);
// Updated (single-select) is a definitive single choice, so it splices AND runs
// immediately (with the fresh value, avoiding the async draft-state lag).
const handleUpdatedChange = useCallback(
(window: UpdatedWindow): void => {
onFilterApplied?.('updatedWindow', {});
const next = spliceClause(draft, 'updated_at', updatedClause(window));
setDraft(next);
commit(next);
},
[draft, commit, onFilterApplied],
);
// Clear-all on the Created-by select removes its clause and runs immediately.
const handleClearCreatedBy = useCallback((): void => {
const next = spliceClause(draft, 'created_by', null);
setDraft(next);
commit(next);
}, [draft, commit]);
// Clear resets the draft and runs immediately (empty query).
const handleClear = useCallback((): void => {
onCleared?.();
setDraft('');
if (query !== '') {
onQueryChange('');
}
onClearAll?.();
}, [query, onQueryChange, onCleared, onClearAll]);
return {
draft,
setDraft,
reflected,
dirty,
isEmpty,
run,
handleCreatedByChange,
handleUpdatedChange,
handleClearCreatedBy,
handleClear,
};
}

View File

@@ -1,41 +0,0 @@
import { EditorView } from '@uiw/react-codemirror';
import type { SuggestionSource } from '../dsl/types';
export const EMPTY_SOURCE: SuggestionSource = {
dynamicKeys: [],
valuesByKey: {},
creatorEmails: [],
};
// Token colours come from `.cm-dsl-*` classes (SearchBar.module.scss); this only
// handles layout — transparent background, monospace, single line.
export const editorTheme = EditorView.theme({
'&': { fontSize: '13px', backgroundColor: 'transparent' },
'&.cm-focused': { outline: 'none' },
'.cm-content': {
// Leave room for the overlaid lead icon (left) and Run button (right).
padding: '8px 120px 8px 30px',
fontFamily: "'Space Mono', monospace",
color: 'var(--l1-foreground)',
caretColor: 'var(--l1-foreground)',
},
'.cm-line': { padding: '0' },
// Whole pixels: at 19.5px the placeholder line rounds 1px taller than text.
'.cm-scroller': { fontFamily: 'inherit', lineHeight: '20px' },
'.cm-placeholder': { color: 'var(--l3-foreground)' },
});
export const BASIC_SETUP = {
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightActiveLineGutter: false,
highlightSelectionMatches: false,
bracketMatching: false,
closeBrackets: false,
autocompletion: false,
// Use the browser's native caret so it blinks (a CM-drawn cursor needs extra
// styling and doesn't blink by default).
drawSelection: false,
} as const;

View File

@@ -1,69 +0,0 @@
import { RULES_DSL_GRAMMAR } from 'container/ListAlertRules/list.config';
import { getSuggestions } from './dslSuggestions';
import type { SuggestionSource } from './types';
const source: SuggestionSource = {
reservedKeys: [
'alert_type',
'created_at',
'created_by',
'labels.<key>',
'name',
'rule_type',
'severity',
'updated_at',
'updated_by',
],
dynamicKeys: ['labels.team', 'labels.env'],
valuesByKey: { 'labels.team': ['infra'], 'labels.severity': ['critical'] },
creatorEmails: ['alice@x.io'],
currentUserEmail: 'me@x.io',
};
const suggest = (q: string): ReturnType<typeof getSuggestions>['items'] =>
getSuggestions(q, q.length, source, RULES_DSL_GRAMMAR).items;
const labels = (q: string): string[] => suggest(q).map((i) => i.label);
describe('getSuggestions — rules grammar', () => {
it('orders reserved keys, hides labels.<key> and appends label keys', () => {
expect(labels('')).toStrictEqual([
'name',
'severity',
'alert_type',
'rule_type',
'created_by',
'updated_by',
'created_at',
'updated_at',
'labels.team',
'labels.env',
]);
expect(suggest('labels.t').map((i) => i.detail)).toStrictEqual(['label']);
});
it('restricts enum keys to = != IN NOT IN and suggests their values', () => {
expect(labels('rule_type ')).toStrictEqual([
'rule_type =',
'rule_type !=',
'rule_type IN',
'rule_type NOT IN',
]);
expect(labels('rule_type = ')).toStrictEqual([
'threshold_rule',
'promql_rule',
'anomaly_rule',
]);
expect(labels("alert_type = 'LOGS")).toStrictEqual(['LOGS_BASED_ALERT']);
});
it('gives severity and labels.* the label operators and values', () => {
expect(labels('severity ')).toContain('severity EXISTS');
expect(labels('severity = ')).toStrictEqual(['critical']);
expect(labels('labels.team = ')).toStrictEqual(['infra']);
});
it('suggests user emails for updated_by', () => {
expect(labels('updated_by = ')).toStrictEqual(['me@x.io (me)', 'alice@x.io']);
});
});

View File

@@ -1,61 +0,0 @@
import type { CaretContext } from './dslTokenizer';
// `dynamic` keys (tags, labels) take string ops plus EXISTS / NOT EXISTS.
export type DslFieldType = 'string' | 'timestamp' | 'bool' | 'enum' | 'dynamic';
export interface DslField {
key: string;
type: DslFieldType;
values?: readonly string[];
}
// A list-filter DSL grammar, mirroring a backend `list_filter.go` allow-list.
export interface DslGrammar {
// Reserved fields in suggestion order. Any key not listed here is dynamic.
fields: readonly DslField[];
// `detail` labels dynamic keys in the popup. A reserved dynamic key (e.g.
// rules' `severity`) aliases `${prefix}${key}` for value lookups.
dynamicKey: { detail: string; prefix: string };
// Advertised by the API but not filterable (e.g. `labels.<key>`).
hiddenKeys?: readonly string[];
}
export type ClauseValue = string | boolean | readonly string[];
export type ClauseBuilder<K extends string> = (
key: K,
op: string,
value: ClauseValue,
) => string;
export interface SuggestionSource {
// Reserved keys from the list response, falling back to the grammar's.
reservedKeys?: string[];
// Dynamic keys exactly as typed (e.g. `team`, `labels.team`).
dynamicKeys: string[];
// Known values per dynamic key (lower-cased full key -> values).
valuesByKey: Record<string, string[]>;
// Values for `created_by` / `updated_by`.
creatorEmails: string[];
currentUserEmail?: string;
}
export interface Suggestion {
label: string;
insertText: string;
kind: 'key' | 'operator' | 'value' | 'connector';
// Right-aligned hint in the popup — e.g. 'field' vs 'tag' so reserved columns
// are distinguishable from dynamic keys.
detail?: string;
// Chars to move the caret back from the end of `insertText` after applying.
// Used to land the caret inside a `[...]` list so multi-select can continue.
caretOffset?: number;
}
export interface SuggestionsResult {
items: Suggestion[];
ctx: CaretContext;
}
// Relative "updated within" windows offered by the Updated filter dropdown.
export type UpdatedWindow = 'any' | 'today' | '7d' | '30d';

View File

@@ -1,9 +0,0 @@
export interface CreatorOption {
email: string;
label: string;
}
export type FilterAppliedType = 'createdBy' | 'updatedWindow';
// Analytics payload for a chip change (`valueCount` for Created by).
export type FilterAppliedPayload = Record<string, unknown>;

View File

@@ -1,46 +0,0 @@
.commandHeader {
display: flex;
align-items: center;
gap: 12px;
}
.headingBlock {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.grow {
flex: 1;
}
.dirtyDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--warning-background);
flex: none;
}
.countPill {
padding: 2px 9px;
border-radius: 999px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
color: var(--l2-foreground);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
}
.title {
color: var(--l1-foreground);
font-size: var(--font-size-lg);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: 28px;
letter-spacing: -0.09px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,264 +0,0 @@
import {
type ChangeEvent,
type ReactElement,
useCallback,
useState,
} from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { Bookmark, Plus, Search } from '@signozhq/icons';
import cx from 'classnames';
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
import ViewNamePopover from 'components/ViewNamePopover/ViewNamePopover';
import type { BuiltinListView, ViewSaveMode } from '../types';
import ViewsRailDirtyPanel from './ViewsRailDirtyPanel';
import ViewsRailItem from './ViewsRailItem';
import styles from './ViewsRail.module.scss';
interface Props {
activeViewId: string;
builtinViews: readonly BuiltinListView[];
customViews: { id: string; name: string }[];
customViewsLoading: boolean;
isCustomActive: boolean;
isModified: boolean;
collapsed?: boolean;
onSelect: (id: string) => void;
onSave: (name: string) => void;
onSaveChanges: () => void;
onReset: () => void;
onDelete: (id: string) => Promise<void>;
onRename: (id: string, name: string) => void;
/** Prefixes the rail's test ids, e.g. `<prefix>-view-<id>`. */
testIdPrefix: string;
saveTestIdPrefix?: string;
renameTestIdPrefix?: string;
customSectionLabel?: string;
deleteDescription?: string;
/** Wraps the rail body, e.g. in an authz guard. */
guard?: (content: ReactElement) => ReactElement;
onViewSaved?: (mode: ViewSaveMode) => void;
onViewRenamed?: () => void;
onViewDeleted?: () => void;
}
// Purely presentational — active view, dirty state, and handlers come from
// `useListViews`.
function ViewsRail({
activeViewId,
builtinViews,
customViews,
customViewsLoading,
isCustomActive,
isModified,
collapsed = false,
onSelect,
onSave,
onSaveChanges,
onReset,
onDelete,
onRename,
testIdPrefix,
saveTestIdPrefix = 'save-view',
renameTestIdPrefix = 'rename-view',
customSectionLabel = 'My views',
deleteDescription = 'This removes the saved view.',
guard,
onViewSaved,
onViewRenamed,
onViewDeleted,
}: Props): JSX.Element {
const [saveOpen, setSaveOpen] = useState(false);
const [query, setQuery] = useState('');
const { contextHolder, confirmDelete } = useDeleteConfirm();
const q = query.trim().toLowerCase();
const matchesQuery = (label: string): boolean =>
!q || label.toLowerCase().includes(q);
const personal = builtinViews.filter(
(v) => v.section === 'personal' && matchesQuery(v.label),
);
const system = builtinViews.filter(
(v) => v.section === 'system' && matchesQuery(v.label),
);
const custom = customViews.filter((v) => matchesQuery(v.name));
const noMatches =
!!q && personal.length === 0 && system.length === 0 && custom.length === 0;
const onConfirmDelete = useCallback(
(id: string, label: string): void => {
confirmDelete({
title: (
<Typography.Title level={5}>
Delete the{' '}
<Typography.Text className={styles.deleteName}>{label}</Typography.Text>{' '}
view?
</Typography.Title>
),
content: deleteDescription,
// Return the delete promise so the modal's Delete button stays loading
// until the backend call settles, then closes — matching dashboard delete.
onConfirm: (): Promise<void> => {
onViewDeleted?.();
return onDelete(id);
},
});
},
[confirmDelete, deleteDescription, onDelete, onViewDeleted],
);
const handleRename = useCallback(
(id: string, name: string): void => {
onViewRenamed?.();
onRename(id, name);
},
[onRename, onViewRenamed],
);
const handleSaveAsView = (name: string): void => {
onViewSaved?.('new');
onSave(name);
};
const handleSaveViewChanges = (): void => {
onViewSaved?.('update');
onSaveChanges();
};
const renderBuiltin = (v: BuiltinListView): JSX.Element => (
<ViewsRailItem
key={v.id}
id={v.id}
label={v.label}
icon={v.icon}
active={v.id === activeViewId}
isModified={isModified}
testIdPrefix={testIdPrefix}
renameTestIdPrefix={renameTestIdPrefix}
onSelect={onSelect}
/>
);
const content = (
<>
<div className={styles.header}>
<h4 className={styles.headerTitle}>Views</h4>
<ViewNamePopover
open={saveOpen}
onOpenChange={setSaveOpen}
onSubmit={handleSaveAsView}
title="Save as view"
confirmLabel="Save view"
testIdPrefix={saveTestIdPrefix}
tooltip="Save current filters as a view"
trigger={
<Button
aria-label="Save current filters as a view"
variant="ghost"
color="secondary"
size="sm"
icon
testId={`${testIdPrefix}-view-save-trigger`}
>
<Plus size={14} />
</Button>
}
/>
</div>
<div className={styles.search}>
<Input
value={query}
placeholder="Filter views by name"
prefix={<Search size={12} />}
testId={`${testIdPrefix}-view-search`}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setQuery(e.target.value)
}
/>
</div>
<div className={styles.scroll}>
{personal.length > 0 && (
<>
<div className={styles.groupLabel}>Personal</div>
{personal.map(renderBuiltin)}
</>
)}
{system.length > 0 && (
<>
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
System
</div>
{system.map(renderBuiltin)}
</>
)}
{(!q || custom.length > 0) && (
<>
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
{customSectionLabel}
<Typography.Text className={styles.groupCount}>
{customViews.length}
</Typography.Text>
</div>
{customViewsLoading ? (
<div className={styles.empty}>Loading views…</div>
) : customViews.length === 0 ? (
<div className={styles.empty}>
No saved views yet. Filter the list, then save it as a view.
</div>
) : (
custom.map((v) => (
<ViewsRailItem
key={v.id}
id={v.id}
label={v.name}
icon={Bookmark}
active={v.id === activeViewId}
isModified={isModified}
testIdPrefix={testIdPrefix}
renameTestIdPrefix={renameTestIdPrefix}
onSelect={onSelect}
onRename={handleRename}
onDelete={onConfirmDelete}
/>
))
)}
</>
)}
{noMatches && (
<div className={styles.searchEmpty}>
No views match &ldquo;{query}&rdquo;
</div>
)}
</div>
{isModified && (
<ViewsRailDirtyPanel
isCustomActive={isCustomActive}
testIdPrefix={testIdPrefix}
onSaveChanges={handleSaveViewChanges}
onSaveAs={(): void => setSaveOpen(true)}
onReset={onReset}
/>
)}
{contextHolder}
</>
);
return (
<aside className={cx(styles.rail, { [styles.collapsed]: collapsed })}>
{guard ? guard(content) : content}
</aside>
);
}
export default ViewsRail;

View File

@@ -1,87 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { Plus } from '@signozhq/icons';
import cx from 'classnames';
import styles from './ViewsRail.module.scss';
interface Props {
isCustomActive: boolean;
testIdPrefix: string;
onSaveChanges: () => void;
onSaveAs: () => void;
onReset: () => void;
}
function ViewsRailDirtyPanel({
isCustomActive,
testIdPrefix,
onSaveChanges,
onSaveAs,
onReset,
}: Props): JSX.Element {
if (isCustomActive) {
return (
<div className={styles.dirtyPanel}>
<div className={styles.dirtyTitle}>Unsaved changes</div>
<div className={styles.dirtyActions}>
<Button
variant="solid"
color="primary"
size="sm"
onClick={onSaveChanges}
testId={`${testIdPrefix}-view-save-changes`}
>
Save
</Button>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={onSaveAs}
testId={`${testIdPrefix}-view-save-as`}
>
Save as…
</Button>
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={onReset}
testId={`${testIdPrefix}-view-reset`}
>
Reset
</Button>
</div>
</div>
);
}
return (
<div className={cx(styles.dirtyPanel, styles.dirtyPanelDefault)}>
<div className={styles.dirtyTitle}>Filters active</div>
<div className={styles.dirtyActions}>
<Button
variant="solid"
color="primary"
size="sm"
prefix={<Plus size={12} />}
onClick={onSaveAs}
testId={`${testIdPrefix}-view-save-as-new`}
>
Save as new view
</Button>
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={onReset}
testId={`${testIdPrefix}-view-reset`}
>
Reset
</Button>
</div>
</div>
);
}
export default ViewsRailDirtyPanel;

View File

@@ -1,105 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { PenLine, Trash2 } from '@signozhq/icons';
import cx from 'classnames';
import ViewNamePopover from 'components/ViewNamePopover/ViewNamePopover';
import type { ListViewIcon } from '../types';
import styles from './ViewsRail.module.scss';
interface Props {
id: string;
label: string;
icon: ListViewIcon;
active: boolean;
isModified: boolean;
testIdPrefix: string;
renameTestIdPrefix: string;
onSelect: (id: string) => void;
/** Present only for custom (editable) views. */
onRename?: (id: string, name: string) => void;
onDelete?: (id: string, label: string) => void;
}
function ViewsRailItem({
id,
label,
icon: Icon,
active,
isModified,
testIdPrefix,
renameTestIdPrefix,
onSelect,
onRename,
onDelete,
}: Props): JSX.Element {
const [renaming, setRenaming] = useState(false);
return (
<div className={cx(styles.row, { [styles.rowActive]: active })}>
<Button
size="md"
variant="ghost"
color="secondary"
width="100%"
onClick={(): void => onSelect(id)}
testId={`${testIdPrefix}-view-${id}`}
prefix={<Icon size={16} className={styles.itemIcon} />}
suffix={
active && isModified ? (
<div className={styles.dirtyDot} title="Unsaved changes" />
) : undefined
}
>
{label}
</Button>
{onRename && onDelete && (
<div className={styles.itemActions}>
<ViewNamePopover
open={renaming}
onOpenChange={setRenaming}
onSubmit={(name): void => onRename(id, name)}
title="Rename view"
confirmLabel="Rename"
initialName={label}
testIdPrefix={renameTestIdPrefix}
tooltip="Rename view"
trigger={
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label="Rename view"
testId={`${testIdPrefix}-view-rename-${id}`}
onClick={(e): void => e.stopPropagation()}
>
<PenLine size={12} />
</Button>
}
/>
<Tooltip title="Delete view">
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label="Delete view"
testId={`${testIdPrefix}-view-delete-${id}`}
onClick={(e): void => {
e.stopPropagation();
onDelete(id, label);
}}
>
<Trash2 size={12} />
</Button>
</Tooltip>
</div>
)}
</div>
);
}
export default ViewsRailItem;

View File

@@ -1,151 +0,0 @@
import { Bookmark, Pin } from '@signozhq/icons';
import { render, screen, userEvent } from 'tests/test-utils';
import type { BuiltinListView } from '../../types';
import ViewsRail from '../ViewsRail';
const PREFIX = 'things';
const BUILTIN_VIEWS: BuiltinListView[] = [
{ id: 'mine', label: 'My things', icon: Pin, section: 'personal' },
{ id: 'all', label: 'All things', icon: Bookmark, section: 'system' },
];
const CUSTOM_VIEWS = [
{ id: 'view-1', name: 'Prod things' },
{ id: 'view-2', name: 'Staging things' },
];
type Props = React.ComponentProps<typeof ViewsRail>;
function renderRail(props?: Partial<Props>): Props {
const allProps: Props = {
activeViewId: 'all',
builtinViews: BUILTIN_VIEWS,
customViews: CUSTOM_VIEWS,
customViewsLoading: false,
isCustomActive: false,
isModified: false,
onSelect: jest.fn(),
onSave: jest.fn(),
onSaveChanges: jest.fn(),
onReset: jest.fn(),
onDelete: jest.fn(),
onRename: jest.fn(),
testIdPrefix: PREFIX,
...props,
};
render(<ViewsRail {...allProps} />);
return allProps;
}
describe('ViewsRail', () => {
it('groups built-in views by section and custom views under their label', () => {
renderRail({ customSectionLabel: 'Team views' });
expect(screen.getByText('Personal')).toBeInTheDocument();
expect(screen.getByText('System')).toBeInTheDocument();
expect(screen.getByText('Team views')).toBeInTheDocument();
['mine', 'all', 'view-1', 'view-2'].forEach((id) =>
expect(screen.getByTestId(`${PREFIX}-view-${id}`)).toBeInTheDocument(),
);
});
it('selects a view by id', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const props = renderRail();
await user.click(screen.getByTestId(`${PREFIX}-view-view-2`));
expect(props.onSelect).toHaveBeenCalledWith('view-2');
});
it('filters views by name and says when none match', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderRail();
const search = screen.getByTestId(`${PREFIX}-view-search`);
await user.type(search, 'prod');
expect(screen.getByTestId(`${PREFIX}-view-view-1`)).toBeInTheDocument();
expect(screen.queryByTestId(`${PREFIX}-view-view-2`)).not.toBeInTheDocument();
expect(screen.queryByTestId(`${PREFIX}-view-all`)).not.toBeInTheDocument();
await user.clear(search);
await user.type(search, 'nothing');
expect(screen.getByText(/No views match/)).toBeInTheDocument();
});
it('shows loading and empty states for custom views', () => {
const { unmount } = render(
<ViewsRail
activeViewId="all"
builtinViews={BUILTIN_VIEWS}
customViews={[]}
customViewsLoading
isCustomActive={false}
isModified={false}
onSelect={jest.fn()}
onSave={jest.fn()}
onSaveChanges={jest.fn()}
onReset={jest.fn()}
onDelete={jest.fn()}
onRename={jest.fn()}
testIdPrefix={PREFIX}
/>,
);
expect(screen.getByText('Loading views…')).toBeInTheDocument();
unmount();
renderRail({ customViews: [] });
expect(screen.getByText(/No saved views yet/)).toBeInTheDocument();
});
it('hides the unsaved-changes panel while filters match the view', () => {
renderRail();
expect(screen.queryByTestId(`${PREFIX}-view-reset`)).not.toBeInTheDocument();
});
it('offers save, save as and reset for a modified custom view', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onViewSaved = jest.fn();
const props = renderRail({
activeViewId: 'view-1',
isCustomActive: true,
isModified: true,
onViewSaved,
});
await user.click(screen.getByTestId(`${PREFIX}-view-save-changes`));
expect(onViewSaved).toHaveBeenCalledWith('update');
expect(props.onSaveChanges).toHaveBeenCalledTimes(1);
expect(screen.getByTestId(`${PREFIX}-view-save-as`)).toBeInTheDocument();
await user.click(screen.getByTestId(`${PREFIX}-view-reset`));
expect(props.onReset).toHaveBeenCalledTimes(1);
});
it('offers only save as new for a modified built-in view', () => {
renderRail({ isModified: true });
expect(screen.getByTestId(`${PREFIX}-view-save-as-new`)).toBeInTheDocument();
expect(
screen.queryByTestId(`${PREFIX}-view-save-changes`),
).not.toBeInTheDocument();
});
it('wraps its body in the guard it is given', () => {
renderRail({
guard: (content): JSX.Element => (
<div data-testid="rail-guard">{content}</div>
),
});
expect(screen.getByTestId('rail-guard')).toContainElement(
screen.getByTestId(`${PREFIX}-view-all`),
);
});
});

View File

@@ -1,43 +0,0 @@
import type { Pin } from '@signozhq/icons';
// All @signozhq icons share this component type.
export type ListViewIcon = typeof Pin;
export interface BuiltinListView {
id: string;
label: string;
icon: ListViewIcon;
section: 'personal' | 'system';
}
export interface EmptyStateCopy {
title: string;
description: string;
}
// `clientFilter` narrows the rows the server already returned.
export interface BuiltinViewConfig<
F,
Ctx = undefined,
Row = never,
> extends BuiltinListView {
filter: (current: F, ctx: Ctx) => F;
clientFilter?: (rows: Row[], ctx: Ctx) => Row[];
emptyState?: EmptyStateCopy;
}
export interface SavedListView<F> {
id: string;
name: string;
filter: F;
}
export interface SavedListViewsApi<F> {
views: SavedListView<F>[];
isLoading: boolean;
createView: (name: string, filter: F) => Promise<SavedListView<F> | null>;
updateView: (id: string, name: string, filter: F) => void;
deleteView: (id: string) => Promise<void>;
}
export type ViewSaveMode = 'new' | 'update';

View File

@@ -9,6 +9,7 @@ import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicat
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
@@ -22,7 +23,6 @@ import { useOptionsMenu } from 'container/OptionsMenu';
import { FontSize } from 'container/OptionsMenu/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { getBodyMessage } from 'lib/logs/logBody';
import { cloneDeep } from 'lodash-es';
import {
ArrowDown,
@@ -33,6 +33,8 @@ import {
TextSelect,
X,
} from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -65,6 +67,10 @@ function LogDetailInner({
const [selectedView, setSelectedView] = useState<VIEWS>(selectedTab);
const [isFilterVisible, setIsFilterVisible] = useState<boolean>(false);
const { featureFlags } = useAppContext();
const isBodyJsonQueryEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
const [filters, setFilters] = useState<TagFilter | null>(null);
const [isEdit, setIsEdit] = useState<boolean>(false);
@@ -167,7 +173,20 @@ function LogDetailInner({
}
};
const logBody = useMemo(() => getBodyMessage(log?.body), [log?.body]);
const logBody = useMemo(() => {
if (!isBodyJsonQueryEnabled) {
return (log?.body as string) ?? '';
}
// Feature enabled: body is always a map; message is always a string
const bodyObj = log?.body as ILogBody;
if (!bodyObj) {
return '';
}
if (bodyObj.message) {
return bodyObj.message;
}
return JSON.stringify(bodyObj);
}, [isBodyJsonQueryEnabled, log?.body]);
const htmlBody = useMemo(
() => ({

View File

@@ -4,7 +4,6 @@ import cx from 'classnames';
import { OPERATORS } from 'constants/queryBuilder';
import { FontSize } from 'container/OptionsMenu/types';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { ScalarFieldValue } from 'utils/stringUtils';
import './AddToQueryHOC.styles.scss';
@@ -41,10 +40,10 @@ function AddToQueryHOC({
export interface AddToQueryHOCProps {
fieldKey: string;
fieldValue: ScalarFieldValue;
fieldValue: string;
onAddToQuery: (
fieldKey: string,
fieldValue: ScalarFieldValue,
fieldValue: string,
operator: string,
dataType?: DataTypes,
) => void;

View File

@@ -5,19 +5,19 @@ import cx from 'classnames';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ChangeViewFunctionType } from 'hooks/useHandleExplorerTabChange';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
import { useIsDarkMode } from 'hooks/useDarkMode';
// utils
import { FlatLogData, getLogFieldValue } from 'lib/logs/flatLogData';
import { getBodyDisplay } from 'lib/logs/logBody';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
// interfaces
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { coerceToString, isScalarFieldValue } from 'utils/stringUtils';
// components
import AddToQueryHOC, { AddToQueryHOCProps } from '../AddToQueryHOC';
@@ -26,6 +26,7 @@ import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
import { getLogIndicatorType } from '../LogStateIndicator/utils';
// styles
import { Container, LogContainer, LogText } from './styles';
import { isValidLogField } from './util';
import './ListLogView.styles.scss';
@@ -36,9 +37,8 @@ interface LogFieldProps {
fontSize: FontSize;
}
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow' | 'fieldValue'> &
Pick<AddToQueryHOCProps, 'fieldValue'> &
Partial<Pick<AddToQueryHOCProps, 'onAddToQuery'>>;
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow'> &
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
function LogGeneralField({
fieldKey,
@@ -75,35 +75,27 @@ function LogSelectedField({
onAddToQuery,
fontSize,
}: LogSelectedFieldProps): JSX.Element {
const fieldKeyText = (
<Typography.Text>
<span
style={{ color: blue[4] }}
className={cx('selected-log-field-key', fontSize)}
>
{fieldKey}
</span>
</Typography.Text>
);
return (
<div className="log-selected-fields">
{onAddToQuery ? (
<AddToQueryHOC
fieldKey={fieldKey}
fieldValue={fieldValue}
onAddToQuery={onAddToQuery}
fontSize={fontSize}
>
{fieldKeyText}
</AddToQueryHOC>
) : (
fieldKeyText
)}
<AddToQueryHOC
fieldKey={fieldKey}
fieldValue={fieldValue}
onAddToQuery={onAddToQuery}
fontSize={fontSize}
>
<Typography.Text>
<span
style={{ color: blue[4] }}
className={cx('selected-log-field-key', fontSize)}
>
{fieldKey}
</span>
</Typography.Text>
</AddToQueryHOC>
<Typography.Text truncate={1} className={cx('selected-log-kv', fontSize)}>
<span className={cx('selected-log-field-key', fontSize)}>{': '}</span>
<span className={cx('selected-log-value', fontSize)}>
{coerceToString(fieldValue) || "''"}
{fieldValue || "''"}
</span>
</Typography.Text>
</div>
@@ -138,7 +130,6 @@ function ListLogView({
onClearActiveLog,
}: ListLogViewProps): JSX.Element {
const flattenLogData = useMemo(() => FlatLogData(logData), [logData]);
const isBodyJsonEnabled = useIsBodyJsonEnabled();
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
logData.id,
@@ -209,7 +200,7 @@ function ListLogView({
{updatedSelecedFields.some((field) => field.name === 'body') && (
<LogGeneralField
fieldKey="Log"
fieldValue={getBodyDisplay(logData.body)}
fieldValue={getBodyDisplayString(logData.body)}
linesPerRow={linesPerRow}
fontSize={fontSize}
/>
@@ -231,22 +222,17 @@ function ListLogView({
{updatedSelecedFields
.filter((field) => !['timestamp', 'body'].includes(field.name))
.map((field) => {
const value = getLogFieldValue(logData, field.name, isBodyJsonEnabled);
if (value == null) {
return null;
}
const queryValue = isScalarFieldValue(value) ? value : undefined;
return (
.map((field) =>
isValidLogField(flattenLogData[field.name] as never) ? (
<LogSelectedField
key={field.name}
fieldKey={field.name}
fieldValue={queryValue ?? coerceToString(value)}
onAddToQuery={queryValue === undefined ? undefined : onAddToQuery}
fieldValue={flattenLogData[field.name] as never}
onAddToQuery={onAddToQuery}
fontSize={fontSize}
/>
);
})}
) : null,
)}
</LogContainer>
</div>
</div>

View File

@@ -0,0 +1 @@
export const isValidLogField = (value: never): boolean => value !== undefined;

View File

@@ -9,16 +9,16 @@ import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
// hooks
import { useIsDarkMode } from 'hooks/useDarkMode';
import { getLogFieldValue } from 'lib/logs/flatLogData';
import { getBodyDisplay } from 'lib/logs/logBody';
import { isBoolean, isEmpty, isNumber, isUndefined } from 'lodash-es';
import { FlatLogData } from 'lib/logs/flatLogData';
import { isEmpty, isNumber, isUndefined } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { coerceToString } from 'utils/stringUtils';
import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButtons';
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
@@ -46,7 +46,7 @@ function RawLogView({
isLogsExplorerPage,
onLogCopy,
} = useCopyLogLink(data.id);
const isBodyJsonEnabled = useIsBodyJsonEnabled();
const flattenLogData = useMemo(() => FlatLogData(data), [data]);
const isDarkMode = useIsDarkMode();
const isReadOnlyLog = !isLogsExplorerPage || isReadOnly;
@@ -60,16 +60,15 @@ function RawLogView({
const attributesValues = updatedSelecedFields
.filter((field) => !['timestamp', 'body'].includes(field.name))
.map((field) => getLogFieldValue(data, field.name, isBodyJsonEnabled))
.map((field) => flattenLogData[field.name])
.filter((attribute) => {
// loadash isEmpty doesnot work with numbers
if (isNumber(attribute) || isBoolean(attribute)) {
if (isNumber(attribute)) {
return true;
}
return !isUndefined(attribute) && !isEmpty(attribute);
})
.map((attribute) => coerceToString(attribute));
});
let attributesText = attributesValues.join(' | ');
@@ -103,7 +102,7 @@ function RawLogView({
// Check if body is selected
const showBody = selectedFields.some((field) => field.name === 'body');
if (showBody) {
parts.push(`${attributesText} ${getBodyDisplay(data.body)}`);
parts.push(`${attributesText} ${getBodyDisplayString(data.body)}`);
} else {
parts.push(attributesText);
}

View File

@@ -1,20 +1,16 @@
import { renderHook } from '@testing-library/react';
import { FontSize } from 'container/OptionsMenu/types';
import { render, renderHook, screen } from 'tests/test-utils';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { useLogsTableColumns } from '../useLogsTableColumns';
jest.mock('providers/Timezone', () => ({
__esModule: true,
...jest.requireActual('providers/Timezone'),
useTimezone: (): { formatTimezoneAdjustedTimestamp: jest.Mock } => ({
formatTimezoneAdjustedTimestamp: jest.fn(() => 'TS'),
}),
}));
jest.mock('providers/App/App', () => ({
...jest.requireActual('providers/App/App'),
useAppContext: (): { featureFlags: [] } => ({ featureFlags: [] }),
}));
@@ -169,36 +165,3 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
expect(result.current.map((c) => c.id)).toStrictEqual(['state-indicator']);
});
});
describe('useLogsTableColumns — cell values', () => {
const asLog = (partial: Partial<ILog>): ILog => partial as unknown as ILog;
it('shows an empty body cell for a row without a body', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('body', 'log')],
fontSize: FontSize.SMALL,
}),
);
const bodyCol = result.current.find((c) => c.id === 'log:body');
expect(bodyCol?.accessorFn?.(asLog({}))).toBe('');
});
it('renders an object-valued field as JSON, not [object Object]', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('order')],
fontSize: FontSize.SMALL,
}),
);
const orderCol = result.current.find((c) => c.id === 'order');
const Cell = orderCol?.cell as (props: { value: unknown }) => JSX.Element;
render(<Cell value={{ id: 8412, coupons: ['WELCOME10'] }} />);
expect(
screen.getByText('{"id":8412,"coupons":["WELCOME10"]}'),
).toBeInTheDocument();
});
});

View File

@@ -2,16 +2,18 @@ import type { ReactElement } from 'react';
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { FeatureKeys } from 'constants/features';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
import { getLogFieldValue } from 'lib/logs/flatLogData';
import { getBodyDisplay } from 'lib/logs/logBody';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { coerceToString } from 'utils/stringUtils';
import type { TableColumnDef } from '../../TanStackTableView/types';
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
@@ -26,7 +28,10 @@ export function useLogsTableColumns({
fontSize,
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const isBodyJsonEnabled = useIsBodyJsonEnabled();
const { featureFlags } = useAppContext();
const isBodyJsonEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
return useMemo<TableColumnDef<ILog>[]>(() => {
const stateIndicatorCol: TableColumnDef<ILog> = {
@@ -70,7 +75,7 @@ export function useLogsTableColumns({
const bodyCol: TableColumnDef<ILog> = {
id: buildCompositeKey('body', 'log'),
header: 'Body',
accessorFn: (log): string => getBodyDisplay(log.body),
accessorFn: (log): string => getBodyDisplayString(log.body),
canBeHidden: false,
enableRemove: false,
width: { default: '100%', min: 300 },
@@ -94,7 +99,7 @@ export function useLogsTableColumns({
enableRemove: true,
width: { min: 192 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{coerceToString(value)}</TanStackTable.Text>
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
),
});

View File

@@ -18,51 +18,71 @@
right: -2px;
width: 240px;
[data-format-menu='back'],
[data-format-menu='font-option'],
[data-format-menu='font-size'],
[data-format-menu='edit-columns'] {
--button-justify-content: flex-start;
}
[data-format-menu='font-option'],
[data-format-menu='font-size'],
[data-format-menu='edit-columns'] {
--button-label-min-width: 100%;
--button-font-size: 13px;
--button-variant-link-font-weight: 400;
--button-size-line-height: 20px;
}
[data-format-menu='back'] {
text-transform: uppercase;
}
[data-format-menu='font-option'],
[data-format-menu='font-size'] {
text-transform: capitalize;
}
.menu-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.font-size-dropdown {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
.horizontal-line {
margin-inline: -12px;
.back-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 12px;
border: none !important;
box-shadow: none !important;
.icon {
flex-shrink: 0;
}
.text {
color: var(--l1-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: 0.14px;
text-transform: uppercase;
}
}
.back-btn:hover {
background-color: unset !important;
}
.content {
display: flex;
flex-direction: column;
.option-btn {
display: flex;
align-items: center;
padding: 12px;
border: none !important;
box-shadow: none !important;
justify-content: space-between;
.icon {
flex-shrink: 0;
}
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal; /* 142.857% */
letter-spacing: 0.14px;
text-transform: capitalize;
}
.text:hover {
color: var(--l2-foreground);
}
}
.option-btn:hover {
background-color: unset !important;
}
}
}
@@ -82,6 +102,31 @@
letter-spacing: 0.88px;
text-transform: uppercase;
}
.value {
display: flex;
height: 20px;
padding: 4px 0px;
justify-content: space-between;
align-items: center;
border: none !important;
.font-value {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
text-transform: capitalize;
}
.icon {
}
}
.value:hover {
background-color: unset !important;
}
}
.menu-container {
@@ -153,10 +198,90 @@
cursor: pointer;
}
}
.max-lines-per-row-input {
display: flex;
.ant-input-number-handler-wrap {
display: none;
}
.ant-input-number {
min-width: 36px;
width: auto;
border-right: none;
border-left: none;
border-top: 1px solid var(--l1-border);
border-bottom: 1px solid var(--l1-border);
text-align: center;
height: 26px;
border-radius: 0;
&:active,
&:focus {
border: none;
box-shadow: none;
}
}
.ant-input-number-focused {
box-shadow: none !important;
}
.ant-input-number-input-wrap {
input {
text-align: center;
font-size: 13px;
&:active,
&:focus {
border: none;
}
}
&:active,
&:focus {
border: none;
}
}
.periscope-btn {
box-shadow: none;
padding: 6px 12px;
height: 26px;
border-radius: 0px 1px 1px 0px;
background: var(--l3-background);
}
}
}
.edit-columns-container {
padding: 12px;
.edit-columns-btn {
display: flex;
width: 100%;
height: 20px;
padding: 4px 0px;
justify-content: space-between;
align-items: center;
border: none !important;
.edit-columns-text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
}
}
.edit-columns-btn:hover {
background-color: unset !important;
}
}
.add-new-column-container {

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { Popover, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { InputNumber } from '@signozhq/ui/input-number';
import { Button, InputNumber, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import {
LogViewMode,
@@ -12,15 +11,13 @@ import {
Check,
ChevronLeft,
ChevronRight,
Minus,
Plus,
SlidersVertical,
} from '@signozhq/icons';
import './LogsFormatOptionsMenu.styles.scss';
const MIN_LINES_PER_ROW = 1;
const MAX_LINES_PER_ROW = 10;
const FONT_SIZES = [FontSize.SMALL, FontSize.MEDIUM, FontSize.LARGE];
interface LogsFormatOptionsMenuProps {
items: any;
selectedOptionFormat: any;
@@ -65,11 +62,25 @@ function OptionsMenu({
onChange(key);
};
const handleLinesPerRowChange = (value: number | null): void => {
if (value !== null) {
setMaxLinesPerRow(
Math.min(Math.max(value, MIN_LINES_PER_ROW), MAX_LINES_PER_ROW),
);
const incrementMaxLinesPerRow = (): void => {
if (maxLinesPerRow < 10) {
setMaxLinesPerRow(maxLinesPerRow + 1);
}
};
const decrementMaxLinesPerRow = (): void => {
if (maxLinesPerRow > 1) {
setMaxLinesPerRow(maxLinesPerRow - 1);
}
};
const handleLinesPerRowChange = (maxLinesPerRow: number | null): void => {
if (
maxLinesPerRow &&
Number.isInteger(maxLinesNumber) &&
maxLinesPerRow > 1
) {
setMaxLinesPerRow(maxLinesPerRow);
}
};
@@ -100,36 +111,51 @@ function OptionsMenu({
{isFontSizeOptionsOpen ? (
<div className="font-size-dropdown">
<Button
variant="link"
color="secondary"
size="sm"
width="100%"
data-format-menu="back"
testId="font-size-back"
prefix={<ChevronLeft size={14} />}
onClick={(): void => setIsFontSizeOptionsOpen(false)}
className="back-btn"
type="text"
>
Select font size
<ChevronLeft size={14} className="icon" />
<Typography.Text className="text">Select font size</Typography.Text>
</Button>
<div className="horizontal-line" />
<div className="content">
{FONT_SIZES.map((size) => (
<Button
key={size}
variant="link"
color="secondary"
size="sm"
width="100%"
data-format-menu="font-option"
testId={`font-size-option-${size}`}
onClick={(): void => setFontSizeValue(size)}
>
<span className="menu-row">
{size}
{fontSizeValue === size && <Check size={14} />}
</span>
</Button>
))}
<Button
onClick={(): void => {
setFontSizeValue(FontSize.SMALL);
}}
className="option-btn"
type="text"
>
<Typography.Text className="text">{FontSize.SMALL}</Typography.Text>
{fontSizeValue === FontSize.SMALL && (
<Check size={14} className="icon" />
)}
</Button>
<Button
onClick={(): void => {
setFontSizeValue(FontSize.MEDIUM);
}}
className="option-btn"
type="text"
>
<Typography.Text className="text">{FontSize.MEDIUM}</Typography.Text>
{fontSizeValue === FontSize.MEDIUM && (
<Check size={14} className="icon" />
)}
</Button>
<Button
onClick={(): void => {
setFontSizeValue(FontSize.LARGE);
}}
className="option-btn"
type="text"
>
<Typography.Text className="text">{FontSize.LARGE}</Typography.Text>
{fontSizeValue === FontSize.LARGE && (
<Check size={14} className="icon" />
)}
</Button>
</div>
</div>
) : (
@@ -137,20 +163,14 @@ function OptionsMenu({
<div className="font-size-container">
<div className="title">Font Size</div>
<Button
variant="link"
color="secondary"
size="sm"
width="100%"
data-format-menu="font-size"
testId="font-size-select"
className="value"
type="text"
onClick={(): void => {
setIsFontSizeOptionsOpen(true);
}}
>
<span className="menu-row">
{fontSizeValue}
<ChevronRight size={14} />
</span>
<Typography.Text className="font-value">{fontSizeValue}</Typography.Text>
<ChevronRight size={14} className="icon" />
</Button>
</div>
<div className="horizontal-line" />
@@ -180,20 +200,30 @@ function OptionsMenu({
<div className="horizontal-line" />
<div className="max-lines-per-row">
<div className="title"> max lines per row </div>
<InputNumber
controls
mode="spinner"
size="small"
min={MIN_LINES_PER_ROW}
max={MAX_LINES_PER_ROW}
precision={0}
value={maxLinesPerRow}
onChange={handleLinesPerRowChange}
aria-label="Max lines per row"
decrementAriaLabel="Fewer lines per row"
incrementAriaLabel="More lines per row"
testId="max-lines-per-row"
/>
<div className="raw-format max-lines-per-row-input">
<button
type="button"
className="periscope-btn"
onClick={decrementMaxLinesPerRow}
>
{' '}
<Minus size={12} />{' '}
</button>
<InputNumber
min={1}
max={10}
value={maxLinesPerRow}
onChange={handleLinesPerRowChange}
/>
<button
type="button"
className="periscope-btn"
onClick={incrementMaxLinesPerRow}
>
{' '}
<Plus size={12} />{' '}
</button>
</div>
</div>
</>
)}
@@ -203,18 +233,15 @@ function OptionsMenu({
<div className="horizontal-line" />
<div className="edit-columns-container">
<Button
variant="link"
color="secondary"
size="sm"
width="100%"
data-format-menu="edit-columns"
className="edit-columns-btn"
type="text"
onClick={handleEditColumns}
testId="periscope-btn-edit-columns"
data-testid="periscope-btn-edit-columns"
>
<span className="menu-row">
<Typography.Text className="edit-columns-text">
Edit columns
<ChevronRight size={14} />
</span>
</Typography.Text>
<ChevronRight size={14} className="icon" />
</Button>
</div>
</>
@@ -253,15 +280,10 @@ function LogsFormatOptionsMenu({
>
<Tooltip title="Options">
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label="Options"
testId="periscope-btn-format-options"
>
<SlidersVertical size={14} />
</Button>
className="periscope-btn ghost"
icon={<SlidersVertical size="md" />}
data-testid="periscope-btn-format-options"
/>
</Tooltip>
</Popover>
);

View File

@@ -1,11 +1,5 @@
import { FontSize } from 'container/OptionsMenu/types';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { fireEvent, render, waitFor } from 'tests/test-utils';
import LogsFormatOptionsMenu from '../LogsFormatOptionsMenu';
@@ -113,12 +107,18 @@ describe('LogsFormatOptionsMenu (unit)', () => {
});
it('increments max-lines and calls onChange', async () => {
const { maxLinesOnChange, getByTestId } = setup();
const { maxLinesOnChange } = setup();
// Increment max lines
const input = getByTestId('max-lines-per-row') as HTMLInputElement;
const input = document.querySelector(
'.max-lines-per-row-input input',
) as HTMLInputElement;
const initial = Number(input.value);
fireEvent.click(screen.getByRole('button', { name: 'More lines per row' }));
const buttons = document.querySelectorAll(
'.max-lines-per-row-input .periscope-btn',
);
const incrementBtn = buttons[1] as HTMLElement;
fireEvent.click(incrementBtn);
await waitFor(() => {
expect(Number(input.value)).toBe(initial + 1);
@@ -128,47 +128,28 @@ describe('LogsFormatOptionsMenu (unit)', () => {
});
});
it('clamps a typed max-lines value to 10', async () => {
const { maxLinesOnChange, getByTestId } = setup();
const input = getByTestId('max-lines-per-row') as HTMLInputElement;
const user = userEvent.setup();
await user.clear(input);
await user.type(input, '15');
await user.tab();
await waitFor(() => expect(input.value).toBe('10'));
expect(maxLinesOnChange).toHaveBeenLastCalledWith(10);
});
it('stops max-lines at 10', async () => {
const { getByTestId } = setup();
const input = getByTestId('max-lines-per-row') as HTMLInputElement;
const increment = screen.getByRole('button', { name: 'More lines per row' });
const user = userEvent.setup();
for (let i = 0; i < 12; i++) {
await user.click(increment);
}
await waitFor(() => expect(input.value).toBe('10'));
});
it('changes font size to MEDIUM and calls onChange', async () => {
const { fontSizeOnChange, getByTestId } = setup();
const { fontSizeOnChange } = setup();
// Open font dropdown
fireEvent.click(getByTestId('font-size-select'));
const fontButton = document.querySelector(
'.font-size-container .value',
) as HTMLElement;
fireEvent.click(fontButton);
// Choose MEDIUM
fireEvent.click(getByTestId(`font-size-option-${FontSize.MEDIUM}`));
const optionButtons = Array.from(
document.querySelectorAll('.font-size-dropdown .option-btn'),
);
const mediumBtn = optionButtons[1] as HTMLElement;
fireEvent.click(mediumBtn);
await waitFor(() => {
expect(
getByTestId(`font-size-option-${FontSize.MEDIUM}`).querySelector('svg'),
).toBeInTheDocument();
document.querySelectorAll('.font-size-dropdown .option-btn .icon'),
).toHaveLength(1);
expect(
getByTestId(`font-size-option-${FontSize.SMALL}`).querySelector('svg'),
).not.toBeInTheDocument();
(optionButtons[1] as Element).querySelector('.icon'),
).toBeInTheDocument();
});
await waitFor(() => {
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);

View File

@@ -26,7 +26,6 @@ import {
RAW_QUERY_FIELDS,
resolveQueryBuilderFields,
} from '../../queryBuilderFields.utils';
import { parseLimit, sanitizeLimit } from '../../utils';
import BucketOptions from './BucketOptions/BucketOptions';
import HavingFilter from './HavingFilter/HavingFilter';
@@ -279,7 +278,7 @@ function QueryAddOns({
const handleChangeLimit = useCallback(
(value: string) => {
handleChangeQueryData('limit', parseLimit(value));
handleChangeQueryData('limit', Number(value) || null);
},
[handleChangeQueryData],
);
@@ -375,7 +374,6 @@ function QueryAddOns({
label="Limit"
type="number"
onChange={handleChangeLimit}
sanitize={sanitizeLimit}
initialValue={query?.limit ?? undefined}
placeholder={placeholderOf(QueryBuilderField.Limit)}
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}

View File

@@ -169,15 +169,8 @@ describe('QueryAddOns', () => {
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
expect(input.value).toBe('5');
const user = userEvent.setup();
await user.clear(input);
await user.type(input, '10');
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 10);
await user.clear(input);
await user.type(input, '-5');
expect(input.value).toBe('5');
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 5);
fireEvent.change(input, { target: { value: '10' } });
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
});
it.each([

View File

@@ -12,62 +12,10 @@ import {
convertExpressionToFilters,
convertFiltersToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
formatValueForExpression,
parseLimit,
removeKeysFromExpression,
sanitizeLimit,
} from '../utils';
describe('convertHavingToExpression', () => {
it.each([
['COUNT()', 'count() > 5'],
['COUNT_DISTINCT(service.name)', 'count_distinct(service.name) > 5'],
['P99(duration_nano)', 'p99(duration_nano) > 5'],
['SUM(http.Request.Size)', 'sum(http.Request.Size) > 5'],
['count()', 'count() > 5'],
])('references %s by its lower-case function', (columnName, expected) => {
expect(
convertHavingToExpression([{ columnName, op: '>', value: 5 }]),
).toStrictEqual({ expression: expected });
});
it('joins several conditions and formats list values', () => {
expect(
convertHavingToExpression([
{ columnName: 'COUNT()', op: '>', value: 5 },
{ columnName: 'COUNT()', op: 'NOT IN', value: [1, 2] },
]),
).toStrictEqual({ expression: 'count() > 5 AND count() NOT IN [1, 2]' });
});
});
describe('sanitizeLimit', () => {
it.each([
['10', '10'],
['-10', '10'],
['2.5', '25'],
['1e3', '13'],
['', ''],
])('sanitizes %j to %j', (value, expected) => {
expect(sanitizeLimit(value)).toBe(expected);
});
});
describe('parseLimit', () => {
it.each([
['10', 10],
['1', 1],
['', null],
['0', null],
['-5', null],
['2.5', null],
['abc', null],
])('parses %j as %j', (value, expected) => {
expect(parseLimit(value)).toBe(expected);
});
});
describe('convertFiltersToExpression', () => {
beforeEach(() => {
jest.clearAllMocks();

View File

@@ -840,16 +840,6 @@ export const appendAndClause = (
return hasTopLevelOr ? `(${base}) AND ${clause}` : `${base} AND ${clause}`;
};
/** Strips everything but digits, so a limit input can't hold a sign or decimal. */
export const sanitizeLimit = (value: string): string =>
value.replace(/\D/g, '');
/** A query or formula limit: a positive integer, otherwise none. */
export const parseLimit = (value: string): number | null => {
const limit = Number(value);
return Number.isInteger(limit) && limit > 0 ? limit : null;
};
/**
* Convert old having format to new having format
* @param having - Array of old having objects with columnName, op, and value
@@ -881,14 +871,7 @@ export const convertHavingToExpression = (
formattedValue = String(value);
}
// The old having filter upper-cased the function (`COUNT()`); v5 only
// resolves the aggregation's own lower-case expression.
const reference = columnName.replace(
/^\s*([A-Za-z_]\w*)\(/,
(_, fn: string) => `${fn.toLowerCase()}(`,
);
return `${reference} ${op} ${formattedValue}`;
return `${columnName} ${op} ${formattedValue}`;
})
.filter((expression) => expression !== ''); // Remove empty expressions

View File

@@ -1,38 +0,0 @@
import { useCallback } from 'react';
export type UseRowNavigationParams<TData> = {
rows: TData[];
activeIndex: number;
onNavigate: (row: TData, index: number) => void;
};
export type UseRowNavigationResult = {
goToPrev: () => void;
goToNext: () => void;
isPrevDisabled: boolean;
isNextDisabled: boolean;
};
/** Steps the active row through the loaded rows; stops at either end. */
export function useRowNavigation<TData>({
rows,
activeIndex,
onNavigate,
}: UseRowNavigationParams<TData>): UseRowNavigationResult {
const isPrevDisabled = activeIndex <= 0;
const isNextDisabled = activeIndex < 0 || activeIndex >= rows.length - 1;
const goToPrev = useCallback((): void => {
if (!isPrevDisabled) {
onNavigate(rows[activeIndex - 1], activeIndex - 1);
}
}, [isPrevDisabled, onNavigate, rows, activeIndex]);
const goToNext = useCallback((): void => {
if (!isNextDisabled) {
onNavigate(rows[activeIndex + 1], activeIndex + 1);
}
}, [isNextDisabled, onNavigate, rows, activeIndex]);
return { goToPrev, goToNext, isPrevDisabled, isNextDisabled };
}

View File

@@ -1,26 +0,0 @@
.savePopover {
display: flex;
flex-direction: column;
gap: 8px;
width: 280px;
padding: 4px;
}
.saveTitle {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--l1-foreground);
}
.saveLabel {
font-size: var(--font-size-xs);
color: var(--l3-foreground);
margin-top: 2px;
}
.saveActions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}

View File

@@ -8,3 +8,35 @@ export enum Events {
TOOLTIP_CONTENT_SCROLLED = 'TOOLTIP_CONTENT_SCROLLED',
TOOLTIP_SYNC_MODE_CHANGED = 'TOOLTIP_SYNC_MODE_CHANGED',
}
export enum InfraMonitoringEvents {
PageVisited = 'Infra Monitoring: page visited',
PageNumberChanged = 'Infra Monitoring: page number changed',
GroupByChanged = 'Infra Monitoring: group by changed',
FilterApplied = 'Infra Monitoring: filter applied',
ItemClicked = 'Infra Monitoring: item clicked',
TabChanged = 'Infra Monitoring: tab changed',
TimeUpdated = 'Infra Monitoring: time updated',
ExploreClicked = 'Infra Monitoring: explore clicked',
HostEntity = 'host',
K8sEntity = 'k8s',
ListPage = 'list',
DetailedPage = 'detailed',
LogsView = 'logs',
TracesView = 'traces',
EventsView = 'events',
QuickFiltersView = 'quick filters',
MetricsView = 'metrics',
Total = 'total',
Cluster = 'cluster',
Container = 'container',
DaemonSet = 'daemonSet',
Deployment = 'deployment',
Job = 'job',
Namespace = 'namespace',
Node = 'node',
Volume = 'volume',
Pod = 'pod',
StatefulSet = 'statefulSet',
Volumes = 'volumes',
}

View File

@@ -13,7 +13,6 @@ export enum LOCALSTORAGE {
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
AI_OBSERVABILITY_PANEL_COLUMNS = 'AI_OBSERVABILITY_PANEL_COLUMNS',
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
@@ -45,7 +44,6 @@ export enum LOCALSTORAGE {
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
ALERT_RULES_LIST_VIEWS = 'ALERT_RULES_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
}

View File

@@ -26,7 +26,6 @@ import {
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
import { openInNewTab } from 'utils/navigation';
import {
ArchiveRestore,
@@ -48,19 +47,14 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { getOpenResourceTarget } from './utils/getOpenResourceTarget';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { isOnResourcePage } from './utils/isOnResourcePage';
import { openSavedViewByKey } from './utils/openSavedView';
import {
isSavedViewOpenAction,
resolveOpenResourceType,
resolveResourceId,
resolveSavedViewSourceHint,
} from './utils/resolveOpenResource';
import { ResourceType } from './utils/resourceRoute';
import { ResourceType, resourceRoute } from './utils/resourceRoute';
import styles from './ActionsSection.module.scss';
@@ -339,11 +333,6 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
);
return;
}
// Read before toUrlCompositeQuery, which drops the requestType envelope.
const panelType = getPanelTypeForRequestType(
requestTypeFromActionQuery(action.query),
action.signal,
);
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
// eslint-disable-next-line no-console
console.log('[apply_filter] normalized', normalized);
@@ -353,9 +342,7 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
normalized.builder.queryData.forEach((q, i) => {
deps.handleSetQueryData(i, q);
});
deps.redirectWithQueryBuilderData(normalized, {
[QueryParams.panelTypes]: panelType,
});
deps.redirectWithQueryBuilderData(normalized);
return;
}
const base = explorerRouteForSignal(action.signal);
@@ -366,10 +353,8 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → navigate', base);
const url = buildExplorerNavigationUrl(base, normalized, {
[QueryParams.panelTypes]: panelType,
});
navigate(url);
const encoded = encodeURIComponent(JSON.stringify(normalized));
navigate(`${base}?${QueryParams.compositeQuery}=${encoded}`);
}
/** Picks the right rollback API call for a given action kind. */
@@ -398,7 +383,7 @@ export default function ActionsSection({
actions,
messageId,
}: ActionsSectionProps): JSX.Element | null {
const { pathname, search } = useAppLocation();
const { pathname } = useAppLocation();
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
@@ -494,17 +479,23 @@ export default function ActionsSection({
return;
}
const target = getOpenResourceTarget(action);
if (!target) {
const resourceType = resolveOpenResourceType(action);
const resourceId = resolveResourceId(action);
if (!resourceType || !resourceId) {
return;
}
const path = resourceRoute(resourceType, resourceId);
if (!path) {
return;
}
void logEvent(AIAssistantEvents.ResourceOpened, {
threadId,
messageId,
targetModule: targetModuleForResource(target.resourceType),
resourceId: target.resourceId,
targetModule: targetModuleForResource(resourceType),
resourceId,
});
navigate(target.path);
navigate(path);
};
const handleClick = (key: string, action: MessageActionDTO): void => {
@@ -602,10 +593,6 @@ export default function ActionsSection({
// is still surfaced visually via the suffix pill below.
const tooltip = isError ? result.error : (action.tooltip ?? undefined);
const openTarget = getOpenResourceTarget(action);
const isViewingResource =
openTarget !== null &&
isOnResourcePage(openTarget.path, { pathname, search });
let icon: JSX.Element;
if (isSuccess) {
@@ -618,19 +605,14 @@ export default function ActionsSection({
const chip = (
<Button
disabledTooltip={
isViewingResource
? "You're already viewing this page"
: 'This action already ran'
}
disabledTooltip="This action already ran"
variant="outlined"
color="secondary"
size="sm"
onClick={(): void => handleClick(key, action)}
disabled={isSuccess || isViewingResource}
disabled={isSuccess}
loading={isLoading}
aria-label={action.label}
testId={`ai-action-chip-${action.kind}`}
prefix={icon}
>
<span className={styles.chipLabel}>{action.label}</span>

View File

@@ -1,99 +0,0 @@
import {
MessageActionDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import logEvent from 'api/common/logEvent';
import { getCurrentLocation, navigate } from 'lib/router/navigation';
import { act, render, screen, userEvent, waitFor } from 'tests/test-utils';
import { AIAssistantEvents } from '../../../events';
import ActionsSection from '../ActionsSection';
jest.mock('api/common/logEvent');
const mockLogEvent = jest.mocked(logEvent);
const OPEN_ALERT: MessageActionDTO = {
kind: MessageActionKindDTO.open_resource,
label: 'Open alert',
resourceType: 'alert',
resourceId: 'rule-1',
actionMetadataId: 'meta-1',
};
const OPEN_ALERT_CHIP = 'ai-action-chip-open_resource';
describe('ActionsSection open_resource', () => {
beforeEach(() => {
mockLogEvent.mockClear();
});
it.each([
'/alerts/edit?ruleId=rule-1&tab=1',
'/alerts/overview?ruleId=rule-1&relativeTime=6h',
])('disables Open alert while that alert is open at %s', async (route) => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection actions={[OPEN_ALERT]} messageId="message-1" />,
undefined,
{ initialRoute: route },
);
const chip = screen.getByTestId(OPEN_ALERT_CHIP);
// With a disabled tooltip the button is aria-disabled so the tooltip stays reachable.
expect(chip).toHaveAttribute('aria-disabled', 'true');
await user.click(chip);
expect(`${getCurrentLocation().pathname}${getCurrentLocation().search}`).toBe(
route,
);
expect(mockLogEvent).not.toHaveBeenCalled();
});
it.each(['/alerts/edit?ruleId=rule-2', '/alerts/history?ruleId=rule-1'])(
'opens the alert from %s',
async (route) => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection actions={[OPEN_ALERT]} messageId="message-2" />,
undefined,
{ initialRoute: route },
);
const chip = screen.getByTestId(OPEN_ALERT_CHIP);
expect(chip).not.toHaveAttribute('aria-disabled', 'true');
await user.click(chip);
await waitFor(() =>
expect(
`${getCurrentLocation().pathname}${getCurrentLocation().search}`,
).toBe('/alerts/edit?ruleId=rule-1'),
);
expect(mockLogEvent).toHaveBeenCalledWith(
AIAssistantEvents.ResourceOpened,
expect.objectContaining({ resourceId: 'rule-1' }),
);
},
);
it('enables Open alert again after navigating away from that alert', () => {
render(
<ActionsSection actions={[OPEN_ALERT]} messageId="message-3" />,
undefined,
{ initialRoute: '/alerts/edit?ruleId=rule-1' },
);
expect(screen.getByTestId(OPEN_ALERT_CHIP)).toHaveAttribute(
'aria-disabled',
'true',
);
act(() => navigate('/alerts/edit?ruleId=rule-2'));
expect(screen.getByTestId(OPEN_ALERT_CHIP)).not.toHaveAttribute(
'aria-disabled',
'true',
);
});
});

View File

@@ -1,16 +1,7 @@
import { undoExecution } from 'api/ai-assistant/chat';
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
ApplyFilterSignalDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getCurrentLocation } from 'lib/router/navigation';
import { MessageActionKindDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import ActionsSection from '../ActionsSection';
@@ -37,37 +28,6 @@ function renderUndoChip(): HTMLElement {
return screen.getByRole('button', { name: 'Undo change' });
}
// Labels differ per test: auto-applied filters are deduped module-wide by label.
function countByServiceAction(label: string): MessageActionDTO {
return {
kind: MessageActionKindDTO.apply_filter,
label,
signal: ApplyFilterSignalDTO.logs,
query: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ name: 'service.name' }],
},
},
],
},
},
};
}
function currentPanelTypesParam(): string | null {
return new URLSearchParams(getCurrentLocation().search).get(
QueryParams.panelTypes,
);
}
describe('ActionsSection rollback chip', () => {
beforeEach(() => {
mockUndo.mockReset();
@@ -121,52 +81,3 @@ describe('ActionsSection rollback chip', () => {
).resolves.toBeInTheDocument();
});
});
describe('ActionsSection apply_filter', () => {
it('opens the explorer in the Table view when the chip is clicked from another page', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection
actions={[countByServiceAction('Open in Logs Explorer')]}
messageId="message-1"
/>,
undefined,
{ initialRoute: ROUTES.HOME },
);
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentPanelTypesParam()).toBe(JSON.stringify(PANEL_TYPES.TABLE));
});
it('switches the open explorer to the Table view when applied on it', async () => {
const redirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['redirectWithQueryBuilderData']
>;
const handleSetQueryData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['handleSetQueryData']
>;
render(
<ActionsSection
actions={[countByServiceAction('Apply on Logs Explorer')]}
messageId="message-2"
/>,
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 },
);
});
});

View File

@@ -1,106 +0,0 @@
import { ApplyFilterSignalDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from '../getPanelTypeForRequestType';
describe('getPanelTypeForRequestType', () => {
it.each([
[
Querybuildertypesv5RequestTypeDTO.scalar,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.TABLE,
],
[
Querybuildertypesv5RequestTypeDTO.time_series,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.TIME_SERIES,
],
[
Querybuildertypesv5RequestTypeDTO.raw,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.LIST,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.traces,
PANEL_TYPES.TRACE,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.LIST,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.metrics,
PANEL_TYPES.LIST,
],
])('maps %s on the %s signal to the %s view', (requestType, signal, view) => {
expect(getPanelTypeForRequestType(requestType, signal)).toBe(view);
});
it.each([
undefined,
null,
'',
Querybuildertypesv5RequestTypeDTO.raw_stream,
Querybuildertypesv5RequestTypeDTO.heatmap,
'distribution',
'nonsense',
'toString',
'constructor',
42,
{},
])(
'falls back to the List view for unmapped or invalid requestType (%p)',
(value) => {
expect(getPanelTypeForRequestType(value, ApplyFilterSignalDTO.traces)).toBe(
PANEL_TYPES.LIST,
);
},
);
});
describe('requestTypeFromActionQuery', () => {
it('reads the requestType envelope field and returns undefined when absent', () => {
expect(
requestTypeFromActionQuery({
requestType: 'scalar',
schemaVersion: 'v5',
compositeQuery: { queries: [] },
}),
).toBe('scalar');
expect(requestTypeFromActionQuery({})).toBeUndefined();
expect(requestTypeFromActionQuery(null)).toBeUndefined();
expect(requestTypeFromActionQuery(undefined)).toBeUndefined();
});
it('maps a grouped count() payload to the Table view', () => {
const query = {
requestType: 'scalar',
schemaVersion: 'v5',
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
signal: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ name: 'service.name' }],
},
},
],
},
};
expect(
getPanelTypeForRequestType(
requestTypeFromActionQuery(query),
ApplyFilterSignalDTO.logs,
),
).toBe(PANEL_TYPES.TABLE);
});
});

View File

@@ -1,39 +0,0 @@
import {
type MessageActionDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
isSavedViewOpenAction,
resolveOpenResourceType,
resolveResourceId,
} from './resolveOpenResource';
import { resourceRoute } from './resourceRoute';
export interface OpenResourceTarget {
path: string;
resourceType: string;
resourceId: string;
}
// Saved views return null so they're never disabled: reopening one resets the explorer to the saved query.
export function getOpenResourceTarget(
action: MessageActionDTO,
): OpenResourceTarget | null {
if (
action.kind !== MessageActionKindDTO.open_resource ||
isSavedViewOpenAction(action)
) {
return null;
}
const resourceType = resolveOpenResourceType(action);
const resourceId = resolveResourceId(action);
if (!resourceType || !resourceId) {
return null;
}
const path = resourceRoute(resourceType, resourceId);
if (!path) {
return null;
}
return { path, resourceType, resourceId };
}

View File

@@ -1,30 +0,0 @@
import {
ApplyFilterSignalDTO,
type MessageActionDTOQuery,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
export function getPanelTypeForRequestType(
requestType: unknown,
signal: ApplyFilterSignalDTO,
): PANEL_TYPES {
switch (requestType) {
case Querybuildertypesv5RequestTypeDTO.scalar:
return PANEL_TYPES.TABLE;
case Querybuildertypesv5RequestTypeDTO.time_series:
return PANEL_TYPES.TIME_SERIES;
case Querybuildertypesv5RequestTypeDTO.trace:
return signal === ApplyFilterSignalDTO.traces
? PANEL_TYPES.TRACE
: PANEL_TYPES.LIST;
default:
return PANEL_TYPES.LIST;
}
}
export function requestTypeFromActionQuery(
query: MessageActionDTOQuery | undefined,
): unknown {
return query?.requestType;
}

View File

@@ -1,23 +0,0 @@
import ROUTES from 'constants/routes';
import type { AppLocation } from 'lib/router/types';
// The alert details Overview tab renders the same editor as the edit route.
const EQUIVALENT_PATHNAMES: Record<string, string[]> = {
[ROUTES.EDIT_ALERTS]: [ROUTES.ALERT_OVERVIEW],
};
// Extra params on the current page (time range, view options) don't make it a different resource.
export function isOnResourcePage(
path: string,
location: Pick<AppLocation, 'pathname' | 'search'>,
): boolean {
const [pathname, query = ''] = path.split('?');
const pathnames = [pathname, ...(EQUIVALENT_PATHNAMES[pathname] ?? [])];
if (!pathnames.includes(location.pathname)) {
return false;
}
const current = new URLSearchParams(location.search);
return [...new URLSearchParams(query)].every(
([key, value]) => current.get(key) === value,
);
}

View File

@@ -3,7 +3,6 @@ import { Tooltip } from 'antd';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { ArrowDownLeft, ArrowUpRight, Calendar } from '@signozhq/icons';
import { useAlertHistoryQueryParams } from 'pages/AlertDetails/hooks';
import { AlertRuleStats } from 'types/api/alerts/def';
import { calculateChange } from 'utils/calculateChange';
@@ -87,7 +86,7 @@ function StatsCard({
}: StatsCardProps): JSX.Element {
const urlQuery = useUrlQuery();
const { relativeTime } = useAlertHistoryQueryParams();
const relativeTime = urlQuery.get('relativeTime');
const { changePercentage, changeDirection } = calculateChange(
totalCurrentCount,

View File

@@ -1,8 +1,6 @@
import { useMemo } from 'react';
import {
useAlertHistoryQueryParams,
useGetAlertRuleDetailsTimelineGraphData,
} from 'pages/AlertDetails/hooks';
import useUrlQuery from 'hooks/useUrlQuery';
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
@@ -15,7 +13,9 @@ function GraphWrapper({
}: {
totalCurrentTriggers: number;
}): JSX.Element {
const { relativeTime } = useAlertHistoryQueryParams();
const urlQuery = useUrlQuery();
const relativeTime = urlQuery.get('relativeTime');
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
useGetAlertRuleDetailsTimelineGraphData();

View File

@@ -2,12 +2,7 @@ import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/s
import { toChannelConfig, toPostableChannel } from './channelConfig';
import { toChannelFormState } from './channelFormValues';
import {
ChannelFormValues,
ChannelKind,
JiraDeployment,
WebhookAuth,
} from './types';
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
const slackValues: ChannelFormValues = {
name: 'prod alerts',
@@ -150,11 +145,11 @@ describe('jira reopen duration', () => {
])('sends %s as %s, the only spelling the api accepts back', (typed, sent) => {
expect(
toChannelConfig(ChannelKind.jira, {
deployment: JiraDeployment.cloud,
site: 'https://acme.atlassian.net',
project: 'OPS',
issueType: 'Task',
auth: { email: 'a@b.c', apiToken: 'tok' },
email: 'a@b.c',
apiToken: 'tok',
reopenDuration: typed,
}).spec,
).toMatchObject({ reopenDuration: sent });
@@ -166,53 +161,3 @@ describe('jira reopen duration', () => {
).toMatchObject({ reopenDuration: 'tomorrow' });
});
});
describe('jira deployment', () => {
const base = {
site: 'https://jira.acme.com',
project: 'OPS',
issueType: 'Task',
};
it('sends cloud with email and api token under auth, never the pat', () => {
expect(
toChannelConfig(ChannelKind.jira, {
...base,
deployment: JiraDeployment.cloud,
auth: { email: 'a@b.c', apiToken: 'tok', pat: 'stale-pat' },
}).spec,
).toStrictEqual({
...base,
deployment: 'cloud',
auth: { email: 'a@b.c', apiToken: 'tok' },
});
});
it('sends selfhosted with the pat under auth, never email or api token', () => {
expect(
toChannelConfig(ChannelKind.jira, {
...base,
deployment: JiraDeployment.selfhosted,
auth: { email: 'stale@b.c', apiToken: 'stale', pat: 'pat-1' },
}).spec,
).toStrictEqual({
...base,
deployment: 'selfhosted',
auth: { pat: 'pat-1' },
});
});
it('drops blank credentials inside auth', () => {
expect(
toChannelConfig(ChannelKind.jira, {
...base,
deployment: JiraDeployment.cloud,
auth: { email: 'a@b.c', apiToken: '' },
}).spec,
).toStrictEqual({
...base,
deployment: 'cloud',
auth: { email: 'a@b.c' },
});
});
});

View File

@@ -22,8 +22,6 @@ import {
ChannelFormValues,
ChannelKind,
ChannelSpecFormValues,
JiraDeployment,
KeysOfUnion,
WebhookAuth,
} from './types';
import {
@@ -97,11 +95,11 @@ const SPEC_FIELDS = {
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelGoogleChatConfigDTO)[],
[ChannelKind.jira]: [
'deployment',
'site',
'project',
'issueType',
'auth',
'email',
'apiToken',
'summary',
'description',
'priority',
@@ -147,7 +145,7 @@ const SPEC_FIELDS = {
* instead, and the error names the missing field.
*/
type MissingFrom<D, T extends readonly PropertyKey[]> = Exclude<
KeysOfUnion<D>,
keyof D,
T[number]
>;
@@ -232,15 +230,6 @@ export function toChannelConfig(
}
}
if (kind === ChannelKind.jira) {
const auth = values.auth ?? {};
picked.auth = omitBlank(
values.deployment === JiraDeployment.selfhosted
? pick(auth, ['pat'])
: pick(auth, ['email', 'apiToken']),
);
}
const spec: Record<string, unknown> = omitBlank(picked);
// the API answers a read with the duration prometheus would print, and

View File

@@ -68,11 +68,11 @@ const SPECS: Record<string, Record<string, unknown>> = {
sendResolved: true,
},
jira: {
deployment: 'cloud',
site: 'https://acme.atlassian.net',
project: 'OPS',
issueType: 'Task',
auth: { email: 'a@b.c', apiToken: 'tok' },
email: 'a@b.c',
apiToken: 'tok',
summary: 's',
description: 'd',
priority: 'High',

View File

@@ -10,7 +10,7 @@ import {
AlertmanagertypesChannelTelegramConfigDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ChannelKind, ChannelSpecFormValues, JiraDeployment } from './types';
import { ChannelKind, ChannelSpecFormValues } from './types';
// shared by slack and ms teams, both render the same title / description boxes
export const SlackInitialConfig: Partial<AlertmanagertypesChannelSlackConfigDTO> =
@@ -58,7 +58,6 @@ export const GoogleChatInitialConfig: Partial<AlertmanagertypesChannelGoogleChat
// wrapped in the ADF status panel + deep-links server-side.
export const JiraInitialConfig: Partial<AlertmanagertypesChannelJiraConfigDTO> =
{
deployment: JiraDeployment.cloud,
issueType: 'Task',
summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}

View File

@@ -1,40 +1,20 @@
import { z } from 'zod';
import { JiraDeployment } from '../types';
import { isValidJiraReopenDuration } from '../utils';
import { requiredString } from './shared';
const required = requiredString('jira_required_fields');
export const jiraSchema = z
.object({
deployment: z.enum(JiraDeployment),
site: required,
auth: z
.object({
email: z.string().optional(),
apiToken: z.string().optional(),
pat: z.string().optional(),
})
.optional(),
project: required,
issueType: required,
reopenDuration: z
.string()
.optional()
.refine((v) => !v || isValidJiraReopenDuration(v), {
message: 'jira_reopen_duration_invalid',
}),
})
.refine(
(values) =>
values.deployment === JiraDeployment.selfhosted ||
(!!values.auth?.email?.trim() && !!values.auth?.apiToken?.trim()),
{ message: 'jira_cloud_credentials_required', path: ['auth', 'email'] },
)
.refine(
(values) =>
values.deployment !== JiraDeployment.selfhosted ||
!!values.auth?.pat?.trim(),
{ message: 'jira_pat_required', path: ['auth', 'pat'] },
);
export const jiraSchema = z.object({
site: required,
email: required,
apiToken: required,
project: required,
issueType: required,
reopenDuration: z
.string()
.optional()
.refine((v) => !v || isValidJiraReopenDuration(v), {
message: 'jira_reopen_duration_invalid',
}),
});

View File

@@ -2,7 +2,6 @@ import {
AlertmanagertypesChannelEmailConfigDTO,
AlertmanagertypesChannelGoogleChatConfigDTO,
AlertmanagertypesChannelIncidentIOConfigDTO,
AlertmanagertypesChannelJiraAuthDTO,
AlertmanagertypesChannelJiraConfigDTO,
AlertmanagertypesChannelJSMOpsConfigDTO,
AlertmanagertypesChannelKindDTO,
@@ -12,33 +11,11 @@ import {
AlertmanagertypesChannelSlackConfigDTO,
AlertmanagertypesChannelTelegramConfigDTO,
AlertmanagertypesChannelWebhookConfigDTO,
AlertmanagertypesJiraDeploymentDTO,
} from 'api/generated/services/sigNoz.schemas';
export const ChannelKind = AlertmanagertypesChannelKindDTO;
export type ChannelKind = AlertmanagertypesChannelKindDTO;
export const JiraDeployment = AlertmanagertypesJiraDeploymentDTO;
export type JiraDeployment = AlertmanagertypesJiraDeploymentDTO;
export type KeysOfUnion<T> = T extends unknown ? keyof T : never;
/** Every key of every member, optional, so a form can hold any member's fields. */
export type FlattenUnion<T> = {
[K in KeysOfUnion<T>]?: T extends unknown
? K extends keyof T
? T[K]
: never
: never;
};
export type JiraFormSpec = Omit<
AlertmanagertypesChannelJiraConfigDTO,
'auth'
> & {
auth?: FlattenUnion<AlertmanagertypesChannelJiraAuthDTO>;
};
/** Merged so switching kind keeps the fields the kinds share. */
export type ChannelSpecFormValues = Partial<
AlertmanagertypesChannelSlackConfigDTO &
@@ -48,7 +25,7 @@ export type ChannelSpecFormValues = Partial<
AlertmanagertypesChannelOpsgenieConfigDTO &
AlertmanagertypesChannelMSTeamsConfigDTO &
AlertmanagertypesChannelGoogleChatConfigDTO &
JiraFormSpec &
AlertmanagertypesChannelJiraConfigDTO &
AlertmanagertypesChannelJSMOpsConfigDTO &
AlertmanagertypesChannelIncidentIOConfigDTO &
AlertmanagertypesChannelTelegramConfigDTO

View File

@@ -1,11 +1,6 @@
import { TFunction } from 'i18next';
import {
ChannelFormValues,
ChannelKind,
JiraDeployment,
WebhookAuth,
} from './types';
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
import { validateChannel } from './validation';
const t = ((key: string) => key) as TFunction;
@@ -22,50 +17,14 @@ describe('validateChannel', () => {
'a jira site that is not on atlassian.net',
ChannelKind.jira,
{
deployment: JiraDeployment.cloud,
site: 'https://jira-proxy.acme.com/',
auth: { email: 'a@acme.com', apiToken: 't' },
email: 'a@acme.com',
apiToken: 't',
project: 'OPS',
issueType: 'Bug',
},
null,
],
[
'a self-hosted jira site with a personal access token',
ChannelKind.jira,
{
deployment: JiraDeployment.selfhosted,
site: 'https://jira.acme.com',
auth: { pat: 'pat-1' },
project: 'OPS',
issueType: 'Task',
},
null,
],
[
'a self-hosted jira site without a personal access token',
ChannelKind.jira,
{
deployment: JiraDeployment.selfhosted,
site: 'https://jira.acme.com',
auth: { email: 'a@acme.com', apiToken: 't' },
project: 'OPS',
issueType: 'Task',
},
'jira_pat_required',
],
[
'a jira cloud site without an api token',
ChannelKind.jira,
{
deployment: JiraDeployment.cloud,
site: 'https://acme.atlassian.net',
auth: { email: 'a@acme.com' },
project: 'OPS',
issueType: 'Task',
},
'jira_cloud_credentials_required',
],
[
'any incident.io url',
ChannelKind.incidentio,

View File

@@ -1,3 +0,0 @@
.deployment {
--radio-group-display: flex;
}

View File

@@ -1,19 +1,13 @@
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { RadioGroup } from '@signozhq/ui/radio-group';
import { Input as AntdInput, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import {
ChannelSpecFormValues,
JiraDeployment,
} from '../../CreateAlertChannels/types';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
import styles from './Jira.module.scss';
const { TextArea } = AntdInput;
function JiraSettings(): JSX.Element {
@@ -23,8 +17,6 @@ function JiraSettings(): JSX.Element {
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
const deployment = useWatch({ control, name: 'deployment' });
const selfHosted = deployment === JiraDeployment.selfhosted;
const advanced = (
<>
@@ -107,132 +99,62 @@ function JiraSettings(): JSX.Element {
return (
<>
<FormCard title={t('section_connection')}>
<FormField label={t('field_jira_deployment')}>
<Controller
control={control}
name="deployment"
render={({ field }): JSX.Element => (
<div className={styles.deployment}>
<RadioGroup
color="primary"
aria-label={t('field_jira_deployment')}
value={deployment}
onChange={field.onChange}
testId="jira-deployment-radio"
items={[
{
value: JiraDeployment.cloud,
label: t('option_jira_deployment_cloud'),
},
{
value: JiraDeployment.selfhosted,
label: t('option_jira_deployment_selfhosted'),
},
]}
/>
</div>
)}
/>
</FormField>
{selfHosted ? (
<Typography.Text
color="muted"
size="sm"
testId="jira-selfhosted-tip"
style={{ display: 'block', marginBottom: 16 }}
<Typography.Text
color="muted"
size="sm"
testId="jira-service-account-tip"
style={{ display: 'block', marginBottom: 16 }}
>
{t('jira_service_account_tip')}{' '}
<Typography.Link
href="https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended"
target="_blank"
rel="noopener noreferrer"
>
{t('jira_selfhosted_tip')}{' '}
<Typography.Link
href="https://signoz.io/docs/alerts-management/notification-channel/jira/#self-hosted-jira"
target="_blank"
rel="noopener noreferrer"
>
{t('jira_selfhosted_tip_link')}
</Typography.Link>
</Typography.Text>
) : (
<Typography.Text
color="muted"
size="sm"
testId="jira-service-account-tip"
style={{ display: 'block', marginBottom: 16 }}
>
{t('jira_service_account_tip')}{' '}
<Typography.Link
href="https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended"
target="_blank"
rel="noopener noreferrer"
>
{t('jira_service_account_tip_link')}
</Typography.Link>
</Typography.Text>
)}
{t('jira_service_account_tip_link')}
</Typography.Link>
</Typography.Text>
<FormField
label={t('field_jira_site')}
tooltip={t(
selfHosted ? 'tooltip_jira_site_selfhosted' : 'tooltip_jira_site',
)}
tooltip={t('tooltip_jira_site')}
error={errors.site?.message}
required
>
<Input
{...register('site')}
aria-required
placeholder={
selfHosted
? 'https://jira.your-company.com'
: 'https://your-domain.atlassian.net'
}
placeholder="https://your-domain.atlassian.net"
data-testid="jira-site-textbox"
/>
</FormField>
{selfHosted ? (
<FormField
label={t('field_jira_pat')}
help={t('help_jira_pat')}
error={errors.auth?.pat?.message}
required
>
<Input
{...register('auth.pat')}
aria-required
type="password"
data-testid="jira-pat-textbox"
/>
</FormField>
) : (
<>
<FormField
label={t('field_jira_email')}
help={t('help_jira_email')}
error={errors.auth?.email?.message}
required
>
<Input
{...register('auth.email')}
aria-required
data-testid="jira-email-textbox"
/>
</FormField>
<FormField
label={t('field_jira_email')}
help={t('help_jira_email')}
error={errors.email?.message}
required
>
<Input
{...register('email')}
aria-required
data-testid="jira-email-textbox"
/>
</FormField>
<FormField
label={t('field_jira_api_token')}
help={t('help_jira_api_token')}
error={errors.auth?.apiToken?.message}
required
>
<Input
{...register('auth.apiToken')}
aria-required
type="password"
data-testid="jira-api-token-textbox"
/>
</FormField>
</>
)}
<FormField
label={t('field_jira_api_token')}
help={t('help_jira_api_token')}
error={errors.apiToken?.message}
required
>
<Input
{...register('apiToken')}
aria-required
type="password"
data-testid="jira-api-token-textbox"
/>
</FormField>
<FormField
label={t('field_jira_project')}
@@ -281,9 +203,7 @@ function JiraSettings(): JSX.Element {
<FormField
label={t('field_jira_description')}
help={t(
selfHosted ? 'help_jira_description_selfhosted' : 'help_jira_description',
)}
help={t('help_jira_description')}
error={errors.description?.message}
>
<Controller

View File

@@ -17,6 +17,7 @@ import {
QuickFilterChangeEventData,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { initialQueriesMap } from 'constants/queryBuilder';
import K8sBaseDetails, {
K8sDetailsFilters,
@@ -272,6 +273,7 @@ function Hosts(): JSX.Element {
fetchListData={fetchListData}
getRowKey={getHostRowKey}
getItemKey={getHostItemKey}
eventCategory={InfraMonitoringEvents.HostEntity}
detailsQueryKeyPrefix="hosts"
/>
</div>
@@ -279,6 +281,7 @@ function Hosts(): JSX.Element {
</div>
<K8sBaseDetails
category={InfraMonitoringEntity.HOSTS}
eventCategory={InfraMonitoringEvents.HostEntity}
getSelectedItemExpression={hostGetSelectedItemExpression}
fetchEntityData={fetchEntityData}
getEntityName={hostGetEntityName}

View File

@@ -6,8 +6,10 @@ import { Button } from '@signozhq/ui/button';
import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer';
import { toast } from '@signozhq/ui/sonner';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import APIError from 'types/api/error';
import { InfraMonitoringEvents } from 'constants/events';
import {
GlobalTimeProvider,
NANO_SECOND_MULTIPLIER,
@@ -65,6 +67,7 @@ const DRAWER_MOTION_PROPS = {
export default function K8sBaseDetails<T>({
category,
eventCategory,
getSelectedItemExpression,
fetchEntityData,
getEntityName,
@@ -221,6 +224,16 @@ export default function K8sBaseDetails<T>({
};
}, [entity, entityName, category]);
useEffect(() => {
if (entity) {
void logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
});
}
}, [entity, eventCategory]);
// Names the resource it returns to, so a drill several levels deep stays clear
const backTarget = previousEntry
? `Back to ${K8S_CATEGORY_SINGULAR_LABELS[previousEntry.category]}`
@@ -320,6 +333,7 @@ export default function K8sBaseDetails<T>({
<K8sBaseDetailsContent<T>
entity={entity}
category={category}
eventCategory={eventCategory}
metadataConfig={metadataConfig}
countsConfig={countsConfig}
getCountsFilterExpression={getCountsFilterExpression}

View File

@@ -10,7 +10,9 @@ import {
import { Button } from '@signozhq/ui/button';
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { Tooltip } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
@@ -53,6 +55,7 @@ import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/B
export default function K8sBaseDetailsContent<T>({
entity,
category,
eventCategory,
metadataConfig,
countsConfig,
getCountsFilterExpression,
@@ -135,18 +138,14 @@ export default function K8sBaseDetailsContent<T>({
// Wait until the view is a valid tab so we don't log a pending value before
// the validation effect above corrects an out-of-scope query param
const loggedDefaultTabFor = useRef<string | null>(null);
const hasLoggedDefaultTab = useRef(false);
useEffect(() => {
const visitKey = `${category}:${selectedItem}`;
if (
loggedDefaultTabFor.current === visitKey ||
!validTabs.includes(effectiveView)
) {
if (hasLoggedDefaultTab.current || !validTabs.includes(effectiveView)) {
return;
}
loggedDefaultTabFor.current = visitKey;
hasLoggedDefaultTab.current = true;
logInfraDrawerTabViewedEvent(category, effectiveView, true);
}, [validTabs, effectiveView, category, selectedItem]);
}, [validTabs, effectiveView, category]);
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
@@ -168,6 +167,12 @@ export default function K8sBaseDetailsContent<T>({
void setLogFiltersParam(null);
void setTracesFiltersParam(null);
void setEventsFiltersParam(null);
void logEvent(InfraMonitoringEvents.TabChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: value,
});
logInfraDrawerTabViewedEvent(category, value, false);
};
@@ -183,6 +188,13 @@ export default function K8sBaseDetailsContent<T>({
urlQuery.set(QueryParams.endTime, (timeRange.endTime * 1000).toString());
}
void logEvent(InfraMonitoringEvents.ExploreClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: effectiveView,
});
logInfraExplorerNavigatedEvent({
entityType: category,
destination:
@@ -404,6 +416,7 @@ export default function K8sBaseDetailsContent<T>({
{effectiveView === VIEW_TYPES.OVERVIEW && tabVisibility.showOverview && (
<EntityOverview
category={category}
eventEntity={eventCategory}
attributes={entityAttributes}
entity={entity}
entityName={entityName}
@@ -413,6 +426,7 @@ export default function K8sBaseDetailsContent<T>({
{effectiveView === VIEW_TYPES.METRICS && (
<EntityMetrics<T>
entity={entity}
eventEntity={eventCategory}
entityWidgetInfo={entityWidgetInfo}
getEntityQueryPayload={getEntityQueryPayload}
category={category}
@@ -422,6 +436,7 @@ export default function K8sBaseDetailsContent<T>({
)}
{effectiveView === VIEW_TYPES.LOGS && (
<EntityLogs
eventEntity={eventCategory}
queryKey={`${queryKeyPrefix}Logs`}
category={category}
initialExpression={logsAndTracesInitialExpression}
@@ -429,6 +444,7 @@ export default function K8sBaseDetailsContent<T>({
)}
{effectiveView === VIEW_TYPES.TRACES && (
<EntityTraces
eventEntity={eventCategory}
queryKey={`${queryKeyPrefix}Traces`}
category={category}
initialExpression={logsAndTracesInitialExpression}
@@ -436,6 +452,7 @@ export default function K8sBaseDetailsContent<T>({
)}
{effectiveView === VIEW_TYPES.EVENTS && tabVisibility.showEvents && (
<EntityEvents
eventEntity={eventCategory}
queryKey={`${queryKeyPrefix}Events`}
initialExpression={eventsInitialExpression}
category={category}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import TanStackTable, {
SortState,
TableColumnDef,
@@ -9,6 +10,7 @@ import TanStackTable, {
useRecoverFromEmptyPage,
useTableParams,
} from 'components/TanStackTableView';
import { InfraMonitoringEvents } from 'constants/events';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
@@ -49,7 +51,6 @@ import styles from './K8sBaseList.module.scss';
import cx from 'classnames';
import {
logInfraColumnSortedEvent,
logInfraListRowClickedEvent,
logInfraTimeRangeCustomizedEvent,
} from 'container/InfraMonitoringK8sV2/Base/events';
@@ -91,6 +92,7 @@ export type K8sBaseListProps<
getRowKey: (record: T) => string;
/** Function to get the item key used for selection. Can return string or SelectedItemParams. */
getItemKey: (record: T) => TItemKey;
eventCategory: InfraMonitoringEvents;
renderEmptyState?: (
context: K8sBaseListEmptyStateContext,
) => React.ReactNode | null;
@@ -113,6 +115,7 @@ export function K8sBaseList<
fetchListData,
getRowKey,
getItemKey,
eventCategory,
renderEmptyState,
extraQueryKeyParts = [],
detailsQueryKeyPrefix,
@@ -277,6 +280,15 @@ export function K8sBaseList<
[groupBy],
);
useEffect(() => {
void logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
total: totalCount,
});
}, [eventCategory, totalCount]);
const prevSelectedTimeRef = useRef(selectedTime);
useEffect(() => {
if (prevSelectedTimeRef.current !== selectedTime) {
@@ -314,10 +326,14 @@ export function K8sBaseList<
setSelectedItemParams(params);
}
logInfraListRowClickedEvent(entity, false, groupBy.length > 0);
void logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
},
[
entity,
eventCategory,
groupBy.length,
setSelectedItemParams,
detailsQueryKeyPrefix,
@@ -369,9 +385,13 @@ export function K8sBaseList<
}
openInNewTab(url.pathname + url.search);
logInfraListRowClickedEvent(entity, true, false);
void logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
},
[entity, groupBy.length],
[eventCategory, groupBy.length],
);
const isGroupedByAttribute = groupBy.length > 0;
@@ -471,6 +491,7 @@ export function K8sBaseList<
<K8sTableToolbar
entity={entity}
eventCategory={eventCategory}
leftFilters={leftFilters}
onOpenOptionsDrawer={handleOpenOptionsDrawer}
/>

View File

@@ -1,6 +1,8 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { useAppLocation } from 'lib/router/useAppLocation';
import logEvent from 'api/common/logEvent';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
@@ -90,6 +92,12 @@ function K8sHeader({
void invalidateQueries();
if (finalExpression?.trim()) {
void logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: entity,
});
logInfraFilterCustomizedEvent(entity, 'search', finalExpression);
}
},

View File

@@ -3,6 +3,8 @@ import { Button } from '@signozhq/ui/button';
import { Select } from 'antd';
import { Download, SlidersVertical } from '@signozhq/icons';
import { Tooltip } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import { InfraMonitoringEvents } from 'constants/events';
import {
INFRA_MONITORING_ATTR_KEYS,
@@ -33,6 +35,7 @@ const NAME_COLUMN_KEYS: Set<string> = new Set([
interface K8sTableToolbarProps {
entity: InfraMonitoringEntity;
eventCategory: InfraMonitoringEvents;
leftFilters?: React.ReactNode;
onOpenOptionsDrawer: () => void;
onDownload?: () => void;
@@ -40,6 +43,7 @@ interface K8sTableToolbarProps {
function K8sTableToolbar({
entity,
eventCategory,
leftFilters,
onOpenOptionsDrawer,
onDownload,
@@ -61,9 +65,15 @@ function K8sTableToolbar({
void setGroupBy(value);
void logEvent(InfraMonitoringEvents.GroupByChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
logInfraGroupByCustomizedEvent(entity, value);
},
[entity, orderBy, setCurrentPage, setOrderBy, setGroupBy],
[entity, eventCategory, orderBy, setCurrentPage, setOrderBy, setGroupBy],
);
return (

View File

@@ -1,10 +1,10 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InfraMonitoringEvents } from 'constants/events';
import {
NuqsTestingAdapter,
type OnUrlUpdateFunction,
} from 'nuqs/adapters/testing';
import logEvent from 'api/common/logEvent';
import { act, render, waitFor } from 'tests/test-utils';
import {
@@ -19,13 +19,6 @@ jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
default: (): JSX.Element => <div data-testid="mock-datetime" />,
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve()),
}));
const logEventMock = jest.mocked(logEvent);
type TestEntity = { name: string };
const mockEntity: TestEntity = { name: 'test-container' };
@@ -56,6 +49,7 @@ function renderDrawer(onUrlUpdate?: OnUrlUpdateFunction): void {
>
<K8sBaseDetails<TestEntity>
category={InfraMonitoringEntity.CONTAINERS}
eventCategory={InfraMonitoringEvents.Pod}
getSelectedItemExpression={(): string => "k8s.pod.uid = 'abc'"}
fetchEntityData={jest
.fn()
@@ -77,7 +71,6 @@ function renderDrawer(onUrlUpdate?: OnUrlUpdateFunction): void {
describe('K8sBaseDetails - back navigation', () => {
beforeEach(() => {
useDrawerHistoryStore.getState().reset();
logEventMock.mockClear();
});
it('closes rather than going back when opened from the list', async () => {
@@ -156,36 +149,4 @@ describe('K8sBaseDetails - back navigation', () => {
expect(useDrawerHistoryStore.getState().entries).toHaveLength(0);
});
});
it('logs a drawer visit for a resource reached from the cache', async () => {
const user = userEvent.setup();
useDrawerHistoryStore.getState().push({
params: {
selectedItem: 'container-0',
category: InfraMonitoringEntity.CONTAINERS,
clusterName: null,
namespaceName: null,
},
category: InfraMonitoringEntity.CONTAINERS,
label: 'container-0',
entity: { name: 'container-0' },
queryKeyPrefix: 'testContainer',
});
act(() => {
renderDrawer();
});
const drawerVisits = (): number =>
logEventMock.mock.calls.filter(
([name]) => name === 'Infra Monitoring: Drawer tab viewed',
).length;
await waitFor(() => expect(drawerVisits()).toBe(1));
await user.click(await screen.findByTestId('drawer-back-button'));
await waitFor(() => expect(drawerVisits()).toBe(2));
});
});

View File

@@ -1,5 +1,6 @@
import { Box } from '@signozhq/icons';
import { screen } from '@testing-library/react';
import { InfraMonitoringEvents } from 'constants/events';
import userEvent from '@testing-library/user-event';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { act, render, waitFor } from 'tests/test-utils';
@@ -42,6 +43,7 @@ const relatedEntity: TestEntity = {
function createBaseProps(category: InfraMonitoringEntity, entity = mockEntity) {
return {
category,
eventCategory: InfraMonitoringEvents.Pod,
getSelectedItemExpression: (): string => 'k8s.pod.name = "test-pod"',
fetchEntityData: jest.fn().mockResolvedValue({ data: entity, error: null }),
getEntityName: (e: TestEntity): string => e.name,

View File

@@ -5,6 +5,7 @@ import { Provider } from 'react-redux';
import { VirtuosoMockContext } from 'react-virtuoso';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InfraMonitoringEvents } from 'constants/events';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
@@ -16,9 +17,7 @@ import { AppProvider } from 'providers/App/App';
import TimezoneProvider from 'providers/Timezone';
import store from 'store';
import { TestRouter } from 'tests/router';
import { within } from 'tests/test-utils';
import APIError from 'types/api/error';
import logEvent from 'api/common/logEvent';
import { openInNewTab } from 'utils/navigation';
import { TableColumnDef } from 'components/TanStackTableView';
@@ -41,13 +40,7 @@ jest.mock('utils/navigation', () => ({
openInNewTab: jest.fn(),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve()),
}));
const openInNewTabMock = openInNewTab as jest.Mock;
const logEventMock = jest.mocked(logEvent);
// Mock Date.now to prevent flaky tests due to time-dependent values
const MOCK_NOW = 1700000000000; // Fixed timestamp
@@ -234,7 +227,6 @@ describe('K8sBaseList', () => {
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
logEventMock.mockClear();
openInNewTabMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [
@@ -248,6 +240,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItemWithTitle>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithTitle(),
getRowKey: (row): string => row.id,
@@ -306,40 +299,6 @@ describe('K8sBaseList', () => {
});
});
it('should log the list row clicked event when a row opens the drawer', async () => {
const user = userEvent.setup();
const row = await screen.findByTestId(`row-PodId:${itemId}`);
await user.click(within(row).getByText(`PodId:${itemId}`));
expect(logEventMock).toHaveBeenCalledWith(
'Infra Monitoring: List row clicked',
{
entityType: InfraMonitoringEntity.PODS,
openedInNewTab: false,
isGrouped: false,
},
);
});
it('should log the list row clicked event as a new tab on ctrl+click', async () => {
const user = userEvent.setup();
const row = await screen.findByTestId(`row-PodId:${itemId}`);
await user.keyboard('{Control>}');
await user.click(within(row).getByText(`PodId:${itemId}`));
await user.keyboard('{/Control}');
expect(logEventMock).toHaveBeenCalledWith(
'Infra Monitoring: List row clicked',
{
entityType: InfraMonitoringEntity.PODS,
openedInNewTab: true,
isGrouped: false,
},
);
});
it('should update orderBy in URL when clicking sortable column header', async () => {
const user = userEvent.setup();
@@ -504,6 +463,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
orderBy: JSON.stringify({ columnName: 'cpu', order: 'desc' }),
@@ -578,6 +538,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
orderBy: JSON.stringify({ columnName: 'cpu', order: 'desc' }),
@@ -642,6 +603,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -700,6 +662,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: { page: '7', pageSize: '10' },
tableColumns: createTestColumns(),
@@ -771,6 +734,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: { page: '0', pageSize: '10' },
tableColumns: createTestColumns(),
@@ -855,6 +819,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -892,6 +857,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -929,6 +895,7 @@ describe('K8sBaseList', () => {
it('should show all columns defined in tableColumns', async () => {
renderComponent<TestItemWithName>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithName(),
getRowKey: (row): string => row.id,
@@ -976,6 +943,7 @@ describe('K8sBaseList', () => {
it('should show columns when NOT grouped', async () => {
renderComponent<TestItemWithGroup>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {},
tableColumns: createTestColumnsWithGroup(),
@@ -1000,6 +968,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItemWithGroup>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
groupBy: JSON.stringify(groupByValue),
@@ -1041,6 +1010,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
groupBy: JSON.stringify(groupByValue),
@@ -1085,6 +1055,7 @@ describe('K8sBaseList', () => {
it('should render all defined columns', async () => {
renderComponent<TestItemWithName>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithName(),
getRowKey: (row): string => row.id,
@@ -1107,6 +1078,7 @@ describe('K8sBaseList', () => {
it('should render data in cells correctly', async () => {
renderComponent<TestItemWithName>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithName(),
getRowKey: (row): string => row.id,
@@ -1143,6 +1115,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -1190,6 +1163,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItemWithTitle, SelectedItemParams>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithTitle(),
getRowKey: (row): string => row.id,
@@ -1214,6 +1188,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItemWithTitle, SelectedItemParams>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithTitle(),
getRowKey: (row): string => row.id,
@@ -1240,6 +1215,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItemWithTitle, SelectedItemParams>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithTitle(),
getRowKey: (row): string => row.id,
@@ -1301,6 +1277,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -1335,6 +1312,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -1374,6 +1352,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -1422,6 +1401,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -1469,6 +1449,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -1525,6 +1506,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -1598,6 +1580,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
// k8s.pod.name is a name column - should be cleared
@@ -1640,6 +1623,7 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
// cpu is NOT a name column - should be kept
@@ -1692,6 +1676,7 @@ describe('K8sBaseList status filters', () => {
renderComponent<TestItem>({
queryParams,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,

View File

@@ -22,10 +22,10 @@ export function logInfraFilterCustomizedEvent(
return;
}
void logEvent('Infra Monitoring: Filter customized', {
entityType,
void logEvent('infra_filter_customized', {
entity_type: entityType,
source,
expressionKeys: [...new Set(expressionKeys)],
expression_keys: [...new Set(expressionKeys)],
});
}
@@ -34,8 +34,8 @@ export function logInfraMonitoringListViewedEvent(
): void {
const referrer = getNavigationReferrer();
void logEvent('Infra Monitoring: List viewed', {
entityType: entity,
void logEvent('infra_list_viewed', {
entity,
referrer,
});
}
@@ -44,9 +44,9 @@ export function logInfraTimeRangeCustomizedEvent(
entityType: InfraMonitoringEntity,
rangeLabel: string,
): void {
void logEvent('Infra Monitoring: Time range customized', {
entityType,
rangeLabel: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
void logEvent('infra_time_range_customized', {
entity_type: entityType,
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
});
}
@@ -57,11 +57,11 @@ export function logInfraColumnCustomizedEvent(
maxLinesPerRow: number,
source: 'list' | 'expanded',
): void {
void logEvent('Infra Monitoring: Column customized', {
entityType,
columnsList,
fontSize,
maxLinesPerRow,
void logEvent('infra_column_customized', {
entity_type: entityType,
columns_list: columnsList,
font_size: fontSize,
max_lines_per_row: maxLinesPerRow,
source,
});
}
@@ -72,9 +72,9 @@ export function logInfraColumnSortedEvent(
direction: 'asc' | 'desc',
source: 'list' | 'expanded',
): void {
void logEvent('Infra Monitoring: Column sorted', {
entityType,
columnKey,
void logEvent('infra_column_sorted', {
entity_type: entityType,
column_key: columnKey,
direction,
source,
});
@@ -84,9 +84,9 @@ export function logInfraGroupByCustomizedEvent(
entityType: InfraMonitoringEntity,
groupByKeysList: string[],
): void {
void logEvent('Infra Monitoring: Group by customized', {
entityType,
groupByKeysList,
void logEvent('infra_group_by_customized', {
entity_type: entityType,
group_by_keys_list: groupByKeysList,
});
}
@@ -106,24 +106,12 @@ export function logInfraExplorerNavigatedEvent(params: {
sourceKey: string | null;
drawerDurationMsAtNavigation: number | null;
}): void {
void logEvent('Infra Monitoring: Explorer navigated', {
entityType: params.entityType,
void logEvent('infra_explorer_navigated', {
entity_type: params.entityType,
destination: params.destination,
source: params.source,
tab: params.tab,
sourceKey: params.sourceKey,
drawerDurationMsAtNavigation: params.drawerDurationMsAtNavigation,
});
}
export function logInfraListRowClickedEvent(
entityType: InfraMonitoringEntity,
openedInNewTab: boolean,
isGrouped: boolean,
): void {
void logEvent('Infra Monitoring: List row clicked', {
entityType,
openedInNewTab,
isGrouped,
source_key: params.sourceKey,
drawer_duration_ms_at_navigation: params.drawerDurationMsAtNavigation,
});
}

View File

@@ -8,6 +8,7 @@ import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { InfraMonitoringEvents } from 'constants/events';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import APIError from 'types/api/error';
@@ -110,6 +111,7 @@ export interface K8sDetailsCustomTab<T> {
export interface K8sBaseDetailsProps<T> {
category: InfraMonitoringEntity;
eventCategory: InfraMonitoringEvents;
// Data fetching configuration
getSelectedItemExpression: (params: SelectedItemParams) => string;
fetchEntityData: (
@@ -135,6 +137,7 @@ export interface K8sBaseDetailsProps<T> {
export interface K8sBaseDetailsContentProps<T> {
entity: T;
category: InfraMonitoringEntity;
eventCategory: InfraMonitoringEvents;
metadataConfig: K8sDetailsMetadataConfig<T>[];
countsConfig?: K8sDetailsCountConfig<T>[];
getCountsFilterExpression?: (entity: T) => string;

View File

@@ -7,6 +7,7 @@ import {
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { InfraMonitoringEvents } from 'constants/events';
import { K8sEntityConfig } from '../Base/entity.config.types';
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
@@ -116,6 +117,7 @@ export const clusterEntityConfig: K8sEntityConfig<InframonitoringtypesClusterRec
{
list: {
entity: InfraMonitoringEntity.CLUSTERS,
eventCategory: InfraMonitoringEvents.Cluster,
tableColumns: k8sClustersColumnsConfig,
fetchListData,
getRowKey: getK8sClusterRowKey,
@@ -124,6 +126,7 @@ export const clusterEntityConfig: K8sEntityConfig<InframonitoringtypesClusterRec
},
details: {
category: InfraMonitoringEntity.CLUSTERS,
eventCategory: InfraMonitoringEvents.Cluster,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'cluster',
getSelectedItemExpression: k8sClusterGetSelectedItemExpression,

View File

@@ -7,6 +7,7 @@ import {
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { InfraMonitoringEvents } from 'constants/events';
import { K8sEntityConfig } from '../Base/entity.config.types';
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
@@ -119,6 +120,7 @@ export const containerEntityConfig: K8sEntityConfig<
> = {
list: {
entity: InfraMonitoringEntity.CONTAINERS,
eventCategory: InfraMonitoringEvents.Container,
tableColumns: k8sContainerColumnsConfig,
fetchListData,
getRowKey: getK8sContainerRowKey,
@@ -127,6 +129,7 @@ export const containerEntityConfig: K8sEntityConfig<
},
details: {
category: InfraMonitoringEntity.CONTAINERS,
eventCategory: InfraMonitoringEvents.Container,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'container',
getSelectedItemExpression: k8sContainerGetSelectedItemExpression,

View File

@@ -7,6 +7,7 @@ import {
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { InfraMonitoringEvents } from 'constants/events';
import { K8sEntityConfig } from '../Base/entity.config.types';
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
@@ -124,6 +125,7 @@ export const daemonSetEntityConfig: K8sEntityConfig<
> = {
list: {
entity: InfraMonitoringEntity.DAEMONSETS,
eventCategory: InfraMonitoringEvents.DaemonSet,
tableColumns: k8sDaemonSetsColumnsConfig,
fetchListData,
getRowKey: getK8sDaemonSetRowKey,
@@ -132,6 +134,7 @@ export const daemonSetEntityConfig: K8sEntityConfig<
},
details: {
category: InfraMonitoringEntity.DAEMONSETS,
eventCategory: InfraMonitoringEvents.DaemonSet,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'daemonset',
getSelectedItemExpression: k8sDaemonSetGetSelectedItemExpression,

View File

@@ -7,6 +7,7 @@ import {
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { InfraMonitoringEvents } from 'constants/events';
import { K8sEntityConfig } from '../Base/entity.config.types';
import {
@@ -139,6 +140,7 @@ export const deploymentEntityConfig: K8sEntityConfig<
> = {
list: {
entity: InfraMonitoringEntity.DEPLOYMENTS,
eventCategory: InfraMonitoringEvents.Deployment,
tableColumns: k8sDeploymentsColumnsConfig,
fetchListData,
getRowKey: getK8sDeploymentRowKey,
@@ -147,6 +149,7 @@ export const deploymentEntityConfig: K8sEntityConfig<
},
details: {
category: InfraMonitoringEntity.DEPLOYMENTS,
eventCategory: InfraMonitoringEvents.Deployment,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'deployment',
getSelectedItemExpression: k8sDeploymentGetSelectedItemExpression,

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