Compare commits

..

23 Commits

Author SHA1 Message Date
srikanthccv
20d6bd0eb5 fix(telemetrystore): return invalid input with a fix for five more ClickHouse SQL mistakes
Code 48 (NOT_IMPLEMENTED), which a correlated subquery over a distributed
table raises, stays a 500: the code covers every unimplemented feature, so a
mapping could hide a fault in generated SQL.

Assisted-by: Claude Opus 5.5
2026-11-05 04:16:02 +05:30
Pandey
07fd9ac5b8 chore: update codeowners for types and tests (#13427)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
- Update codeowners
2026-11-04 21:43:28 +00:00
Vinicius Lourenço
e1fdd1282a chore(frontend): move AI rules where agents load them and add lessons from Greptile reviews (#13338)
#### Description

Greptile keeps flagging the same things on frontend PRs, and authors
keep fixing them by hand. I read every Greptile thread on frontend PRs
from 2026-09-16 to 2026-10-30 (50 PRs, 129 threads) and checked each one
against the final head of its PR, to find out why. This PR fixes the
causes, not the findings.

What was going wrong:

1. The agents that write our code never saw the testing rules. They
lived only in `frontend/.cursor/rules/*.mdc`. Claude Code loads
`CLAUDE.md` and `.claude/rules/`, Codex loads `AGENTS.md`, and neither
pointed at the `.mdc` files. Greptile reads all of them. So 60 of the 76
threads that cite a rule came from rules the authoring agent was never
given: untyped `jest.fn()`, imports from `@testing-library/react`,
`fireEvent`, too many tests per file.
2. With no rule in sight, the agent copies the test next to it, and most
of those break the rules. Of 914 test files on `main`, 41% import from
`@testing-library/react` and 54% have an untyped `jest.fn()`.
3. Two files disagreed. `frontend/AGENTS.md` said to find elements by
test ID; `testing-conventions.mdc` said `getByRole` first and test ID as
a last resort. Greptile enforced whichever it read.
4. Some rules were too broad. When an author replied with a reason,
Greptile conceded 8 of 9 times: deleting a comment whose code was
removed, a reason next to a `pnpm-workspace.yaml` exclusion, the
AI-owned banner the `signoz-page-story` skill requires, an
`onX={jest.fn()}` placeholder that is never asserted, an antd button
added beside antd tabs, and "3 to 5 tests per file", which half of
`main` breaks.
5. The test harness pushed people off it. `customRender` used `wrapper:
() => <AllTheProviders>{ui}</AllTheProviders>` and ignored `children`,
so `rerender(newUi)` kept rendering the first element. Authors imported
`@testing-library/react` directly to get a working `rerender`.
6. Most logic findings fell into a few shapes no rule named: a test that
passes without the change, a story play that fails on a valid control
value, a stacked branch broken by a rename in the branch below, a URL
rewrite that drops params it doesn't own, a query key missing a request
input, an identity key built from joined display text.
7. Review fixes landed by force push: 60 of 139 force pushes were
amends, although `pull-requests.md` forbids amending pushed commits.
After each one Greptile re-posted findings it had already made. 34
threads that kept their code were resolved with no reply, so Greptile
never learned why.

What this PR changes:

- `tests/test-utils` renders `children`, so `rerender` works. Three
`withAuthZ` "does not re-render" tests passed only because of that bug:
`rerender` never re-rendered, so the render count could not change. They
now record the router context each `checks` call receives and assert it
is the same object after a rerender with equal params. They fail on the
old harness and with an unstable router context.
- `frontend/.cursor` is gone. Its content moved to files every agent
loads:
- `.claude/rules/frontend-tests.md`, scoped to `*.test.ts(x)`: the three
testing `.mdc` files in one page. Test ID first, the `jest.fn()`
placeholder exemption, no per-file test limit, and "a test fails without
its change".
- `.claude/rules/frontend-state.md`, scoped to `src/**/*.ts(x)`:
`state-management.mdc` plus the URL, query key and form state lessons
from point 6.
- The `migrate-state-management` skill moved to
`frontend/.claude/skills`.
- The `@signozhq/ui` rule from `ui-components-and-icons.mdc` moved to
`frontend/AGENTS.md`, with the antd-sibling exception.
- `frontend/AGENTS.md` lists which rule file applies to which path, for
agents that don't load `.claude/rules/` on their own.
- New lessons from the logic findings:
- `frontend/AGENTS.md`: identity keys come from structured values; a
payload saved under another kind keeps only the fields that kind
accepts; a flow that gains a request gates on that request's permission.
Verification adds "revert the change and watch the test fail" and
"type-check every branch above you on a stack".
- `storybook.md` and the `signoz-page-story` skill: plays live in
`__story_mockdata__/<page>.play.ts`, pass for every control value, and
mocks answer every request a reachable state makes.
- `comments.md`: update or delete a comment together with its code;
config entries and skill banners keep their reason.
- `pull-requests.md`: review fixes are new commits and `main` comes in
by merge (stacks excepted); every thread gets the fixing commit or a
reason before it is resolved.
- `src/tests/README.md` and `src/storybook/README.md` now match the
rules.

What we want a PR to look like after this. A test:

```tsx
// before: raw import, untyped mock, role query, fireEvent, asserts only that something exists
import { render, screen, fireEvent } from '@testing-library/react';
const onSave = jest.fn();
fireEvent.click(screen.getByRole('button', { name: /save/i }));
expect(screen.getByText(/saved/i)).toBeInTheDocument();

// after: harness import, typed mock, test ID, userEvent, asserts the payload
import { render, screen, userEvent } from 'tests/test-utils';
const onSave = jest.fn() as jest.MockedFunction<(view: SavedView) => void>;
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByTestId('save-view'));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'errors' }));
```

A story whose file reads as a list of states:

```tsx
// stories/__story_mockdata__/<page>.play.ts holds the clicks
export async function openEditDrawer({ canvasElement, args }) { ... }

// <Page>.stories.tsx only names it
export const EditDrawer: Story = { play: openEditDrawer };
```

A review thread, answered and then resolved:

```
Fixed in 4df6f3198d: the row link now carries the current time range.
```

or, when the code stays:

```
Keeping this: the button sits in an antd tab strip, a @signozhq/ui button would not match its siblings until the strip migrates.
```

#### Additional Information

- Only one code change: `tests/test-utils.tsx` plus the three
`withAuthZ` tests. Every jest suite that calls `rerender` passes with
the fixed wrapper (61 suites, 735 tests). To check the harness fix:
`pnpm jest src/lib/authz/components/withAuthZ`, then revert
`tests/test-utils.tsx` and run it again: the three router context tests
fail.
- About 80 existing stories still hold inline plays. They move the first
time their story file is edited; there is no sweep in this PR.
- Open for review: the antd-sibling exception in `AGENTS.md`, whether
`frontend-state.md` should apply to every `src` file, and merge instead
of rebase for bringing in `main`.
2026-11-04 19:45:45 +00:00
Ashwin Bhatkal
b5cac39eb7 chore(infra-monitoring): drop old analytics events that newer ones cover (#13407)
#### Description

- Infrastructure Monitoring sent two analytics events for most actions:
an old `Infra Monitoring: …` event and a newer `infra_*` one. The old
duplicates are gone. The newer events now follow the event naming
convention: `Infra Monitoring: <Action>` names with camelCase
attributes.
- Each action now sends one event:
- List opened or category switched: `Infra Monitoring: List viewed`, was
`infra_list_viewed`. Replaces `page visited` and the category switch
`filter applied`. The old `total` row count has no equivalent.
- Drawer opened or tab changed: `Infra Monitoring: Drawer tab viewed`,
was `infra_drawer_tab_viewed`. Replaces the drawer `page visited` and
`tab changed`.
- List search: `Infra Monitoring: Filter customized`, was
`infra_filter_customized`. Replaces `filter applied`.
- Drawer logs, traces and events filter: `Infra Monitoring: Drawer
filter customized`, was `infra_drawer_filter_customized`. Replaces
`filter applied`.
- Drawer time range: `Infra Monitoring: Drawer time range customized`,
was `infra_drawer_time_range_customized`. Replaces `time updated`.
- Group by: `Infra Monitoring: Group by customized`, was
`infra_group_by_customized`. Replaces `group by changed`.
- Explorer link: `Infra Monitoring: Explorer navigated`, was
`infra_explorer_navigated`. Replaces `explore clicked`.
- List row click: `Infra Monitoring: List row clicked`, new, with
`openedInNewTab` and `isGrouped`. Replaces `item clicked`.
- Drawer trace row click: `Infra Monitoring: Drawer trace clicked`, new.
Replaces `item clicked`.
- List time range, column settings and column sort: `Infra Monitoring:
Time range customized`, `Column customized` and `Column sorted`, renamed
from `infra_*`. They had no old duplicate.
- Every event carries `entityType`, so host rows report `hosts` where
`item clicked` reported `k8s`.
- `Infra Monitoring: Get Early Access Clicked` is now `Infra Monitoring:
Get early access clicked`, with `entityType` instead of `entity_type`.
- Removed the `eventEntity` and `eventCategory` props and the
`InfraMonitoringEvents` enum, which only fed the old events.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/356
2026-11-04 19:12:04 +00:00
Ashwin Bhatkal
7d6ea4df77 feat(dashboard): add a copy dashboard ID button next to the title (#13404)
#### Description

- The dashboard header has a copy button next to the title. It copies
the dashboard ID, so users no longer have to pull it out of the URL.
- It reuses the `CopyButton` from the Infrastructure Monitoring details
drawer and shows the same kind of toast.
- The button hides while the title is being renamed.

#### Screenshots

##### Before
<img width="978" height="27" alt="before-dark"
src="https://github.com/user-attachments/assets/fd8d8a92-cf8a-470e-a609-4a3fb5220a72"
/>
<img width="978" height="27" alt="before-light"
src="https://github.com/user-attachments/assets/2a134c2a-cd99-44d0-833d-362f448ca70f"
/>

##### After
<img width="978" height="27" alt="after-dark"
src="https://github.com/user-attachments/assets/bbdb0948-7ca1-47b2-ae1f-116392bf1982"
/>
<img width="978" height="27" alt="after-light"
src="https://github.com/user-attachments/assets/c3db7861-1787-4c96-9224-0be32a7759fa"
/>
<img width="356" height="34" alt="toast-dark"
src="https://github.com/user-attachments/assets/7579b899-0de1-4d82-8102-2dfa17ce9d6a"
/>
<img width="356" height="34" alt="toast-light"
src="https://github.com/user-attachments/assets/5ba2df68-9e22-4b4f-b888-b80b2c2249d4"
/>

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/586
2026-11-04 16:56:08 +00:00
Aditya Singh
b8353eccf8 fix(logs): render json log bodies and object fields in every logs surface (#13362)
#### Description

- On `use_json_body` tenants the log body is an object. Each view had
its own way to read it, so the Default view crashed on object fields,
Column and Raw printed `[object Object]`, and Live logs, the dashboard
List panel and the pod Events tab showed the raw `{"message":…}`
wrapper.
- Added `lib/logs/logBody.ts` as the one place that turns a body into
display text. Every view, the drawer header, the List panel and k8s
Events read the body through it. A missing body renders as an empty
string.
- The `{message}`-only collapse moved out of the V5 converter into this
module. The converter still calls it, so the Explorer behaves the same.
- Selected fields with object values render as JSON in all three
Explorer views. They are not offered as add-to-query filters, same as
the drawer.
- Added `useIsBodyJsonEnabled` for the feature flag lookup. Stories
cover each surface with JSON bodies.

#### Issues closed by this PR

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

#### Screenshots / Screen Recordings

Full before/after diff of every logs, list panel and pod events story:
https://claude.ai/artifact/5Xeyedu4HNpiaEk7DF6C9c

#### Additional Information

- Touches `ListLogView`, `RawLogView` and the table columns hook, which
Pipelines, Hosts and the other Kubernetes entities also render through.
The visual diff shows those screens pixel-identical.
- `.claude/rules/log-body.md` documents the access pattern, so a review
flags a direct body read. A planned follow-up that made `ILog.body` an
opaque type was dropped: a cast or `any` walks past the brand, so it
added type ceremony without a real guarantee.
- Add-to-query from the Default view keeps a number or boolean value
typed, as the drawer does.
- Quick filters on `body.*` keys are not covered here.
2026-11-04 15:53:48 +00:00
Gaurav Tewari
0cc223fb5a chore(deps): bump dompurify, sharp and source-map-js (#13416)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Fixes the Vanta findings for dompurify (GHSA-cg2g-xh64-v4q7), sharp
(GHSA-wq5f-xc86-pv6w) and source-map-js (CVE-2026-93749).
- dompurify → 3.4.17 and sharp → 0.35.5. The dompurify override floor is
raised so the copies pinned by `@grafana/data` and `monaco-editor` move
too.
- source-map-js → 1.2.2 by bumping `postcss` to 8.5.32 and
`typescript-plugin-css-modules` to 5.3.0, which now require it. `sass`
and `css-tree` accept any 1.x and share that copy.
- Those bumps also floor nanoid `^3.3.20` and less `^4.9.1`, so both
overrides are removed. Resolved versions are unchanged.

#### Issues closed by this PR


https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=293486763&issue=SigNoz%7Cnerve-pod%7C589

#### Screenshots / Screen Recordings

sbshot: https://claude.ai/artifact/JZ4gauNPS8CTnMu7aXGUeT
- 129 stories (first of every title plus log-body states), dark and
light: 237 of 258 pairs identical, 21 moved from clock text, focus
rings, scroll offset and animation.

#### Additional Information

- `typescript-plugin-css-modules` 5.3.0 brings its own `sass` 1.107.1.
The build still uses the direct `sass` 1.97.3.
- uuid (CVE-2026-41907, via `@storybook/test-runner`) and certifi
(`tests/uv.lock`) are left for a follow-up.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-04 14:44:14 +00:00
Gaurav Tewari
436a13430a feat: add nerve pod as codeowner for AI assistant (#13409)
#### Description
- Adds `@SigNoz/nerve` as code owner for the AI Assistant frontend
(`container/AIAssistant`, `hooks/aiAssistant`, `pages/AIAssistantPage`).

#### Issues closed by this PR

#### Screenshots / Screen Recordings

#### Additional Information
- `@SigNoz/nerve` has no write access to this repo, so GitHub ignores it
as an owner until an org admin grants it. // @therealpandey

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-04 09:02:15 +00:00
Aditya Singh
825991864d feat(traces-explorer): open list rows in a span details drawer (#13361)
#### Description

- A row click in the traces explorer list opens the span in a side
drawer. The trace page no longer opens for every span. The drawer sits
behind the `TRACES_SPAN_DRAWER_ENABLED` localStorage flag.
- The drawer reuses the trace details span panel, so the overview,
events and logs tabs match the trace page. The up and down buttons and
arrow keys step through the rows. A click outside the table closes the
drawer.
- The list rows have no trace start and end time. The drawer fetches the
trace summary in the background for them. The % exec time shows when the
summary loads. The logs wait for it and query the trace window ±5 min.
- When the summary fails, the panel falls back to the span. The logs
query the span window ±5 min, and the % exec time is hidden.
- The trace ID now has a copy button instead of a link or a
click-to-copy toast.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/195

#### Screen recording



https://github.com/user-attachments/assets/6de21fc0-da64-4068-9f33-ee137b2de1d5

sbshot:
https://claude.ai/artifact/VBp6qqxdkTxR7ZUVXopY2X?sk=jsc4RO2UvZSzUXBJr1Yk6g



#### Additional Information

- The drawer adds one trace summary call per trace. React-query caches
it, so stepping between spans of one trace does not call it again.
- The drawer closes when the open span leaves the list, for example on a
refresh with a relative time or a page change. The infinite scroll PR
reworks the list query.
- Linked spans are matched by span ID.
- `useCloseOnOutsidePress` is a new shared hook. The logs drawer and
saved views will use it next.
- The explorer story moved to `Pages/Traces/Explorer/Overview`, so its
story IDs change.
2026-11-04 06:57:56 +00:00
Gaurav Tewari
d19b9afff4 feat(trace-details): show AI dot on thread tab and log empty thread with AI summary (#13380)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description
- Thread tab shows a dot when the trace summary has an `ai` block, so AI
traces stand out before the tab is opened.
- New `Trace Detail: Thread empty with AI summary` event fires when a
trace has AI usage but the Thread tab shows its empty state, to measure
how often AI traces cannot be shown as a thread.
- The event carries the trace summary (root service, entry point, spans,
duration, errors, missing spans, AI tokens, cost) to find what those
traces have in common.

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

https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=292126938&issue=SigNoz%7Cnerve-pod%7C583

https://signoz-team.slack.com/archives/C0AMEPC0DFV/p1793636954294369 

#### Screenshots / Screen Recordings
<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->



https://github.com/user-attachments/assets/e675aefd-9581-43c5-b4fa-afe5ea5e35b8




 
#### Additional Information
- Stacked on #13358.
- Fires once per trace, like the thread `Data loaded` event; the summary
is reused, not fetched again.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-04 06:53:22 +00:00
Srikanth Chekuri
4cb022dd06 fix(telemetrystore): return invalid input with a fix for three ClickHouse SQL mistakes (#13328)
#### Description

- ClickHouse errors 170 (`BAD_GET`), 288
(`DISTRIBUTED_IN_JOIN_SUBQUERY_DENIED`) and 407 (`DECIMAL_OVERFLOW`)
from raw SQL came back as 500s, though each is a fixable query mistake.
They now return invalid input with the fix as a suggestion.

#### Additional Information

- 170 and 407 and their fixes were verified on ClickHouse 25.12. 288
needs a multi-shard cluster, so its suggestion follows the
`distributed_product_mode` docs.
- Like the existing entries, these also apply to SQL SigNoz generates.
- Part of SigNoz/nerve-pod#465.
2026-11-04 05:12:37 +00:00
Vinicius Lourenço
9d465d54f5 test(e2e): retry clicks swallowed by dnd-kit and loading antd buttons (#13401)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- `app-shell/sidenav` TC-03: dnd-kit stops every click for 50 ms after a
drop, so the Save click that follows the drag was sometimes lost and no
preference request went out. `saveShortcuts` now clicks again while the
dialog is still open.
- `messaging-queues/kafka` TC-01: the health check button is a `loading`
antd `Button` while its onboarding queries fetch, and a loading antd
button ignores clicks without being disabled. `openHealthCheck` now
clicks again until the dialog opens.
- Both tests came in with #13189 and have been failing or needing
retries in CI on unrelated PRs. Notes added to
`tests/e2e/docs/flake-notes.md`.
2026-11-03 19:13:45 +00:00
Abhi kumar
7d95fa7b25 fix(query-builder): added query follows the previous query's signal (#13323)
#### Description

- In the V2 panel editor, "Add query" always created a Metrics query:
`createNewBuilderQuery` fell back to `DataSource.METRICS` because the
panel editor passes no fixed `initialDataSource`. A logs or traces panel
got a metrics query B.
- The new query now continues the last query's signal (and its `source`,
so a meter query stays meter). Hosts with a fixed data source
(explorers, alerts) are unchanged.
- No story adds a query, so no story renders differently.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#659

#### Additional Information

- Stacked on #13315; #13313 (query builder e2e) sits on top and covers
this with a regression test.
2026-11-03 17:47:48 +00:00
Abhi kumar
cd1068e1c3 fix(query-builder): legacy having casing and non-positive limits (#13315)
#### Description

- **Legacy having:** the old having filter stored `COUNT()` (upper-cased
function). `convertHavingToExpression` passed it through and the v5
backend rejects it with a 400 (`Invalid references in Having expression:
[COUNT]`), so any saved legacy query with a having failed to run. Only
the function name is now lower-cased; attribute keys keep their case.
- **Limit:** a negative limit was sent as-is and the backend returned a
400. The query and formula Limit inputs now accept digits only (`-10`
shows as `10`), so the shown value is always what's sent. Empty or `0`
means no limit.
- `InputWithLabel` gains an optional `sanitize` prop for this.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#657

#### Additional Information

- Found by the query builder e2e suite (#13313), where both cases are
`test.fixme` until this merges. The negative-limit case should expect
the sign to be dropped.
2026-11-03 17:43:47 +00:00
Nityananda Gohain
dca68d89bc feat(stats): report AI observability usage from the stats endpoint (#13359)
#### Description

 New stats from `/api/v1/stats`:

- `telemetry.traces.gen_ai.request.model.exists` – a span carried a
request model since the start of the previous day.
  - `telemetry.traces.gen_ai.tool.name.exists` – same, for a tool name.
- `telemetry.traces.gen_ai.agent.name.exists` – same, for an agent name.
- `llmpricingrule.custom.count` – pricing rules users added themselves.
  - `llmpricingrule.override.count` – synced default rules users pinned.
- `llmpricingrule.unmapped_model.count` – models seen in that window
that no rule prices.
  - `dashboard.panels.ai.count` – panels with an AI builder query.
 
The gen_ai values come from the traces tag table through the metadata
store, never from a span scan. The model list is capped at 100 names,
which is more than any org is expected to use in two days; past the cap
the unmapped count is a lower bound.

Existing stats change: every `dashboard.*` count now skips system
dashboards, and AI queries no longer count under
`dashboard.panels.traces.count`.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/nerve-pod/issues/577

  #### Additional Information

- `/api/v1/stats` latency against `main` on the same ClickHouse with
200k spans: sequential p50 14 ms → 21 ms, at 10 concurrent callers p50
78 ms → 101 ms. The three lookups run concurrently and cost about 8 ms
each regardless of limit.
- If ClickHouse is unreachable the rule counts are still reported and
only the gen_ai keys are omitted, so the reporter keeps their last
value.
2026-11-03 17:15:15 +00:00
Vishal Sharma
1be806c58d fix(ai-assistant): open explorer chips in the view the query implies (#11733)
#### Description
- Noz's `apply_filter` ("Open in Explorer") chips always opened the
Explorer in List view: the handler dropped the query's `requestType` and
never set `panelTypes`, so a grouped "count by service" landed on raw
rows.
- The chip now opens the view the query implies, on both the on-page
redirect and off-page navigation: `scalar` → Table, `time_series` → Time
Series, `trace` → Traces (traces signal only), anything else → List.

#### Issues closed by this PR


https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=292161688&issue=SigNoz%7Cnerve-pod%7C584
#### Screenshots / Screen Recordings
Prev - 


https://github.com/user-attachments/assets/a5f9f45c-6460-4109-a061-e2d49901d438



Now - 


https://github.com/user-attachments/assets/15c17c68-538a-43b9-965a-4440ec24a1ec



#### Additional Information
- Reviewable commit by commit: the fix, the `panelTypes` encoding
regression test, then the `trace` mapping from review.
- `distribution` isn't mapped: `query_range` rejects it, so the agent
can't send it.
- `trace` stays on List for logs and metrics, which have no Traces view.
- The existing `[apply_filter]` debug logs are left untouched.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
Co-authored-by: Gaurav Tewari <gauravtewari111@gmail.com>
2026-11-03 17:10:21 +00:00
Ashwin Bhatkal
8569702161 test(query-builder): stop the QueryV2 Delete menu test timing out in CI (#13394)
#### Description

- The "shows Delete with more than one query and deletes on click" test
from [#13189](https://github.com/SigNoz/signoz/pull/13189) hits jest's 5
s timeout in CI. It has failed most `jsci` runs since it merged,
including merge-queue runs for unrelated PRs.
- It rendered the whole `QueryBuilderV2` with two queries only to make
the Delete item show. It now renders one `QueryV2` with
`queriesCount={2}`, which takes it from about 0.8 s to 0.3 s locally.
- The query actions menu tests find the trigger and items by the test
ids `Dropdown` already sets, in place of a class selector and role
lookups.
- The Delete test keeps a 15 s timeout as headroom for CI load.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/685
2026-11-03 17:06:32 +00:00
Vikrant Gupta
1b8451650a docs(go): require descriptive receiver names (#13391)
#### Description

- The packages guide asked for one- or two-character receivers, which is
what the older half of the codebase does; the newer layers use the
type's role name (`provider`, `module`, `handler`, `store`). The guide
now requires the latter.
- Settles the import-shadowing case the same way
`signozapiserver/registry.go` already does: alias the import, keep the
descriptive receiver.
2026-11-03 16:08:29 +00:00
Swapnil Nakade
a5bfebbc29 refactor: using foundry commands for aws and azure integration (#13270)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- moving aws and azure integrations to use foundry commands for
integration connection initiation

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
contributes to https://github.com/keystone-pod/issues/167
2026-11-03 14:44:21 +00:00
Gaurav Tewari
afbe1ac7a6 feat: add events for span detail open and trace view (#13358)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description
- New `Trace Detail: Span details opened` event, fired once per span
shown in the details panel (click or `spanId` deep link) on both the
Overview and Thread tabs.
- Thread tab now fires the existing `Trace Detail: Data loaded` event,
with the trace-level attributes it has: `totalSpansCount`,
`traceDurationMs`, `hadErrors`, `spanPanelVariant`.
- Both events carry `detailsTab` (`overview` | `thread`) so the tabs can
be split in analytics.

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

#### Screenshots / Screen Recordings
Before - 


https://github.com/user-attachments/assets/ef1c188c-898b-4a46-b113-39215d5e93d1


Now - 


https://github.com/user-attachments/assets/844dbb67-343c-4cd1-9947-d3c9c40a5e62



#### Additional Information
- Named `detailsTab`, not `tab`: `tab` already means the span panel's
tab on other trace-detail events.
- Data loaded fires once per trace per tab; the guard ref lives in the
page, so revisiting a tab doesn't re-fire it.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-03 14:10:53 +00:00
ashu8912
2103d6c7b7 fix(notification-channels): scroll channel forms inside configuration and pin actions to the bottom (#13382)
#### Description

- New and edit channel forms now scroll inside the configuration pane,
with Discard / Test / Save pinned to the bottom. Before, the pane's
hidden overflow cut the form off, so the bottom fields and the actions
could not be reached on shorter screens.
- Playwright spec opens both forms in a 600px-tall viewport, wheels to
the end and checks the last control is reachable and sits above the
pinned actions. It fails without the fix.
- The existing ChannelsNew and ChannelsEdit stories already render these
pages.
2026-11-03 13:08:24 +00:00
ashu8912
efb2100e69 fix(alerts): papercut fixes for routing policies and planned downtime tables (#13320)
#### Description

- Page titles now read "All routing policies" / "All planned downtimes"
and the create buttons are title-cased, matching Notification Channels.
- Channels and Alerts Silenced show as many chips as fit the cell, then
a "+N" chip that lists the rest on hover; a long name truncates with an
ellipsis. Dropped the native `title` that doubled the badge tooltip.
- Resizing a column now moves only its right edge. The name column no
longer fills; the column before Actions does, via a new opt-in
`passLastColumnFill` on the shared table, so other tables are unchanged.
- Channels is now the second column in Routing Policies.
- Stories updated: the first routing policy and downtime now overflow
into "+N" in their Default stories.

#### Additional Information

- Truncation sets `--badge-max-width` on the cell, because the global
override in `styles.scss` outranks Badge's `maxWidth` prop.
- Notification Channels has the same column-fill setup and likely the
same resize bug; not changed here.

Closes: https://github.com/SigNoz/pulse-pod/issues/670
2026-11-03 12:50:21 +00:00
Ashwin Bhatkal
ce44f1a9cf ci(e2e): report the e2e check on merge queue runs (#13381)
#### Description

- `e2eci` now also runs in the merge queue, so the `e2e` check is
reported on merge queue commits.
- In the queue, a `merge-queue-pass` step posts a notice, the e2e jobs
are skipped, and the `e2e` check passes. E2E has already gated the PR by
then, the same way integration tests run on PRs and never in the queue.
- Without this, making `e2e` a required check stalls the queue. Merge
queue commits never get an `e2e` check, so every PR waits out the
60-minute timeout and is removed with `checks_timed_out`. This happened
on 2026-11-02 until `e2e` was made optional again.
- Once this merges, `e2e` can be required again. This PR's own merge
queue run should show a passing `e2e` check on its
`gh-readonly-queue/...` commit.
2026-11-03 12:43:35 +00:00
377 changed files with 7238 additions and 8595 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 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.
- **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.
- **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

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

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

15
.claude/rules/log-body.md Normal file
View File

@@ -0,0 +1,15 @@
---
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,3 +9,6 @@
- **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,6 +10,9 @@ 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`. 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).
- **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).
- **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,6 +143,8 @@ 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
/pkg/types/ @therealpandey @srikanthccv
/pkg/valuer/ @therealpandey
/cmd/ @therealpandey
.golangci.yml @therealpandey
@@ -136,7 +136,7 @@ go.mod @therealpandey
# Integration tests
/tests/integration/ @therealpandey
/tests/integration/ @therealpandey @srikanthccv
# e2e tests
/tests/e2e/ @AshwinBhatkal
@@ -302,3 +302,8 @@ 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

@@ -2,6 +2,9 @@ name: e2eci
on:
pull_request:
merge_group:
types:
- checks_requested
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -13,8 +16,12 @@ jobs:
outputs:
e2e: ${{ steps.changes.outputs.e2e }}
steps:
- name: merge-queue-pass
if: github.event_name == 'merge_group'
run: echo "::notice::Merge queue run. E2E already gated the PR, so the required e2e check auto-passes."
- name: changes
id: changes
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}

View File

@@ -2205,10 +2205,10 @@ components:
type: object
CloudintegrationtypesAWSConnectionArtifact:
properties:
connectionUrl:
cliCommand:
type: string
required:
- connectionUrl
- cliCommand
type: object
CloudintegrationtypesAWSIntegrationConfig:
properties:
@@ -2364,11 +2364,8 @@ components:
properties:
cliCommand:
type: string
cloudPowerShellCommand:
type: string
required:
- cliCommand
- cloudPowerShellCommand
type: object
CloudintegrationtypesAzureIntegrationConfig:
properties:
@@ -4482,104 +4479,6 @@ components:
required:
- name
type: object
DashboardtypesDistributionAxes:
properties:
x:
allOf:
- $ref: '#/components/schemas/DashboardtypesDistributionXAxis'
description: The bucket axis.
"y":
allOf:
- $ref: '#/components/schemas/DashboardtypesDistributionYAxis'
description: The count axis.
type: object
DashboardtypesDistributionChartAppearance:
properties:
percentiles:
allOf:
- $ref: '#/components/schemas/DashboardtypesDistributionPercentiles'
description: Percentile markers drawn across the bucket axis.
stack:
allOf:
- $ref: '#/components/schemas/DashboardtypesStackMode'
description: 'How groups combine in a bucket: normal (default) stacks them,
none draws them side by side, percent stacks them as shares of the bucket.'
required:
- stack
type: object
DashboardtypesDistributionPanelSpec:
properties:
axes:
allOf:
- $ref: '#/components/schemas/DashboardtypesDistributionAxes'
description: The X (bucket) and Y (count) axis scales.
chartAppearance:
allOf:
- $ref: '#/components/schemas/DashboardtypesDistributionChartAppearance'
description: Stacking and percentile markers.
formatting:
allOf:
- $ref: '#/components/schemas/DashboardtypesPanelFormatting'
description: Unit and decimal precision of the bucket bounds; counts take
no unit.
legend:
allOf:
- $ref: '#/components/schemas/DashboardtypesLegend'
description: Legend placement and series colors.
visualization:
allOf:
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
description: The time range the bucket counts are summed over.
type: object
DashboardtypesDistributionPercentiles:
properties:
enabled:
description: Whether percentile markers are drawn. Defaults to true.
type: boolean
values:
description: The percentiles to mark, each in (0, 100), at most 5, no duplicates.
Defaults to [50, 90, 99].
items:
format: double
type: number
nullable: true
type: array
required:
- enabled
- values
type: object
DashboardtypesDistributionXAxis:
properties:
scale:
allOf:
- $ref: '#/components/schemas/DashboardtypesDistributionXScale'
description: 'How bucket bounds are spaced: auto (default), linear, log,
symlog, or ordinal (equal widths labelled by range).'
required:
- scale
type: object
DashboardtypesDistributionXScale:
enum:
- auto
- linear
- log
- symlog
- ordinal
type: string
DashboardtypesDistributionYAxis:
properties:
scale:
allOf:
- $ref: '#/components/schemas/DashboardtypesDistributionYScale'
description: 'How counts are spaced: linear (default) or log.'
required:
- scale
type: object
DashboardtypesDistributionYScale:
enum:
- linear
- log
type: string
DashboardtypesDynamicVariableSignal:
enum:
- traces
@@ -5350,7 +5249,6 @@ components:
mapping:
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/DistributionPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
@@ -5373,7 +5271,6 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -5388,7 +5285,6 @@ components:
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
- signoz/DistributionPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -5420,21 +5316,6 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec:
properties:
kind:
description: The panel kind.
enum:
- signoz/DistributionPanel
type: string
spec:
allOf:
- $ref: '#/components/schemas/DashboardtypesDistributionPanelSpec'
description: The options for this panel kind. {} accepts every default.
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
properties:
kind:
@@ -5588,9 +5469,9 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPlugin'
description: 'The visualization: kind is one of signoz/TimeSeriesPanel,
signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel,
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel,
signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind''s
options ({} accepts every default).'
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel
or signoz/HeatmapPanel, and spec holds that kind''s options ({} accepts
every default).'
queries:
description: The queries the panel runs. Use exactly one entry for a data
panel and [] for signoz/TextPanel. To run several named queries or formulas,
@@ -5726,8 +5607,8 @@ components:
- $ref: '#/components/schemas/Querybuildertypesv5RequestType'
description: 'The result shape the panel requests: time_series for time
series, bar, area and histogram panels, scalar for number, table and pie
panels, raw for list panels, heatmap for heatmap panels, distribution
for distribution panels, trace for trace panels.'
panels, raw for list panels, heatmap for heatmap panels, trace for trace
panels.'
spec:
allOf:
- $ref: '#/components/schemas/DashboardtypesQuerySpec'
@@ -5884,9 +5765,9 @@ components:
(builder query over AI observability traces), signoz/CompositeQuery (several
named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL
or signoz/TraceOperator, and spec holds that kind''s query. Pie and table
panels reject signoz/PromQLQuery; heatmap and distribution panels reject
signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only
signoz/BuilderQuery and signoz/AIBuilderQuery.'
panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery
and signoz/TraceOperator; list panels accept only signoz/BuilderQuery
and signoz/AIBuilderQuery.'
required:
- plugin
type: object
@@ -9681,9 +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.
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
@@ -9838,56 +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
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.
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
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.'
@@ -10711,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
@@ -10906,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:
@@ -11005,7 +10833,6 @@ components:
- raw_stream
- trace
- heatmap
- distribution
type: string
Querybuildertypesv5ScalarData:
properties:
@@ -11117,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

View File

@@ -28,7 +28,7 @@ func ConvertConfig(src ExternalConfig) (InternalConfig, error)
// Over this:
type ConfigAdapter struct { ... }
func NewConfigAdapter(src ExternalConfig) *ConfigAdapter
func (a *ConfigAdapter) ToInternal() (InternalConfig, error)
func (adapter *ConfigAdapter) ToInternal() (InternalConfig, error)
```
The two-step version is only justified when `ConfigAdapter` has multiple distinct consumers that use it in different ways.
@@ -66,10 +66,10 @@ A method on a structured type should not strip meaning from the structure it bel
```go
// Problematic: callers cannot distinguish how items were related.
func (o *Order) AllLineItems() []LineItem { ... }
func (order *Order) AllLineItems() []LineItem { ... }
// Better: the validation logic operates on the full structure.
func ValidateOrder(o *Order) error { ... }
func ValidateOrder(order *Order) error { ... }
```
Public methods shape how a type is used. Once a lossy accessor exists, callers will depend on it, and the lost information becomes unrecoverable at those call sites.

View File

@@ -74,7 +74,7 @@ The `*Visitor` passed in provides everything needed to build predicates. Use the
In the simplest case, keys map straight to columns and the resolver is a switch. The doc's running example, an imaginary `sample_entity` table:
```go
func (r sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
func (resolver sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
switch key {
case "created_by":
return v.BuildStringOperation(v.Sb, ctx, operation, "sample_entity.created_by", key)

View File

@@ -82,7 +82,7 @@ In the module implementation, implement the new method. A typical implementation
- Uses the `render` package to write responses or errors
```go
func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
func (handler *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
// Extract authentication and organization context from req.Context()
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
@@ -98,7 +98,7 @@ func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
}
// Call module functions
out, err := h.module.CreateThing(req.Context(), claims.OrgID, &in)
out, err := handler.module.CreateThing(req.Context(), claims.OrgID, &in)
if err != nil {
render.Error(rw, err)
return

View File

@@ -59,7 +59,8 @@ Follow these rules:
### Unexported symbols
- Struct receivers: one or two characters (`c`, `f`, `br`).
- Struct receivers: the lowercase name of the role the type plays, the same on every method of the type (`provider`, `module`, `handler`, `store`, `middleware`, `migration`). Never one or two characters: `c`, `h`, `m` say nothing at the call site and collide with local variables.
- When the receiver name would shadow an imported package, alias the import, not the receiver (`pkghandler "github.com/SigNoz/signoz/pkg/http/handler"` with a `handler` receiver).
- Helper functions: descriptive lowercase names (`parseToken`, `buildQuery`).
### Constants

View File

@@ -70,16 +70,16 @@ The instrumentation SDK (`pkg/instrumentation/sdk.go`) is idle while running but
```go
// pkg/instrumentation/sdk.go
func (i *SDK) Start(ctx context.Context) error {
<-i.startCh
func (sdk *SDK) Start(ctx context.Context) error {
<-sdk.startCh
return nil
}
func (i *SDK) Stop(ctx context.Context) error {
close(i.startCh)
func (sdk *SDK) Stop(ctx context.Context) error {
close(sdk.startCh)
return errors.Join(
i.sdk.Shutdown(ctx),
i.meterProviderShutdownFunc(ctx),
sdk.sdk.Shutdown(ctx),
sdk.meterProviderShutdownFunc(ctx),
)
}
```
@@ -197,8 +197,8 @@ A standalone service only has the `factory.Service` lifecycle i.e it does not se
}
}
func (s *service) Start(ctx context.Context) error { ... }
func (s *service) Stop(ctx context.Context) error { ... }
func (service *service) Start(ctx context.Context) error { ... }
func (service *service) Stop(ctx context.Context) error { ... }
```
### Provider that is also a service

View File

@@ -17,7 +17,7 @@ Two rules shape how the core type behaves:
func NewGettableAuthDomainFromAuthDomain(d *AuthDomain, info *AuthNProviderInfo) *GettableAuthDomain
// Receiver form
func (m *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
func (maintenance *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
```
- **`X` can double as the storage row** when the DB shape would be identical. `Channel` embeds `bun.BaseModel` directly, and there is no `StorableChannel`. This is the preferred shape when it works.
@@ -175,10 +175,10 @@ The rules that make the envelope work:
```go
// Domain invariants: every write path re-runs these.
func (x *X) Validate() error { ... }
func (entity *X) Validate() error { ... }
// Request-shape-only: checks that do not apply once the value is persisted.
func (p *PostableX) Validate() error { ... }
func (postable *PostableX) Validate() error { ... }
```
- **Type aliases, not wrappers**, when two shapes are identical. `type GettableChannels = []*Channel` is correct because it adds no semantics beyond the underlying type.
- **Serialization tags** follow [handler.md](handler.md): `required:"true"` means the JSON key must be present, `nullable:"true"` is required on any slice or map that may serialize as `null`, and types with a fixed value set must implement `Enum() []any`.

View File

@@ -2,8 +2,6 @@ package implcloudprovider
import (
"context"
"fmt"
"net/url"
"sort"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
@@ -18,29 +16,14 @@ func NewAWSCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore)
return &awscloudprovider{serviceDefinitions: defStore}, nil
}
// TODO: move URL construction logic to cloudintegrationtypes and add unit tests for it.
func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.AWS.DeploymentRegion)
u, _ := url.Parse(baseURL)
q := u.Query()
q.Set("region", req.Config.AWS.DeploymentRegion)
u.Fragment = "/stacks/quickcreate"
u.RawQuery = q.Encode()
q = u.Query()
q.Set("stackName", cloudintegrationtypes.AgentCloudFormationBaseStackName.StringValue())
q.Set("templateURL", fmt.Sprintf(cloudintegrationtypes.AgentCloudFormationTemplateS3Path.StringValue(), req.Config.AgentVersion))
q.Set("param_SigNozIntegrationAgentVersion", req.Config.AgentVersion)
q.Set("param_SigNozApiUrl", req.Credentials.SigNozAPIURL)
q.Set("param_SigNozApiKey", req.Credentials.SigNozAPIKey)
q.Set("param_SigNozAccountId", account.ID.StringValue())
q.Set("param_IngestionUrl", req.Credentials.IngestionURL)
q.Set("param_IngestionKey", req.Credentials.IngestionKey)
connectionArtifact, err := cloudintegrationtypes.NewAWSConnectionArtifact(account.ID, req.Config.AgentVersion, req.Credentials, req.Config.AWS.DeploymentRegion)
if err != nil {
return nil, err
}
return &cloudintegrationtypes.ConnectionArtifact{
AWS: cloudintegrationtypes.NewAWSConnectionArtifact(u.String() + "?&" + q.Encode()), // this format is required by AWS
AWS: connectionArtifact,
}, nil
}

View File

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

View File

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

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

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

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

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

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

View File

@@ -25,15 +25,24 @@ 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. 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. 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:
- 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.
- 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
- Fixed ALL resulting errors
4. BEHAVIOR CHANGE DETECTION: When modifying existing behavior:
@@ -41,6 +50,14 @@ 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.15",
"dompurify": "3.4.17",
"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.26",
"postcss": "8.5.32",
"postcss-scss": "4.0.9",
"react-resizable": "3.0.4",
"redux-mock-store": "1.5.4",
"sass": "1.97.3",
"sharp": "0.35.4",
"sharp": "0.35.5",
"storybook": "10.6.3",
"stylelint": "17.15.0",
"svgo": "4.1.0",
"ts-jest": "29.4.15",
"typescript-plugin-css-modules": "5.2.0",
"typescript-plugin-css-modules": "5.3.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.15, but @grafana/data
# remove: blocked. The direct dep is already on 3.4.17, but @grafana/data
# (latest 13.1.0) and monaco-editor (latest 0.55.1) still pin vulnerable versions
dompurify@<=3.4.12: '>=3.4.13 <4'
dompurify@<=3.4.16: '>=3.4.17 <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,17 +60,10 @@ 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.2.0 > postcss-modules-local-by-default@4.2.0
# via: typescript-plugin-css-modules@5.3.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

@@ -3936,7 +3936,7 @@ export interface CloudintegrationtypesAWSConnectionArtifactDTO {
/**
* @type string
*/
connectionUrl: string;
cliCommand: string;
}
export interface CloudintegrationtypesAWSLogsCollectionStrategyDTO {
@@ -4146,10 +4146,6 @@ export interface CloudintegrationtypesAzureConnectionArtifactDTO {
* @type string
*/
cliCommand: string;
/**
* @type string
*/
cloudPowerShellCommand: string;
}
export interface CloudintegrationtypesAzureLogsCollectionStrategyDTO {
@@ -6921,108 +6917,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
/**
* The panel kind.
*/
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind {
'signoz/DistributionPanel' = 'signoz/DistributionPanel',
}
export enum DashboardtypesDistributionXScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
ordinal = 'ordinal',
}
export interface DashboardtypesDistributionXAxisDTO {
/**
* @description How bucket bounds are spaced: auto (default), linear, log, symlog, or ordinal (equal widths labelled by range).
*/
scale: DashboardtypesDistributionXScaleDTO;
}
export enum DashboardtypesDistributionYScaleDTO {
linear = 'linear',
log = 'log',
}
export interface DashboardtypesDistributionYAxisDTO {
/**
* @description How counts are spaced: linear (default) or log.
*/
scale: DashboardtypesDistributionYScaleDTO;
}
export interface DashboardtypesDistributionAxesDTO {
/**
* @description The bucket axis.
*/
x?: DashboardtypesDistributionXAxisDTO;
/**
* @description The count axis.
*/
y?: DashboardtypesDistributionYAxisDTO;
}
export interface DashboardtypesDistributionPercentilesDTO {
/**
* @type boolean
* @description Whether percentile markers are drawn. Defaults to true.
*/
enabled: boolean;
/**
* @type array,null
* @description The percentiles to mark, each in (0, 100), at most 5, no duplicates. Defaults to [50, 90, 99].
*/
values: number[] | null;
}
export interface DashboardtypesDistributionChartAppearanceDTO {
/**
* @description Percentile markers drawn across the bucket axis.
*/
percentiles?: DashboardtypesDistributionPercentilesDTO;
/**
* @description How groups combine in a bucket: normal (default) stacks them, none draws them side by side, percent stacks them as shares of the bucket.
*/
stack: DashboardtypesStackModeDTO;
}
export interface DashboardtypesDistributionPanelSpecDTO {
/**
* @description The X (bucket) and Y (count) axis scales.
*/
axes?: DashboardtypesDistributionAxesDTO;
/**
* @description Stacking and percentile markers.
*/
chartAppearance?: DashboardtypesDistributionChartAppearanceDTO;
/**
* @description Unit and decimal precision of the bucket bounds; counts take no unit.
*/
formatting?: DashboardtypesPanelFormattingDTO;
/**
* @description Legend placement and series colors.
*/
legend?: DashboardtypesLegendDTO;
/**
* @description The time range the bucket counts are summed over.
*/
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTO {
/**
* @enum signoz/DistributionPanel
* @type string
* @description The panel kind.
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind;
/**
* @description The options for this panel kind. {} accepts every default.
*/
spec: DashboardtypesDistributionPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -7034,8 +6928,7 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -7044,7 +6937,6 @@ export enum Querybuildertypesv5RequestTypeDTO {
raw_stream = 'raw_stream',
trace = 'trace',
heatmap = 'heatmap',
distribution = 'distribution',
}
/**
* The query kind.
@@ -7519,14 +7411,14 @@ export interface DashboardtypesQuerySpecDTO {
*/
name?: string;
/**
* @description The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap and distribution panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery.
* @description The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery.
*/
plugin: DashboardtypesQueryPluginDTO;
}
export interface DashboardtypesQueryDTO {
/**
* @description The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, distribution for distribution panels, trace for trace panels.
* @description The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, trace for trace panels.
*/
kind: Querybuildertypesv5RequestTypeDTO;
/**
@@ -7546,7 +7438,7 @@ export interface DashboardtypesPanelSpecDTO {
*/
links?: DashboardtypesLinkDTO[] | null;
/**
* @description The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel, signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind's options ({} accepts every default).
* @description The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel or signoz/HeatmapPanel, and spec holds that kind's options ({} accepts every default).
*/
plugin: DashboardtypesPanelPluginDTO;
/**
@@ -8317,7 +8209,6 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
'signoz/DistributionPanel' = 'signoz/DistributionPanel',
}
/**
* The RFC 6902 operations, applied in order against the dashboard's postable shape.
@@ -11082,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[];
}
@@ -11703,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.
* @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[];
/**
@@ -11824,54 +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 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;
}
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.
*/
@@ -12127,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;
});
@@ -12282,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

@@ -1,8 +1,10 @@
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';
import {
BuilderQuery,
DistributionData,
MetricRangePayloadV5,
QueryEnvelope,
QueryRangeRequestV5,
@@ -280,19 +282,6 @@ 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
*/
@@ -325,6 +314,20 @@ function convertRawData(
};
}
/**
* Converts V5 DistributionData to legacy format
*/
function convertDistributionData(
distributionData: DistributionData,
legendMap: Record<string, string>,
): any {
// Convert V5 distribution format to legacy histogram format
return {
...distributionData,
legendMap,
};
}
/**
* Helper function to convert V5 data based on type
*/
@@ -372,6 +375,15 @@ function convertV5DataByType(
result: traceData.map((trace) => convertRawData(trace, legendMap)),
};
}
case 'distribution': {
const distributionData = v5Data.data.results as DistributionData[];
return {
resultType: 'distribution',
result: distributionData.map((distribution) =>
convertDistributionData(distribution, legendMap),
),
};
}
default:
return {
resultType: '',

View File

@@ -906,10 +906,6 @@ describe('prepareQueryRangePayloadV5', () => {
});
describe('mapPanelTypeToRequestType', () => {
it('sends a histogram as time_series, which it bins in the browser', () => {
expect(mapPanelTypeToRequestType(PANEL_TYPES.HISTOGRAM)).toBe('time_series');
});
it('sends a scatter plot as scalar, like a table', () => {
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
});

View File

@@ -47,11 +47,8 @@ type PrepareQueryRangePayloadV5Result = {
*/
export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
switch (panelType) {
// Histogram bins a time series in the browser; `distribution` is the
// Distribution panel's request type.
case PANEL_TYPES.TIME_SERIES:
case PANEL_TYPES.BAR:
case PANEL_TYPES.HISTOGRAM:
return 'time_series';
case PANEL_TYPES.TABLE:
case PANEL_TYPES.SCATTER:
@@ -62,6 +59,8 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
return 'trace';
case PANEL_TYPES.LIST:
return 'raw';
case PANEL_TYPES.HISTOGRAM:
return 'distribution';
default:
return '';
}

View File

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

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

View File

@@ -1,12 +1,15 @@
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Drawer, DrawerContent } from '@signozhq/ui/drawer';
import cx from 'classnames';
import { AnimatePresence } from 'motion/react';
import './DetailsPanelDrawer.styles.scss';
import styles from './DetailsPanelDrawer.module.scss';
interface DetailsPanelDrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
testId?: string;
}
function DetailsPanelDrawer({
@@ -14,22 +17,41 @@ function DetailsPanelDrawer({
onClose,
children,
className,
testId,
}: DetailsPanelDrawerProps): JSX.Element {
return (
<DrawerWrapper
<Drawer
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
showOverlay={false}
className={`details-panel-drawer ${className || ''}`}
modal={false}
>
<div className="details-panel-drawer__body">{children}</div>
</DrawerWrapper>
<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>
);
}
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', {
entity_type: entityType,
logEvent('Infra Monitoring: Get early access clicked', {
entityType,
userEmail: user.email,
})
.then(() => {

View File

@@ -18,6 +18,7 @@ function InputWithLabel({
className,
closeIcon,
disabled,
sanitize,
}: {
label: string;
initialValue?: string | number | null;
@@ -29,14 +30,17 @@ 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 => {
setInputValue(e.target.value);
onChange?.(e.target.value);
const value = sanitize ? sanitize(e.target.value) : e.target.value;
setInputValue(value);
onChange?.(value);
};
return (
@@ -78,6 +82,7 @@ InputWithLabel.defaultProps = {
initialValue: undefined,
className: undefined,
closeIcon: undefined,
sanitize: undefined,
};
export default InputWithLabel;

View File

@@ -9,7 +9,6 @@ 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';
@@ -23,6 +22,7 @@ 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,8 +33,6 @@ 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';
@@ -67,10 +65,6 @@ 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);
@@ -173,20 +167,7 @@ function LogDetailInner({
}
};
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 logBody = useMemo(() => getBodyMessage(log?.body), [log?.body]);
const htmlBody = useMemo(
() => ({

View File

@@ -4,6 +4,7 @@ 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';
@@ -40,10 +41,10 @@ function AddToQueryHOC({
export interface AddToQueryHOCProps {
fieldKey: string;
fieldValue: string;
fieldValue: ScalarFieldValue;
onAddToQuery: (
fieldKey: string,
fieldValue: string,
fieldValue: ScalarFieldValue,
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 {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { 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 } from 'lib/logs/flatLogData';
import { FlatLogData, getLogFieldValue } from 'lib/logs/flatLogData';
import { getBodyDisplay } from 'lib/logs/logBody';
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,7 +26,6 @@ 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';
@@ -37,8 +36,9 @@ interface LogFieldProps {
fontSize: FontSize;
}
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow'> &
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow' | 'fieldValue'> &
Pick<AddToQueryHOCProps, 'fieldValue'> &
Partial<Pick<AddToQueryHOCProps, 'onAddToQuery'>>;
function LogGeneralField({
fieldKey,
@@ -75,27 +75,35 @@ 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">
<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>
{onAddToQuery ? (
<AddToQueryHOC
fieldKey={fieldKey}
fieldValue={fieldValue}
onAddToQuery={onAddToQuery}
fontSize={fontSize}
>
{fieldKeyText}
</AddToQueryHOC>
) : (
fieldKeyText
)}
<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)}>
{fieldValue || "''"}
{coerceToString(fieldValue) || "''"}
</span>
</Typography.Text>
</div>
@@ -130,6 +138,7 @@ function ListLogView({
onClearActiveLog,
}: ListLogViewProps): JSX.Element {
const flattenLogData = useMemo(() => FlatLogData(logData), [logData]);
const isBodyJsonEnabled = useIsBodyJsonEnabled();
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
logData.id,
@@ -200,7 +209,7 @@ function ListLogView({
{updatedSelecedFields.some((field) => field.name === 'body') && (
<LogGeneralField
fieldKey="Log"
fieldValue={getBodyDisplayString(logData.body)}
fieldValue={getBodyDisplay(logData.body)}
linesPerRow={linesPerRow}
fontSize={fontSize}
/>
@@ -222,17 +231,22 @@ function ListLogView({
{updatedSelecedFields
.filter((field) => !['timestamp', 'body'].includes(field.name))
.map((field) =>
isValidLogField(flattenLogData[field.name] as never) ? (
.map((field) => {
const value = getLogFieldValue(logData, field.name, isBodyJsonEnabled);
if (value == null) {
return null;
}
const queryValue = isScalarFieldValue(value) ? value : undefined;
return (
<LogSelectedField
key={field.name}
fieldKey={field.name}
fieldValue={flattenLogData[field.name] as never}
onAddToQuery={onAddToQuery}
fieldValue={queryValue ?? coerceToString(value)}
onAddToQuery={queryValue === undefined ? undefined : onAddToQuery}
fontSize={fontSize}
/>
) : null,
)}
);
})}
</LogContainer>
</div>
</div>

View File

@@ -1 +0,0 @@
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 {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
// hooks
import { useIsDarkMode } from 'hooks/useDarkMode';
import { FlatLogData } from 'lib/logs/flatLogData';
import { isEmpty, isNumber, isUndefined } from 'lodash-es';
import { getLogFieldValue } from 'lib/logs/flatLogData';
import { getBodyDisplay } from 'lib/logs/logBody';
import { isBoolean, 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 flattenLogData = useMemo(() => FlatLogData(data), [data]);
const isBodyJsonEnabled = useIsBodyJsonEnabled();
const isDarkMode = useIsDarkMode();
const isReadOnlyLog = !isLogsExplorerPage || isReadOnly;
@@ -60,15 +60,16 @@ function RawLogView({
const attributesValues = updatedSelecedFields
.filter((field) => !['timestamp', 'body'].includes(field.name))
.map((field) => flattenLogData[field.name])
.map((field) => getLogFieldValue(data, field.name, isBodyJsonEnabled))
.filter((attribute) => {
// loadash isEmpty doesnot work with numbers
if (isNumber(attribute)) {
if (isNumber(attribute) || isBoolean(attribute)) {
return true;
}
return !isUndefined(attribute) && !isEmpty(attribute);
});
})
.map((attribute) => coerceToString(attribute));
let attributesText = attributesValues.join(' | ');
@@ -102,7 +103,7 @@ function RawLogView({
// Check if body is selected
const showBody = selectedFields.some((field) => field.name === 'body');
if (showBody) {
parts.push(`${attributesText} ${getBodyDisplayString(data.body)}`);
parts.push(`${attributesText} ${getBodyDisplay(data.body)}`);
} else {
parts.push(attributesText);
}

View File

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

View File

@@ -0,0 +1,17 @@
import type { ReactNode } from 'react';
import { Badge } from '@signozhq/ui/badge';
interface ItemBadgeProps {
children: ReactNode;
testId?: string;
}
function ItemBadge({ children, testId }: ItemBadgeProps): JSX.Element {
return (
<Badge textTransform="none" variant="solid" color="secondary" testId={testId}>
{children}
</Badge>
);
}
export default ItemBadge;

View File

@@ -0,0 +1,32 @@
.badges {
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.overflow {
flex-shrink: 0;
--badge-max-width: none;
}
/* Lays every badge out at its natural width, off-screen, to size the visible row. */
.measure {
position: absolute;
top: 0;
left: 0;
display: flex;
width: max-content;
visibility: hidden;
pointer-events: none;
}
.extraBadges {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
max-width: 320px;
}

View File

@@ -0,0 +1,75 @@
import { type CSSProperties, useRef } from 'react';
import { Tooltip } from '@signozhq/ui/tooltip';
import ItemBadge from './ItemBadge';
import type { OverflowBadgeItem } from './types';
import { useOverflowBadgesLayout } from './useOverflowBadgesLayout';
import styles from './OverflowBadges.module.scss';
interface OverflowBadgesProps {
items: OverflowBadgeItem[];
}
/**
* A row of badges: as many as fit the width inline, the rest revealed on hover
* of a "+N" badge. A lone badge too wide to fit truncates instead.
*/
function OverflowBadges({ items }: OverflowBadgesProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const { visibleCount, firstMaxWidth, isMeasuring } = useOverflowBadgesLayout(
items,
containerRef,
measureRef,
);
const visible = items.slice(0, visibleCount);
const extra = items.slice(visibleCount);
// The app sets `--badge-max-width` globally, which outranks Badge's `maxWidth` prop.
const containerStyle =
firstMaxWidth === undefined
? undefined
: ({ '--badge-max-width': `${firstMaxWidth}px` } as CSSProperties);
return (
<div ref={containerRef} className={styles.badges} style={containerStyle}>
{visible.map(({ key, label }) => (
<ItemBadge key={key} testId="overflow-badges-item">
{label}
</ItemBadge>
))}
{extra.length > 0 && (
<Tooltip
side="bottom"
title={
<div className={styles.extraBadges}>
{extra.map(({ key, label }) => (
<ItemBadge key={key}>{label}</ItemBadge>
))}
</div>
}
>
{/* Stop the click so the "+N" badge doesn't trigger the row's click. */}
<span
role="presentation"
className={styles.overflow}
onClick={(e): void => e.stopPropagation()}
>
<ItemBadge testId="overflow-badges-more">+{extra.length}</ItemBadge>
</span>
</Tooltip>
)}
{isMeasuring && (
<div ref={measureRef} className={styles.measure} aria-hidden>
{items.map(({ key, label }) => (
<ItemBadge key={key}>{label}</ItemBadge>
))}
<ItemBadge>+{items.length}</ItemBadge>
</div>
)}
</div>
);
}
export default OverflowBadges;

View File

@@ -0,0 +1,74 @@
import { render, screen } from 'tests/test-utils';
import OverflowBadges from '../OverflowBadges';
import { computeOverflowBadgesLayout } from '../utils';
const items = ['alpha', 'beta', 'gamma', 'delta'].map((label) => ({
key: label,
label,
}));
describe('computeOverflowBadgesLayout', () => {
it('fits as many badges as the width allows, keeping room for +N', () => {
// 50 + 4 + 50 + 4 + 30 (+N) = 138 fits; a third badge would not.
expect(
computeOverflowBadgesLayout(140, 4, [50, 50, 50, 50], 30),
).toStrictEqual({ visibleCount: 2 });
});
it('shows every badge without +N when all fit', () => {
expect(computeOverflowBadgesLayout(200, 4, [50, 50, 50], 30)).toStrictEqual({
visibleCount: 3,
});
});
it('truncates the first badge when even it does not fit', () => {
expect(computeOverflowBadgesLayout(100, 4, [300, 50], 30)).toStrictEqual({
visibleCount: 1,
firstMaxWidth: 66,
});
});
it('gives a lone too-wide badge the full width', () => {
expect(computeOverflowBadgesLayout(100, 4, [300], 30)).toStrictEqual({
visibleCount: 1,
firstMaxWidth: 100,
});
});
it('returns visibleCount 0 when badgeWidths is empty', () => {
expect(computeOverflowBadgesLayout(100, 4, [], 30)).toStrictEqual({
visibleCount: 0,
});
});
});
describe('OverflowBadges', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('collapses the badges that do not fit into +N', () => {
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(160);
jest
.spyOn(Element.prototype, 'getBoundingClientRect')
.mockReturnValue({ width: 50 } as DOMRect);
render(<OverflowBadges items={items} />);
expect(screen.getAllByTestId('overflow-badges-item')).toHaveLength(2);
expect(screen.getByTestId('overflow-badges-more')).toHaveTextContent('+2');
});
it('omits +N when every badge fits', () => {
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000);
jest
.spyOn(Element.prototype, 'getBoundingClientRect')
.mockReturnValue({ width: 50 } as DOMRect);
render(<OverflowBadges items={items} />);
expect(screen.getAllByTestId('overflow-badges-item')).toHaveLength(4);
expect(screen.queryByTestId('overflow-badges-more')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,20 @@
import type { Key, ReactNode } from 'react';
export interface OverflowBadgeItem {
key: Key;
label: ReactNode;
}
export interface OverflowBadgesLayout {
visibleCount: number;
/** Set when even the first badge does not fit and must truncate. */
firstMaxWidth?: number;
}
export interface OverflowBadgesMeasurement {
/** The items these widths were measured for. */
itemsKey: string;
badgeWidths: number[];
overflowWidth: number;
gap: number;
}

View File

@@ -0,0 +1,71 @@
import { type RefObject, useLayoutEffect, useMemo, useState } from 'react';
import type {
OverflowBadgeItem,
OverflowBadgesLayout,
OverflowBadgesMeasurement,
} from './types';
import { computeOverflowBadgesLayout } from './utils';
/**
* Fits the badges to the container's width, re-fitting on resize. Badge widths
* are measured once per set of items, from a measuring row holding every badge
* at its natural width followed by "+N", rendered only while `isMeasuring`.
*/
export function useOverflowBadgesLayout(
items: OverflowBadgeItem[],
containerRef: RefObject<HTMLElement>,
measureRef: RefObject<HTMLElement>,
): OverflowBadgesLayout & { isMeasuring: boolean } {
const itemsKey = items.map((item) => String(item.key)).join('\u0000');
const [measurement, setMeasurement] =
useState<OverflowBadgesMeasurement | null>(null);
const [available, setAvailable] = useState(0);
const isMeasuring = items.length > 0 && measurement?.itemsKey !== itemsKey;
useLayoutEffect(() => {
const container = containerRef.current;
const measure = measureRef.current;
if (!isMeasuring || !container || !measure) {
return;
}
// Rounded up so sub-pixel widths never add up past the container.
const badgeWidths = Array.from(measure.children, (el) =>
Math.ceil(el.getBoundingClientRect().width),
);
const overflowWidth = badgeWidths.pop() ?? 0;
setMeasurement({
itemsKey,
badgeWidths,
overflowWidth,
gap: parseFloat(getComputedStyle(container).columnGap) || 0,
});
}, [isMeasuring, itemsKey, containerRef, measureRef]);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const update = (): void => setAvailable(container.clientWidth);
update();
const observer = new ResizeObserver(update);
observer.observe(container);
return (): void => observer.disconnect();
}, [containerRef]);
const layout = useMemo(
(): OverflowBadgesLayout =>
isMeasuring || !measurement || available === 0
? { visibleCount: items.length }
: computeOverflowBadgesLayout(
available,
measurement.gap,
measurement.badgeWidths,
measurement.overflowWidth,
),
[isMeasuring, measurement, available, items.length],
);
return { ...layout, isMeasuring };
}

View File

@@ -0,0 +1,33 @@
import type { OverflowBadgesLayout } from './types';
/**
* How many badges fit `available` px, keeping room for the "+N" badge while any
* are left out. Always shows at least one, capped to truncate when too wide.
*/
export const computeOverflowBadgesLayout = (
available: number,
gap: number,
badgeWidths: number[],
overflowWidth: number,
): OverflowBadgesLayout => {
if (badgeWidths.length === 0) {
return { visibleCount: 0 };
}
let used = 0;
let visibleCount = 0;
for (let i = 0; i < badgeWidths.length; i++) {
const next = used + (i > 0 ? gap : 0) + badgeWidths[i];
const reserve = i < badgeWidths.length - 1 ? gap + overflowWidth : 0;
if (next + reserve > available) {
break;
}
used = next;
visibleCount++;
}
if (visibleCount > 0) {
return { visibleCount };
}
const reserve = badgeWidths.length > 1 ? gap + overflowWidth : 0;
return { visibleCount: 1, firstMaxWidth: Math.max(available - reserve, 0) };
};

View File

@@ -5,7 +5,6 @@ import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { Tooltip, TooltipProvider } from '@signozhq/ui/tooltip';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { isBucketCountingPanelType } from 'lib/query/bucketCountingPanelTypes';
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
@@ -27,6 +26,7 @@ import {
RAW_QUERY_FIELDS,
resolveQueryBuilderFields,
} from '../../queryBuilderFields.utils';
import { parseLimit, sanitizeLimit } from '../../utils';
import BucketOptions from './BucketOptions/BucketOptions';
import HavingFilter from './HavingFilter/HavingFilter';
@@ -99,7 +99,7 @@ function QueryAddOns({
}
if (
isBucketCountingPanelType(panelType) &&
panelType === PANEL_TYPES.HEATMAP &&
query.dataSource === DataSource.METRICS &&
!HISTOGRAM_ATTRIBUTE_TYPES.has(query.aggregateAttribute?.type ?? '')
) {
@@ -279,7 +279,7 @@ function QueryAddOns({
const handleChangeLimit = useCallback(
(value: string) => {
handleChangeQueryData('limit', Number(value) || null);
handleChangeQueryData('limit', parseLimit(value));
},
[handleChangeQueryData],
);
@@ -375,6 +375,7 @@ 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

@@ -107,8 +107,8 @@ export const BASE_ADD_ONS = (
export const REDUCE_TO = toAddOn(QueryBuilderField.ReduceTo);
// Offered only by a heatmap or distribution over metrics: the bucket axis is what they
// plot against, and no other panel type sends one. A histogram brings its own buckets.
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
// against, and no other panel type sends one. A histogram brings its own buckets.
export const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
ATTRIBUTE_TYPES.HISTOGRAM,
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,

View File

@@ -25,6 +25,8 @@ import {
import '@testing-library/jest-dom';
import { QueryBuilderV2 } from '../../QueryBuilderV2';
import { QueryBuilderV2Provider } from '../../QueryBuilderV2Context';
import { QueryV2 } from '../QueryV2';
// Local mocks for domain-specific heavy child components
jest.mock(
@@ -249,11 +251,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
describe('query actions menu', () => {
const openQueryActions = async (): Promise<void> => {
const trigger = document.querySelector(
'.query-actions-container [aria-haspopup="menu"]',
) as HTMLElement;
expect(trigger).toBeInTheDocument();
await userEvent.click(trigger);
await userEvent.click(screen.getAllByTestId('query-actions')[0]);
};
it('Clone calls cloneQuery with the query', async () => {
@@ -261,7 +259,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
await openQueryActions();
await userEvent.click(
await screen.findByRole('menuitem', { name: 'Clone' }),
await screen.findByTestId('query-actions-item-clone-query'),
);
expect(baseQBContext.cloneQuery).toHaveBeenCalledTimes(1);
@@ -275,41 +273,44 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
await openQueryActions();
await screen.findByRole('menuitem', { name: 'Clone' });
await screen.findByTestId('query-actions-item-clone-query');
expect(
screen.queryByRole('menuitem', { name: 'Delete' }),
screen.queryByTestId('query-actions-item-delete-query'),
).not.toBeInTheDocument();
});
it('shows Delete with more than one query and deletes on click', async () => {
const currentQueryBase = baseQBContext.currentQuery as Query;
const [queryA] = currentQueryBase.builder.queryData;
mockedUseQueryBuilder.mockReturnValue({
...baseQBContext,
currentQuery: {
...currentQueryBase,
builder: {
...currentQueryBase.builder,
queryData: [queryA, { ...queryA, queryName: 'B', legend: 'B' }],
},
},
});
const [queryA] = (baseQBContext.currentQuery as Query).builder.queryData;
const handleDeleteQuery = jest.fn();
mockedUseQueryOperations.mockReturnValue({
...mockedUseQueryOperations({} as Parameters<UseQueryOperations>[0]),
handleDeleteQuery,
});
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
render(
<QueryBuilderV2Provider>
<QueryV2
index={0}
query={queryA}
version="v4"
isAvailableToDisable={false}
isMultiQueryAllowed
queriesCount={2}
onSignalSourceChange={jest.fn()}
signalSourceChangeEnabled={false}
savePreviousQuery={false}
/>
</QueryBuilderV2Provider>,
);
await openQueryActions();
await userEvent.click(
await screen.findByRole('menuitem', { name: 'Delete' }),
await screen.findByTestId('query-actions-item-delete-query'),
);
expect(handleDeleteQuery).toHaveBeenCalledTimes(1);
});
}, 15000);
});
it('fx button is disabled when functions already exist', () => {

View File

@@ -169,8 +169,15 @@ describe('QueryAddOns', () => {
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
expect(input.value).toBe('5');
fireEvent.change(input, { target: { value: '10' } });
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
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);
});
it.each([
@@ -431,24 +438,6 @@ describe('QueryAddOns', () => {
).toBeInTheDocument();
});
it('is offered on a metrics distribution too', () => {
render(
<QueryAddOns
query={baseQuery({ dataSource: DataSource.METRICS })}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.DISTRIBUTION}
index={0}
isForTraceOperator={false}
/>,
);
expect(
screen.getByTestId('query-add-on-bucket_options'),
).toBeInTheDocument();
});
it('is not offered on other panel types', () => {
render(
<QueryAddOns

View File

@@ -12,10 +12,62 @@ 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,6 +840,16 @@ 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
@@ -871,7 +881,14 @@ export const convertHavingToExpression = (
formattedValue = String(value);
}
return `${columnName} ${op} ${formattedValue}`;
// 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}`;
})
.filter((expression) => expression !== ''); // Remove empty expressions

View File

@@ -4,6 +4,7 @@ import type {
TouchEvent as ReactTouchEvent,
} from 'react';
import { useCallback, useMemo } from 'react';
import { flushSync } from 'react-dom';
import {
ArrowDown,
ArrowUp,
@@ -31,7 +32,7 @@ type TanStackHeaderRowProps<TData = unknown> = {
onRemoveColumn?: (columnId: string) => void;
orderBy?: SortState | null;
onSort?: (sort: SortState | null) => void;
/** Last column cannot be resized */
/** The fill column and any after it cannot be resized */
isLastColumn?: boolean;
};
@@ -89,6 +90,19 @@ function TanStackHeaderRow<TData>({
): void => {
event.preventDefault();
event.stopPropagation();
// TanStack starts the drag from the configured size, which an auto or
// redistributed column does not render at.
const renderedWidth = Math.round(
event.currentTarget.closest('th')?.getBoundingClientRect().width ?? 0,
);
if (header && renderedWidth > 0 && renderedWidth !== header.getSize()) {
flushSync(() => {
header.getContext().table.setColumnSizing((sizing) => ({
...sizing,
[header.column.id]: renderedWidth,
}));
});
}
resizeHandler?.(event);
};
const {

View File

@@ -53,7 +53,11 @@ import { useFlatItems } from './useFlatItems';
import { useResetScroll } from './useResetScroll';
import { useRowKeyData } from './useRowKeyData';
import { useTableParams } from './useTableParams';
import { buildPageSizeItems, buildTanstackColumnDef } from './utils';
import {
buildPageSizeItems,
buildTanstackColumnDef,
getFillColumnIndex,
} from './utils';
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
import tableStyles from './TanStackTable.module.scss';
@@ -428,6 +432,9 @@ function TanStackTableInner<TData, TItemKey = string>(
);
const tableHeader = useCallback(() => {
const fillColumnIndex = getFillColumnIndex(
flatHeaders.map((header) => columnsById.get(header.id)),
);
return (
<DndContext
sensors={sensors}
@@ -453,7 +460,7 @@ function TanStackTableInner<TData, TItemKey = string>(
canRemoveColumn={canRemoveColumn}
orderBy={orderBy}
onSort={setOrderBy}
isLastColumn={index === flatHeaders.length - 1}
isLastColumn={index >= fillColumnIndex}
/>
);
})}

View File

@@ -1,7 +1,7 @@
import type { Table } from '@tanstack/react-table';
import type { TableColumnDef } from './types';
import { getColumnWidthStyle } from './utils';
import { getColumnWidthStyle, getFillColumnIndex } from './utils';
export function VirtuosoTableColGroup<TData>({
columns,
@@ -13,6 +13,9 @@ export function VirtuosoTableColGroup<TData>({
const visibleTanstackColumns = table.getVisibleFlatColumns();
const columnDefsById = new Map(columns.map((c) => [c.id, c]));
const columnSizing = table.getState().columnSizing;
const fillColumnIndex = getFillColumnIndex(
visibleTanstackColumns.map((c) => columnDefsById.get(c.id)),
);
return (
<colgroup>
@@ -22,7 +25,7 @@ export function VirtuosoTableColGroup<TData>({
return <col key={tanstackCol.id} />;
}
const persistedWidth = columnSizing[tanstackCol.id];
const isLastColumn = index === visibleTanstackColumns.length - 1;
const isLastColumn = index === fillColumnIndex;
return (
<col
key={tanstackCol.id}

View File

@@ -0,0 +1,51 @@
import { screen } from '@testing-library/react';
import type { TableColumnDef } from '../types';
import { defaultColumns, renderTanStackTable, type TestRow } from './testUtils';
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
const actionsColumn: TableColumnDef<TestRow> = {
id: 'actions',
header: 'Actions',
cell: (): string => 'act',
width: { fixed: '96px', passLastColumnFill: true },
};
const colWidths = (container: HTMLElement): string[] =>
Array.from(container.querySelectorAll('colgroup col')).map(
(col) => (col as HTMLElement).style.width,
);
const isResizable = (headerText: string): boolean =>
Boolean(
screen
.getByText(headerText)
.closest('th')
?.querySelector('[title="Drag to resize column"]'),
);
describe('TanStackTable column fill', () => {
it('fills and locks the last column by default', () => {
const { container } = renderTanStackTable();
expect(colWidths(container).at(-1)).toBe('100%');
expect(isResizable('Name')).toBe(true);
expect(isResizable('Value')).toBe(false);
});
it('hands the fill to the previous column when the last passes it on', () => {
const { container } = renderTanStackTable({
props: { columns: [...defaultColumns, actionsColumn] },
});
const widths = colWidths(container);
expect(widths.at(-2)).toBe('100%');
expect(widths.at(-1)).toBe('96px');
expect(isResizable('Name')).toBe(true);
expect(isResizable('Value')).toBe(false);
expect(isResizable('Actions')).toBe(false);
});
});

View File

@@ -0,0 +1,28 @@
import type { TableColumnDef } from '../types';
import { getFillColumnIndex } from '../utils';
const column = (
id: string,
width?: TableColumnDef<unknown>['width'],
): TableColumnDef<unknown> => ({
id,
header: id,
cell: (): null => null,
width,
});
describe('getFillColumnIndex', () => {
it('fills the last column by default', () => {
expect(getFillColumnIndex([column('a'), column('b')])).toBe(1);
});
it('fills the previous column when the last passes it on', () => {
expect(
getFillColumnIndex([
column('a'),
column('b'),
column('actions', { fixed: '96px', passLastColumnFill: true }),
]),
).toBe(1);
});
});

View File

@@ -74,6 +74,8 @@ export type TableColumnDef<
default?: number | string;
max?: number | string;
ignoreLastColumnFill?: boolean;
/** When last, hand the fill to the previous column instead of taking it. */
passLastColumnFill?: boolean;
};
};

View File

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

@@ -63,6 +63,19 @@ export const getColumnWidthStyle = <TData>(
};
};
/**
* Index of the column that fills spare width: the last one, or the one before
* it when the last sets `width.passLastColumnFill`.
*/
export const getFillColumnIndex = <TData>(
columns: (TableColumnDef<TData> | undefined)[],
): number => {
const lastIndex = columns.length - 1;
return columns[lastIndex]?.width?.passLastColumnFill
? lastIndex - 1
: lastIndex;
};
const isSkeletonRow = (row: unknown): boolean => {
const r = row as Record<string, unknown>;
return typeof r?.id === 'string' && r.id.startsWith('skeleton-');

View File

@@ -8,35 +8,3 @@ 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

@@ -46,4 +46,5 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
TRACES_SPAN_DRAWER_ENABLED = 'TRACES_SPAN_DRAWER_ENABLED',
}

View File

@@ -34,7 +34,6 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HISTOGRAM]: Uplot,
// V2-only kind; it renders through the V2 panel registry.
[PANEL_TYPES.HEATMAP]: null,
[PANEL_TYPES.DISTRIBUTION]: null,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,

View File

@@ -340,7 +340,6 @@ export enum PANEL_TYPES {
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
DISTRIBUTION = 'distribution',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

View File

@@ -26,6 +26,7 @@ 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,
@@ -47,6 +48,10 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { openSavedViewByKey } from './utils/openSavedView';
import {
isSavedViewOpenAction,
@@ -333,6 +338,11 @@ 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);
@@ -342,7 +352,9 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
normalized.builder.queryData.forEach((q, i) => {
deps.handleSetQueryData(i, q);
});
deps.redirectWithQueryBuilderData(normalized);
deps.redirectWithQueryBuilderData(normalized, {
[QueryParams.panelTypes]: panelType,
});
return;
}
const base = explorerRouteForSignal(action.signal);
@@ -353,8 +365,10 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → navigate', base);
const encoded = encodeURIComponent(JSON.stringify(normalized));
navigate(`${base}?${QueryParams.compositeQuery}=${encoded}`);
const url = buildExplorerNavigationUrl(base, normalized, {
[QueryParams.panelTypes]: panelType,
});
navigate(url);
}
/** Picks the right rollback API call for a given action kind. */
@@ -613,6 +627,7 @@ export default function ActionsSection({
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,7 +1,16 @@
import { undoExecution } from 'api/ai-assistant/chat';
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { MessageActionKindDTO } 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 { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import ActionsSection from '../ActionsSection';
@@ -28,6 +37,37 @@ 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();
@@ -81,3 +121,52 @@ 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

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

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

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

View File

@@ -6,10 +6,8 @@ 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,
@@ -67,7 +65,6 @@ const DRAWER_MOTION_PROPS = {
export default function K8sBaseDetails<T>({
category,
eventCategory,
getSelectedItemExpression,
fetchEntityData,
getEntityName,
@@ -224,16 +221,6 @@ 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]}`
@@ -333,7 +320,6 @@ export default function K8sBaseDetails<T>({
<K8sBaseDetailsContent<T>
entity={entity}
category={category}
eventCategory={eventCategory}
metadataConfig={metadataConfig}
countsConfig={countsConfig}
getCountsFilterExpression={getCountsFilterExpression}

View File

@@ -10,9 +10,7 @@ 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,
@@ -55,7 +53,6 @@ import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/B
export default function K8sBaseDetailsContent<T>({
entity,
category,
eventCategory,
metadataConfig,
countsConfig,
getCountsFilterExpression,
@@ -138,14 +135,18 @@ 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 hasLoggedDefaultTab = useRef(false);
const loggedDefaultTabFor = useRef<string | null>(null);
useEffect(() => {
if (hasLoggedDefaultTab.current || !validTabs.includes(effectiveView)) {
const visitKey = `${category}:${selectedItem}`;
if (
loggedDefaultTabFor.current === visitKey ||
!validTabs.includes(effectiveView)
) {
return;
}
hasLoggedDefaultTab.current = true;
loggedDefaultTabFor.current = visitKey;
logInfraDrawerTabViewedEvent(category, effectiveView, true);
}, [validTabs, effectiveView, category]);
}, [validTabs, effectiveView, category, selectedItem]);
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
@@ -167,12 +168,6 @@ 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);
};
@@ -188,13 +183,6 @@ 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:
@@ -416,7 +404,6 @@ export default function K8sBaseDetailsContent<T>({
{effectiveView === VIEW_TYPES.OVERVIEW && tabVisibility.showOverview && (
<EntityOverview
category={category}
eventEntity={eventCategory}
attributes={entityAttributes}
entity={entity}
entityName={entityName}
@@ -426,7 +413,6 @@ export default function K8sBaseDetailsContent<T>({
{effectiveView === VIEW_TYPES.METRICS && (
<EntityMetrics<T>
entity={entity}
eventEntity={eventCategory}
entityWidgetInfo={entityWidgetInfo}
getEntityQueryPayload={getEntityQueryPayload}
category={category}
@@ -436,7 +422,6 @@ export default function K8sBaseDetailsContent<T>({
)}
{effectiveView === VIEW_TYPES.LOGS && (
<EntityLogs
eventEntity={eventCategory}
queryKey={`${queryKeyPrefix}Logs`}
category={category}
initialExpression={logsAndTracesInitialExpression}
@@ -444,7 +429,6 @@ export default function K8sBaseDetailsContent<T>({
)}
{effectiveView === VIEW_TYPES.TRACES && (
<EntityTraces
eventEntity={eventCategory}
queryKey={`${queryKeyPrefix}Traces`}
category={category}
initialExpression={logsAndTracesInitialExpression}
@@ -452,7 +436,6 @@ export default function K8sBaseDetailsContent<T>({
)}
{effectiveView === VIEW_TYPES.EVENTS && tabVisibility.showEvents && (
<EntityEvents
eventEntity={eventCategory}
queryKey={`${queryKeyPrefix}Events`}
initialExpression={eventsInitialExpression}
category={category}

View File

@@ -1,7 +1,6 @@
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,
@@ -10,7 +9,6 @@ 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';
@@ -51,6 +49,7 @@ import styles from './K8sBaseList.module.scss';
import cx from 'classnames';
import {
logInfraColumnSortedEvent,
logInfraListRowClickedEvent,
logInfraTimeRangeCustomizedEvent,
} from 'container/InfraMonitoringK8sV2/Base/events';
@@ -92,7 +91,6 @@ 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;
@@ -115,7 +113,6 @@ export function K8sBaseList<
fetchListData,
getRowKey,
getItemKey,
eventCategory,
renderEmptyState,
extraQueryKeyParts = [],
detailsQueryKeyPrefix,
@@ -280,15 +277,6 @@ 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) {
@@ -326,14 +314,10 @@ export function K8sBaseList<
setSelectedItemParams(params);
}
void logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
logInfraListRowClickedEvent(entity, false, groupBy.length > 0);
},
[
eventCategory,
entity,
groupBy.length,
setSelectedItemParams,
detailsQueryKeyPrefix,
@@ -385,13 +369,9 @@ export function K8sBaseList<
}
openInNewTab(url.pathname + url.search);
void logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
logInfraListRowClickedEvent(entity, true, false);
},
[eventCategory, groupBy.length],
[entity, groupBy.length],
);
const isGroupedByAttribute = groupBy.length > 0;
@@ -491,7 +471,6 @@ export function K8sBaseList<
<K8sTableToolbar
entity={entity}
eventCategory={eventCategory}
leftFilters={leftFilters}
onOpenOptionsDrawer={handleOpenOptionsDrawer}
/>

View File

@@ -1,8 +1,6 @@
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';
@@ -92,12 +90,6 @@ 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,8 +3,6 @@ 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,
@@ -35,7 +33,6 @@ const NAME_COLUMN_KEYS: Set<string> = new Set([
interface K8sTableToolbarProps {
entity: InfraMonitoringEntity;
eventCategory: InfraMonitoringEvents;
leftFilters?: React.ReactNode;
onOpenOptionsDrawer: () => void;
onDownload?: () => void;
@@ -43,7 +40,6 @@ interface K8sTableToolbarProps {
function K8sTableToolbar({
entity,
eventCategory,
leftFilters,
onOpenOptionsDrawer,
onDownload,
@@ -65,15 +61,9 @@ function K8sTableToolbar({
void setGroupBy(value);
void logEvent(InfraMonitoringEvents.GroupByChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
logInfraGroupByCustomizedEvent(entity, value);
},
[entity, eventCategory, orderBy, setCurrentPage, setOrderBy, setGroupBy],
[entity, 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,6 +19,13 @@ 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' };
@@ -49,7 +56,6 @@ function renderDrawer(onUrlUpdate?: OnUrlUpdateFunction): void {
>
<K8sBaseDetails<TestEntity>
category={InfraMonitoringEntity.CONTAINERS}
eventCategory={InfraMonitoringEvents.Pod}
getSelectedItemExpression={(): string => "k8s.pod.uid = 'abc'"}
fetchEntityData={jest
.fn()
@@ -71,6 +77,7 @@ 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 () => {
@@ -149,4 +156,36 @@ 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,6 +1,5 @@
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';
@@ -43,7 +42,6 @@ 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,7 +5,6 @@ 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 {
@@ -17,7 +16,9 @@ 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';
@@ -40,7 +41,13 @@ 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
@@ -227,6 +234,7 @@ describe('K8sBaseList', () => {
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
logEventMock.mockClear();
openInNewTabMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [
@@ -240,7 +248,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItemWithTitle>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithTitle(),
getRowKey: (row): string => row.id,
@@ -299,6 +306,40 @@ 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();
@@ -463,7 +504,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
orderBy: JSON.stringify({ columnName: 'cpu', order: 'desc' }),
@@ -538,7 +578,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
orderBy: JSON.stringify({ columnName: 'cpu', order: 'desc' }),
@@ -603,7 +642,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -662,7 +700,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: { page: '7', pageSize: '10' },
tableColumns: createTestColumns(),
@@ -734,7 +771,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: { page: '0', pageSize: '10' },
tableColumns: createTestColumns(),
@@ -819,7 +855,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -857,7 +892,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -895,7 +929,6 @@ 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,
@@ -943,7 +976,6 @@ describe('K8sBaseList', () => {
it('should show columns when NOT grouped', async () => {
renderComponent<TestItemWithGroup>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {},
tableColumns: createTestColumnsWithGroup(),
@@ -968,7 +1000,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItemWithGroup>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
groupBy: JSON.stringify(groupByValue),
@@ -1010,7 +1041,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
groupBy: JSON.stringify(groupByValue),
@@ -1055,7 +1085,6 @@ 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,
@@ -1078,7 +1107,6 @@ 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,
@@ -1115,7 +1143,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -1163,7 +1190,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItemWithTitle, SelectedItemParams>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithTitle(),
getRowKey: (row): string => row.id,
@@ -1188,7 +1214,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItemWithTitle, SelectedItemParams>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithTitle(),
getRowKey: (row): string => row.id,
@@ -1215,7 +1240,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItemWithTitle, SelectedItemParams>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithTitle(),
getRowKey: (row): string => row.id,
@@ -1277,7 +1301,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -1312,7 +1335,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -1352,7 +1374,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -1401,7 +1422,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -1449,7 +1469,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -1506,7 +1525,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
@@ -1580,7 +1598,6 @@ 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
@@ -1623,7 +1640,6 @@ describe('K8sBaseList', () => {
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
// cpu is NOT a name column - should be kept
@@ -1676,7 +1692,6 @@ 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_filter_customized', {
entity_type: entityType,
void logEvent('Infra Monitoring: Filter customized', {
entityType,
source,
expression_keys: [...new Set(expressionKeys)],
expressionKeys: [...new Set(expressionKeys)],
});
}
@@ -34,8 +34,8 @@ export function logInfraMonitoringListViewedEvent(
): void {
const referrer = getNavigationReferrer();
void logEvent('infra_list_viewed', {
entity,
void logEvent('Infra Monitoring: List viewed', {
entityType: entity,
referrer,
});
}
@@ -44,9 +44,9 @@ export function logInfraTimeRangeCustomizedEvent(
entityType: InfraMonitoringEntity,
rangeLabel: string,
): void {
void logEvent('infra_time_range_customized', {
entity_type: entityType,
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
void logEvent('Infra Monitoring: Time range customized', {
entityType,
rangeLabel: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
});
}
@@ -57,11 +57,11 @@ export function logInfraColumnCustomizedEvent(
maxLinesPerRow: number,
source: 'list' | 'expanded',
): void {
void logEvent('infra_column_customized', {
entity_type: entityType,
columns_list: columnsList,
font_size: fontSize,
max_lines_per_row: maxLinesPerRow,
void logEvent('Infra Monitoring: Column customized', {
entityType,
columnsList,
fontSize,
maxLinesPerRow,
source,
});
}
@@ -72,9 +72,9 @@ export function logInfraColumnSortedEvent(
direction: 'asc' | 'desc',
source: 'list' | 'expanded',
): void {
void logEvent('infra_column_sorted', {
entity_type: entityType,
column_key: columnKey,
void logEvent('Infra Monitoring: Column sorted', {
entityType,
columnKey,
direction,
source,
});
@@ -84,9 +84,9 @@ export function logInfraGroupByCustomizedEvent(
entityType: InfraMonitoringEntity,
groupByKeysList: string[],
): void {
void logEvent('infra_group_by_customized', {
entity_type: entityType,
group_by_keys_list: groupByKeysList,
void logEvent('Infra Monitoring: Group by customized', {
entityType,
groupByKeysList,
});
}
@@ -106,12 +106,24 @@ export function logInfraExplorerNavigatedEvent(params: {
sourceKey: string | null;
drawerDurationMsAtNavigation: number | null;
}): void {
void logEvent('infra_explorer_navigated', {
entity_type: params.entityType,
void logEvent('Infra Monitoring: Explorer navigated', {
entityType: params.entityType,
destination: params.destination,
source: params.source,
tab: params.tab,
source_key: params.sourceKey,
drawer_duration_ms_at_navigation: params.drawerDurationMsAtNavigation,
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,
});
}

View File

@@ -8,7 +8,6 @@ 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';
@@ -111,7 +110,6 @@ export interface K8sDetailsCustomTab<T> {
export interface K8sBaseDetailsProps<T> {
category: InfraMonitoringEntity;
eventCategory: InfraMonitoringEvents;
// Data fetching configuration
getSelectedItemExpression: (params: SelectedItemParams) => string;
fetchEntityData: (
@@ -137,7 +135,6 @@ 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,7 +7,6 @@ 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';
@@ -117,7 +116,6 @@ export const clusterEntityConfig: K8sEntityConfig<InframonitoringtypesClusterRec
{
list: {
entity: InfraMonitoringEntity.CLUSTERS,
eventCategory: InfraMonitoringEvents.Cluster,
tableColumns: k8sClustersColumnsConfig,
fetchListData,
getRowKey: getK8sClusterRowKey,
@@ -126,7 +124,6 @@ export const clusterEntityConfig: K8sEntityConfig<InframonitoringtypesClusterRec
},
details: {
category: InfraMonitoringEntity.CLUSTERS,
eventCategory: InfraMonitoringEvents.Cluster,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'cluster',
getSelectedItemExpression: k8sClusterGetSelectedItemExpression,

View File

@@ -7,7 +7,6 @@ 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';
@@ -120,7 +119,6 @@ export const containerEntityConfig: K8sEntityConfig<
> = {
list: {
entity: InfraMonitoringEntity.CONTAINERS,
eventCategory: InfraMonitoringEvents.Container,
tableColumns: k8sContainerColumnsConfig,
fetchListData,
getRowKey: getK8sContainerRowKey,
@@ -129,7 +127,6 @@ export const containerEntityConfig: K8sEntityConfig<
},
details: {
category: InfraMonitoringEntity.CONTAINERS,
eventCategory: InfraMonitoringEvents.Container,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'container',
getSelectedItemExpression: k8sContainerGetSelectedItemExpression,

View File

@@ -7,7 +7,6 @@ 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';
@@ -125,7 +124,6 @@ export const daemonSetEntityConfig: K8sEntityConfig<
> = {
list: {
entity: InfraMonitoringEntity.DAEMONSETS,
eventCategory: InfraMonitoringEvents.DaemonSet,
tableColumns: k8sDaemonSetsColumnsConfig,
fetchListData,
getRowKey: getK8sDaemonSetRowKey,
@@ -134,7 +132,6 @@ export const daemonSetEntityConfig: K8sEntityConfig<
},
details: {
category: InfraMonitoringEntity.DAEMONSETS,
eventCategory: InfraMonitoringEvents.DaemonSet,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'daemonset',
getSelectedItemExpression: k8sDaemonSetGetSelectedItemExpression,

View File

@@ -7,7 +7,6 @@ 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 {
@@ -140,7 +139,6 @@ export const deploymentEntityConfig: K8sEntityConfig<
> = {
list: {
entity: InfraMonitoringEntity.DEPLOYMENTS,
eventCategory: InfraMonitoringEvents.Deployment,
tableColumns: k8sDeploymentsColumnsConfig,
fetchListData,
getRowKey: getK8sDeploymentRowKey,
@@ -149,7 +147,6 @@ export const deploymentEntityConfig: K8sEntityConfig<
},
details: {
category: InfraMonitoringEntity.DEPLOYMENTS,
eventCategory: InfraMonitoringEvents.Deployment,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'deployment',
getSelectedItemExpression: k8sDeploymentGetSelectedItemExpression,

View File

@@ -2,8 +2,6 @@ import { useCallback } from 'react';
import { Undo } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import { InfraMonitoringEvents } from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
@@ -17,15 +15,11 @@ import styles from './EntityDateTimeSelector.module.scss';
import { logInfraDrawerTimeRangeCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
interface EntityDateTimeSelectorProps {
eventEntity: string;
category: InfraMonitoringEntity;
view: string;
}
function EntityDateTimeSelector({
eventEntity,
category,
view,
}: EntityDateTimeSelectorProps): JSX.Element {
const {
timeRange,
@@ -37,17 +31,10 @@ function EntityDateTimeSelector({
const onTimeChange = useCallback(
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
void logEvent(InfraMonitoringEvents.TimeUpdated, {
entity: eventEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view,
interval,
});
logInfraDrawerTimeRangeCustomizedEvent(category, interval);
handleTimeChange(interval, dateTimeRange);
},
[category, view, eventEntity, handleTimeChange],
[category, handleTimeChange],
);
return (

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Table, TableColumnsType } from 'antd';
import logEvent from 'api/common/logEvent';
import {
QuerySearchV2Provider,
useExpression,
@@ -16,12 +15,12 @@ import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { getBodyDisplay } from 'lib/logs/logBody';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { useQueryState } from 'nuqs';
import { DataSource } from 'types/common/queryBuilder';
@@ -52,7 +51,6 @@ interface EventDataType {
}
interface Props {
eventEntity: string;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
@@ -67,7 +65,6 @@ const handleExpandRow = (record: EventDataType): JSX.Element => (
);
function EntityEventsContent({
eventEntity,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
@@ -120,13 +117,6 @@ function EntityEventsContent({
saveRecentQueryByExpression(DataSource.LOGS, newUserExpression);
querySearchOnRun(newUserExpression || '');
void logEvent(InfraMonitoringEvents.FilterApplied, {
entity: eventEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.EventsView,
});
logInfraDrawerFilterCustomizedEvent(
category,
'events',
@@ -137,14 +127,7 @@ function EntityEventsContent({
refetch();
}
},
[
inputExpression,
initialExpression,
refetch,
querySearchOnRun,
category,
eventEntity,
],
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
);
const queryData = useMemo(
@@ -163,7 +146,7 @@ function EntityEventsContent({
key: event.data.id,
id: event.data.id,
timestamp: event.timestamp,
body: event.data.body,
body: getBodyDisplay(event.data.body),
severity: event.data.severity_text,
attributes_string: event.data.attributes_string,
resources_string: event.data.resources_string,
@@ -232,11 +215,7 @@ function EntityEventsContent({
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={InfraMonitoringEvents.EventsView}
/>
<EntityDateTimeSelector category={category} />
<RunQueryBtn
isLoadingQueries={isFetching}

View File

@@ -69,7 +69,6 @@ describe('EntityEvents', () => {
searchParams={`${K8S_ENTITY_EVENTS_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
>
<EntityEvents
eventEntity="test"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression='k8s.pod.name = "x"'

View File

@@ -61,7 +61,6 @@ describe('EntityEvents time range wiring', () => {
)}`}
>
<EntityEvents
eventEntity="test"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression={expression}

View File

@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { getEntityEventsQueryPayload } from './utils';
@@ -10,7 +11,7 @@ export const K8S_ENTITY_EVENTS_EXPRESSION_KEY = 'k8sEntityEventsExpression';
export interface EventRowData {
id: string;
body: string;
body: ILog['body'];
severity_text: string;
attributes_string?: Record<string, string>;
resources_string?: Record<string, string>;

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import LogDetail from 'components/LogDetail';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
@@ -20,7 +19,6 @@ import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
@@ -52,14 +50,12 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { logInfraDrawerFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
interface Props {
eventEntity: string;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
}
function EntityLogsContent({
eventEntity,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
@@ -127,13 +123,6 @@ function EntityLogsContent({
saveRecentQueryByExpression(DataSource.LOGS, newUserExpression);
querySearchOnRun(newUserExpression);
void logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.LogsView,
});
logInfraDrawerFilterCustomizedEvent(
category,
'logs',
@@ -277,11 +266,7 @@ function EntityLogsContent({
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={InfraMonitoringEvents.LogsView}
/>
<EntityDateTimeSelector category={category} />
<RunQueryBtn
isLoadingQueries={isFetching}

View File

@@ -94,7 +94,6 @@ describe('EntityLogs', () => {
>
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight }}>
<EntityLogs
eventEntity="test"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression='k8s.pod.name = "x"'

View File

@@ -73,7 +73,6 @@ describe('EntityLogs time range wiring', () => {
)}`}
>
<EntityLogs
eventEntity="test"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression={expression}

View File

@@ -2,7 +2,6 @@ import { useCallback, useMemo, useRef, useState } from 'react';
import { UseQueryResult } from 'react-query';
import { Skeleton } from 'antd';
import cx from 'classnames';
import { InfraMonitoringEvents } from 'constants/events';
import { PANEL_TYPES } from 'constants/queryBuilder';
import TimeSeries from 'lib/visualization/charts/TimeSeries/TimeSeries';
import {
@@ -41,7 +40,6 @@ import ChartTooltipFooter from './ChartTooltipFooter';
interface EntityMetricsProps<T> {
entity: T;
eventEntity: string;
entityWidgetInfo: K8sDetailsWidgetInfo[];
getEntityQueryPayload: (
node: T,
@@ -55,7 +53,6 @@ interface EntityMetricsProps<T> {
function EntityMetrics<T>({
entity,
eventEntity,
entityWidgetInfo,
getEntityQueryPayload,
queryKey,
@@ -203,11 +200,7 @@ function EntityMetrics<T>({
<div ref={setTooltipPortalEl} style={{ display: 'contents' }} />
<div className={styles.metricsHeader}>
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={InfraMonitoringEvents.MetricsView}
/>
<EntityDateTimeSelector category={category} />
</div>
<div className={styles.entityMetricsContainer}>
{queries.map((query, idx) => (

View File

@@ -293,7 +293,6 @@ const renderEntityMetrics = (overrides = {}): any => {
<TestRouter>
<EntityMetrics
entity={defaultProps.entity}
eventEntity="test"
entityWidgetInfo={defaultProps.entityWidgetInfo}
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
queryKey={defaultProps.queryKey}

View File

@@ -60,7 +60,6 @@ function renderEntityMetrics(store: GlobalTimeStoreApi): jest.Mock {
<GlobalTimeContext.Provider value={store}>
<EntityMetrics
entity={entity}
eventEntity="test"
entityWidgetInfo={[{ title: 'CPU Usage', yAxisUnit: 'percentage' }]}
getEntityQueryPayload={getEntityQueryPayload}
queryKey="test-query-key"

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