Compare commits

...

17 Commits

Author SHA1 Message Date
Ashwin Bhatkal
04db70bade feat(infra-monitoring): add the overview tab to the kubernetes side drawer (#12948)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- Adds an **Overview** tab to the Kubernetes side drawer. It lists
resources related to the drawer's entity, using the existing v2
infra-monitoring list APIs (`listPods`, `listContainers`, …) and reusing
each category's table columns and fetcher from the entity registry.
- The drawer entity's identity expression (e.g. `k8s.node.name = 'xyz'`)
is locked into every related query as the search's initial expression,
so it cannot be edited away. Anything the user types is added on top.
- A dropdown switches the listed resource (defaults to pods), and a
compass button opens the same list, with the same filter, on the
corresponding k8s list page.
- Clicking a row opens that resource's own drawer, overview tab
included, so you can walk node -> pod -> container.
- Overview is the landing tab for every category that has related
resources; categories without any (volumes, containers) are unchanged
and still land on Metrics.

#### Screen Recording


https://github.com/user-attachments/assets/2bfdf320-0267-4552-b7c2-1eb54451256d

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/512
Closes https://github.com/SigNoz/pulse-pod/issues/513
2026-10-08 08:40:44 +00:00
Abhi kumar
54aaad2cc3 fix(dashboard): heatmap legend search and two-row cap (#13082)
#### Description

- Heatmap legend now shows the search bar and caps at two rows, like
other panels. The layout already reserved the search row, but
`customLegend` only received the item width, so the heatmap never
rendered it and its grid spilled into that space as a third row.
`customLegend` now gets the full `LegendLayout`.
- Legend readouts take an optional `itemLabel` (default `series`);
heatmap passes `groups`, since its entries are summed into one grid
rather than drawn as separate series.

#### Screenshots
Before
<img width="987" height="436" alt="image"
src="https://github.com/user-attachments/assets/f4ec5170-dc11-46c4-8de3-452cbc1fb094"
/>

After
<img width="984" height="429" alt="image"
src="https://github.com/user-attachments/assets/f64aea62-f167-4151-a060-c0e2bf603aa1"
/>


Closes https://github.com/SigNoz/pulse-pod/issues/532
2026-10-08 07:35:06 +00:00
Ashwin Bhatkal
c407456be7 fix(new-select): reveal truncated values and contain dropdown scroll (#13063)
#### Description

- Truncated option labels in `CustomSelect` and `CustomMultiSelect` show
the full value in a tooltip on hover. The tooltip appears only when the
label is actually cut off.
- The selected value in a closed `CustomSelect` shows the same tooltip
when truncated.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/525


#### Screenshots

<img width="242" height="183" alt="Screenshot 2026-10-07 at 2 13 03 PM"
src="https://github.com/user-attachments/assets/e5397b1f-c305-4e9b-902d-fd73af69e3ba"
/>
<img width="375" height="168" alt="Screenshot 2026-10-07 at 3 17 18 PM"
src="https://github.com/user-attachments/assets/8807220a-5822-4198-b31d-787fa22ffb5d"
/>
<img width="616" height="285" alt="Screenshot 2026-10-07 at 2 13 23 PM"
src="https://github.com/user-attachments/assets/fb115f10-8190-4d82-8187-1ba7343ee7a2"
/>
<img width="579" height="481" alt="Screenshot 2026-10-07 at 2 12 49 PM"
src="https://github.com/user-attachments/assets/55c28a24-e3ab-4626-84f5-654c75e3eb96"
/>
2026-10-08 04:25:58 +00:00
Abhi kumar
49a0f7552e feat(dashboard): redesign panel editor config pane (#13010)
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

- Collapsed sections show a one-line summary; a dot marks sections with
non-default settings, and fields get a Reset link. Defaults come from
`buildPluginSpec`, so there's no second source of truth.
- Visual choices (line, interpolation, fill, gaps, scale, legend
position, stacking, decimals) are drawing tiles that reflow with pane
width; on/off settings are cards with a sketch and description.
- Axes show one axis with an inline range and flag a min above max; text
position is a 3×3 alignment grid.
- Threshold rows summarise the rule (`> 500 ms · Red background`); the
editor uses operator tiles, value and unit side by side, an "Apply color
to" preview, and warns on a unit mismatch.
- One `ColorSwatches` control serves threshold colors and text panel
backgrounds; shared controls (`ConfigField`, `ConfigTiles`, …) replace
`ConfigSegmented`.
- Leaving the editor with unsaved changes asks for confirmation.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/506

#### Screenshots / Screen Recordings

<table>
<tr><td valign="top" align="center"><img width="220"
alt="01-overview-time-series"
src="https://github.com/user-attachments/assets/b0866ba1-3b2d-48e0-bdf2-08f68237dde4"
/><br /><sub>Overview</sub></td><td valign="top" align="center"><img
width="220" alt="02-formatting"
src="https://github.com/user-attachments/assets/ea805fe3-5f01-4b8a-86eb-d83481c4328d"
/><br /><sub>Formatting & Units</sub></td><td valign="top"
align="center"><img width="220" alt="03-axes"
src="https://github.com/user-attachments/assets/71c2280f-fd65-4296-9027-d3ebb235c46e"
/><br /><sub>Axes</sub></td><td valign="top" align="center"><img
width="220" alt="04-legend"
src="https://github.com/user-attachments/assets/15983d33-5a51-4523-a068-573929058fcc"
/><br /><sub>Legend</sub></td></tr>
<tr><td valign="top" align="center"><img width="220"
alt="05-chart-appearance"
src="https://github.com/user-attachments/assets/b315df9a-2187-40b8-8905-a92ee8bde1b6"
/><br /><sub>Chart appearance</sub></td><td valign="top"
align="center"><img width="220" alt="06-thresholds-time-series"
src="https://github.com/user-attachments/assets/943bf5f3-f956-4142-a138-270055a5e660"
/><br /><sub>Thresholds · line</sub></td><td valign="top"
align="center"><img width="220" alt="07-thresholds-number"
src="https://github.com/user-attachments/assets/9be5956a-971f-4fd3-84fe-70ce0b15f5a8"
/><br /><sub>Thresholds · number</sub></td><td valign="top"
align="center"><img width="220" alt="08-thresholds-table"
src="https://github.com/user-attachments/assets/65aa5342-8cb1-476f-a84a-33a9599069e8"
/><br /><sub>Thresholds · table</sub></td></tr>
<tr><td valign="top" align="center"><img width="220"
alt="09-context-links"
src="https://github.com/user-attachments/assets/377dde95-b078-40c7-a5b9-145c28d6ca5f"
/><br /><sub>Context Links</sub></td><td valign="top"
align="center"><img width="220" alt="10-area-visualization"
src="https://github.com/user-attachments/assets/a6ad1862-70a3-4ad2-aca8-811de7ffe9b6"
/><br /><sub>Area · series layout</sub></td><td valign="top"
align="center"><img width="220" alt="11-area-chart-appearance"
src="https://github.com/user-attachments/assets/a288a71f-75d0-4597-bd5d-5824fbb5bcea"
/><br /><sub>Area · fill & opacity</sub></td><td valign="top"
align="center"><img width="220" alt="12-bar-visualization"
src="https://github.com/user-attachments/assets/53f31c1e-045c-4eb9-b54e-aae520c21161"
/><br /><sub>Bar · series layout</sub></td></tr>
<tr><td valign="top" align="center"><img width="220"
alt="13-histogram-buckets"
src="https://github.com/user-attachments/assets/5b8770b3-01ce-4e8e-b5d0-13e0973fcba9"
/><br /><sub>Histogram · buckets</sub></td><td valign="top"
align="center"><img width="220" alt="14-text-panel"
src="https://github.com/user-attachments/assets/f543934b-d913-45ba-966e-b45b91f9c7e9"
/><br /><sub>Text panel</sub></td><td valign="top" align="center"><img
width="220" alt="15-heatmap-chart-appearance"
src="https://github.com/user-attachments/assets/6e908e48-de47-4b2f-9a88-867f5bb94c9e"
/><br /><sub>Heatmap · palette</sub></td><td valign="top"
align="center"><img width="220" alt="16-heatmap-opacity"
src="https://github.com/user-attachments/assets/9f665013-1dcc-4af8-abb2-018598b9616d"
/><br /><sub>Heatmap · opacity</sub></td></tr>
<tr><td valign="top" align="center"><img width="220"
alt="17-heatmap-axes"
src="https://github.com/user-attachments/assets/f075f163-2eed-465f-a7ba-0728461bdf1d"
/><br /><sub>Heatmap · Y-axis scale</sub></td></tr>
</table>

#### Diff Screenshots

Previous · current · diff, per panel kind.

<table>
<tr><th>Panel</th><th>Dark</th><th>Light</th></tr>
<tr><td>Area</td><td><img width="420" alt="area-chart dark"
src="https://github.com/user-attachments/assets/6233824c-b67d-4a63-bbb5-6464367bc0e3"
/></td><td><img width="420" alt="area-chart light"
src="https://github.com/user-attachments/assets/0ac30d50-249a-4fb5-8cfc-7a0bd3e7ed83"
/></td></tr>
<tr><td>Bar</td><td><img width="420" alt="bar-chart dark"
src="https://github.com/user-attachments/assets/5f01976c-cc37-45cd-88a8-e1606f11594f"
/></td><td><img width="420" alt="bar-chart light"
src="https://github.com/user-attachments/assets/f39662f2-0347-48f5-a171-1b0bc9a6a7f0"
/></td></tr>
<tr><td>Histogram</td><td><img width="420" alt="histogram dark"
src="https://github.com/user-attachments/assets/6faf02ac-5881-4636-bbac-8de622551df4"
/></td><td><img width="420" alt="histogram light"
src="https://github.com/user-attachments/assets/3584feea-69fb-4f34-aec8-6878f08871be"
/></td></tr>
<tr><td>List</td><td><img width="420" alt="list dark"
src="https://github.com/user-attachments/assets/585290de-d2b3-46d7-b931-f85d04a2a8c6"
/></td><td><img width="420" alt="list light"
src="https://github.com/user-attachments/assets/7f35f708-6a9a-44b0-8f92-72982d946eac"
/></td></tr>
<tr><td>Number</td><td><img width="420" alt="number dark"
src="https://github.com/user-attachments/assets/b123d885-d927-484a-9836-39980f640e68"
/></td><td><img width="420" alt="number light"
src="https://github.com/user-attachments/assets/4e743061-06e0-4999-8746-0f35eae9cdcb"
/></td></tr>
<tr><td>Pie</td><td><img width="420" alt="pie-chart dark"
src="https://github.com/user-attachments/assets/d3ed466d-ca2a-4e65-a1d9-9e90723309ae"
/></td><td><img width="420" alt="pie-chart light"
src="https://github.com/user-attachments/assets/73cf2f7a-5e17-4d51-a42e-f2fc8b6eee28"
/></td></tr>
<tr><td>Table</td><td><img width="420" alt="table dark"
src="https://github.com/user-attachments/assets/abc4872d-fc7e-4454-a2cf-46ccfedb2728"
/></td><td><img width="420" alt="table light"
src="https://github.com/user-attachments/assets/1cad1c70-70b3-486f-b668-00cbfff46808"
/></td></tr>
<tr><td>Text</td><td><img width="420" alt="text dark"
src="https://github.com/user-attachments/assets/55c92675-9d2b-4d7c-af20-e21972e98216"
/></td><td><img width="420" alt="text light"
src="https://github.com/user-attachments/assets/62fd2598-eb9d-4559-bf0f-87b69aeae717"
/></td></tr>
<tr><td>Time series</td><td><img width="420" alt="time-series dark"
src="https://github.com/user-attachments/assets/614e16fa-57c6-403e-be50-6a4c054a9271"
/></td><td><img width="420" alt="time-series light"
src="https://github.com/user-attachments/assets/f9344078-590f-4011-bb59-1990721a827e"
/></td></tr>
</table>

#### Additional Information

- Stacked on #12992.
2026-10-07 17:16:29 +00:00
Abhi kumar
75cfe0b2c7 feat(dashboard): new-panel drawer with section placement and type browser (#12992)
#### Description

- New-panel picker is now a searchable drawer with category filters and
preview tiles.
- The footer picks the target section, can name a new section, or places
the panel in the dashboard root; the main New Panel button defaults to
the root.
- Behind the drawer, the target section is highlighted and a placeholder
shows where the panel will land; a new section is previewed as a draft.
- The panel editor's Panel Type dropdown opens the same type browser,
with a button to revert to the original type.

#### Diff Screenshots

Previous · current · diff, per picker state.

<table>
<tr><th>State</th><th>Dark</th><th>Light</th></tr>
<tr><td>Toolbar</td><td><img width="420" alt="new-panel-picker dark"
src="https://github.com/user-attachments/assets/a7ffb4fa-701e-4a09-a925-3fd3bcbfab44"
/></td><td><img width="420" alt="new-panel-picker light"
src="https://github.com/user-attachments/assets/843102de-656f-4a43-b5b5-45b393a9555a"
/></td></tr>
<tr><td>Kind selected</td><td><img width="420"
alt="new-panel-picker-type-selected dark"
src="https://github.com/user-attachments/assets/b5a7b4b4-576c-4ffe-8308-8447d020de35"
/></td><td><img width="420" alt="new-panel-picker-type-selected light"
src="https://github.com/user-attachments/assets/95084971-8449-4fc7-857d-fb72b40409de"
/></td></tr>
<tr><td>No sections</td><td><img width="420"
alt="new-panel-picker-no-sections dark"
src="https://github.com/user-attachments/assets/82694bec-0322-454e-ac1d-10204994374e"
/></td><td><img width="420" alt="new-panel-picker-no-sections light"
src="https://github.com/user-attachments/assets/00e6f05b-2322-4775-b236-a9930e4ae55e"
/></td></tr>
<tr><td>From a section</td><td><img width="420"
alt="new-panel-picker-from-section dark"
src="https://github.com/user-attachments/assets/05443c73-6fea-4eb3-a771-8f4d1200cfb2"
/></td><td><img width="420" alt="new-panel-picker-from-section light"
src="https://github.com/user-attachments/assets/a39f7e7d-4186-413c-8a08-d1883d68b81d"
/></td></tr>
</table>

#### Additional Information

- A new section or root is created only when the panel is saved, in the
same patch as the panel, so abandoning the editor leaves the dashboard
untouched. On a free-flowing dashboard, loose panels are titled "Section
1, 2…" first, matching the existing first-section migration.
- The View modal keeps its compact type dropdown.

Closes https://github.com/SigNoz/pulse-pod/issues/505
2026-10-07 17:16:28 +00:00
Aditya Singh
db3b7f7c34 fix(logs): log details header shifts when switching to the Context tab (#13070)
#### Description
- The log details header moved when the user switched from Overview to
Context. The drawer body used the native scrollbar, which appeared and
disappeared with the content height. The header grid followed the width
change.
- The drawer body now uses `OverlayScrollbar`, the same as the dashboard
settings drawer. The overlay takes no width, so the header stays in
place on every tab.
- The content box grows with its children, so the arrows hint at the
bottom stays pinned across the whole scroll.

#### Issues closed by this PR

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

#### Screenshot

Before 


https://github.com/user-attachments/assets/908d761c-d723-40db-8178-1e6ad02b9547

After



https://github.com/user-attachments/assets/65560758-c9d4-4d3f-8303-029b3d9114e6



#### Additional Information
- Most of the tsx diff is formatting from the wrap. The change is three
lines.
- The drawer tab stories (`LogDetails`, `LogDetailsContext`) were not
run locally. The manual check on staging is the verification for this
change.
2026-10-07 15:42:47 +00:00
Nityananda Gohain
bb62c9c060 feat: trace detail thread endpoint (#12999)
<!--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
- Adds `GET /api/v1/traces/{traceID}/thread`, which returns the spans
that carry `gen_ai.input.messages` or `gen_ai.output.messages`, in
timestamp order.
- Pages with `after` / `before` cursors (`nextCursor` / `prevCursor`),
or `spanId` to open the page around a span (404 if absent); the three
are exclusive.
- Message normalisation (`formatted_input` / `formatted_output`) comes
in a stacked follow-up PR.

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

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
- The thread reads only the `attributes` JSON column, so spans that have
messages only in the old attribute maps don't appear.
  - The waterfall API is unchanged.
2026-10-07 12:08:32 +00:00
Nityananda Gohain
636c023483 feat: add trace summary endpoint (#12911)
<!--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

- Adds `GET /api/v1/traces/{traceID}/summary`. It returns the trace
header fields shown with the waterfall, plus AI token and cost totals,
using one query.
- The waterfall loads every span to get these fields. It will stop
returning them once the frontend uses this endpoint.
- Fixes these waterfall bugs:
  - On traces over 10k spans, the entry point is empty.
- If some spans point to a parent that was never received, the root can
change on every reload.



| Trace size | Endpoint | Latency | Server memory | Data sent to server
|
|---|---|---|---|---|
| 2M spans | summary | 0.65 s | 84 MiB | 3 KB |
| 2M spans | waterfall | 12.5 s | 2.6 GiB | 128 MiB |

<!--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/265

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
- AI token and cost fields are now typed `number`, the same as in
metadata.

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-10-07 11:05:13 +00:00
Aditya Singh
1d54a93e02 feat(quick-filters): saved views slots in the sidebar layout (#12994)
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
- first slice of saved views in the quick filters sidebar..
`QuickFiltersLayout` gets a `savedViewProps` next to `quickFilterProps`,
both optional. views header sits above the filters, `≡` opens an All
views panel in the sidebar's place and the filters slide right over the
content as one drawer. nothing remounts, so expanded sections and an
open settings drawer survive the toggle.
- header and panel are placeholders for now (dummy name, empty list)..
this PR is the layout, the real header / list / actions come on top of
it. behind `SAVED_VIEW_ENABLED`, flag off is today's ui.
- metrics has no quick filters, so it gets the layout with only the
views side.. header on top, list always visible, nothing slides.
- logs explorer moved onto `QuickFiltersLayout`.. it was the last one
mounting quick filters on its own. content wrapper now hands a definite
height down so logs can keep sizing its list from the pane instead of
`calc(100vh - ..)`.
- quick filters settings drawer animates with transform instead of
width.. width relaid out the drawer every frame, that was the jitter.
clipped so it comes out from behind the filters.

#### Issues closed by this PR

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


#### Screenshots/ Screen recording 

<img width="1728" height="1001" alt="12994 - metrics dummy section"
src="https://github.com/user-attachments/assets/7a57fd43-a11b-44e7-a8ca-4b197826bd36"
/>


https://github.com/user-attachments/assets/fd6a4ac4-6464-4059-9986-99d42e159a3d




#### Additional Information
- flag off checked on logs / traces / metrics / exceptions.. sidebar,
list scroll and pane scroll same as before.
- borders and l1 background per figma, quick filters width still 280px..
will take that up with design.
- cc. @H4ad since `QuickFiltersLayout` is from the scroll fix PR.
2026-10-07 07:08:31 +00:00
Abhi kumar
ea127308b6 chore(storybook): add new-panel picker and config pane stories (#13058)
#### Description

- Stories for the current new-panel picker and panel editor config pane,
as the visual baseline for #12992 and #13010.
- Dashboard detail: picker opened from the toolbar, with a kind
selected, without sections, and from a section's menu.
- Panel editor: one story per panel kind with every config section
expanded; Area and Text added to the editor's kind list.
- `story-shots.mjs`: round the grown viewport height up, since
Playwright rejects fractional sizes.
2026-10-07 06:22:19 +00:00
Naman Verma
9f4108350a feat: add heatmap support in dashboard schema (#12844)
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
<!--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

To be merged along with UI changes

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

Closes https://github.com/SigNoz/pulse-pod/issues/347
Closes https://github.com/SigNoz/pulse-pod/issues/193

---------

Co-authored-by: Abhi kumar <ahrefabhi@gmail.com>
2026-10-06 18:35:51 +00:00
Nikhil Mantri
32350e61e5 feat(ruler): add saved views CRUD APIs for the alerts list page (#12814)
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

- Adds saved views for the alerts list page: a view stores the v3 list
params (`query` DSL string, `states`, `sort`, `order`) and the FE
replays them, mirroring dashboard saved views.
- New CRUD APIs: `GET/POST /api/v2/rule_views`, `PUT/DELETE
/api/v2/rule_views/{id}`. Open to all roles including viewer, like
dashboard views; shared org-wide.
- New `rule_view` table (migration 127), same shape as `dashboard_view`
plus an explicit index on `org_id` (neither Postgres nor SQLite
auto-indexes an FK's referencing column).
- Validation on save: name (required, trimmed, max 64), `version` must
be `v1`, query length-capped at 1024 (not compiled, a work-in-progress
query is saveable), `states`/`sort`/`order` enum-checked; zero
sort/order normalize to the list defaults (`updated_at`/`desc`); unknown
body fields rejected.
- Covered by unit tests and a new integration suite
(`alerts/07_rule_views.py`: rejection table, 404s, full lifecycle);
OpenAPI spec regenerated.
- discussion: SigNoz/pulse-pod#304.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#227

#### Additional Information

- Based on #12780 (alerts listing page revamp), which this PR's branch
is cut from; backend only, FE ships separately.

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-10-06 09:35:15 +00:00
Gaurav Tewari
2bd0153a51 chore: replace banner in llm with callout (#13037)
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
<!--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

Prev. we had a banner with this hardcoded values. now we have replaced
it. the callout component.


#### Issues closed by this PR

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


before - 
<img width="1517" height="325" alt="image"
src="https://github.com/user-attachments/assets/257639f0-0e97-4606-b9c9-55ee8cabe53f"
/>

Now - 
<img width="1515" height="339" alt="image"
src="https://github.com/user-attachments/assets/b02a9cae-3fc0-4980-90c9-2bde1c9b2c98"
/>

#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-06 05:57:19 +00:00
dependabot[bot]
23776bbd26 chore(deps): bump urllib3 from 2.7.0 to 2.8.0 in /tests (#13032)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.7.0 to 2.8.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/urllib3/urllib3/releases">urllib3's
releases</a>.</em></p>
<blockquote>
<h2>2.8.0</h2>
<h2>🚀 urllib3 is fundraising for HTTP/2 support</h2>
<p><a
href="https://sethmlarson.dev/urllib3-is-fundraising-for-http2-support">urllib3
is raising ~$40,000 USD</a> to release HTTP/2 support and ensure
long-term sustainable maintenance of the project. If your company or
organization uses Python and would benefit from HTTP/2 support in
Requests, pip, cloud SDKs, and thousands of other projects <a
href="https://opencollective.com/urllib3">please consider contributing
financially</a> to ensure HTTP/2 support is developed sustainably and
maintained for the long-haul.</p>
<p>Thank you for your support.</p>
<h2>Security</h2>
<p>Fixed the following security issues:</p>
<ul>
<li>The TLS configuration for HTTPS proxies could be ignored or
overridden. (High severity, GHSA-8988-9cw3-xx77)</li>
<li><code>HTTPResponse.stream()</code> and <code>read_chunked()</code>
could buffer a chunk-size line of unbounded length in memory. (High
severity, GHSA-vxq7-64xx-v4gw)</li>
<li>Chunked Deflate streaming could enter an infinite loop. (Medium
severity, GHSA-gh4c-6fx4-qh6g)</li>
</ul>
<blockquote>
<p>[!IMPORTANT]
urllib3 2.8.0 fixes HTTPS proxy TLS configuration being ignored or
overridden by destination settings. Configurations relying on that
behavior may require changes.</p>
<p>Configure proxy CA certificates and client certificates in
<code>proxy_ssl_context</code>, and proxy identity checks with
<code>proxy_assert_hostname</code> or
<code>proxy_assert_fingerprint</code>. Destination client certificates
and identity overrides no longer apply to HTTPS forwarding proxy
connections.</p>
</blockquote>
<blockquote>
<p>[!NOTE]
CVE IDs had not yet been assigned to these advisories at the time of
release due to a backlog at GitHub's CNA.</p>
</blockquote>
<h2>Deprecations &amp; Removals</h2>
<ul>
<li>Deprecated using an empty collection as the <code>Retry</code>
option <code>allowed_methods</code> to retry any verb. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5044">#5044</a>)</li>
</ul>
<h2>Features</h2>
<ul>
<li>Added <code>Url.auth_decoded</code> and
<code>Url.auth_decoded_joined</code> convenience properties to the
result of <code>parse_url()</code>. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/4945">#4945</a>)</li>
<li>Added <code>basic_auth_encoding</code> and
<code>proxy_basic_auth_encoding</code> parameters to
<code>urllib3.util.make_headers()</code>. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5092">#5092</a>)</li>
</ul>
<h2>Bugfixes</h2>
<ul>
<li>
<p>Fixed response header handling to replace obsolete folded header
lines (<code>obs-fold</code>) with spaces in accordance with RFC 9112,
preventing raw CRLF sequences from appearing in header values such as
<code>Set-Cookie</code>. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/1362">#1362</a>)</p>
</li>
<li>
<p>Fixed usage of <code>proxy_ssl_context</code> with
<code>ProxyManager</code> when
<code>use_forwarding_for_https=True</code>. Passing
<code>ssl_context</code> instead of <code>proxy_ssl_context</code> for
HTTPS proxies in this configuration now emits a
<code>FutureWarning</code> and will raise an error in v3.0. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/2577">#2577</a>)</p>
</li>
<li>
<p>Changed behavior of the default <code>ConnectionPool.pool</code>
initialization. <code>LifoQueue</code> is now resolved from the
<code>queue</code> module after the <code>ConnectionPool</code> is
instantiated instead of using the default cached <code>QueueCls</code>
class property. This is done because sometimes the
<code>queue.LifoQueue</code> is monkey-patched late in the program, such
as by gevent. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3289">#3289</a>)</p>
</li>
<li>
<p>Raised <code>UnrewindableBodyError</code> instead of
<code>ValueError</code> when retrying a request whose body had
<code>tell()</code> but not <code>seek()</code>. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3779">#3779</a>)</p>
</li>
<li>
<p>Decoded percent-encoded SOCKS proxy credentials before authenticating
with the proxy server. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3785">#3785</a>)</p>
</li>
<li>
<p>Fixed <code>HTTPResponse.drain_conn()</code> to discard unread
response data in 64 KiB chunks (same as the default <code>amt</code>
when doing <code>HTTPResponse.stream(...)</code>). (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5019">#5019</a>)</p>
</li>
<li>
<p>Fixed <code>is_ipaddress()</code> to detect non-standard IPv4 forms
accepted by <code>socket.connect</code>, such as hex
(<code>0x7f000001</code>), octal (<code>0177.0.0.1</code>), and decimal
integers (<code>2130706433</code>), ensuring SSL certificate
verification uses the correct mode for these addresses. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5029">#5029</a>)</p>
</li>
<li>
<p>Fixed <code>HTTPConnectionPool.urlopen</code> raising a misleading
<code>FullPoolError</code> instead of <code>ValueError</code> when
called with an invalid <code>timeout</code> argument on a pool created
with <code>block=True</code>. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5059">#5059</a>)</p>
</li>
<li>
<p>Fixed port-zero handling to preserve explicit <code>:0</code> values
instead of substituting the default ports 80 or 443 in URL parsing, pool
selection, proxy configuration, <code>connection_from_url()</code>, and
HTTP/2 request authority. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5071">#5071</a>,
<a
href="https://redirect.github.com/urllib3/urllib3/issues/5101">#5101</a>)</p>
</li>
<li>
<p>Fixed a bug where <code>PoolManager</code> passed the
<code>assert_hostname</code> and <code>assert_fingerprint</code>
parameters to HTTP connection pools. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5077">#5077</a>)</p>
</li>
<li>
<p>Fixed <code>HTTPConnectionPool.urlopen()</code> and HTTP proxy
forwarding to strip URL fragments from absolute request targets before
sending requests. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5079">#5079</a>)</p>
</li>
<li>
<p>Added safeguards to the proxy tunneling code to prevent potential
security issues when handling invalid characters in the proxy host and
HTTP headers. This change affects users of Python 3.10, Python 3.11, and
Python 3.12 when the standard library does not contain the fix; those on
newer Python versions should upgrade to 3.13.14+ or 3.14.5+ to get the
same security fixes. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5091">#5091</a>)</p>
</li>
<li>
<p>Fixed <code>HTTPSConnection.connect()</code> overriding
<code>ProxyConfig.ssl_context</code>'s certificate policy and proxy
identity checks with the target connection's TLS settings when
forwarding through an HTTPS proxy.</p>
<p><code>HTTPSConnection</code> no longer applies target SNI,
assertions, or client credentials to forwarding proxy handshakes and
continues to use its <code>ssl_context</code> as a fallback when an
HTTPS proxy forwards an HTTP target. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5093">#5093</a>)</p>
</li>
<li>
<p>Fixed URL parsing to more strictly enforce RFC 3986 host syntax,
rejecting invalid host input such as raw spaces and control characters,
malformed percent-encodings, and percent-encoded control characters in
HTTP(S) hosts and IPv6 zone identifiers, including proxy CONNECT tunnel
targets. Host normalization now also follows RFC 3986 normalization
rules for percent-encoded octets by decoding percent-encoded unreserved
characters and uppercasing the hexadecimal digits of retained
percent-encoded octets. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5095">#5095</a>)</p>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/urllib3/urllib3/blob/main/CHANGES.rst">urllib3's
changelog</a>.</em></p>
<blockquote>
<h1>2.8.0 (2026-09-15)</h1>
<h2>Security</h2>
<p>Fixed the following security issues:</p>
<ul>
<li>The TLS configuration for HTTPS proxies could be ignored or
overridden.
(High severity, <code>GHSA-8988-9cw3-xx77
&lt;https://github.com/urllib3/urllib3/security/advisories/GHSA-8988-9cw3-xx77&gt;</code>__)</li>
<li><code>HTTPResponse.stream()</code> and <code>read_chunked()</code>
could buffer a chunk-size
line of unbounded length in memory. (High severity,
<code>GHSA-vxq7-64xx-v4gw
&lt;https://github.com/urllib3/urllib3/security/advisories/GHSA-vxq7-64xx-v4gw&gt;</code>__)</li>
<li>Chunked Deflate streaming could enter an infinite loop. (Medium
severity,
<code>GHSA-gh4c-6fx4-qh6g
&lt;https://github.com/urllib3/urllib3/security/advisories/GHSA-gh4c-6fx4-qh6g&gt;</code>__)</li>
</ul>
<p>.. caution::</p>
<pre><code>urllib3 2.8.0 fixes HTTPS proxy TLS configuration being
ignored or
overridden by destination settings. Configurations relying on that
behavior may require changes.
<p>Configure proxy CA certificates and client certificates in
<code>proxy_ssl_context</code>, and proxy identity checks with
<code>proxy_assert_hostname</code> or
<code>proxy_assert_fingerprint</code>.
Destination client certificates and identity overrides no longer
apply to HTTPS forwarding proxy connections.
</code></pre></p>
<h2>Deprecations &amp; Removals</h2>
<ul>
<li>Deprecated using an empty collection as the <code>Retry</code>
option
<code>allowed_methods</code> to retry any verb.
(<code>[#5044](https://github.com/urllib3/urllib3/issues/5044)
&lt;https://github.com/urllib3/urllib3/issues/5044&gt;</code>__)</li>
</ul>
<h2>Features</h2>
<ul>
<li>Added <code>Url.auth_decoded</code> and
<code>Url.auth_decoded_joined</code> convenience
properties to the result of <code>parse_url()</code>.
(<code>[#4945](https://github.com/urllib3/urllib3/issues/4945)
&lt;https://github.com/urllib3/urllib3/issues/4945&gt;</code>__)</li>
<li>Added <code>basic_auth_encoding</code> and
<code>proxy_basic_auth_encoding</code> parameters to
<code>urllib3.util.make_headers()</code>.
(<code>[#5092](https://github.com/urllib3/urllib3/issues/5092)
&lt;https://github.com/urllib3/urllib3/issues/5092&gt;</code>__)</li>
</ul>
<h2>Bugfixes</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="b1d30ab61f"><code>b1d30ab</code></a>
Release 2.8.0</li>
<li><a
href="9016d7e8af"><code>9016d7e</code></a>
Skip <code>test_read_chunked_with_trailing_data_does_not_hang</code> for
brotlicffi (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5258">#5258</a>)</li>
<li><a
href="9101f581a8"><code>9101f58</code></a>
Fix <code>nox -s docs</code> warning (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5256">#5256</a>)</li>
<li><a
href="cd770b059b"><code>cd770b0</code></a>
Merge commit from fork</li>
<li><a
href="ea2ad7b21a"><code>ea2ad7b</code></a>
Merge commit from fork</li>
<li><a
href="0716e31534"><code>0716e31</code></a>
Fix loading unencrypted client keys with a password in pyOpenSSL (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5255">#5255</a>)</li>
<li><a
href="43c68c8b43"><code>43c68c8</code></a>
Test pickling of <code>InvalidChunkLength</code> (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5247">#5247</a>)</li>
<li><a
href="308b279b3f"><code>308b279</code></a>
Share security policy between GitHub and Read the Docs (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5253">#5253</a>)</li>
<li><a
href="53fa0731b2"><code>53fa073</code></a>
Add policy on duplicate pull requests (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5252">#5252</a>)</li>
<li><a
href="5f2a6a843d"><code>5f2a6a8</code></a>
Assert on the ALPN extension in test_tunnel_sets_http_11_alpn (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5232">#5232</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/urllib3/urllib3/compare/2.7.0...2.8.0">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 04:07:41 +00:00
Srikanth Chekuri
f941d1ea11 chore(deps): bump grpc to v1.83.2 and opentelemetry-go to v1.45.0 (#13047)
#### Description

- Bump `google.golang.org/grpc` to v1.83.2 and the opentelemetry-go core
and trace exporter modules to v1.45.0. This fixes the open Dependabot
alerts for these modules.
- Take the resource schema URL from `sdkresource.Default()`. From
v1.44.0, the SDK uses semconv 1.41.0. With the pinned 1.40.0 URL, the
resource merge fails and the server does not start.
- Keep the otel log modules at their current versions. Their v0.21.0 API
breaks `otelzap` v0.13.0 and the log exporters that
`signoz-otel-collector` v0.144.6 uses.
- Replaces #12828, #12900, #12902, #12903 and #12904.
2026-10-06 03:23:37 +00:00
dependabot[bot]
185677634c chore(deps): bump google.golang.org/grpc from 1.83.1 to 1.83.2 in /scripts/promqltestcorpus (#12804)
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
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from
1.83.1 to 1.83.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/grpc/grpc-go/releases">google.golang.org/grpc's
releases</a>.</em></p>
<blockquote>
<h2>Release 1.83.2</h2>
<h1>Security</h1>
<ul>
<li>server: Reject requests missing both <code>:authority</code> and
<code>Host</code> headers with HTTP 400 and status
<code>Internal</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/pull/9365">grpc/grpc-go#9365</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/winklemad"><code>@​winklemad</code></a></li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="030ee8becb"><code>030ee8b</code></a>
Update version to 1.83.2 (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9375">#9375</a>)</li>
<li><a
href="8668b69c16"><code>8668b69</code></a>
cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9365">#9365</a> to
v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9366">#9366</a>)</li>
<li><a
href="a3e952d2b7"><code>a3e952d</code></a>
cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9346">#9346</a> to
v1.83.x and update x/net dependency (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9369">#9369</a>)</li>
<li><a
href="58f8fd9a00"><code>58f8fd9</code></a>
Change version to 1.83.2-dev (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9337">#9337</a>)</li>
<li>See full diff in <a
href="https://github.com/grpc/grpc-go/compare/v1.83.1...v1.83.2">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-05 13:04:53 +00:00
Naman Verma
e8c22cb259 fix(promql): serve transpiled series without their synthetic __name__ (#13031)
<!--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

`max_over_time` expects a range vector as input, which is `labels ->
[(timestamp,value),...]`. it turns each entry into
`labelsWith__name__removed -> maxOfAllValues`.

However, if two entries have `labelsWith__name__removed` as the same,
then `max_over_time` throws an error. For eg if the input is:
1. {"host":"a", "__name__": "transpiled_1"} -> ....
2. {"host":"a", "__name__": "transpiled_2"} -> ....
then `max_over_time` will break.

Currently, `executeHybrid` in
`pkg/prometheus/clickhouseprometheusv2/transpiler_exec.go` always puts
in the `__name__` label as `signoz_transpiled_*`, which can lead to the
above scenario.

Removing this `__name__` label fixes that issue. Also, nothing ever
needs this label. When the engine asks for `signoz_transpiled_0`, our
storage finds the data with a map lookup on that name and returns it.
The series' own labels play no part in the lookup.

After this change, no synthetic `__name__` exists anymore. So, the code
that stripped it after the engine ran, and `mergeMatrixByLabelset`,
which re-merged the rows those names had split, are deleted. The
engine's own merging handles this now.

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

Closes https://github.com/SigNoz/pulse-pod/issues/508

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

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-10-05 09:56:02 +00:00
426 changed files with 24932 additions and 3723 deletions

View File

@@ -68,6 +68,7 @@ jobs:
- semconvfamilies
- serviceaccount
- spanmapper
- tracedetail
- querier_json_body
- querier_skip_resource_fingerprint
- ttl

View File

@@ -3731,6 +3731,84 @@ components:
hide:
type: boolean
type: object
DashboardtypesHeatmapAxes:
properties:
"y":
$ref: '#/components/schemas/DashboardtypesHeatmapAxis'
type: object
DashboardtypesHeatmapAxis:
properties:
scale:
$ref: '#/components/schemas/DashboardtypesHeatmapYScale'
type: object
DashboardtypesHeatmapChartAppearance:
properties:
colors:
$ref: '#/components/schemas/DashboardtypesHeatmapColors'
type: object
DashboardtypesHeatmapColorMode:
enum:
- palette
- opacity
type: string
DashboardtypesHeatmapColorScale:
enum:
- log
- sqrt
- linear
type: string
DashboardtypesHeatmapColors:
properties:
fill:
type: string
maxCount:
nullable: true
type: number
minCount:
nullable: true
type: number
mode:
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
palette:
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
scale:
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
steps:
type: integer
type: object
DashboardtypesHeatmapPalette:
enum:
- ice
- moss
- rust
- graphite
- ember
- lagoon
- orchid
- verdant
- lava
- beacon
type: string
DashboardtypesHeatmapPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesHeatmapAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesHeatmapChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesHeatmapYScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -4084,6 +4162,7 @@ components:
mapping:
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
@@ -4102,6 +4181,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4114,6 +4194,7 @@ components:
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4139,6 +4220,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
properties:
kind:
enum:
- signoz/HeatmapPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesHeatmapPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec:
properties:
kind:
@@ -9041,6 +9134,15 @@ components:
- alertType
- ruleType
type: object
RuletypesListableRuleViews:
properties:
views:
items:
$ref: '#/components/schemas/RuletypesRuleView'
type: array
required:
- views
type: object
RuletypesListableRules:
properties:
labels:
@@ -9138,6 +9240,16 @@ components:
- ruleType
- condition
type: object
RuletypesPostableRuleView:
properties:
data:
$ref: '#/components/schemas/RuletypesRuleViewData'
name:
type: string
required:
- name
- data
type: object
RuletypesQueryType:
enum:
- builder
@@ -9276,6 +9388,45 @@ components:
- promql_rule
- anomaly_rule
type: string
RuletypesRuleView:
properties:
createdAt:
format: date-time
type: string
data:
$ref: '#/components/schemas/RuletypesRuleViewData'
id:
type: string
name:
type: string
orgId:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- data
- orgId
type: object
RuletypesRuleViewData:
properties:
order:
$ref: '#/components/schemas/RuletypesListOrder'
query:
type: string
sort:
$ref: '#/components/schemas/RuletypesListSort'
states:
items:
type: string
type: array
version:
type: string
required:
- version
type: object
RuletypesScheduleType:
enum:
- hourly
@@ -9693,6 +9844,53 @@ components:
required:
- aggregations
type: object
SpantypesGettableTraceSummary:
properties:
ai:
$ref: '#/components/schemas/SpantypesTraceAISummary'
endTimestampMillis:
minimum: 0
type: integer
hasMissingSpans:
type: boolean
rootServiceEntryPoint:
type: string
rootServiceName:
type: string
rootSpanStatusCode:
type: string
startTimestampMillis:
minimum: 0
type: integer
totalErrorSpansCount:
minimum: 0
type: integer
totalSpansCount:
minimum: 0
type: integer
required:
- startTimestampMillis
- endTimestampMillis
- rootServiceName
- rootServiceEntryPoint
- rootSpanStatusCode
- totalSpansCount
- totalErrorSpansCount
- hasMissingSpans
type: object
SpantypesGettableTraceThread:
properties:
nextCursor:
type: string
prevCursor:
type: string
spans:
items:
$ref: '#/components/schemas/SpantypesThreadSpan'
type: array
required:
- spans
type: object
SpantypesGettableWaterfallTrace:
properties:
endTimestampMillis:
@@ -10007,6 +10205,94 @@ components:
nullable: true
type: object
type: object
SpantypesThreadSpan:
properties:
attributes:
additionalProperties: {}
type: object
duration_nano:
minimum: 0
type: integer
events:
items:
$ref: '#/components/schemas/SpantypesEvent'
type: array
has_error:
type: boolean
kind_string:
type: string
name:
type: string
parent_span_id:
type: string
references:
items:
$ref: '#/components/schemas/SpantypesOtelSpanRef'
type: array
resource:
additionalProperties:
type: string
type: object
span_id:
type: string
status_code_string:
type: string
status_message:
type: string
time_unix:
minimum: 0
type: integer
trace_id:
type: string
required:
- span_id
- trace_id
- parent_span_id
- name
- kind_string
- time_unix
- duration_nano
- has_error
- status_code_string
- status_message
- resource
- attributes
- events
- references
type: object
SpantypesTraceAISummary:
properties:
tokens:
$ref: '#/components/schemas/SpantypesTraceAITokens'
totalCost:
type: number
required:
- tokens
type: object
SpantypesTraceAITokens:
properties:
cacheRead:
minimum: 0
type: integer
cacheWrite:
minimum: 0
type: integer
input:
minimum: 0
type: integer
output:
minimum: 0
type: integer
reasoning:
minimum: 0
type: integer
required:
- input
- output
- cacheRead
- cacheWrite
- reasoning
type: object
SpantypesUpdatableSpanMapper:
properties:
config:
@@ -15731,6 +16017,163 @@ paths:
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/traces/{traceID}/summary:
get:
deprecated: false
description: Returns the trace's time range, root span, span and error counts,
and whether any spans are missing. AI traces also include token and cost totals.
operationId: GetTraceSummary
parameters:
- in: path
name: traceID
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableTraceSummary'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get summary for a trace
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/traces/{traceID}/thread:
get:
deprecated: false
description: Returns the spans carrying gen_ai input or output messages in timestamp
order. Pass nextCursor as after or prevCursor as before to page, or spanId
to open the page around a span.
operationId: GetTraceThread
parameters:
- description: Page size, at most 100. 0 means 20.
in: query
name: limit
schema:
description: Page size, at most 100. 0 means 20.
type: integer
- description: The nextCursor of a page; returns the spans after it. Set only
one of after, before and spanId.
in: query
name: after
schema:
description: The nextCursor of a page; returns the spans after it. Set only
one of after, before and spanId.
type: string
- description: The prevCursor of a page; returns the spans before it. Set only
one of after, before and spanId.
in: query
name: before
schema:
description: The prevCursor of a page; returns the spans before it. Set
only one of after, before and spanId.
type: string
- description: Returns the page around this span. Set only one of after, before
and spanId.
in: query
name: spanId
schema:
description: Returns the page around this span. Set only one of after, before
and spanId.
type: string
- in: path
name: traceID
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableTraceThread'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get thread view for a trace
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/user/me:
get:
deprecated: true
@@ -21193,6 +21636,235 @@ paths:
tags:
- users
x-signoz-stability: alpha
/api/v2/rule_views:
get:
deprecated: false
description: Returns every saved view in the calling user's org. Saved views
are shared org-wide.
operationId: ListRuleViews
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesListableRuleViews'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List rule saved views
tags:
- rules
x-signoz-stability: alpha
post:
deprecated: false
description: Persists the calling user's rule listing state (query, states,
sort, order) as a named, reusable view shared across the org.
operationId: CreateRuleView
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RuletypesPostableRuleView'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRuleView'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Create rule saved view
tags:
- rules
x-signoz-stability: alpha
/api/v2/rule_views/{id}:
delete:
deprecated: false
description: Removes a saved view. Saved views are shared org-wide. Deleting
a non-existent view returns 404.
operationId: DeleteRuleView
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Delete rule saved view
tags:
- rules
x-signoz-stability: alpha
put:
deprecated: false
description: Replaces a saved view's name and data. Saved views are shared org-wide.
operationId: UpdateRuleView
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RuletypesPostableRuleView'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRuleView'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Update rule saved view
tags:
- rules
x-signoz-stability: alpha
/api/v2/rules:
get:
deprecated: true

View File

@@ -2,6 +2,8 @@
// Mock for uplot library used in tests
export interface MockUPlotInstance {
/** Consumers read `root.parentElement` to detect a re-mounted container. */
root: HTMLDivElement;
setData: jest.Mock;
setSize: jest.Mock;
destroy: jest.Mock;
@@ -17,13 +19,20 @@ export interface MockUPlotPaths {
}
// Create mock instance methods
const createMockUPlotInstance = (): MockUPlotInstance => ({
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
redraw: jest.fn(),
setSeries: jest.fn(),
});
const createMockUPlotInstance = (target?: HTMLElement): MockUPlotInstance => {
const root = document.createElement('div');
// Real uPlot mounts its root inside the target; without it a re-render reads
// `root.parentElement` off undefined and throws.
target?.appendChild(root);
return {
root,
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
redraw: jest.fn(),
setSeries: jest.fn(),
};
};
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
const createMockPathBuilder = (name: string): jest.Mock =>
@@ -53,14 +62,16 @@ const mockTzDate = jest.fn(
function MockUPlot(
_options: unknown,
_data: unknown,
_target: HTMLElement,
target: HTMLElement,
): MockUPlotInstance {
return createMockUPlotInstance();
return createMockUPlotInstance(target);
}
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;
// Export the constructor as default
export default MockUPlot;

View File

@@ -417,7 +417,7 @@ for (const theme of themes.length ? themes : [null]) {
break;
}
height = needed;
height = Math.ceil(needed);
await page.setViewportSize({ width: Number(opts.width), height });
await page.waitForTimeout(Number(opts.settle));
}

View File

@@ -19,7 +19,9 @@ import type {
import type {
CreateRule201,
CreateRuleView201,
DeleteRuleByIDPathParameters,
DeleteRuleViewPathParameters,
GetRuleByID200,
GetRuleByIDPathParameters,
GetRuleHistoryFilterKeys200,
@@ -40,6 +42,7 @@ import type {
GetRuleHistoryTopContributors200,
GetRuleHistoryTopContributorsParams,
GetRuleHistoryTopContributorsPathParameters,
ListRuleViews200,
ListRules200,
ListRulesV3200,
ListRulesV3Params,
@@ -47,8 +50,11 @@ import type {
PatchRuleByIDPathParameters,
RenderErrorResponseDTO,
RuletypesPostableRuleDTO,
RuletypesPostableRuleViewDTO,
TestRule200,
UpdateRuleByIDPathParameters,
UpdateRuleView200,
UpdateRuleViewPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -74,6 +80,351 @@ const withQueryKey = <T extends object, K>(
return result;
};
/**
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
* @summary List rule saved views
*/
export const listRuleViews = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListRuleViews200>({
url: `/api/v2/rule_views`,
method: 'GET',
signal,
});
};
export const getListRuleViewsQueryKey = () => {
return [`/api/v2/rule_views`] as const;
};
export const getListRuleViewsQueryOptions = <
TData = Awaited<ReturnType<typeof listRuleViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRuleViews>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListRuleViewsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRuleViews>>> = ({
signal,
}) => listRuleViews(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listRuleViews>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListRuleViewsQueryResult = NonNullable<
Awaited<ReturnType<typeof listRuleViews>>
>;
export type ListRuleViewsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List rule saved views
*/
export function useListRuleViews<
TData = Awaited<ReturnType<typeof listRuleViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRuleViews>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListRuleViewsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary List rule saved views
*/
export const invalidateListRuleViews = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListRuleViewsQueryKey() },
options,
);
return queryClient;
};
/**
* Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.
* @summary Create rule saved view
*/
export const createRuleView = (
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateRuleView201>({
url: `/api/v2/rule_views`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostableRuleViewDTO,
signal,
});
};
export const getCreateRuleViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
> => {
const mutationKey = ['createRuleView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createRuleView>>,
{ data?: BodyType<RuletypesPostableRuleViewDTO> }
> = (props) => {
const { data } = props ?? {};
return createRuleView(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateRuleViewMutationResult = NonNullable<
Awaited<ReturnType<typeof createRuleView>>
>;
export type CreateRuleViewMutationBody =
| BodyType<RuletypesPostableRuleViewDTO>
| undefined;
export type CreateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create rule saved view
*/
export const useCreateRuleView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
> => {
return useMutation(getCreateRuleViewMutationOptions(options));
};
/**
* Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.
* @summary Delete rule saved view
*/
export const deleteRuleView = (
{ id }: DeleteRuleViewPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/rule_views/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteRuleViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
> => {
const mutationKey = ['deleteRuleView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteRuleView>>,
{ pathParams: DeleteRuleViewPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteRuleView(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteRuleViewMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteRuleView>>
>;
export type DeleteRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete rule saved view
*/
export const useDeleteRuleView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
> => {
return useMutation(getDeleteRuleViewMutationOptions(options));
};
/**
* Replaces a saved view's name and data. Saved views are shared org-wide.
* @summary Update rule saved view
*/
export const updateRuleView = (
{ id }: UpdateRuleViewPathParameters,
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateRuleView200>({
url: `/api/v2/rule_views/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostableRuleViewDTO,
signal,
});
};
export const getUpdateRuleViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
> => {
const mutationKey = ['updateRuleView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateRuleView>>,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateRuleView(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateRuleViewMutationResult = NonNullable<
Awaited<ReturnType<typeof updateRuleView>>
>;
export type UpdateRuleViewMutationBody =
| BodyType<RuletypesPostableRuleViewDTO>
| undefined;
export type UpdateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update rule saved view
*/
export const useUpdateRuleView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
> => {
return useMutation(getUpdateRuleViewMutationOptions(options));
};
/**
* This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.
* @deprecated

View File

@@ -5291,6 +5291,87 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesTextPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind {
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
}
export enum DashboardtypesHeatmapYScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesHeatmapAxisDTO {
scale?: DashboardtypesHeatmapYScaleDTO;
}
export interface DashboardtypesHeatmapAxesDTO {
y?: DashboardtypesHeatmapAxisDTO;
}
export enum DashboardtypesHeatmapColorModeDTO {
palette = 'palette',
opacity = 'opacity',
}
export enum DashboardtypesHeatmapPaletteDTO {
ice = 'ice',
moss = 'moss',
rust = 'rust',
graphite = 'graphite',
ember = 'ember',
lagoon = 'lagoon',
orchid = 'orchid',
verdant = 'verdant',
lava = 'lava',
beacon = 'beacon',
}
export enum DashboardtypesHeatmapColorScaleDTO {
log = 'log',
sqrt = 'sqrt',
linear = 'linear',
}
export interface DashboardtypesHeatmapColorsDTO {
/**
* @type string
*/
fill?: string;
/**
* @type number,null
*/
maxCount?: number | null;
/**
* @type number,null
*/
minCount?: number | null;
mode?: DashboardtypesHeatmapColorModeDTO;
palette?: DashboardtypesHeatmapPaletteDTO;
scale?: DashboardtypesHeatmapColorScaleDTO;
/**
* @type integer
*/
steps?: number;
}
export interface DashboardtypesHeatmapChartAppearanceDTO {
colors?: DashboardtypesHeatmapColorsDTO;
}
export interface DashboardtypesHeatmapPanelSpecDTO {
axes?: DashboardtypesHeatmapAxesDTO;
chartAppearance?: DashboardtypesHeatmapChartAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO {
/**
* @enum signoz/HeatmapPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind;
spec: DashboardtypesHeatmapPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5300,7 +5381,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6226,6 +6308,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
}
/**
* @nullable
@@ -10328,6 +10411,56 @@ export interface RuletypesListableRuleDTO {
updatedBy?: string;
}
export interface RuletypesRuleViewDataDTO {
order?: RuletypesListOrderDTO;
/**
* @type string
*/
query?: string;
sort?: RuletypesListSortDTO;
/**
* @type array
*/
states?: string[];
/**
* @type string
*/
version: string;
}
export interface RuletypesRuleViewDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
data: RuletypesRuleViewDataDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface RuletypesListableRuleViewsDTO {
/**
* @type array
*/
views: RuletypesRuleViewDTO[];
}
export interface RuletypesListableRulesDTO {
/**
* @type array
@@ -10496,6 +10629,14 @@ export interface RuletypesPostableRuleDTO {
version?: string;
}
export interface RuletypesPostableRuleViewDTO {
data: RuletypesRuleViewDataDTO;
/**
* @type string
*/
name: string;
}
export type RuletypesRuleDTOAnnotations = { [key: string]: string };
export type RuletypesRuleDTOLabels = { [key: string]: string };
@@ -11194,6 +11335,82 @@ export interface SpantypesGettableTraceAggregationsDTO {
aggregations: SpantypesSpanAggregationResultDTO[];
}
export interface SpantypesTraceAITokensDTO {
/**
* @type integer
* @minimum 0
*/
cacheRead: number;
/**
* @type integer
* @minimum 0
*/
cacheWrite: number;
/**
* @type integer
* @minimum 0
*/
input: number;
/**
* @type integer
* @minimum 0
*/
output: number;
/**
* @type integer
* @minimum 0
*/
reasoning: number;
}
export interface SpantypesTraceAISummaryDTO {
tokens: SpantypesTraceAITokensDTO;
/**
* @type number
*/
totalCost?: number;
}
export interface SpantypesGettableTraceSummaryDTO {
ai?: SpantypesTraceAISummaryDTO;
/**
* @type integer
* @minimum 0
*/
endTimestampMillis: number;
/**
* @type boolean
*/
hasMissingSpans: boolean;
/**
* @type string
*/
rootServiceEntryPoint: string;
/**
* @type string
*/
rootServiceName: string;
/**
* @type string
*/
rootSpanStatusCode: string;
/**
* @type integer
* @minimum 0
*/
startTimestampMillis: number;
/**
* @type integer
* @minimum 0
*/
totalErrorSpansCount: number;
/**
* @type integer
* @minimum 0
*/
totalSpansCount: number;
}
export interface SpantypesOtelSpanRefDTO {
/**
* @type string
@@ -11209,6 +11426,86 @@ export interface SpantypesOtelSpanRefDTO {
traceId?: string;
}
export type SpantypesThreadSpanDTOAttributes = { [key: string]: unknown };
export type SpantypesThreadSpanDTOResource = { [key: string]: string };
export interface SpantypesThreadSpanDTO {
/**
* @type object
*/
attributes: SpantypesThreadSpanDTOAttributes;
/**
* @type integer
* @minimum 0
*/
duration_nano: number;
/**
* @type array
*/
events: SpantypesEventDTO[];
/**
* @type boolean
*/
has_error: boolean;
/**
* @type string
*/
kind_string: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
parent_span_id: string;
/**
* @type array
*/
references: SpantypesOtelSpanRefDTO[];
/**
* @type object
*/
resource: SpantypesThreadSpanDTOResource;
/**
* @type string
*/
span_id: string;
/**
* @type string
*/
status_code_string: string;
/**
* @type string
*/
status_message: string;
/**
* @type integer
* @minimum 0
*/
time_unix: number;
/**
* @type string
*/
trace_id: string;
}
export interface SpantypesGettableTraceThreadDTO {
/**
* @type string
*/
nextCursor?: string;
/**
* @type string
*/
prevCursor?: string;
/**
* @type array
*/
spans: SpantypesThreadSpanDTO[];
}
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
@@ -12882,6 +13179,51 @@ export type GetTraceAggregations200 = {
status: string;
};
export type GetTraceSummaryPathParameters = {
traceID: string;
};
export type GetTraceSummary200 = {
data: SpantypesGettableTraceSummaryDTO;
/**
* @type string
*/
status: string;
};
export type GetTraceThreadPathParameters = {
traceID: string;
};
export type GetTraceThreadParams = {
/**
* @type integer
* @description Page size, at most 100. 0 means 20.
*/
limit?: number;
/**
* @type string
* @description The nextCursor of a page; returns the spans after it. Set only one of after, before and spanId.
*/
after?: string;
/**
* @type string
* @description The prevCursor of a page; returns the spans before it. Set only one of after, before and spanId.
*/
before?: string;
/**
* @type string
* @description Returns the page around this span. Set only one of after, before and spanId.
*/
spanId?: string;
};
export type GetTraceThread200 = {
data: SpantypesGettableTraceThreadDTO;
/**
* @type string
*/
status: string;
};
export type ListUserPreferences200 = {
/**
* @type array
@@ -13783,6 +14125,36 @@ export type GetUsersByRoleID200 = {
status: string;
};
export type ListRuleViews200 = {
data: RuletypesListableRuleViewsDTO;
/**
* @type string
*/
status: string;
};
export type CreateRuleView201 = {
data: RuletypesRuleViewDTO;
/**
* @type string
*/
status: string;
};
export type DeleteRuleViewPathParameters = {
id: string;
};
export type UpdateRuleViewPathParameters = {
id: string;
};
export type UpdateRuleView200 = {
data: RuletypesRuleViewDTO;
/**
* @type string
*/
status: string;
};
export type ListRules200 = {
/**
* @type array

View File

@@ -4,11 +4,17 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
@@ -16,6 +22,11 @@ import type {
GetFlamegraphPathParameters,
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetTraceSummary200,
GetTraceSummaryPathParameters,
GetTraceThread200,
GetTraceThreadParams,
GetTraceThreadPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
@@ -27,6 +38,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* Computes span aggregations grouped by requested field.
* @summary Get aggregations for a trace
@@ -127,6 +158,223 @@ export const useGetTraceAggregations = <
> => {
return useMutation(getGetTraceAggregationsMutationOptions(options));
};
/**
* Returns the trace's time range, root span, span and error counts, and whether any spans are missing. AI traces also include token and cost totals.
* @summary Get summary for a trace
*/
export const getTraceSummary = (
{ traceID }: GetTraceSummaryPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceSummary200>({
url: `/api/v1/traces/${traceID}/summary`,
method: 'GET',
signal,
});
};
export const getGetTraceSummaryQueryKey = ({
traceID,
}: GetTraceSummaryPathParameters) => {
return [`/api/v1/traces/${traceID}/summary`] as const;
};
export const getGetTraceSummaryQueryOptions = <
TData = Awaited<ReturnType<typeof getTraceSummary>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceSummaryPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetTraceSummaryQueryKey({ traceID });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceSummary>>> = ({
signal,
}) => getTraceSummary({ traceID }, signal);
return {
queryKey,
queryFn,
enabled: traceID !== null && traceID !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetTraceSummaryQueryResult = NonNullable<
Awaited<ReturnType<typeof getTraceSummary>>
>;
export type GetTraceSummaryQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get summary for a trace
*/
export function useGetTraceSummary<
TData = Awaited<ReturnType<typeof getTraceSummary>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceSummaryPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetTraceSummaryQueryOptions({ traceID }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get summary for a trace
*/
export const invalidateGetTraceSummary = async (
queryClient: QueryClient,
{ traceID }: GetTraceSummaryPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetTraceSummaryQueryKey({ traceID }) },
options,
);
return queryClient;
};
/**
* Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* @summary Get thread view for a trace
*/
export const getTraceThread = (
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceThread200>({
url: `/api/v1/traces/${traceID}/thread`,
method: 'GET',
params,
signal,
});
};
export const getGetTraceThreadQueryKey = (
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
) => {
return [
`/api/v1/traces/${traceID}/thread`,
...(params ? [params] : []),
] as const;
};
export const getGetTraceThreadQueryOptions = <
TData = Awaited<ReturnType<typeof getTraceThread>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetTraceThreadQueryKey({ traceID }, params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceThread>>> = ({
signal,
}) => getTraceThread({ traceID }, params, signal);
return {
queryKey,
queryFn,
enabled: traceID !== null && traceID !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetTraceThreadQueryResult = NonNullable<
Awaited<ReturnType<typeof getTraceThread>>
>;
export type GetTraceThreadQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get thread view for a trace
*/
export function useGetTraceThread<
TData = Awaited<ReturnType<typeof getTraceThread>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetTraceThreadQueryOptions(
{ traceID },
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get thread view for a trace
*/
export const invalidateGetTraceThread = async (
queryClient: QueryClient,
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetTraceThreadQueryKey({ traceID }, params) },
options,
);
return queryClient;
};
/**
* Returns the flamegraph view of spans for a given trace ID.
* @summary Get flamegraph view for a trace

View File

@@ -198,6 +198,7 @@ function createBaseSpec(
: undefined,
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
having: normalizeHaving(queryData.having),
bucketOptions: queryData.bucketOptions,
functions: isEmpty(queryData.functions)
? undefined
: queryData.functions.map((func: QueryFunction): QueryFunction => {

View File

@@ -5,6 +5,10 @@
border-radius: 2px 0px 0px 2px;
.label {
// Typography.Text takes its display from this token; at its `inline` default
// the label blockifies as a flex item and the text rides the top of the row.
--typography-text-display: flex;
font-size: 12px;
font-style: normal;
font-weight: 500;

View File

@@ -42,6 +42,9 @@
display: flex;
flex-direction: column;
padding: 0 16px;
// OverlayScrollbar owns the scroll. A native scrollbar here changes the
// body width when it toggles and shifts the header grid.
overflow: hidden;
}
.title {
@@ -69,7 +72,7 @@
}
.log-detail-drawer__content {
height: 100%;
min-height: 100%;
}
.log-detail-drawer__log {

View File

@@ -5,6 +5,7 @@ import { Drawer, Tooltip } from 'antd';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Typography } from '@signozhq/ui/typography';
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
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';
@@ -288,148 +289,150 @@ function LogDetailInner({
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
<div
className="log-detail-drawer__content"
data-log-detail-ignore="true"
data-testid="log-detail-drawer"
>
<div className="log-detail-drawer__log">
<LogStateIndicator
severityText={log.severity_text}
severityNumber={log.severity_number}
fontSize={options?.fontSize ?? FontSize.MEDIUM}
/>
<Tooltip
title={removeEscapeCharacters(logBody)}
placement="left"
mouseLeaveDelay={0}
>
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
</Tooltip>
<div className="log-overflow-shadow">&nbsp;</div>
</div>
<LogHighlights log={log} />
<div className="log-detail-drawer__section-divider" />
<div className="tabs-and-search">
<ToggleGroupSimple
type="single"
className="views-tabs"
onChange={handleModeChange}
value={selectedView}
items={[
{
value: VIEW_TYPES.OVERVIEW,
label: (
<div className="view-title">
<Table size={14} />
Overview
</div>
),
},
{
value: VIEW_TYPES.CONTEXT,
label: (
<div className="view-title">
<TextSelect size={14} />
Context
</div>
),
},
{
value: VIEW_TYPES.INFRAMETRICS,
label: (
<div className="view-title">
<Histogram size="md" />
Metrics
</div>
),
},
]}
/>
<div className="log-detail-drawer__actions">
{selectedView === VIEW_TYPES.CONTEXT && (
<Tooltip
title="Show Filters"
placement="topLeft"
aria-label="Show Filters"
mouseLeaveDelay={0}
>
<Button
variant="link"
color="secondary"
size="sm"
prefix={<Filter size="lg" />}
onClick={handleFilterVisible}
/>
</Tooltip>
)}
</div>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
<div className="log-detail-drawer-query-container">
<QuerySearch
onChange={(value): void => handleQueryExpressionChange(value, 0)}
dataSource={DataSource.LOGS}
queryData={contextQuery?.builder.queryData[0]}
onRun={handleRunQuery}
<OverlayScrollbar style={{ height: '100%' }}>
<div
className="log-detail-drawer__content"
data-log-detail-ignore="true"
data-testid="log-detail-drawer"
>
<div className="log-detail-drawer__log">
<LogStateIndicator
severityText={log.severity_text}
severityNumber={log.severity_number}
fontSize={options?.fontSize ?? FontSize.MEDIUM}
/>
<Tooltip
title={removeEscapeCharacters(logBody)}
placement="left"
mouseLeaveDelay={0}
>
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
</Tooltip>
<div className="log-overflow-shadow">&nbsp;</div>
</div>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<Overview
logData={log}
isListViewPanel={isListViewPanel}
handleChangeSelectedView={handleChangeSelectedView}
onApplyLogFilter={onApplyLogFilter}
/>
)}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView
log={log}
filters={filters}
contextQuery={contextQuery}
isEdit={isEdit}
/>
)}
{selectedView === VIEW_TYPES.INFRAMETRICS && (
<InfraMetrics
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
timestamp={log.timestamp.toString()}
dataSource={DataSource.LOGS}
/>
)}
<LogHighlights log={log} />
{selectedView === VIEW_TYPES.OVERVIEW && (
<div className="log-detail-drawer__footer-hint">
<div className="log-detail-drawer__footer-hint-content">
<Typography.Text
color="muted"
className="log-detail-drawer__footer-hint-text"
>
Use
</Typography.Text>
<ArrowUp size={14} className="log-detail-drawer__footer-hint-icon" />
<span>/</span>
<ArrowDown size={14} className="log-detail-drawer__footer-hint-icon" />
<Typography.Text
color="muted"
className="log-detail-drawer__footer-hint-text"
>
to view previous/next log
</Typography.Text>
<div className="log-detail-drawer__section-divider" />
<div className="tabs-and-search">
<ToggleGroupSimple
type="single"
className="views-tabs"
onChange={handleModeChange}
value={selectedView}
items={[
{
value: VIEW_TYPES.OVERVIEW,
label: (
<div className="view-title">
<Table size={14} />
Overview
</div>
),
},
{
value: VIEW_TYPES.CONTEXT,
label: (
<div className="view-title">
<TextSelect size={14} />
Context
</div>
),
},
{
value: VIEW_TYPES.INFRAMETRICS,
label: (
<div className="view-title">
<Histogram size="md" />
Metrics
</div>
),
},
]}
/>
<div className="log-detail-drawer__actions">
{selectedView === VIEW_TYPES.CONTEXT && (
<Tooltip
title="Show Filters"
placement="topLeft"
aria-label="Show Filters"
mouseLeaveDelay={0}
>
<Button
variant="link"
color="secondary"
size="sm"
prefix={<Filter size="lg" />}
onClick={handleFilterVisible}
/>
</Tooltip>
)}
</div>
</div>
)}
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
<div className="log-detail-drawer-query-container">
<QuerySearch
onChange={(value): void => handleQueryExpressionChange(value, 0)}
dataSource={DataSource.LOGS}
queryData={contextQuery?.builder.queryData[0]}
onRun={handleRunQuery}
/>
</div>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<Overview
logData={log}
isListViewPanel={isListViewPanel}
handleChangeSelectedView={handleChangeSelectedView}
onApplyLogFilter={onApplyLogFilter}
/>
)}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView
log={log}
filters={filters}
contextQuery={contextQuery}
isEdit={isEdit}
/>
)}
{selectedView === VIEW_TYPES.INFRAMETRICS && (
<InfraMetrics
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
timestamp={log.timestamp.toString()}
dataSource={DataSource.LOGS}
/>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<div className="log-detail-drawer__footer-hint">
<div className="log-detail-drawer__footer-hint-content">
<Typography.Text
color="muted"
className="log-detail-drawer__footer-hint-text"
>
Use
</Typography.Text>
<ArrowUp size={14} className="log-detail-drawer__footer-hint-icon" />
<span>/</span>
<ArrowDown size={14} className="log-detail-drawer__footer-hint-icon" />
<Typography.Text
color="muted"
className="log-detail-drawer__footer-hint-text"
>
to view previous/next log
</Typography.Text>
</div>
</div>
)}
</div>
</OverlayScrollbar>
</Drawer>
);
}

View File

@@ -21,7 +21,6 @@ import { Color } from '@signozhq/design-tokens';
import { Button, Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';
@@ -30,6 +29,7 @@ import { capitalize, isEmpty } from 'lodash-es';
import type { BaseSelectRef } from 'rc-select';
import { popupContainer } from 'utils/selectPopupContainer';
import TruncatedTooltip from './TruncatedTooltip';
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
import {
ALL_SELECTED_VALUE,
@@ -729,65 +729,71 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
};
return (
<div
<TruncatedTooltip
key={option.value || `option-${index}`}
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onKeyDown={(e): void => {
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
e.stopPropagation();
e.preventDefault();
handleItemSelection();
}
}}
onMouseEnter={(): void => {
setActiveIndex(index ?? -1);
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
}}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
title={String(option.label || '')}
>
<Checkbox
value={isSelected}
className="option-checkbox"
onClick={(e): void => selectFromButton(e, 'checkbox')}
>
<div className="option-content">
<Typography.Text truncate={1} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</Typography.Text>
{(option.type === 'custom' || option.type === 'regex') && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
{option.value && ensureValidOption(option.value) && (
<Button
type="text"
className="only-btn"
onClick={(e): void => selectFromButton(e, 'option')}
>
{currentToggleTagValue({ option: option.value })}
</Button>
)}
<Button
type="text"
className="toggle-btn"
{(textRef): JSX.Element => (
<div
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onKeyDown={(e): void => {
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
e.stopPropagation();
e.preventDefault();
handleItemSelection();
}
}}
onMouseEnter={(): void => {
setActiveIndex(index ?? -1);
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
}}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<Checkbox
value={isSelected}
className="option-checkbox"
onClick={(e): void => selectFromButton(e, 'checkbox')}
>
Toggle
</Button>
<div className="option-content">
<span ref={textRef} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</span>
{(option.type === 'custom' || option.type === 'regex') && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
{option.value && ensureValidOption(option.value) && (
<Button
type="text"
className="only-btn"
onClick={(e): void => selectFromButton(e, 'option')}
>
{currentToggleTagValue({ option: option.value })}
</Button>
)}
<Button
type="text"
className="toggle-btn"
onClick={(e): void => selectFromButton(e, 'checkbox')}
>
Toggle
</Button>
</div>
</Checkbox>
</div>
</Checkbox>
</div>
)}
</TruncatedTooltip>
);
},
[

View File

@@ -16,6 +16,7 @@ import {
X,
} from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { Select } from 'antd';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
@@ -25,6 +26,7 @@ import { capitalize, isEmpty } from 'lodash-es';
import type { BaseSelectRef } from 'rc-select';
import { popupContainer } from 'utils/selectPopupContainer';
import TruncatedTooltip from './TruncatedTooltip';
import { CustomSelectProps, OptionData } from './types';
import {
filterOptionsBySearch,
@@ -207,41 +209,46 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const optionId = `option-${index}`;
return (
<div
key={option.value}
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onClick={(e): void => {
e.stopPropagation();
handleSelection();
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
e.preventDefault();
handleSelection();
}
}}
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<div className="option-content">
<div>{highlightMatchedText(String(option.label || ''), searchText)}</div>
{option.type === 'custom' && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
</div>
</div>
<TruncatedTooltip key={option.value} title={String(option.label || '')}>
{(textRef): JSX.Element => (
<div
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onClick={(e): void => {
e.stopPropagation();
handleSelection();
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
e.preventDefault();
handleSelection();
}
}}
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<div className="option-content">
<span ref={textRef} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</span>
{option.type === 'custom' && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
</div>
</div>
)}
</TruncatedTooltip>
);
},
[highlightMatchedText, searchText, onChange, activeOptionIndex],
@@ -702,50 +709,67 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
// ===== Final Processing =====
// Apply highlight to matched text in options
const optionsWithHighlight = useMemo(
() =>
options
?.filter((option) =>
String(option.label || '')
.toLowerCase()
.includes(searchText.toLowerCase()),
)
?.map((option) => ({
...option,
label: highlightMatchedText(String(option.label || ''), searchText),
})),
[options, searchText, highlightMatchedText],
);
const optionsWithHighlight = useMemo(() => {
const matching =
options?.filter((option) =>
String(option.label || '')
.toLowerCase()
.includes(searchText.toLowerCase()),
) ?? [];
// antd shows a value with no matching option as a bare string, so keep the
// selected value listed to render it through the tooltip label below.
return (
isEmpty(value)
? matching
: prioritizeOrAddOptionForSingleSelect(matching, value)
).map((option) => ({
...option,
// Rendered as the selected value in the closed control.
label: (
<TruncatedTooltip title={String(option.label || '')} side="bottom">
{(textRef): JSX.Element => (
<span ref={textRef} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</span>
)}
</TruncatedTooltip>
),
}));
}, [options, searchText, value, highlightMatchedText]);
// ===== Component Rendering =====
return (
<Select
ref={selectRef}
className={cx('custom-select', className)}
placeholder={placeholder}
showSearch
filterOption={false}
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={popupMatchSelectWidth}
allowClear={allowClear ? { clearIcon } : false}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
listHeight={300}
placement={placement}
optionFilterProp="label"
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
{...rest}
/>
// Self-provided so label tooltips work wherever this select is rendered.
<TooltipProvider>
<Select
ref={selectRef}
className={cx('custom-select', className)}
placeholder={placeholder}
showSearch
filterOption={false}
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={popupMatchSelectWidth}
allowClear={allowClear ? { clearIcon } : false}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
listHeight={300}
placement={placement}
optionFilterProp="label"
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
{...rest}
/>
</TooltipProvider>
);
};

View File

@@ -0,0 +1,4 @@
// Above the antd select popup it is shown from.
.tooltipContent {
--tooltip-z-index: 2100;
}

View File

@@ -0,0 +1,41 @@
import { ReactElement, RefObject, useRef, useState } from 'react';
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
import styles from './TruncatedTooltip.module.scss';
interface TruncatedTooltipProps {
title: string;
side?: TooltipSimpleProps['side'];
/** Renders the trigger; attach the ref to the element that may truncate. */
children: (textRef: RefObject<HTMLSpanElement>) => ReactElement;
}
function TruncatedTooltip({
title,
side = 'right',
children,
}: TruncatedTooltipProps): JSX.Element {
const textRef = useRef<HTMLSpanElement>(null);
const [open, setOpen] = useState(false);
const handleOpenChange = (next: boolean): void => {
const el = textRef.current;
setOpen(next && !!el && el.scrollWidth > el.clientWidth);
};
return (
<TooltipSimple
title={title}
side={side}
arrow
delayDuration={300}
open={open}
onOpenChange={handleOpenChange}
tooltipContentProps={{ className: styles.tooltipContent }}
>
{children(textRef)}
</TooltipSimple>
);
}
export default TruncatedTooltip;

View File

@@ -0,0 +1,34 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CustomSelect from '../CustomSelect';
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
describe('CustomSelect selected value tooltip', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(400);
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(200);
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it.each([
['listed in the options', [{ label: LABEL, value: LABEL }]],
['missing from the options', [{ label: 'other', value: 'other' }]],
])('reveals a truncated selected value %s on hover', async (_, options) => {
render(<CustomSelect options={options} value={LABEL} />);
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.hover(screen.getByText(LABEL));
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
});
});

View File

@@ -0,0 +1,63 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import TruncatedTooltip from '../TruncatedTooltip';
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
function renderRow(scrollWidth: number, clientWidth: number): void {
jest
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
.mockReturnValue(scrollWidth);
jest
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
.mockReturnValue(clientWidth);
render(
<TooltipProvider>
<TruncatedTooltip title={LABEL}>
{(textRef): JSX.Element => (
<div data-testid="row">
<span ref={textRef}>{LABEL}</span>
</div>
)}
</TruncatedTooltip>
</TooltipProvider>,
);
}
async function hover(element: HTMLElement): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.hover(element);
act(() => {
jest.advanceTimersByTime(500);
});
}
describe('TruncatedTooltip', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('reveals truncated text when its row is hovered', async () => {
renderRow(400, 200);
await hover(screen.getByTestId('row'));
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
});
it('shows no tooltip when the text fits', async () => {
renderRow(200, 200);
await hover(screen.getByTestId('row'));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});

View File

@@ -135,6 +135,51 @@ const LONG_LABEL_OPTION = {
value: 'checkout-payment-authorisation',
};
/** Truncation: hovering a cut-off option reveals its full label beside the row. */
export const TruncatedOption: Story = {
args: { options: [LONG_LABEL_OPTION, ...options] },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await userEvent.hover(
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
);
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
},
};
/** Truncation: hovering a cut-off selected value reveals it in full. */
export const TruncatedValue: Story = {
args: {
options: [LONG_LABEL_OPTION, ...options],
value: LONG_LABEL_OPTION.value,
},
play: async (): Promise<void> => {
await userEvent.hover(await screen.findByText(LONG_LABEL_OPTION.label));
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
},
};
/** Truncation: a multi-select option reveals its full label the same way. */
export const MultiTruncatedOption: Story = {
render: (): JSX.Element => (
<CustomMultiSelect
aria-label="Services"
options={[LONG_LABEL_OPTION, ...options]}
/>
),
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Services' }),
);
await userEvent.hover(
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
);
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
},
};
/**
* Every tooltip the select renders, held open: the selected chip revealing the
* option label it was cut from. Nothing bounds that label, so the chip is given

View File

@@ -29,6 +29,13 @@ $custom-border-color: #2c3044;
background-color: var(--l2-background);
color: color-mix(in srgb, var(--border) 70%, transparent);
}
// The search input overlays the selected value; lift the value above it while
// closed so hovers reach its tooltip. Clicks still open the select and focus the input.
&:not(.ant-select-open) .ant-select-selection-item {
position: relative;
z-index: 1;
}
}
// Keep chip styles ONLY in the multi-select
@@ -223,6 +230,33 @@ $custom-border-color: #2c3044;
}
}
// Ellipsized option label; also the selected value in the closed single select.
.option-label-text {
display: block;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
// overscroll-behavior only holds on a box that overflows; a 1px sentinel past
// the bottom makes even a short list overflow, so the wheel never scrolls the page.
.custom-select-dropdown,
.custom-multiselect-dropdown {
position: relative;
overscroll-behavior: contain;
&::after {
content: '';
position: absolute;
top: 100%;
left: 0;
width: 1px;
height: 1px;
}
}
// Custom dropdown styles for single select
.custom-select-dropdown {
padding: 8px 0 0 0;

View File

@@ -0,0 +1,142 @@
/**
* Borrows the query builder's control metrics rather than the component defaults the
* toggle group and number input ship with: 36px tall, 2px radius, and the
* `--query-builder-v2-*` surface the selects above this row already paint on.
*/
.bucketOptions {
--toggle-group-radius: var(--radius-1);
--toggle-group-item-size: 36px;
--toggle-group-item-font-size: 13px;
--toggle-group-item-padding-left: var(--spacing-6);
--toggle-group-item-padding-right: var(--spacing-6);
// Repeated from `.query-add-ons` rather than inherited: the section also renders on a
// formula, which has no add-ons ancestor to pick the toggle palette up from.
--toggle-group-secondary-bg: var(
--query-builder-v2-toggle-group-background-color,
var(--l1-background-hover)
);
--toggle-group-secondary-border: var(
--query-builder-v2-toggle-group-border-color,
var(--l2-border)
);
--toggle-group-secondary-active-bg: var(
--query-builder-v2-toggle-group-active-background-color,
var(--l1-background)
);
--toggle-group-secondary-bg-hover: var(
--query-builder-v2-toggle-group-background-color-hover,
var(--l2-background)
);
--input-height: 36px;
--input-font-size: 13px;
--input-border-radius: var(--radius-1);
--input-border-color: var(--query-builder-v2-border-color, var(--l2-border));
--input-background: var(
--query-builder-v2-background-color,
var(--l2-background)
);
--input-foreground: var(--query-builder-v2-color, var(--l2-foreground));
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-4);
box-sizing: border-box;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-radius: var(--radius-1);
background: var(--query-builder-v2-background-color, var(--l2-background));
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
/** One label paired with its control, at the same 10px rhythm as the aggregate rows. */
.field {
display: flex;
align-items: center;
gap: var(--spacing-5);
}
.bounds {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-6);
}
.label {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
color: var(--l3-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: var(--font-weight-medium);
line-height: 18px;
letter-spacing: 0.48px;
text-transform: uppercase;
white-space: nowrap;
}
/**
* The toggle group exposes a font-size token but no family, so each item's label carries
* the query builder's value type itself.
*/
.toggleLabel {
font-family: 'Geist Mono';
font-size: 13px;
font-weight: var(--font-weight-normal);
letter-spacing: -0.07px;
}
/** Surface and metrics come from the `--input-*` tokens above; only the family has none. */
.numberInput {
width: 104px;
font-family: 'Geist Mono';
letter-spacing: -0.07px;
}
/** Unfilled: a pill on the same row as the toggles above reads as another control,
* and the bounds are derived rather than set here. */
.bound {
padding: var(--spacing-1) 0;
font-family: 'Geist Mono';
font-size: 13px;
line-height: 20px;
letter-spacing: -0.07px;
color: var(--query-builder-v2-color, var(--l2-foreground));
}
.overflowBound {
color: var(--l3-foreground);
}
.muted {
font-family: 'Geist Mono';
font-size: 13px;
line-height: 20px;
color: var(--l3-foreground);
}
.hintIcon {
flex: none;
cursor: help;
color: var(--l3-foreground);
}
.closeButton {
margin-left: auto;
width: 24px;
height: 24px;
}

View File

@@ -0,0 +1,279 @@
import { useCallback, useMemo, useState } from 'react';
import { Tooltip } from 'antd';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { InputNumber } from '@signozhq/ui/input-number';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { ChevronUp, Info } from '@signozhq/icons';
import {
Querybuildertypesv5BucketOptionsDTO,
Querybuildertypesv5BucketsKindDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
BUCKET_KIND_HINTS,
BUCKET_KIND_OPTIONS,
DEFAULT_NUM_BUCKETS,
LOG_SCALE_OPTIONS,
MAX_NUM_BUCKETS,
} from './constants';
import {
formatUpperBound,
hasBoundsBeyondPreview,
isLinearBuckets,
kindOptionOf,
linearBuckets,
logBuckets,
logScaleOf,
previewUpperBounds,
} from './utils';
import styles from './BucketOptions.module.scss';
function BucketOptions({
bucketOptions,
unit,
onChange,
onClose,
}: {
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/** The panel's y-axis unit, so the previewed bounds read the way the axis will. */
unit?: string;
onChange: (next: Querybuildertypesv5BucketOptionsDTO | undefined) => void;
/** Omitted where the section isn't dismissable, as on a formula. */
onClose?: () => void;
}): JSX.Element {
// A linear axis has no bounds to describe until it has a max value, so the picked
// kind is held here rather than read back off the emitted options: it has to survive
// the gap between choosing Linear and filling the field in.
const [kind, setKind] = useState<Querybuildertypesv5BucketsKindDTO>(() =>
kindOptionOf(bucketOptions),
);
const linearSpec =
bucketOptions && isLinearBuckets(bucketOptions)
? bucketOptions.spec
: undefined;
const [logScale, setLogScale] = useState<number>(() =>
logScaleOf(bucketOptions),
);
const [maxValue, setMaxValue] = useState<number | null>(
linearSpec?.maxValue ?? null,
);
const [numBuckets, setNumBuckets] = useState<number | null>(
linearSpec?.numBuckets ?? null,
);
const emitLinear = useCallback(
(nextMaxValue: number | null, nextNumBuckets: number | null): void => {
// An incomplete linear axis is sent as no axis at all rather than as a spec the
// request would reject.
if (nextMaxValue === null || nextMaxValue <= 0) {
onChange(undefined);
return;
}
onChange(linearBuckets(nextMaxValue, nextNumBuckets));
},
[onChange],
);
const handleKindChange = useCallback(
(value: string): void => {
// Radix clears the value when the active item is clicked again; a bucket axis is
// always one of the two, so keep the current pick instead.
if (!value) {
return;
}
const nextKind = value as Querybuildertypesv5BucketsKindDTO;
setKind(nextKind);
if (nextKind === Querybuildertypesv5BucketsKindDTO.log) {
onChange(logBuckets(logScale));
} else {
emitLinear(maxValue, numBuckets);
}
},
[emitLinear, logScale, maxValue, numBuckets, onChange],
);
const handleScaleChange = useCallback(
(value: string): void => {
if (!value) {
return;
}
const nextScale = Number(value);
setLogScale(nextScale);
onChange(logBuckets(nextScale));
},
[onChange],
);
const handleMaxValueChange = useCallback(
(value: number | string | null): void => {
const next = value === null || value === '' ? null : Number(value);
setMaxValue(next);
emitLinear(next, numBuckets);
},
[emitLinear, numBuckets],
);
const handleNumBucketsChange = useCallback(
(value: number | string | null): void => {
const next = value === null || value === '' ? null : Number(value);
setNumBuckets(next);
emitLinear(maxValue, next);
},
[emitLinear, maxValue],
);
// The toggle group takes a ReactNode label, which is how each item picks up the
// query builder's value type — the component exposes no font-family token.
const kindItems = useMemo(
() =>
BUCKET_KIND_OPTIONS.map(({ value, label }) => ({
value,
label: <span className={styles.toggleLabel}>{label}</span>,
'aria-label': label,
})),
[],
);
const scaleItems = useMemo(
() =>
LOG_SCALE_OPTIONS.map(({ value, label }) => ({
value,
label: <span className={styles.toggleLabel}>{label}</span>,
'aria-label': label,
})),
[],
);
// The kind toggle can be ahead of what has been emitted, so the preview describes
// the picked kind rather than the emitted options.
const previewedOptions = useMemo(():
| Querybuildertypesv5BucketOptionsDTO
| undefined => {
if (kind === Querybuildertypesv5BucketsKindDTO.log) {
return logBuckets(logScale);
}
if (kind === Querybuildertypesv5BucketsKindDTO.linear && maxValue !== null) {
return linearBuckets(maxValue, numBuckets);
}
return undefined;
}, [kind, logScale, maxValue, numBuckets]);
const bounds = useMemo(
() =>
kind === Querybuildertypesv5BucketsKindDTO.linear && !previewedOptions
? undefined
: previewUpperBounds(previewedOptions),
[kind, previewedOptions],
);
return (
<div className={styles.bucketOptions} data-testid="bucket-options">
<div className={styles.controls}>
<div className={styles.field}>
<span className={styles.label}>Bucket by</span>
<ToggleGroupSimple
type="single"
value={kind}
items={kindItems}
onChange={handleKindChange}
testId="bucket-options-kind"
/>
</div>
{kind === Querybuildertypesv5BucketsKindDTO.log && (
<div className={styles.field}>
<span className={styles.label}>Scale</span>
<ToggleGroupSimple
type="single"
value={String(logScale)}
items={scaleItems}
onChange={handleScaleChange}
testId="bucket-options-scale"
/>
</div>
)}
{kind === Querybuildertypesv5BucketsKindDTO.linear && (
<>
<div className={styles.field}>
<span className={styles.label}>Max value</span>
<InputNumber
className={styles.numberInput}
min={0}
value={maxValue}
onChange={handleMaxValueChange}
placeholder="Required"
status={maxValue !== null && maxValue <= 0 ? 'error' : undefined}
data-testid="bucket-options-max-value"
/>
</div>
<div className={styles.field}>
<span className={styles.label}>Buckets</span>
<InputNumber
className={styles.numberInput}
min={1}
max={MAX_NUM_BUCKETS}
precision={0}
value={numBuckets}
onChange={handleNumBucketsChange}
placeholder={`Default ${DEFAULT_NUM_BUCKETS}`}
data-testid="bucket-options-num-buckets"
/>
</div>
</>
)}
{onClose && (
<Button
color="secondary"
variant="ghost"
size="icon"
aria-label="Close bucket options"
className={styles.closeButton}
prefix={<ChevronUp size={16} />}
onClick={onClose}
data-testid="bucket-options-close"
/>
)}
</div>
<div className={styles.bounds} data-testid="bucket-options-bounds">
<span className={styles.label}>
Bounds
<Tooltip title={BUCKET_KIND_HINTS[kind]} placement="top">
<Info size={12} className={styles.hintIcon} />
</Tooltip>
</span>
{bounds ? (
<>
{bounds.map((bound) => (
<span className={styles.bound} key={bound}>
{formatUpperBound(bound, unit)}
</span>
))}
{hasBoundsBeyondPreview(previewedOptions) && (
<span className={styles.muted}>…</span>
)}
<span className={cx(styles.bound, styles.overflowBound)}>+Inf</span>
</>
) : (
<span className={styles.muted}>Set a max value to see the bounds</span>
)}
</div>
</div>
);
}
BucketOptions.defaultProps = {
bucketOptions: undefined,
unit: undefined,
onClose: undefined,
};
export default BucketOptions;

View File

@@ -0,0 +1,88 @@
import { MAX_LOG_SCALE } from '../constants';
import {
bandsPerDoublingFromScale,
formatUpperBound,
hasBoundsBeyondPreview,
kindOptionOf,
linearBuckets,
logBuckets,
previewUpperBounds,
} from '../utils';
describe('bucket option scales', () => {
it.each([
[-2, 0.25],
[0, 1],
[1, 2],
[2, 4],
[3, 8],
[4, 16],
])('scale %i is %i bands per doubling', (scale, bands) => {
expect(bandsPerDoublingFromScale(scale)).toBe(bands);
});
});
describe('kindOptionOf', () => {
it('reads no options as log, the server default', () => {
expect(kindOptionOf(undefined)).toBe('log');
});
it('reads the kind off the options', () => {
expect(kindOptionOf(logBuckets(MAX_LOG_SCALE))).toBe('log');
expect(kindOptionOf(linearBuckets(10, null))).toBe('linear');
});
});
describe('previewUpperBounds', () => {
it('doubles at one band per doubling', () => {
expect(previewUpperBounds(logBuckets(0))).toStrictEqual([
1, 2, 4, 8, 16, 32, 64, 128,
]);
});
it('falls back to the finest log axis when no options are set', () => {
const bounds = previewUpperBounds(undefined);
expect(bounds).toHaveLength(8);
// 16 bands per doubling: the eighth bound is 2^(7/16), still short of the first doubling.
expect(bounds?.[7]).toBeCloseTo(2 ** (7 / 16));
});
it('spaces a linear axis evenly by bucket width', () => {
expect(previewUpperBounds(linearBuckets(100, 10))).toStrictEqual(
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].slice(0, 8),
);
});
it('stops at the last bucket when the axis has fewer than the preview shows', () => {
expect(previewUpperBounds(linearBuckets(9, 3))).toStrictEqual([3, 6, 9]);
});
it('has no bounds to preview for a linear axis without a usable max value', () => {
expect(previewUpperBounds(linearBuckets(0, null))).toBeUndefined();
expect(previewUpperBounds(linearBuckets(-1, null))).toBeUndefined();
});
});
describe('hasBoundsBeyondPreview', () => {
it('is always true for a log axis, which has no top', () => {
expect(hasBoundsBeyondPreview(logBuckets(0))).toBe(true);
expect(hasBoundsBeyondPreview(undefined)).toBe(true);
});
it('tracks whether a linear axis runs past the preview', () => {
expect(hasBoundsBeyondPreview(linearBuckets(100, 4))).toBe(false);
expect(hasBoundsBeyondPreview(linearBuckets(100, 20))).toBe(true);
});
});
describe('formatUpperBound', () => {
it('reads a bound in the panel unit when one is set', () => {
expect(formatUpperBound(1, 'ms')).toContain('ms');
});
it('keeps three significant digits when unitless', () => {
expect(formatUpperBound(128, undefined)).toBe('128');
expect(formatUpperBound(2 ** (1 / 16), undefined)).toBe('1.04');
});
});

View File

@@ -0,0 +1,54 @@
import { Querybuildertypesv5BucketsKindDTO } from 'api/generated/services/sigNoz.schemas';
/**
* Mirrors the limits `querybuildertypesv5` validates `bucketOptions` against. The
* builder keeps its own copy so an out-of-range axis is refused before the request
* rather than after it.
*/
/**
* A log axis spaces bounds at 2^scale bands per doubling. MaxLogScale is the
* resolution ClickHouse buckets at, so it is both the finest available and what an
* absent `bucketOptions` resolves to.
*/
export const MAX_LOG_SCALE = 4;
/** One band per 16x, the coarsest axis worth rendering. */
export const MIN_LOG_SCALE = -4;
export const MAX_NUM_BUCKETS = 512;
export const DEFAULT_NUM_BUCKETS = 60;
/** Every scale the request accepts, coarsest first. The bounds strip below says
* how coarse a given one is, so the numbers stand alone. */
export const LOG_SCALES = Array.from(
{ length: MAX_LOG_SCALE - MIN_LOG_SCALE + 1 },
(_, index) => MIN_LOG_SCALE + index,
);
/** How many leading upper bounds the bounds strip previews before eliding. */
export const PREVIEW_BOUND_COUNT = 8;
export const BUCKET_KIND_OPTIONS: {
value: Querybuildertypesv5BucketsKindDTO;
label: string;
}[] = [
{ value: Querybuildertypesv5BucketsKindDTO.log, label: 'Log' },
{ value: Querybuildertypesv5BucketsKindDTO.linear, label: 'Linear' },
];
export const LOG_SCALE_OPTIONS = LOG_SCALES.map((scale) => ({
value: String(scale),
label: String(scale),
}));
export const BUCKET_KIND_HINTS: Record<
Querybuildertypesv5BucketsKindDTO,
string
> = {
[Querybuildertypesv5BucketsKindDTO.log]:
'Bounds are spaced evenly on a log axis, so every band is the same height on screen and the tail stays readable. A lower scale means fewer, coarser bands; scale 4, the finest, is the default.',
[Querybuildertypesv5BucketsKindDTO.linear]:
'Bounds are spaced evenly from 0 up to the max value, so a band covers the same width wherever it sits. Everything above the max value lands in a single overflow band.',
};

View File

@@ -0,0 +1,120 @@
import {
Querybuildertypesv5BucketOptionsDTO,
Querybuildertypesv5BucketOptionsLinearDTO,
Querybuildertypesv5BucketOptionsLinearDTOKind,
Querybuildertypesv5BucketOptionsLogDTO,
Querybuildertypesv5BucketOptionsLogDTOKind,
Querybuildertypesv5BucketsKindDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import {
DEFAULT_NUM_BUCKETS,
MAX_LOG_SCALE,
PREVIEW_BOUND_COUNT,
} from './constants';
export const isLinearBuckets = (
bucketOptions: Querybuildertypesv5BucketOptionsDTO,
): bucketOptions is Querybuildertypesv5BucketOptionsLinearDTO =>
bucketOptions.kind === Querybuildertypesv5BucketOptionsLinearDTOKind.linear;
export const linearBuckets = (
maxValue: number,
numBuckets: number | null,
): Querybuildertypesv5BucketOptionsLinearDTO => ({
kind: Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
spec: { maxValue, ...(numBuckets ? { numBuckets } : {}) },
});
export const logBuckets = (
scale: number,
): Querybuildertypesv5BucketOptionsLogDTO => ({
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
spec: { scale },
});
/**
* The log spec's scale, defaulted the way the server defaults it. A linear axis has
* no scale, so it reads as the default too.
*/
export const logScaleOf = (
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): number =>
bucketOptions && !isLinearBuckets(bucketOptions)
? (bucketOptions.spec.scale ?? MAX_LOG_SCALE)
: MAX_LOG_SCALE;
/** A log axis places `2^scale` bounds per doubling. */
export const bandsPerDoublingFromScale = (scale: number): number => 2 ** scale;
export const kindOptionOf = (
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): Querybuildertypesv5BucketsKindDTO =>
bucketOptions && isLinearBuckets(bucketOptions)
? Querybuildertypesv5BucketsKindDTO.linear
: Querybuildertypesv5BucketsKindDTO.log;
/**
* The leading upper bounds the axis will carry. A log axis is anchored at 1 — band
* index 0's boundary — and a linear one at the top of its first band; both continue
* past what the strip shows, and everything above the last one lands in the overflow
* band the UI labels separately.
*
* `undefined` for a linear axis with no max value yet: without a top there is nothing
* to divide.
*/
export function previewUpperBounds(
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): number[] | undefined {
if (bucketOptions && isLinearBuckets(bucketOptions)) {
const { maxValue, numBuckets = DEFAULT_NUM_BUCKETS } = bucketOptions.spec;
if (!Number.isFinite(maxValue) || maxValue <= 0 || numBuckets <= 0) {
return undefined;
}
const width = maxValue / numBuckets;
return Array.from(
{ length: Math.min(numBuckets, PREVIEW_BOUND_COUNT) },
(_, index) => (index + 1) * width,
);
}
const bands = bandsPerDoublingFromScale(logScaleOf(bucketOptions));
return Array.from(
{ length: PREVIEW_BOUND_COUNT },
(_, index) => 2 ** (index / bands),
);
}
/**
* Whether the strip elides bounds after the ones it shows. A linear axis with no more
* buckets than the strip holds ends where the strip does.
*/
export function hasBoundsBeyondPreview(
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): boolean {
if (!bucketOptions || !isLinearBuckets(bucketOptions)) {
return true;
}
return (
(bucketOptions.spec.numBuckets ?? DEFAULT_NUM_BUCKETS) > PREVIEW_BOUND_COUNT
);
}
/**
* A bound is a value on the panel's own axis, so it reads in the panel's unit when one
* is set. Unitless, three significant digits keep the tightly spaced bounds of a fine
* log axis distinguishable without printing the float in full.
*/
export function formatUpperBound(bound: number, unit?: string): string {
if (unit) {
return getYAxisFormattedValue(String(bound), unit);
}
return Number(bound.toPrecision(3)).toString();
}

View File

@@ -3,14 +3,21 @@ import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { get, isEmpty } from 'lodash-es';
import { BarChart, ChevronUp, ExternalLink, ScrollText } from '@signozhq/icons';
import {
BarChart,
ChevronUp,
ExternalLink,
Grid3X3,
ScrollText,
} from '@signozhq/icons';
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -25,6 +32,7 @@ import {
resolveQueryBuilderFields,
} from '../../queryBuilderFields.utils';
import BucketOptions from './BucketOptions/BucketOptions';
import HavingFilter from './HavingFilter/HavingFilter';
import { buildDefaultLegendFromGroupBy } from './utils';
@@ -57,6 +65,7 @@ const ADD_ONS_KEYS_TO_QUERY_PATH: Omit<
[QueryBuilderField.Limit]: 'limit',
[QueryBuilderField.Legend]: 'legend',
[QueryBuilderField.ReduceTo]: 'reduceTo',
[QueryBuilderField.BucketOptions]: 'bucketOptions',
};
const ADD_ONS: AddOn[] = [
@@ -116,6 +125,22 @@ const REDUCE_TO: AddOn = {
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
};
// 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.
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
ATTRIBUTE_TYPES.HISTOGRAM,
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
]);
const BUCKET_OPTIONS: AddOn = {
icon: <Grid3X3 size={14} />,
label: 'Bucket by',
key: QueryBuilderField.BucketOptions,
description:
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
};
const hasValue = (value: unknown): boolean =>
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
@@ -196,7 +221,7 @@ function QueryAddOns({
isForTraceOperator,
});
const { handleSetQueryData } = useQueryBuilder();
const { handleSetQueryData, currentQuery } = useQueryBuilder();
const supportedAddOns = useMemo((): AddOn[] => {
let addOns: AddOn[];
@@ -210,8 +235,25 @@ function QueryAddOns({
addOns = [...ADD_ONS];
}
return showReduceTo ? [...addOns, REDUCE_TO] : addOns;
}, [panelType, query.dataSource, showReduceTo]);
if (showReduceTo) {
addOns = [...addOns, REDUCE_TO];
}
if (
panelType === PANEL_TYPES.HEATMAP &&
query.dataSource === DataSource.METRICS &&
!HISTOGRAM_ATTRIBUTE_TYPES.has(query.aggregateAttribute?.type ?? '')
) {
addOns = [...addOns, BUCKET_OPTIONS];
}
return addOns;
}, [
panelType,
query.dataSource,
query.aggregateAttribute?.type,
showReduceTo,
]);
const resolvedFields = useMemo(
() =>
@@ -392,6 +434,13 @@ function QueryAddOns({
[handleChangeQueryData],
);
const handleChangeBucketOptions = useCallback(
(value: Querybuildertypesv5BucketOptionsDTO | undefined) => {
handleChangeQueryData('bucketOptions', value);
},
[handleChangeQueryData],
);
return (
<div className="query-add-ons" data-testid="query-add-ons">
{selectedViews.length > 0 && (
@@ -559,6 +608,19 @@ function QueryAddOns({
/>
</div>
)}
{selectedViews.find(
(view) => view.key === QueryBuilderField.BucketOptions,
) && (
<div className="add-on-content" data-testid="bucket-options-content">
<BucketOptions
bucketOptions={query.bucketOptions}
unit={currentQuery.unit}
onChange={handleChangeBucketOptions}
onClose={(): void => handleRemoveView(QueryBuilderField.BucketOptions)}
/>
</div>
)}
</div>
)}

View File

@@ -1,4 +1,4 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import {
fireEvent,
render,
@@ -26,8 +26,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
handleSetQueryData: typeof mockHandleSetQueryData;
currentQuery: { unit: string | undefined };
} => ({
handleSetQueryData: mockHandleSetQueryData,
currentQuery: { unit: undefined },
}),
}));
@@ -330,4 +332,175 @@ describe('QueryAddOns', () => {
expect.anything(),
);
});
describe('bucket options', () => {
function renderHeatmap(overrides: Partial<any> = {}): void {
render(
<QueryAddOns
query={baseQuery({ dataSource: DataSource.METRICS, ...overrides })}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.HEATMAP}
index={0}
isForTraceOperator={false}
/>,
);
}
it('is offered on a metrics heatmap only', () => {
renderHeatmap();
expect(
screen.getByTestId('query-add-on-bucket_options'),
).toBeInTheDocument();
});
it('is not offered on other panel types', () => {
render(
<QueryAddOns
query={baseQuery({ dataSource: DataSource.METRICS })}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
expect(
screen.queryByTestId('query-add-on-bucket_options'),
).not.toBeInTheDocument();
});
it('is not offered on a heatmap over another signal', () => {
render(
<QueryAddOns
query={baseQuery({ dataSource: DataSource.LOGS })}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.HEATMAP}
index={0}
isForTraceOperator={false}
/>,
);
expect(
screen.queryByTestId('query-add-on-bucket_options'),
).not.toBeInTheDocument();
});
it.each([ATTRIBUTE_TYPES.HISTOGRAM, ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM])(
'is not offered for a %s metric, which carries its own buckets',
(type) => {
renderHeatmap({
aggregateAttribute: { key: 'http_duration_bucket', type },
});
expect(
screen.queryByTestId('query-add-on-bucket_options'),
).not.toBeInTheDocument();
},
);
it("auto-opens on the query's own kind", () => {
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
expect(screen.getByTestId('bucket-options-content')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
expect(screen.getByRole('radio', { name: '0' })).toBeChecked();
});
it('shows the server default, log at scale 4, without sending it', async () => {
const user = userEvent.setup();
renderHeatmap();
await user.click(screen.getByTestId('query-add-on-bucket_options'));
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
expect(screen.getByRole('radio', { name: '4' })).toBeChecked();
expect(
screen.queryByRole('radio', { name: 'Auto' }),
).not.toBeInTheDocument();
expect(mockHandleChangeQueryData).not.toHaveBeenCalledWith(
'bucketOptions',
expect.anything(),
);
});
it('sends the picked scale', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 4 } } });
await user.click(screen.getByRole('radio', { name: '0' }));
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
kind: 'log',
spec: { scale: 0 },
});
});
it('offers the coarser scales the request accepts below one band per doubling', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByRole('radio', { name: '-4' }));
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
kind: 'log',
spec: { scale: -4 },
});
});
it('previews the bounds the picked axis will carry', () => {
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
const bounds = within(screen.getByTestId('bucket-options-bounds'));
['1', '2', '4', '8', '16', '32', '64', '128', '+Inf'].forEach((bound) => {
expect(bounds.getByText(bound)).toBeInTheDocument();
});
});
it('sends nothing for a linear axis until it has a max value', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByRole('radio', { name: 'Linear' }));
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
'bucketOptions',
undefined,
);
expect(
screen.getByText('Set a max value to see the bounds'),
).toBeInTheDocument();
});
it('sends the linear axis once a max value is filled in', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByRole('radio', { name: 'Linear' }));
await user.type(screen.getByTestId('bucket-options-max-value'), '500');
await waitFor(() => {
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
'bucketOptions',
{ kind: 'linear', spec: { maxValue: 500 } },
);
});
});
it('closes back to the toggle bar', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByTestId('bucket-options-close'));
expect(
screen.queryByTestId('bucket-options-content'),
).not.toBeInTheDocument();
});
});
});

View File

@@ -16,6 +16,7 @@ export enum QueryBuilderField {
Limit = 'limit',
Legend = 'legend_format',
ReduceTo = 'reduce_to',
BucketOptions = 'bucket_options',
// Builder level
Formula = 'formula',
AdditionalQueries = 'additional_queries',

View File

@@ -76,6 +76,7 @@ export const RAW_QUERY_FIELDS: Omit<
[QueryBuilderField.Limit]: { state: 'hidden' },
[QueryBuilderField.Legend]: { state: 'hidden' },
[QueryBuilderField.ReduceTo]: { state: 'hidden' },
[QueryBuilderField.BucketOptions]: { state: 'hidden' },
[QueryBuilderField.Formula]: { state: 'hidden' },
[QueryBuilderField.OrderBy]: { state: 'pinned' },
};

View File

@@ -12,6 +12,7 @@
overflow: visible;
position: relative;
align-self: stretch;
clip-path: inset(0 -100vw 0 0);
}
}

View File

@@ -57,6 +57,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
showFilterCollapse = true,
showQueryName = true,
useFieldApis,
savedViewsHeader,
} = props;
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [params, setParams] = useApiMonitoringParams();
@@ -379,6 +380,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<div className="quick-filters-container">
<div className="quick-filters">
{savedViewsHeader}
{source !== QuickFiltersSource.INFRA_MONITORING && (
<section className="header">
{renderLeftActions()}

View File

@@ -8,9 +8,7 @@
overflow: hidden;
}
// Positioned so overlays (settings drawer) paint above the content pane
// without changing this pane's layout width.
.filters {
.sidebar {
width: 280px;
flex-shrink: 0;
display: flex;
@@ -21,6 +19,44 @@
z-index: 2;
}
.savedViews {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--l1-background);
border-right: 1px solid var(--l1-border);
}
.quickFilters {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--l1-background);
transition: transform 0.2s ease-in-out;
z-index: 1;
&.isOpen {
transform: translateX(100%);
}
}
// No quick filters: the header sits in flow and the list stays under it
.isStatic {
border-right: 1px solid var(--l1-border);
.quickFilters {
position: static;
flex-shrink: 0;
}
.savedViews {
border-right: 0;
}
}
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
// `height: 100%`), which owns the scrolling.
.content {
@@ -31,3 +67,7 @@
flex-direction: column;
overflow: hidden;
}
.contentInner {
height: 100%;
}

View File

@@ -1,6 +1,9 @@
import { ComponentProps, ReactNode } from 'react';
import { ComponentProps, ReactNode, useState } from 'react';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import QuickFilters from '../QuickFilters';
@@ -12,8 +15,15 @@ type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
ComponentProps<typeof QuickFilters>
>;
// What the page configures; the layout adds the open / close wiring.
type SavedViewsElementProps = Omit<
ComponentProps<typeof SavedViewsPanel>,
'onClose'
>;
export interface QuickFiltersLayoutProps {
quickFilterProps: QuickFiltersElementProps;
quickFilterProps?: QuickFiltersElementProps;
savedViewProps?: SavedViewsElementProps;
showFilters: boolean;
className?: string;
contentClassName?: string;
@@ -23,20 +33,60 @@ export interface QuickFiltersLayoutProps {
function QuickFiltersLayout({
quickFilterProps,
savedViewProps,
showFilters,
className,
contentClassName,
testId,
children,
}: QuickFiltersLayoutProps): JSX.Element {
const [isViewsOpen, setIsViewsOpen] = useState(false);
const isSavedViewEnabled = useSavedViewEnabled();
const hasQuickFilters = !!quickFilterProps;
const hasSavedViews = !!savedViewProps && isSavedViewEnabled;
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);
const isPanelPinned = hasSavedViews && !hasQuickFilters;
const isSliding = hasQuickFilters && isViewsOpen;
const savedViewsHeader = hasSavedViews ? (
<SavedViewsHeader
{...savedViewProps}
onOpenViews={isPanelPinned ? undefined : (): void => setIsViewsOpen(true)}
/>
) : undefined;
return (
<div className={cx(styles.layout, className)} data-testid={testId}>
{showFilters && (
{showSidebar && (
<aside
className={styles.filters}
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
data-testid="quick-filters-layout-filters"
>
<QuickFilters {...quickFilterProps} />
<div
className={cx(styles.quickFilters, { [styles.isOpen]: isSliding })}
data-testid="quick-filters-layout-drawer"
>
{hasQuickFilters ? (
<QuickFilters
{...quickFilterProps}
savedViewsHeader={savedViewsHeader}
/>
) : (
savedViewsHeader
)}
</div>
<div className={styles.savedViews}>
{hasSavedViews && (isPanelPinned || isViewsOpen) && (
<SavedViewsPanel
{...savedViewProps}
onClose={isPanelPinned ? undefined : (): void => setIsViewsOpen(false)}
/>
)}
</div>
</aside>
)}
<section
@@ -44,7 +94,7 @@ function QuickFiltersLayout({
data-testid="quick-filters-layout-content"
>
<OverlayScrollbar>
<div>{children}</div>
<div className={styles.contentInner}>{children}</div>
</OverlayScrollbar>
</section>
</div>

View File

@@ -1,4 +1,6 @@
import { render, screen } from 'tests/test-utils';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { render, screen, userEvent } from 'tests/test-utils';
import { QuickFiltersSource } from '../../types';
import QuickFiltersLayout from '../QuickFiltersLayout';
@@ -7,15 +9,33 @@ jest.mock('../QuickFiltersLayout.module.scss', () => ({
__esModule: true,
default: {
layout: 'layout',
filters: 'filters',
sidebar: 'sidebar',
isStatic: 'isStatic',
savedViews: 'savedViews',
quickFilters: 'quickFilters',
isOpen: 'isOpen',
content: 'content',
contentInner: 'contentInner',
},
}));
jest.mock('hooks/useSavedViewEnabled', () => ({
useSavedViewEnabled: jest.fn(() => true),
}));
jest.mock('../../QuickFilters', () => ({
__esModule: true,
default: ({ source }: { source: string }): JSX.Element => (
<div data-testid="quick-filters">{source}</div>
default: ({
source,
savedViewsHeader,
}: {
source: string;
savedViewsHeader?: React.ReactNode;
}): JSX.Element => (
<div data-testid="quick-filters">
{savedViewsHeader}
{source}
</div>
),
}));
@@ -23,8 +43,16 @@ const quickFilterProps = {
source: QuickFiltersSource.TRACES_EXPLORER,
handleFilterVisibilityChange: jest.fn(),
};
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
const drawer = (): HTMLElement =>
screen.getByTestId('quick-filters-layout-drawer');
describe('QuickFiltersLayout', () => {
beforeEach(() => {
jest.mocked(useSavedViewEnabled).mockReturnValue(true);
});
it('renders QuickFilters with the given props inside the filters pane', () => {
render(
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
@@ -40,11 +68,16 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
'content',
);
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
});
it('does not render the filters pane when showFilters is false', () => {
render(
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
<QuickFiltersLayout
showFilters={false}
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
@@ -56,24 +89,138 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByText('content')).toBeInTheDocument();
});
it('renders no sidebar at all without quick filters or saved views', () => {
render(
<QuickFiltersLayout showFilters>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
expect(screen.getByText('content')).toBeInTheDocument();
});
it('merges classNames onto the root and content panes', () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
className="page-root"
contentClassName="page-content"
testId="page"
className="root-extra"
contentClassName="content-extra"
testId="layout"
>
<div>content</div>
</QuickFiltersLayout>,
);
const root = screen.getByTestId('page');
expect(root).toHaveClass('layout', 'page-root');
expect(screen.getByTestId('layout')).toHaveClass('layout', 'root-extra');
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
'content',
'page-content',
'content-extra',
);
});
describe('saved views', () => {
it('ignores savedViewProps while the flag is off', () => {
jest.mocked(useSavedViewEnabled).mockReturnValue(false);
const { unmount } = render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
// Saved views only, so nothing is left to show.
unmount();
render(
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
});
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
const user = userEvent.setup();
const quickFilters = screen.getByTestId('quick-filters');
expect(quickFilters).toContainElement(
screen.getByTestId('saved-views-header'),
);
expect(drawer()).toContainElement(quickFilters);
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
await user.click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(drawer()).toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
await user.click(screen.getByTestId('saved-views-close'));
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
});
it('hands the source to the header and the panel', async () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('saved-views-header')).toHaveAttribute(
'data-source',
SavedviewtypesSourceDTO.traces,
);
await userEvent.setup().click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toHaveAttribute(
'data-source',
SavedviewtypesSourceDTO.traces,
);
});
it('without quick filters: static sidebar, header on top, list always on screen with no open or close controls', () => {
render(
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
<div>content</div>
</QuickFiltersLayout>,
);
const sidebar = screen.getByTestId('quick-filters-layout-filters');
expect(sidebar).toHaveClass('isStatic');
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-close')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
});
});
});

View File

@@ -8,12 +8,20 @@
width: 342px;
height: 100%;
background: var(--l1-background);
transition: width 0.05s ease-in-out;
border-right: 1px solid var(--l1-border);
overflow: hidden;
color: var(--l1-foreground);
transition:
transform 0.2s ease-in-out,
visibility 0s;
&.hidden {
width: 0;
transform: translateX(-100%);
visibility: hidden;
pointer-events: none;
transition:
transform 0.2s ease-in-out,
visibility 0s 0.2s;
}
.qf-header {
display: flex;

View File

@@ -1,3 +1,4 @@
import { ReactNode } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -61,6 +62,7 @@ export interface IQuickFiltersProps {
showFilterCollapse?: boolean;
showQueryName?: boolean;
useFieldApis?: QuickFilterCheckboxUseFieldApis;
savedViewsHeader?: ReactNode;
}
export enum QuickFiltersSource {

View File

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

View File

@@ -339,6 +339,7 @@ export enum PANEL_TYPES {
AREA = 'area',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

View File

@@ -527,6 +527,21 @@ export const metricsHistogramSpaceAggregateOperatorOptions: SelectOption<
},
];
/**
* A heatmap's Y axis is the `le` labels themselves, so every percentile draws the grid a
* count already draws. Sum is also what the statement builder forces on a histogram
* heatmap whatever is asked for, so it is the only honest option to offer.
*/
export const metricsHeatmapHistogramSpaceAggregateOperatorOptions: SelectOption<
string,
string
>[] = [
{
value: MetricAggregateOperator.COUNT,
label: 'Count',
},
];
export const metricsEmptyTimeAggregateOperatorOptions: SelectOption<
string,
string

View File

@@ -467,6 +467,7 @@ describe('Footer utils', () => {
timeAggregation: 'avg',
},
],
bucketOptions: undefined,
disabled: false,
filter: {
expression: '',

View File

@@ -5,7 +5,7 @@ import {
InframonitoringtypesHostRecordDTO,
InframonitoringtypesHostStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/types';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { TextNoData } from 'container/InfraMonitoringK8sV2/components';

View File

@@ -2,6 +2,13 @@
display: flex;
align-items: center;
gap: var(--spacing-5);
// A block span leaves the icon on the text baseline, a couple of pixels
// above the centre of the row it sits in
[data-slot='icon'] {
display: inline-flex;
align-items: center;
}
}
.infoIcon {

View File

@@ -0,0 +1,78 @@
import { rest } from 'msw';
import { toggleControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
export const POD_UID = '63edbad0-398e-48e5-baba-f7ded7c73f1b';
const POD_NAME = 'app-demo-loadgenerator-579fbdd749-q5kgd';
export const NODE_NAME = 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-p6z7';
const POD_META = {
'k8s.pod.uid': POD_UID,
'k8s.pod.name': POD_NAME,
'k8s.namespace.name': 'generator',
'k8s.cluster.name': 'mgmt',
'k8s.node.name': NODE_NAME,
'k8s.deployment.name': 'app-demo-loadgenerator',
};
const podRecord = {
podUID: POD_UID,
podCPU: 0.92,
podCPURequest: 31,
podCPULimit: 30.7,
podMemory: 148 * 1024 * 1024,
podMemoryRequest: 42,
podMemoryLimit: 38,
podStatus: 'running',
podRestarts: 0,
podAge: 49_000_000,
meta: POD_META,
};
const container = {
containerName: 'loadgenerator',
podName: POD_NAME,
status: 'running',
ready: true,
restarts: 0,
meta: POD_META,
};
const list = (records: unknown[]): Record<string, unknown> => ({
status: 'success',
data: { type: 'list', records, total: records.length },
});
export const drawerMocks = defineStoryMocks({
controls: {
openedFromANode: toggleControl('Opened from a node', {
group: 'Drawer',
value: true,
}),
},
handlers: () => [
rest.post('http://localhost/api/v2/infra_monitoring/pods', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(list([podRecord]))),
),
rest.post(
'http://localhost/api/v2/infra_monitoring/kube_containers',
(_req, res, ctx) => res(ctx.status(200), ctx.json(list([container]))),
),
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
),
),
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
),
),
],
});

View File

@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { podEntityConfig } from '../Pods/entity.config';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../constants';
import K8sBaseDetails from './K8sBaseDetails';
import {
drawerMocks,
NODE_NAME,
POD_UID,
} from './K8sBaseDetails.stories.mocks';
import type { K8sBaseDetailsProps } from './types';
import {
getDrawerHistoryDepth,
pushDrawerHistory,
resetDrawerHistory,
} from './useDrawerHistoryStore';
type DrawerArgs = PageStoryArgs<typeof drawerMocks> &
K8sBaseDetailsProps<unknown>;
const route = `/infrastructure-monitoring/kubernetes?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=pods&${INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM}=${POD_UID}`;
const meta = {
title: 'Infra monitoring/Kubernetes drawer',
component: K8sBaseDetails as (props: DrawerArgs) => JSX.Element,
...storyMocks(drawerMocks, { route }),
decorators: [
(Story, context): JSX.Element => {
// The back control appears only for a resource opened from another
const depth = getDrawerHistoryDepth();
if (context.args.openedFromANode && depth === 0) {
pushDrawerHistory({
params: { selectedItem: NODE_NAME, category: InfraMonitoringEntity.NODES },
category: InfraMonitoringEntity.NODES,
label: NODE_NAME,
entity: { nodeName: NODE_NAME },
queryKeyPrefix: 'node',
});
}
if (!context.args.openedFromANode && depth > 0) {
resetDrawerHistory();
}
return <Story />;
},
],
} satisfies Meta<DrawerArgs>;
export default meta;
type Story = StoryObj<DrawerArgs>;
/** A pod opened from a node's overview tab, so the header leads with the way back. */
export const Default: Story = {
args: { ...podEntityConfig.details } as Partial<DrawerArgs> as DrawerArgs,
};

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { X } from '@signozhq/icons';
import { ArrowLeft, X } from '@signozhq/icons';
import { Divider } from '@signozhq/ui/divider';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer';
@@ -16,12 +16,19 @@ import {
useGlobalTimeStore,
} from 'store/globalTime';
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
K8S_CATEGORY_SINGULAR_LABELS,
} from '../constants';
import { useInfraMonitoringSelectedItemParams } from '../hooks';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import LoadingContainer from '../LoadingContainer';
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
import { CategoryIcon } from './categoryIcons';
import { getEntityNameAttributeKey } from './relations';
import { useDrawerHistoryStore } from './useDrawerHistoryStore';
import { usePrimeEntityDetails } from './usePrimeEntityDetails';
import { K8sBaseDetailsProps } from './types';
import { useDrawerLifecycleStore } from './useDrawerLifecycleStore';
@@ -86,6 +93,11 @@ export default function K8sBaseDetails<T>({
useInfraMonitoringSelectedItemParams();
const selectedItem = selectedItemParams.selectedItem;
const selectedItemExpression = useMemo(
() => getSelectedItemExpression(selectedItemParams),
[getSelectedItemExpression, selectedItemParams],
);
const entityQueryKey = useMemo(
() =>
getAutoRefreshQueryKey(
@@ -121,9 +133,10 @@ export default function K8sBaseDetails<T>({
const { minTime, maxTime } = getMinMaxTime();
const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER);
const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER);
const expression = getSelectedItemExpression(selectedItemParams);
return fetchEntityData({ filter: { expression }, start, end }, signal);
return fetchEntityData(
{ filter: { expression: selectedItemExpression }, start, end },
signal,
);
},
cacheTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
staleTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
@@ -158,9 +171,29 @@ export default function K8sBaseDetails<T>({
}
}, [selectedItem, markDrawerOpened, markDrawerClosed]);
const previousEntry = useDrawerHistoryStore(
(store) => store.entries[store.entries.length - 1] ?? null,
);
const popDrawerHistory = useDrawerHistoryStore((store) => store.pop);
const primeDetails = usePrimeEntityDetails();
const resetHistory = useDrawerHistoryStore((store) => store.reset);
const handleClose = useCallback((): void => {
resetHistory();
setSelectedItemParams(null);
}, [setSelectedItemParams]);
}, [resetHistory, setSelectedItemParams]);
// Steps back through resources opened from an overview tab
const handleBack = useCallback((): void => {
const previous = popDrawerHistory();
if (!previous) {
return;
}
primeDetails(previous);
setSelectedItemParams(previous.params);
}, [popDrawerHistory, primeDetails, setSelectedItemParams]);
const handleOpenChange = useCallback(
(open: boolean): void => {
@@ -172,11 +205,25 @@ export default function K8sBaseDetails<T>({
);
const handleCopyId = useCallback((): void => {
toast.success('ID copied to clipboard', { position: 'bottom-left' });
}, []);
toast.success(
`${K8S_CATEGORY_SINGULAR_LABELS[category]} name copied to clipboard`,
{ position: 'bottom-left' },
);
}, [category]);
const entityName = entity ? getEntityName(entity) : '';
// meta carries the resource's parents; its own name comes from the config
const entityAttributes = useMemo((): Record<string, string> => {
const meta = (entity as { meta?: Record<string, string> | null } | null)
?.meta;
return {
...(meta ?? {}),
...(entityName ? { [getEntityNameAttributeKey(category)]: entityName } : {}),
};
}, [entity, entityName, category]);
useEffect(() => {
if (entity) {
void logEvent(InfraMonitoringEvents.PageVisited, {
@@ -187,20 +234,48 @@ export default function K8sBaseDetails<T>({
}
}, [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]}`
: '';
const backLabel = previousEntry ? `${backTarget}: ${previousEntry.label}` : '';
// TODO(H4ad): Improve this on component level
// DrawerWrapper types `title` as string but renders any ReactNode.
const drawerTitle = (
<>
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleClose}
data-testid="close-drawer-button"
className={styles.closeButton}
prefix={<X />}
/>
{/* One control: step back where there is a trail, close otherwise.
Clicking outside the drawer closes it either way. The label is text
rather than a tooltip, which a click on the control would dismiss. */}
{previousEntry ? (
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleBack}
data-testid="drawer-back-button"
className={styles.backButton}
aria-label={backLabel}
prefix={<ArrowLeft size={14} />}
>
{backTarget}
</Button>
) : (
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleClose}
data-testid="close-drawer-button"
className={styles.closeButton}
prefix={<X />}
/>
)}
<Divider type="vertical" />
<span className={styles.entityKind} data-testid="drawer-entity-kind">
<CategoryIcon category={category} />
{K8S_CATEGORY_SINGULAR_LABELS[category]}
</span>
<Typography.Text className={styles.title}>
{entityName ||
((isEntityError || hasResponseError) && 'Failed to load entity details') ||
@@ -208,8 +283,8 @@ export default function K8sBaseDetails<T>({
'-'}
</Typography.Text>
<CopyButton
value={selectedItem ?? ''}
ariaLabel="Copy ID"
value={entityName || selectedItem || ''}
ariaLabel={`Copy ${K8S_CATEGORY_SINGULAR_LABELS[category]} name`}
className={styles.copyIdButton}
testId="copy-id-button"
onCopy={handleCopyId}
@@ -271,6 +346,8 @@ export default function K8sBaseDetails<T>({
customTabs={customTabs}
logsAndTracesInitialExpression={logsAndTracesInitialExpression}
eventsInitialExpression={eventsInitialExpression}
entityAttributes={entityAttributes}
entityName={entityName}
/>
</GlobalTimeProvider>
)}

View File

@@ -4,6 +4,7 @@ import {
ChevronsLeftRight,
Compass,
DraftingCompass,
LayoutGrid,
ScrollText,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
@@ -31,6 +32,7 @@ import EntityEvents from '../EntityDetailsUtils/EntityEvents';
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
import EntityOverview from '../EntityDetailsUtils/EntityOverview/EntityOverview';
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
import {
@@ -67,12 +69,16 @@ export default function K8sBaseDetailsContent<T>({
customTabs,
logsAndTracesInitialExpression,
eventsInitialExpression,
entityAttributes,
entityName,
}: K8sBaseDetailsContentProps<T>): JSX.Element {
const { timeRange, selectedInterval, handleTimeChange } =
useEntityDetailsTime();
const tabVisibility = useMemo(
() => ({
// Declared per category, since only a kubernetes resource relates to others
showOverview: false,
showMetrics: true,
showLogs: true,
showTraces: true,
@@ -86,6 +92,9 @@ export default function K8sBaseDetailsContent<T>({
const validTabs = useMemo(() => {
const tabs: string[] = [];
if (tabVisibility.showOverview) {
tabs.push(VIEW_TYPES.OVERVIEW);
}
if (tabVisibility.showMetrics) {
tabs.push(VIEW_TYPES.METRICS);
}
@@ -104,14 +113,28 @@ export default function K8sBaseDetailsContent<T>({
return tabs;
}, [tabVisibility, customTabs]);
useEffect(() => {
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
void setSelectedView(firstValid);
}
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
const defaultView = validTabs[0] || VIEW_TYPES.METRICS;
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
useEffect(() => {
if (
!hideDetailViewTabs &&
selectedView &&
!validTabs.includes(selectedView)
) {
void setSelectedView(defaultView);
}
}, [
hideDetailViewTabs,
selectedView,
validTabs,
defaultView,
setSelectedView,
]);
// An absent view param lands on the first tab the category offers
const effectiveView = hideDetailViewTabs
? VIEW_TYPES.METRICS
: (selectedView ?? defaultView);
// 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
@@ -169,20 +192,20 @@ export default function K8sBaseDetailsContent<T>({
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: selectedView,
view: effectiveView,
});
logInfraExplorerNavigatedEvent({
entityType: category,
destination:
selectedView === VIEW_TYPES.LOGS ? 'logs_explorer' : 'traces_explorer',
effectiveView === VIEW_TYPES.LOGS ? 'logs_explorer' : 'traces_explorer',
source: 'tab_cta_button',
tab: selectedView,
tab: effectiveView,
sourceKey: null,
drawerDurationMsAtNavigation: getDrawerDurationMs(),
});
if (selectedView === VIEW_TYPES.LOGS) {
if (effectiveView === VIEW_TYPES.LOGS) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userLogsExpression || '',
@@ -207,7 +230,7 @@ export default function K8sBaseDetailsContent<T>({
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
} else if (effectiveView === VIEW_TYPES.TRACES) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userTracesExpression || '',
@@ -264,7 +287,7 @@ export default function K8sBaseDetailsContent<T>({
filterExpression={getCountsFilterExpression(entity)}
closeDrawer={handleClose}
entityType={category}
activeTab={selectedView}
activeTab={effectiveView}
/>
)}
</div>
@@ -275,8 +298,21 @@ export default function K8sBaseDetailsContent<T>({
type="single"
className={styles.viewsTabs}
onChange={handleTabChange}
value={selectedView}
value={effectiveView}
items={[
...(tabVisibility.showOverview
? [
{
value: VIEW_TYPES.OVERVIEW,
label: (
<div className={styles.viewTitle}>
<LayoutGrid size={14} />
Overview
</div>
),
},
]
: []),
...(tabVisibility.showMetrics
? [
{
@@ -341,7 +377,7 @@ export default function K8sBaseDetailsContent<T>({
]}
/>
{selectedView === VIEW_TYPES.LOGS && (
{effectiveView === VIEW_TYPES.LOGS && (
<TooltipSimple title="Go to Logs Explorer" side="left" arrow>
<Button
variant="ghost"
@@ -354,7 +390,7 @@ export default function K8sBaseDetailsContent<T>({
</Button>
</TooltipSimple>
)}
{selectedView === VIEW_TYPES.TRACES && (
{effectiveView === VIEW_TYPES.TRACES && (
<TooltipSimple title="Go to Traces Explorer" side="left" arrow>
<Button
variant="ghost"
@@ -370,6 +406,16 @@ export default function K8sBaseDetailsContent<T>({
</div>
)}
{effectiveView === VIEW_TYPES.OVERVIEW && tabVisibility.showOverview && (
<EntityOverview
category={category}
eventEntity={eventCategory}
attributes={entityAttributes}
entity={entity}
entityName={entityName}
queryKeyPrefix={queryKeyPrefix}
/>
)}
{effectiveView === VIEW_TYPES.METRICS && (
<EntityMetrics<T>
entity={entity}
@@ -406,7 +452,7 @@ export default function K8sBaseDetailsContent<T>({
/>
)}
{customTabs?.map((tab) =>
selectedView === tab.key ? (
effectiveView === tab.key ? (
<Fragment key={tab.key}>
{tab.render({
entity,

View File

@@ -39,6 +39,7 @@ import K8sOptionsSidePanel from './K8sOptionsSidePanel';
import K8sHeader from './K8sHeader';
import { K8sPaginationWarning } from './K8sPaginationWarning';
import K8sTableToolbar from './K8sTableToolbar';
import { resetDrawerHistory } from './useDrawerHistoryStore';
import { K8sBaseFilters } from './types';
import { getGroupedByMeta } from './utils';
import { K8sInstrumentationChecksCallout } from './components/K8sInstrumentationChecksCallout/K8sInstrumentationChecksCallout';
@@ -301,6 +302,8 @@ export function K8sBaseList<
queryClient.setQueryData(detailQueryKey, { data: record });
}
// Opening from the list starts a fresh trail for the drawer's back control
resetDrawerHistory();
setSelectedItemParams(params);
}

View File

@@ -1,6 +1,9 @@
import { useMemo } from 'react';
import { useInfraMonitoringCategory } from '../hooks';
import {
useInfraMonitoringCategory,
useInfraMonitoringSelectedItemParams,
} from '../hooks';
import { getEntityConfig } from './entity.registry';
import { K8sBaseList } from './K8sBaseList';
import K8sBaseDetails from './K8sBaseDetails';
@@ -15,27 +18,35 @@ export function K8sDynamicList({
leftFilters,
}: K8sDynamicListProps): JSX.Element | null {
const [selectedCategory] = useInfraMonitoringCategory();
const [selectedItemParams] = useInfraMonitoringSelectedItemParams();
const config = useMemo(
() => getEntityConfig(selectedCategory),
[selectedCategory],
);
// A row opened from the drawer's overview tab belongs to another category
const drawerCategory = selectedItemParams.category ?? selectedCategory;
const drawerConfig = useMemo(
() => getEntityConfig(drawerCategory),
[drawerCategory],
);
if (!config) {
return null;
}
const { list, details } = config;
return (
<>
<K8sBaseList
{...list}
{...config.list}
controlListPrefix={controlListPrefix}
leftFilters={leftFilters}
/>
<K8sBaseDetails {...details} />
{/* No key: remounting would animate the drawer shut and open again
for what is really a change of contents */}
{drawerConfig && <K8sBaseDetails {...drawerConfig.details} />}
</>
);
}

View File

@@ -30,6 +30,7 @@ import {
useInfraMonitoringPageListing,
useInfraMonitoringSelectedItemParams,
} from '../hooks';
import { resetDrawerHistory } from './useDrawerHistoryStore';
import { K8sBaseFilters } from './types';
import { useLogEventForColumnCustomized } from './useLogEventForColumnCustomized';
import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferencesStore';
@@ -219,6 +220,8 @@ export function K8sExpandedRow<
queryClient.setQueryData(detailQueryKey, { data: row });
}
// Opening from the list starts a fresh trail for the drawer's back control
resetDrawerHistory();
setSelectedItemParams(params);
},
[

View File

@@ -0,0 +1,152 @@
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 { act, render, waitFor } from 'tests/test-utils';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../../constants';
import K8sBaseDetails from '../K8sBaseDetails';
import { useDrawerHistoryStore } from '../useDrawerHistoryStore';
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="mock-datetime" />,
}));
type TestEntity = { name: string };
const mockEntity: TestEntity = { name: 'test-container' };
const NODE_PARAMS = {
selectedItem: 'node-1',
category: InfraMonitoringEntity.NODES,
clusterName: null,
namespaceName: null,
};
/** What the drawer records before opening a resource from the node's overview */
const NODE_ENTRY = {
params: NODE_PARAMS,
category: InfraMonitoringEntity.NODES,
label: 'node-1',
entity: { name: 'node-1' },
queryKeyPrefix: 'node',
};
function renderDrawer(onUrlUpdate?: OnUrlUpdateFunction): void {
render(
<NuqsTestingAdapter
searchParams={{
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: 'container-1',
}}
onUrlUpdate={onUrlUpdate}
>
<K8sBaseDetails<TestEntity>
category={InfraMonitoringEntity.CONTAINERS}
eventCategory={InfraMonitoringEvents.Pod}
getSelectedItemExpression={(): string => "k8s.pod.uid = 'abc'"}
fetchEntityData={jest
.fn()
.mockResolvedValue({ data: mockEntity, error: null })}
getEntityName={(entity): string => entity.name}
getInitialLogTracesExpression={(): string => ''}
getInitialEventsExpression={(): string => ''}
metadataConfig={[
{ label: 'Name', getValue: (entity): string => entity.name },
]}
entityWidgetInfo={[{ title: 'CPU', yAxisUnit: 'percent' }]}
getEntityQueryPayload={jest.fn().mockReturnValue([])}
queryKeyPrefix="testContainer"
/>
</NuqsTestingAdapter>,
);
}
describe('K8sBaseDetails - back navigation', () => {
beforeEach(() => {
useDrawerHistoryStore.getState().reset();
});
it('closes rather than going back when opened from the list', async () => {
act(() => {
renderDrawer();
});
await waitFor(() => {
expect(screen.getAllByText('test-container').length).toBeGreaterThan(0);
});
expect(screen.queryByTestId('drawer-back-button')).not.toBeInTheDocument();
expect(screen.getByTestId('close-drawer-button')).toBeInTheDocument();
});
it('replaces the close control while there is somewhere to go back to', async () => {
useDrawerHistoryStore.getState().push(NODE_ENTRY);
act(() => {
renderDrawer();
});
const backButton = await screen.findByTestId('drawer-back-button');
// The destination is text rather than a tooltip, which a click would dismiss
expect(backButton).toHaveTextContent('Back to Node');
expect(backButton).toHaveAccessibleName('Back to Node: node-1');
expect(screen.queryByTestId('close-drawer-button')).not.toBeInTheDocument();
});
it('returns to the resource this one was opened from', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn();
useDrawerHistoryStore.getState().push(NODE_ENTRY);
act(() => {
renderDrawer(onUrlUpdate);
});
const backButton = await screen.findByTestId('drawer-back-button');
await user.click(backButton);
await waitFor(() => {
const params = onUrlUpdate.mock.calls.map((call) => call[0].searchParams);
expect(
params.some(
(searchParams) =>
searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM) ===
'node-1' &&
searchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY,
) === InfraMonitoringEntity.NODES,
),
).toBe(true);
});
// The trail is one step shorter, so a second back closes instead
expect(useDrawerHistoryStore.getState().entries).toHaveLength(0);
});
it('drops the trail when the drawer is dismissed', async () => {
const user = userEvent.setup();
useDrawerHistoryStore.getState().push(NODE_ENTRY);
act(() => {
renderDrawer();
});
await screen.findByTestId('drawer-back-button');
// Dismissing from outside the drawer, the way a click on the overlay does
await user.keyboard('{Escape}');
await waitFor(() => {
expect(useDrawerHistoryStore.getState().entries).toHaveLength(0);
});
});
});

View File

@@ -21,6 +21,7 @@ type TestEntity = {
name: string;
namespace: string;
cluster: string;
meta?: Record<string, string>;
};
const mockEntity: TestEntity = {
@@ -29,14 +30,22 @@ const mockEntity: TestEntity = {
cluster: 'test-cluster',
};
function createBaseProps() {
/** The overview tab appears only for a resource whose meta relates it to others */
const relatedEntity: TestEntity = {
...mockEntity,
meta: {
'k8s.pod.uid': '63edbad0-398e-48e5-baba-f7ded7c73f1b',
'k8s.namespace.name': 'default',
'k8s.cluster.name': 'test-cluster',
},
};
function createBaseProps(category: InfraMonitoringEntity, entity = mockEntity) {
return {
category: InfraMonitoringEntity.PODS,
category,
eventCategory: InfraMonitoringEvents.Pod,
getSelectedItemExpression: (): string => 'k8s.pod.name = "test-pod"',
fetchEntityData: jest
.fn()
.mockResolvedValue({ data: mockEntity, error: null }),
fetchEntityData: jest.fn().mockResolvedValue({ data: entity, error: null }),
getEntityName: (e: TestEntity): string => e.name,
getInitialLogTracesExpression: (): string => 'k8s.pod.name = "test-pod"',
getInitialEventsExpression: (): string => 'k8s.pod.name = "test-pod"',
@@ -51,7 +60,10 @@ function createBaseProps() {
interface RenderOptions {
view?: string;
category?: InfraMonitoringEntity;
entity?: TestEntity;
tabsConfig?: {
showOverview?: boolean;
showMetrics?: boolean;
showLogs?: boolean;
showTraces?: boolean;
@@ -67,6 +79,9 @@ interface RenderOptions {
function renderK8sBaseDetails({
view = VIEW_TYPES.METRICS,
// Volumes have no related resources, so they get no overview tab
category = InfraMonitoringEntity.VOLUMES,
entity = mockEntity,
tabsConfig,
customTabs,
}: RenderOptions = {}) {
@@ -78,7 +93,7 @@ function renderK8sBaseDetails({
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<K8sBaseDetails<TestEntity>
{...createBaseProps()}
{...createBaseProps(category, entity)}
tabsConfig={tabsConfig}
customTabs={customTabs}
/>
@@ -106,6 +121,25 @@ describe('K8sBaseDetails - Tab Validation', () => {
});
});
it('should land on the overview tab for a category with related resources', async () => {
act(() => {
renderK8sBaseDetails({
view: 'invalid-tab',
category: InfraMonitoringEntity.PODS,
entity: relatedEntity,
tabsConfig: { showOverview: true },
});
});
await waitFor(() => {
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
});
await waitFor(() => {
expect(getSelectedTabText()).toContain('Overview');
});
});
it('should reset to first available tab when METRICS is disabled and view is invalid', async () => {
act(() => {
renderK8sBaseDetails({

View File

@@ -0,0 +1,154 @@
import { InfraMonitoringEntity } from '../../constants';
import {
buildRelatedFilterExpression,
carryOverListFilters,
getRelatedCategories,
} from '../relations';
/** What /api/v2/infra_monitoring/pods returns as a pod's meta. */
const POD_ATTRIBUTES = {
'k8s.pod.uid': '63edbad0-398e-48e5-baba-f7ded7c73f1b',
'k8s.pod.name': 'checkout-7d9f',
'k8s.namespace.name': 'shop',
'k8s.cluster.name': 'prod',
'k8s.node.name': 'node-1',
'k8s.deployment.name': 'checkout',
};
const NODE_ATTRIBUTES = {
'k8s.node.name': 'node-1',
'k8s.cluster.name': 'prod',
};
describe('getRelatedCategories', () => {
it('offers every other kubernetes category, never itself', () => {
expect(
getRelatedCategories(InfraMonitoringEntity.PODS, POD_ATTRIBUTES),
).toStrictEqual([
InfraMonitoringEntity.CONTAINERS,
InfraMonitoringEntity.DEPLOYMENTS,
InfraMonitoringEntity.STATEFULSETS,
InfraMonitoringEntity.DAEMONSETS,
InfraMonitoringEntity.JOBS,
InfraMonitoringEntity.VOLUMES,
InfraMonitoringEntity.NODES,
InfraMonitoringEntity.NAMESPACES,
InfraMonitoringEntity.CLUSTERS,
]);
});
it('scopes a category it has no relation to by what they share', () => {
// Not this pod's statefulsets, which it has none of: the ones beside it
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.STATEFULSETS,
POD_ATTRIBUTES,
),
).toBe("k8s.cluster.name = 'prod' AND k8s.namespace.name = 'shop'");
});
it('falls back to the cluster where that is all two categories share', () => {
// Deployment metrics carry no node, so this relation is unexpressible
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.NODES,
InfraMonitoringEntity.DEPLOYMENTS,
NODE_ATTRIBUTES,
),
).toBe("k8s.cluster.name = 'prod'");
});
it('finds nothing for a host, which is not a kubernetes resource', () => {
// Host rows carry os.type and the host name, no kubernetes attribute
expect(
getRelatedCategories(InfraMonitoringEntity.HOSTS, {
'host.name': 'ip-10-0-0-1',
'os.type': 'linux',
}),
).toStrictEqual([]);
});
it('lists pods first, which makes them the default for a node', () => {
expect(
getRelatedCategories(InfraMonitoringEntity.NODES, NODE_ATTRIBUTES)[0],
).toBe(InfraMonitoringEntity.PODS);
});
});
describe('buildRelatedFilterExpression', () => {
it('scopes the pods of a node by the node, inside its cluster', () => {
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.NODES,
InfraMonitoringEntity.PODS,
NODE_ATTRIBUTES,
),
).toBe("k8s.cluster.name = 'prod' AND k8s.node.name = 'node-1'");
});
it("scopes a pod's containers by the pod uid, which those rows carry", () => {
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.CONTAINERS,
POD_ATTRIBUTES,
),
).toBe(
"k8s.cluster.name = 'prod' AND k8s.namespace.name = 'shop' AND k8s.pod.uid = '63edbad0-398e-48e5-baba-f7ded7c73f1b'",
);
});
it("reaches a pod's deployment by name, the only key that endpoint has", () => {
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.DEPLOYMENTS,
POD_ATTRIBUTES,
),
).toBe(
"k8s.cluster.name = 'prod' AND k8s.namespace.name = 'shop' AND k8s.deployment.name = 'checkout'",
);
});
it('names the parent when the drawer resource belongs to it', () => {
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.NODES,
POD_ATTRIBUTES,
),
).toBe("k8s.cluster.name = 'prod' AND k8s.node.name = 'node-1'");
});
it('asks a cluster list for the cluster alone', () => {
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.CLUSTERS,
POD_ATTRIBUTES,
),
).toBe("k8s.cluster.name = 'prod'");
});
});
describe('carryOverListFilters', () => {
it('keeps the clauses the target endpoint understands', () => {
expect(
carryOverListFilters(
"k8s.namespace.name = 'shop' AND k8s.node.condition_ready = true",
InfraMonitoringEntity.PODS,
),
).toBe("k8s.namespace.name = 'shop'");
});
it('drops a filter written against the source entity only', () => {
expect(
carryOverListFilters('k8s.node.cpu.usage > 0.5', InfraMonitoringEntity.PODS),
).toBe('');
});
it('returns nothing for an empty list filter', () => {
expect(carryOverListFilters(' ', InfraMonitoringEntity.PODS)).toBe('');
});
});

View File

@@ -0,0 +1,48 @@
import type { ComponentProps } from 'react';
import {
ArrowUpDown,
Bolt,
Box,
Boxes,
Computer,
Container,
FilePenLine,
Group,
HardDrive,
Server,
Workflow,
} from '@signozhq/icons';
import { InfraMonitoringEntity } from '../constants';
/**
* One icon per category, so the section tabs and the drawer that opens from
* them show a resource the same way.
*/
type IconSize = ComponentProps<typeof Container>['size'];
const CATEGORY_ICONS: Record<InfraMonitoringEntity, typeof Container> = {
[InfraMonitoringEntity.HOSTS]: Server,
[InfraMonitoringEntity.PODS]: Container,
[InfraMonitoringEntity.CONTAINERS]: Box,
[InfraMonitoringEntity.NODES]: Workflow,
[InfraMonitoringEntity.NAMESPACES]: FilePenLine,
[InfraMonitoringEntity.CLUSTERS]: Boxes,
[InfraMonitoringEntity.DEPLOYMENTS]: Computer,
[InfraMonitoringEntity.STATEFULSETS]: ArrowUpDown,
[InfraMonitoringEntity.DAEMONSETS]: Group,
[InfraMonitoringEntity.JOBS]: Bolt,
[InfraMonitoringEntity.VOLUMES]: HardDrive,
};
export function CategoryIcon({
category,
size = 14,
}: {
category: InfraMonitoringEntity;
size?: IconSize;
}): JSX.Element {
const Icon = CATEGORY_ICONS[category];
return <Icon size={size} />;
}

View File

@@ -3,18 +3,11 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { Compass } from '@signozhq/icons';
import { TextNoData } from '../../../components/TextNoData';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { Link } from 'react-router-dom';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../../../constants';
import { InfraMonitoringEntity } from '../../../constants';
import { getDrawerDurationMs } from '../../useDrawerLifecycleStore';
import { buildK8sListNavigationUrl } from '../../utils';
import styles from './EntityCountsSection.module.scss';
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
@@ -55,70 +48,6 @@ export function EntityCountsSection<T>({
closeDrawer();
};
const buildNavigationUrl = (targetCategory: InfraMonitoringEntity): string => {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
const compositeQuery = {
...defaultQuery,
id: uuid(),
builder: {
...defaultQuery.builder,
queryData: defaultQuery.builder.queryData.map((query) => ({
...query,
filter: { expression: filterExpression },
filters: { items: [], op: 'AND' as const },
})),
},
};
const urlParams = new URLSearchParams();
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
urlParams.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const currentSearchParams = new URLSearchParams(window.location.search);
const detailRelativeTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
);
const detailStartTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
);
const detailEndTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
);
const listRelativeTime = currentSearchParams.get(QueryParams.relativeTime);
const listStartTime = currentSearchParams.get(QueryParams.startTime);
const listEndTime = currentSearchParams.get(QueryParams.endTime);
if (listRelativeTime) {
urlParams.set(QueryParams.relativeTime, listRelativeTime);
} else if (listStartTime && listEndTime) {
urlParams.set(QueryParams.startTime, listStartTime);
urlParams.set(QueryParams.endTime, listEndTime);
}
if (detailRelativeTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
detailRelativeTime,
);
} else if (detailStartTime && detailEndTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
detailStartTime,
);
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
detailEndTime,
);
}
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
};
return (
<div className={styles.countsContainer}>
{countsConfig.map((config) => (
@@ -147,7 +76,7 @@ export function EntityCountsSection<T>({
<TextNoData type="typography" className={styles.countValue} />
)}
<Link
to={buildNavigationUrl(config.targetCategory)}
to={buildK8sListNavigationUrl(config.targetCategory, filterExpression)}
onClick={(): void => handleCardNavigate(config.label)}
data-testid={`navigate-${config.label.toLowerCase().replace(/\s+/g, '-')}`}
>

View File

@@ -97,7 +97,11 @@ export function logInfraExplorerNavigatedEvent(params: {
| 'logs_explorer'
| 'traces_explorer'
| 'k8s_list';
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
source:
| 'chart_compass_icon'
| 'tab_cta_button'
| 'stats_card'
| 'overview_cta';
tab: string;
sourceKey: string | null;
drawerDurationMsAtNavigation: number | null;

View File

@@ -0,0 +1,265 @@
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { extractQueryPairs } from 'utils/queryContextUtils';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
/** The attribute that names one record of a category. */
const PRIMARY_KEY: Record<InfraMonitoringEntity, string> = {
[InfraMonitoringEntity.CLUSTERS]: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
[InfraMonitoringEntity.NAMESPACES]:
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
[InfraMonitoringEntity.NODES]: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
[InfraMonitoringEntity.PODS]: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
[InfraMonitoringEntity.CONTAINERS]:
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
[InfraMonitoringEntity.DEPLOYMENTS]:
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
[InfraMonitoringEntity.STATEFULSETS]:
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
[InfraMonitoringEntity.DAEMONSETS]:
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
[InfraMonitoringEntity.JOBS]: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
[InfraMonitoringEntity.VOLUMES]:
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
[InfraMonitoringEntity.HOSTS]: INFRA_MONITORING_ATTR_KEYS.HOST_NAME,
};
/**
* Attributes each list endpoint can be filtered by, broadest first so a built
* expression reads cluster to container. Only keys the category's metrics
* actually carry belong here.
*/
const FILTERABLE_KEYS: Record<InfraMonitoringEntity, string[]> = {
[InfraMonitoringEntity.CLUSTERS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
],
[InfraMonitoringEntity.NODES]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
],
[InfraMonitoringEntity.NAMESPACES]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
],
[InfraMonitoringEntity.DEPLOYMENTS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
],
[InfraMonitoringEntity.STATEFULSETS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
],
[InfraMonitoringEntity.DAEMONSETS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
],
[InfraMonitoringEntity.JOBS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
],
[InfraMonitoringEntity.VOLUMES]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
],
[InfraMonitoringEntity.PODS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
],
[InfraMonitoringEntity.CONTAINERS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
],
[InfraMonitoringEntity.HOSTS]: [],
};
/** Dropdown order: what a resource usually contains, then what contains it. */
const DISPLAY_ORDER: InfraMonitoringEntity[] = [
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.CONTAINERS,
InfraMonitoringEntity.DEPLOYMENTS,
InfraMonitoringEntity.STATEFULSETS,
InfraMonitoringEntity.DAEMONSETS,
InfraMonitoringEntity.JOBS,
InfraMonitoringEntity.VOLUMES,
InfraMonitoringEntity.NODES,
InfraMonitoringEntity.NAMESPACES,
InfraMonitoringEntity.CLUSTERS,
];
export type EntityAttributes = Record<string, string>;
/** The attribute a category's display name belongs under. */
const NAME_KEY: Partial<Record<InfraMonitoringEntity, string>> = {
[InfraMonitoringEntity.PODS]: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
};
export function getEntityNameAttributeKey(
category: InfraMonitoringEntity,
): string {
return NAME_KEY[category] ?? PRIMARY_KEY[category];
}
function matchingKeys(
target: InfraMonitoringEntity,
attributes: EntityAttributes,
): string[] {
return FILTERABLE_KEYS[target].filter((key) => !!attributes[key]);
}
/**
* The attribute that ties a target to the drawer's resource: the resource
* itself when the target's records belong to it (the containers of this pod),
* otherwise the target's own name as the resource knows it (the node this pod
* runs on). Null when neither holds, which leaves the target scoped only by
* the ancestors the two share.
*/
function relationKey(
source: InfraMonitoringEntity,
target: InfraMonitoringEntity,
attributes: EntityAttributes,
): string | null {
if (target === source) {
return null;
}
const keys = matchingKeys(target, attributes);
if (keys.includes(PRIMARY_KEY[source])) {
return PRIMARY_KEY[source];
}
return keys.includes(PRIMARY_KEY[target]) ? PRIMARY_KEY[target] : null;
}
/**
* Every category the drawer's resource can scope a list of, which for a
* kubernetes resource is all of them: where no closer relation exists the two
* still share a cluster. A target is left out only when nothing is shared at
* all, so a list is never asked for unscoped.
*/
export function getRelatedCategories(
source: InfraMonitoringEntity,
attributes: EntityAttributes,
): InfraMonitoringEntity[] {
return DISPLAY_ORDER.filter(
(target) =>
target !== source &&
buildRelatedFilterClauses(source, target, attributes).length > 0,
);
}
/**
* Cluster and namespace ride along with the relation itself, the way other
* observability tools scope these lists: a pod's deployment is read as that
* deployment, in that namespace, in that cluster.
*/
const SCOPE_KEYS = [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
];
export interface RelatedFilterClause {
key: string;
value: string;
}
/**
* Scope clauses, broadest first. The last carries the relation where one
* exists; without one the list is scoped to the shared ancestors, which reads
* as "beside this resource" rather than "belonging to it".
*/
export function buildRelatedFilterClauses(
source: InfraMonitoringEntity,
target: InfraMonitoringEntity,
attributes: EntityAttributes,
): RelatedFilterClause[] {
const key = relationKey(source, target, attributes);
const scope = SCOPE_KEYS.filter(
(scopeKey) =>
scopeKey !== key &&
FILTERABLE_KEYS[target].includes(scopeKey) &&
!!attributes[scopeKey],
);
return (key ? [...scope, key] : scope).map((clauseKey) => ({
key: clauseKey,
value: attributes[clauseKey] as string,
}));
}
export function formatRelatedFilterClause(clause: RelatedFilterClause): string {
return `${clause.key} = ${formatValueForExpression(clause.value)}`;
}
export function buildRelatedFilterExpression(
source: InfraMonitoringEntity,
target: InfraMonitoringEntity,
attributes: EntityAttributes,
): string {
return buildRelatedFilterClauses(source, target, attributes)
.map(formatRelatedFilterClause)
.join(' AND ');
}
export function resolveRelatedCategory(
source: InfraMonitoringEntity,
attributes: EntityAttributes,
requested: string | null,
): InfraMonitoringEntity | null {
const related = getRelatedCategories(source, attributes);
const match = related.find((target) => target === requested);
return match ?? related[0] ?? null;
}
export function carryOverListFilters(
expression: string,
target: InfraMonitoringEntity,
): string {
if (!expression.trim()) {
return '';
}
const supported = new Set(FILTERABLE_KEYS[target]);
return extractQueryPairs(expression)
.filter((pair) => supported.has(pair.key))
.map((pair) => {
const start = pair.position.negationStart ?? pair.position.keyStart;
const lastValue = pair.valuesPosition?.[pair.valuesPosition.length - 1];
const end =
lastValue?.end ?? pair.position.valueEnd ?? pair.position.operatorEnd;
return expression.slice(start, end + 1).trim();
})
.filter(Boolean)
.join(' AND ');
}

View File

@@ -75,6 +75,8 @@ export type GetEntityQueryPayload<T> = (
) => GetQueryResultsProps[];
export interface K8sDetailsTabsConfig {
/** Declared by categories whose resources relate to others; see relations.ts */
showOverview?: boolean;
showMetrics?: boolean;
showLogs?: boolean;
showTraces?: boolean;
@@ -141,6 +143,10 @@ export interface K8sBaseDetailsContentProps<T> {
customTabs?: K8sDetailsCustomTab<T>[];
logsAndTracesInitialExpression: string;
eventsInitialExpression: string;
/** The drawer entity's own attributes, used to find and scope related resources */
entityAttributes: Record<string, string>;
/** Name of the drawer entity, for the back control of a resource opened from it */
entityName: string;
}
// Aliases for backward compatibility

View File

@@ -0,0 +1,56 @@
import { create } from 'zustand';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
export interface DrawerHistoryEntry {
params: SelectedItemParams;
/** Category of the resource that was open, which the back control names */
category: InfraMonitoringEntity;
/** Name of that resource, shown on hover */
label: string;
/**
* The record the drawer was showing, and the prefix of the query that holds
* it, so stepping back renders from it instead of fetching again.
*/
entity: unknown;
queryKeyPrefix: string;
}
export interface IDrawerHistoryStore {
entries: DrawerHistoryEntry[];
push: (entry: DrawerHistoryEntry) => void;
pop: () => DrawerHistoryEntry | null;
reset: () => void;
}
/**
* The trail left by opening one resource from another's overview tab, so the
* drawer can step back through it instead of only closing.
*/
export const useDrawerHistoryStore = create<IDrawerHistoryStore>()(
(set, get) => ({
entries: [],
push: (entry): void => set({ entries: [...get().entries, entry] }),
pop: (): DrawerHistoryEntry | null => {
const { entries } = get();
const previous = entries[entries.length - 1] ?? null;
if (previous) {
set({ entries: entries.slice(0, -1) });
}
return previous;
},
reset: (): void => set({ entries: [] }),
}),
);
export const resetDrawerHistory = (): void =>
useDrawerHistoryStore.getState().reset();
export const pushDrawerHistory = (entry: DrawerHistoryEntry): void =>
useDrawerHistoryStore.getState().push(entry);
export const getDrawerHistoryDepth = (): number =>
useDrawerHistoryStore.getState().entries.length;

View File

@@ -0,0 +1,47 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { useGlobalTimeStore } from 'store/globalTime';
import { SelectedItemParams } from '../hooks';
interface PrimeEntityDetailsParams {
params: SelectedItemParams;
queryKeyPrefix: string;
entity: unknown;
}
/**
* Seeds the drawer's entity query with a record already in hand, so navigating
* between resources renders straight away instead of passing through a loading
* state. The list page does the same on a row click.
*/
export function usePrimeEntityDetails(): (
params: PrimeEntityDetailsParams,
) => void {
const queryClient = useQueryClient();
const selectedTime = useGlobalTimeStore((store) => store.selectedTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(store) => store.getAutoRefreshQueryKey,
);
return useCallback(
({ params, queryKeyPrefix, entity }): void => {
if (!queryKeyPrefix || !entity) {
return;
}
queryClient.setQueryData(
getAutoRefreshQueryKey(
selectedTime,
`${queryKeyPrefix}EntityDetails`,
params.selectedItem,
params.clusterName,
params.namespaceName,
params.containerName,
),
{ data: entity },
);
},
[queryClient, selectedTime, getAutoRefreshQueryKey],
);
}

View File

@@ -7,7 +7,16 @@ import {
formatValueForExpression,
} from 'components/QueryBuilderV2/utils';
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
import {
INFRA_MONITORING_ATTR_KEYS,
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from 'container/InfraMonitoringK8sV2/constants';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
export function sortByColumnOrder<T>(
items: T[],
@@ -178,3 +187,74 @@ export function buildExpressionFromSelectedItemParams(
return clauses.join(' AND ');
}
/**
* Link to the k8s list page for `targetCategory`, pre-filtered by `filterExpression`
* and carrying whichever time range (list or drawer) the current URL holds.
*/
export function buildK8sListNavigationUrl(
targetCategory: InfraMonitoringEntity,
filterExpression: string,
): string {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
const compositeQuery = {
...defaultQuery,
id: uuid(),
builder: {
...defaultQuery.builder,
queryData: defaultQuery.builder.queryData.map((query) => ({
...query,
filter: { expression: filterExpression },
filters: { items: [], op: 'AND' as const },
})),
},
};
const urlParams = new URLSearchParams();
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
urlParams.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const currentSearchParams = new URLSearchParams(window.location.search);
const detailRelativeTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
);
const detailStartTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
);
const detailEndTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
);
const listRelativeTime = currentSearchParams.get(QueryParams.relativeTime);
const listStartTime = currentSearchParams.get(QueryParams.startTime);
const listEndTime = currentSearchParams.get(QueryParams.endTime);
if (listRelativeTime) {
urlParams.set(QueryParams.relativeTime, listRelativeTime);
} else if (listStartTime && listEndTime) {
urlParams.set(QueryParams.startTime, listStartTime);
urlParams.set(QueryParams.endTime, listEndTime);
}
if (detailRelativeTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
detailRelativeTime,
);
} else if (detailStartTime && detailEndTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
detailStartTime,
);
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
detailEndTime,
);
}
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
}

View File

@@ -6,10 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import {
K8sDetailsCountConfig,
K8sDetailsMetadataConfig,
} from '../Base/K8sBaseDetails';
import { K8sDetailsCountConfig, K8sDetailsMetadataConfig } from '../Base/types';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import {
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -123,6 +123,7 @@ export const clusterEntityConfig: K8sEntityConfig<InframonitoringtypesClusterRec
details: {
category: InfraMonitoringEntity.CLUSTERS,
eventCategory: InfraMonitoringEvents.Cluster,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'cluster',
getSelectedItemExpression: k8sClusterGetSelectedItemExpression,
fetchEntityData,

View File

@@ -127,6 +127,7 @@ export const containerEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.CONTAINERS,
eventCategory: InfraMonitoringEvents.Container,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'container',
getSelectedItemExpression: k8sContainerGetSelectedItemExpression,
fetchEntityData,

View File

@@ -1,4 +1,4 @@
import { Container } from '@signozhq/icons';
import { Box } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import {
InframonitoringtypesContainerReadyDTO,
@@ -94,7 +94,7 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
header: (): React.ReactNode => (
<EntityGroupHeader
title="Container Name"
icon={<Container data-hide-expanded="true" size={14} />}
icon={<Box data-hide-expanded="true" size={14} />}
docPath={`${CONTAINERS_DOC_PATH}#container-name`}
/>
),

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -132,6 +132,7 @@ export const daemonSetEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.DAEMONSETS,
eventCategory: InfraMonitoringEvents.DaemonSet,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'daemonset',
getSelectedItemExpression: k8sDaemonSetGetSelectedItemExpression,
fetchEntityData,

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -147,6 +147,7 @@ export const deploymentEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.DEPLOYMENTS,
eventCategory: InfraMonitoringEvents.Deployment,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'deployment',
getSelectedItemExpression: k8sDeploymentGetSelectedItemExpression,
fetchEntityData,

View File

@@ -0,0 +1,166 @@
.container {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-top: var(--spacing-8);
min-width: 0;
}
.controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-3);
flex-wrap: wrap;
}
.tableHeader {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--l1-border);
}
.resourcePicker {
display: flex;
align-items: center;
gap: var(--spacing-3);
flex: 0 0 auto;
}
.resourceTrigger {
--select-trigger-width: 240px;
--select-trigger-height: 2rem;
}
.resourceOptions {
// The drawer is a dialog at z-index 50 and select content ships without one,
// so without this the dropdown paints behind the drawer.
z-index: 60;
// Radix measures the trigger for us; without this the list is narrower than it
min-width: var(--radix-select-trigger-width);
}
.filterRow {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding-left: var(--spacing-3);
min-width: 0;
border: 1px solid var(--l2-border);
border-radius: 2px;
background: var(--l1-background);
// The row draws the field outline, so the editor inside it draws none
--query-builder-v2-border-color: transparent;
--query-builder-v2-background-color: transparent;
}
.scopeChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
flex: 0 1 auto;
min-width: 0;
max-width: 420px;
padding: var(--spacing-1) var(--spacing-3);
border-radius: 2px;
background: var(--l3-background);
color: var(--l2-foreground);
font-size: 12px;
line-height: 20px;
cursor: default;
}
.scopeMore {
flex: none;
color: var(--l3-foreground);
}
// The editor beside it is CodeMirror on the copilot theme in dark and
// githubLight in light; the chip borrows those token colours so one filter
// does not read as two
.scopeKey {
color: #939da5;
}
.scopeOperator {
color: #939da5;
}
.scopeValue {
color: #5bec95;
}
:global(.lightMode) {
.scopeKey,
.scopeOperator {
color: #005cc5;
}
.scopeValue {
color: #032f62;
}
}
.scopeExpression {
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filterInput {
flex: 1 1 auto;
min-width: 0;
// The locked scope is already shown as a chip, so drop the editor's own
// scope marker and the room it reserves for it
:global(.query-search-initial-scope-label) {
display: none;
}
:global(
.query-where-clause-editor.hasInitialExpression .cm-editor .cm-content
) {
padding-left: 0 !important;
}
// Its page-width minimum would force the filter row to overflow the drawer
:global(.code-mirror-where-clause .query-where-clause-editor) {
min-width: 0;
}
}
.tableCard {
display: flex;
flex-direction: column;
min-height: 480px;
border: 1px solid var(--l2-border);
border-radius: 4px;
overflow: hidden;
min-width: 0;
}
.table {
flex: 1 1 auto;
min-height: 0;
--typography-color: var(--l2-foreground);
}
.noRelations {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
margin-top: var(--spacing-8);
}
.emptyState {
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
}

View File

@@ -0,0 +1,76 @@
import { rest } from 'msw';
import { countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
function buildContainers(count: number): unknown[] {
return Array.from({ length: count }, (_, index) => ({
containerName: index === 0 ? 'loadgenerator' : `sidecar-${index}`,
containerID: `containerd://${index}`,
podName: 'app-demo-loadgenerator-579fbdd749-q5kgd',
status: 'running',
ready: true,
restarts: index,
image: 'ghcr.io/open-telemetry/demo:1.14.0-loadgenerator',
cpuUsage: 0.12,
memoryUsage: 148 * 1024 * 1024,
meta: {
'k8s.pod.name': 'app-demo-loadgenerator-579fbdd749-q5kgd',
'k8s.namespace.name': 'generator',
'k8s.cluster.name': 'mgmt',
'k8s.node.name': 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-p6z7',
},
}));
}
export const overviewMocks = defineStoryMocks({
controls: {
containers: countControl('Containers', {
group: 'Overview tab',
value: 12,
max: 40,
}),
},
handlers: (values) => [
// The filter's suggestions ask for these; without them the search box
// logs a failed request and the story's smoke test fails on it
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
),
),
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
),
),
rest.post(
'http://localhost/api/v2/infra_monitoring/kube_containers',
async (req, res, ctx) => {
const { offset = 0, limit = 10 } = (await req.json()) as {
offset?: number;
limit?: number;
};
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
type: 'list',
records: buildContainers(values.containers).slice(
offset,
offset + limit,
),
total: values.containers,
},
}),
);
},
),
],
});

View File

@@ -0,0 +1,67 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { GlobalTimeProvider } from 'store/globalTime';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../../constants';
import EntityOverview, { EntityOverviewProps } from './EntityOverview';
import { overviewMocks } from './EntityOverview.stories.mocks';
import drawerStyles from '../entityDetails.module.scss';
const POD_UID = '63edbad0-398e-48e5-baba-f7ded7c73f1b';
/** The meta /api/v2/infra_monitoring/pods returns for one pod. */
const POD_ATTRIBUTES = {
'k8s.pod.uid': POD_UID,
'k8s.pod.name': 'app-demo-loadgenerator-579fbdd749-q5kgd',
'k8s.namespace.name': 'generator',
'k8s.cluster.name': 'mgmt',
'k8s.node.name': 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-p6z7',
'k8s.deployment.name': 'app-demo-loadgenerator',
};
const route = `/infrastructure-monitoring/kubernetes?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=pods&${INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM}=${POD_UID}`;
type OverviewArgs = PageStoryArgs<typeof overviewMocks> & EntityOverviewProps;
const meta = {
title: 'Infra monitoring/Drawer overview tab',
component: EntityOverview,
...storyMocks(overviewMocks, { route }),
decorators: [
(Story): JSX.Element => (
<DrawerWrapper
open
direction="right"
title="app-demo-loadgenerator-579fbdd749-q5kgd"
onOpenChange={(): void => undefined}
className={drawerStyles.entityDetailDrawer}
>
<GlobalTimeProvider>
<Story />
</GlobalTimeProvider>
</DrawerWrapper>
),
],
} satisfies Meta<OverviewArgs>;
export default meta;
type Story = StoryObj<OverviewArgs>;
export const Default: Story = {
args: {
category: InfraMonitoringEntity.PODS,
eventEntity: 'pod',
attributes: POD_ATTRIBUTES,
entity: { podUID: POD_UID },
entityName: POD_ATTRIBUTES['k8s.pod.name'],
queryKeyPrefix: 'pod',
},
};

View File

@@ -0,0 +1,343 @@
import { useCallback, useMemo, useState } from 'react';
import { Compass } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@signozhq/ui/select';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import {
QuerySearchV2Provider,
useExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { validateQuery } from 'utils/queryValidationUtils';
import { getEntityConfig } from '../../Base/entity.registry';
import { logInfraExplorerNavigatedEvent } from '../../Base/events';
import {
buildRelatedFilterClauses,
EntityAttributes,
formatRelatedFilterClause,
RelatedFilterClause,
getEntityNameAttributeKey,
getRelatedCategories,
resolveRelatedCategory,
} from '../../Base/relations';
import { getDrawerDurationMs } from '../../Base/useDrawerLifecycleStore';
import { buildK8sListNavigationUrl } from '../../Base/utils';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
K8S_CATEGORY_LABELS,
K8S_CATEGORY_SINGULAR_LABELS,
METRIC_NAMESPACE_BY_ENTITY,
VIEW_TYPES,
} from '../../constants';
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
import { logInfraDrawerFilterCustomizedEvent } from '../events';
import { RelatedEntitiesTable } from './RelatedEntitiesTable';
import { ScopeChip } from './ScopeChip';
import { useCarriedOverFilters } from './useCarriedOverFilters';
import { useRelatedEntities } from './useRelatedEntities';
import styles from './EntityOverview.module.scss';
export interface EntityOverviewProps {
category: InfraMonitoringEntity;
eventEntity: string;
/** The drawer entity's own attributes, which scope every related query */
attributes: EntityAttributes;
/** The drawer's own record and query prefix, so a row click can leave a
* trail that steps back without fetching again */
entity: unknown;
entityName: string;
queryKeyPrefix: string;
}
interface ContentProps extends EntityOverviewProps {
targetCategory: InfraMonitoringEntity;
lockedExpression: string;
lockedClauses: RelatedFilterClause[];
onCategoryChange: (category: string) => void;
}
function EntityOverviewContent({
category,
eventEntity,
attributes,
entity,
entityName,
queryKeyPrefix,
targetCategory,
lockedExpression,
lockedClauses,
onCategoryChange,
}: ContentProps): JSX.Element | null {
const { timeRange } = useEntityDetailsTime();
const expression = useExpression();
const userExpression = useUserExpression();
const inputExpression = useInputExpression();
const querySearchOnChange = useQuerySearchOnChange();
const querySearchOnRun = useQuerySearchOnRun();
const querySearchInitialExpressionProp = useQuerySearchInitialExpressionProp();
const targetConfig = getEntityConfig(targetCategory);
useCarriedOverFilters(targetCategory);
const {
records,
total,
isLoading,
isFetching,
isError,
error,
endTimeBeforeRetention,
setPage,
setLimit,
refetch,
cancel,
} = useRelatedEntities({
fetchListData: targetConfig?.list.fetchListData,
queryKey: `${targetCategory}RelatedEntities`,
expression,
timeRange,
});
// The editor holds only what the user typed; the locked scope is shown beside it
const queryData = useMemo(
(): IBuilderQuery =>
({
aggregateOperator: 'noop',
filter: { expression: userExpression },
}) as IBuilderQuery,
[userExpression],
);
const handleCategoryChange = useCallback(
(value: string | string[]): void => {
if (typeof value === 'string') {
onCategoryChange(value);
}
},
[onCategoryChange],
);
const handleRunQuery = useCallback(
(updatedExpression?: string): void => {
const newUserExpression = updatedExpression
? getUserExpressionFromCombined(lockedExpression, updatedExpression)
: inputExpression;
const validation = validateQuery(
combineInitialAndUserExpression(lockedExpression, newUserExpression || ''),
);
if (!validation.isValid) {
return;
}
querySearchOnRun(newUserExpression);
logInfraDrawerFilterCustomizedEvent(
category,
VIEW_TYPES.OVERVIEW,
newUserExpression || '',
'search',
);
refetch();
},
[lockedExpression, inputExpression, querySearchOnRun, category, refetch],
);
const handleNavigate = useCallback((): void => {
logInfraExplorerNavigatedEvent({
entityType: category,
destination: 'k8s_list',
source: 'overview_cta',
tab: VIEW_TYPES.OVERVIEW,
sourceKey: targetCategory,
drawerDurationMsAtNavigation: getDrawerDurationMs(),
});
openInNewTab(buildK8sListNavigationUrl(targetCategory, expression));
}, [category, targetCategory, expression]);
if (!targetConfig) {
return null;
}
const targetLabel = K8S_CATEGORY_LABELS[targetCategory];
return (
<div className={styles.container}>
<div className={styles.controls}>
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={VIEW_TYPES.OVERVIEW}
/>
<RunQueryBtn
isLoadingQueries={isFetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
/>
</div>
<div className={styles.filterRow}>
<ScopeChip clauses={lockedClauses} expression={lockedExpression} />
<div className={styles.filterInput}>
<QuerySearch
queryData={queryData}
dataSource={DataSource.METRICS}
onChange={querySearchOnChange}
onRun={handleRunQuery}
signalSource=""
showFilterSuggestionsWithoutMetric
placeholder={`Filter these ${targetLabel.toLowerCase()}`}
metricNamespace={METRIC_NAMESPACE_BY_ENTITY[targetCategory]}
initialExpression={querySearchInitialExpressionProp}
/>
</div>
</div>
<RelatedEntitiesTable
key={targetCategory}
targetCategory={targetCategory}
header={
<div className={styles.resourcePicker}>
<Typography.Text color="muted" size="small">
Showing
</Typography.Text>
<Select value={targetCategory} onChange={handleCategoryChange}>
<SelectTrigger
className={styles.resourceTrigger}
data-testid="overview-resource-select"
>
<SelectValue>{targetLabel}</SelectValue>
</SelectTrigger>
{/* The drawer sits at z-index 50 and select content ships without one */}
<SelectContent className={styles.resourceOptions}>
{getRelatedCategories(category, attributes).map((relatedCategory) => (
<SelectItem
key={relatedCategory}
value={relatedCategory}
testId={`overview-resource-option-${relatedCategory}`}
>
{K8S_CATEGORY_LABELS[relatedCategory]}
</SelectItem>
))}
</SelectContent>
</Select>
<TooltipSimple
title={`View on ${targetLabel.toLowerCase()} page`}
side="top"
arrow
>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleNavigate}
data-testid="overview-view-on-list-page"
prefix={<Compass size={16} />}
/>
</TooltipSimple>
</div>
}
listConfig={targetConfig.list}
records={records}
total={total}
isLoading={isLoading}
isError={isError}
error={error}
endTimeBeforeRetention={endTimeBeforeRetention}
source={{ category, label: entityName, entity, queryKeyPrefix }}
onPageChange={setPage}
onLimitChange={setLimit}
/>
</div>
);
}
export default function EntityOverview(
props: EntityOverviewProps,
): JSX.Element | null {
const { category, attributes } = props;
// Tied to the resource it was picked on, so another drawer starts at its
// own default instead of inheriting the last pick
const [selection, setSelection] = useState<{
identity: string;
category: string;
} | null>(null);
const identity = `${category}:${
attributes[getEntityNameAttributeKey(category)] ?? ''
}`;
const targetCategory = resolveRelatedCategory(
category,
attributes,
selection?.identity === identity ? selection.category : null,
);
const handleCategoryChange = useCallback(
(value: string): void => setSelection({ identity, category: value }),
[identity],
);
const lockedClauses = targetCategory
? buildRelatedFilterClauses(category, targetCategory, attributes)
: [];
const lockedExpression = lockedClauses
.map(formatRelatedFilterClause)
.join(' AND ');
if (!targetCategory) {
return (
<div className={styles.noRelations}>
<Typography.Text color="muted">
{`Nothing related to this ${K8S_CATEGORY_SINGULAR_LABELS[category]} was reported in this time range.`}
</Typography.Text>
</div>
);
}
return (
<QuerySearchV2Provider
queryParamKey={INFRA_MONITORING_K8S_PARAMS_KEYS.OVERVIEW_EXPRESSION}
initialExpression={lockedExpression}
>
<EntityOverviewContent
{...props}
targetCategory={targetCategory}
lockedExpression={lockedExpression}
lockedClauses={lockedClauses}
onCategoryChange={handleCategoryChange}
/>
</QuerySearchV2Provider>
);
}

View File

@@ -0,0 +1,142 @@
import { ReactNode, useCallback } from 'react';
import cx from 'classnames';
import TanStackTable from 'components/TanStackTableView';
import APIError from 'types/api/error';
import {
K8sEntityData,
K8sEntityListConfig,
} from '../../Base/entity.config.types';
import { K8sEmptyState } from '../../Base/K8sEmptyState';
import {
DrawerHistoryEntry,
useDrawerHistoryStore,
} from '../../Base/useDrawerHistoryStore';
import {
useInfraMonitoringFontSize,
useInfraMonitoringLineClamp,
} from '../../Base/useInfraMonitoringTablePreferencesStore';
import { InfraMonitoringEntity, K8S_CATEGORY_LABELS } from '../../constants';
import {
SelectedItemParams,
useInfraMonitoringSelectedItemParams,
} from '../../hooks';
import { usePrimeEntityDetails } from '../../Base/usePrimeEntityDetails';
import { RELATED_ENTITIES_PAGE_SIZE } from './useRelatedEntities';
import listStyles from '../../Base/K8sBaseList.module.scss';
import styles from './EntityOverview.module.scss';
interface RelatedEntitiesTableProps {
targetCategory: InfraMonitoringEntity;
listConfig: K8sEntityListConfig<K8sEntityData, string | SelectedItemParams>;
records: K8sEntityData[];
total: number;
isLoading: boolean;
isError: boolean;
error?: APIError | null;
endTimeBeforeRetention?: boolean;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
/** Rendered in the card's header strip, above the rows it describes */
header: ReactNode;
/** The drawer's own resource, so a row click can leave a trail back to it */
source: Omit<DrawerHistoryEntry, 'params'>;
}
export function RelatedEntitiesTable({
targetCategory,
listConfig,
records,
total,
isLoading,
isError,
error,
endTimeBeforeRetention,
onPageChange,
onLimitChange,
header,
source,
}: RelatedEntitiesTableProps): JSX.Element {
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
const pushDrawerHistory = useDrawerHistoryStore((store) => store.push);
const primeDetails = usePrimeEntityDetails();
const lineClamp = useInfraMonitoringLineClamp();
const fontSize = useInfraMonitoringFontSize();
// Opens the clicked resource in its own drawer, overview tab included
const handleRowClick = useCallback(
(record: K8sEntityData, itemKey: string | SelectedItemParams): void => {
const params: SelectedItemParams =
typeof itemKey === 'object'
? itemKey
: { selectedItem: itemKey, clusterName: null, namespaceName: null };
pushDrawerHistory({ ...source, params: selectedItemParams });
// The row already carries the record the drawer is about to fetch
primeDetails({
params,
queryKeyPrefix: listConfig.detailsQueryKeyPrefix,
entity: record,
});
setSelectedItemParams({ ...params, category: targetCategory });
},
[
primeDetails,
pushDrawerHistory,
selectedItemParams,
source,
setSelectedItemParams,
targetCategory,
listConfig.detailsQueryKeyPrefix,
],
);
const isEmpty = !isLoading && records.length === 0;
return (
<div className={styles.tableCard}>
<div className={styles.tableHeader}>{header}</div>
{isEmpty ? (
<div className={styles.emptyState}>
<K8sEmptyState
isError={isError}
error={error}
isLoading={isLoading}
endTimeBeforeRetention={endTimeBeforeRetention}
/>
</div>
) : (
<TanStackTable<K8sEntityData, string | SelectedItemParams>
data={records}
columns={listConfig.tableColumns}
columnStorageKey={`k8s-overview-${targetCategory}-columns`}
isLoading={isLoading}
getRowKey={listConfig.getRowKey}
getItemKey={listConfig.getItemKey}
onRowClick={handleRowClick}
className={cx(listStyles.k8SListTable, styles.table)}
testId={`related-entities-table-${targetCategory}`}
// A page of rows is short enough to render whole, which keeps the card
// at the height of its rows instead of stretching down the drawer
disableVirtualScroll
pagination={{
total,
defaultLimit: RELATED_ENTITIES_PAGE_SIZE,
showTotalCount: true,
totalCountLabel: K8S_CATEGORY_LABELS[targetCategory],
onPageChange,
onLimitChange,
}}
plainTextCellLineClamp={lineClamp}
cellTypographySize={fontSize}
paginationClassname={listStyles.paginationContainer}
resetScrollKey={targetCategory}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { Lock } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { RelatedFilterClause } from '../../Base/relations';
import styles from './EntityOverview.module.scss';
interface ScopeChipProps {
clauses: RelatedFilterClause[];
/** The clauses as one expression, shown in full on hover */
expression: string;
}
/**
* The locked half of the filter. It reads like the editor beside it: the last
* clause carries the relation, the ones scoping it are counted instead.
*/
export function ScopeChip({
clauses,
expression,
}: ScopeChipProps): JSX.Element {
const relation = clauses[clauses.length - 1];
return (
<TooltipSimple title={expression} side="top" arrow>
<span className={styles.scopeChip} data-testid="overview-locked-scope">
<Lock size={12} />
<span className={styles.scopeExpression}>
<span className={styles.scopeKey}>{relation.key}</span>
<span className={styles.scopeOperator}> = </span>
<span className={styles.scopeValue}>
{formatValueForExpression(relation.value)}
</span>
</span>
{clauses.length > 1 && (
<span className={styles.scopeMore}>{`+${clauses.length - 1}`}</span>
)}
</span>
</TooltipSimple>
);
}

View File

@@ -0,0 +1,242 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { VirtuosoMockContext } from 'react-virtuoso';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NuqsTestingAdapter, OnUrlUpdateFunction } from 'nuqs/adapters/testing';
import { TableColumnDef } from 'components/TanStackTableView';
import { InfraMonitoringEntity } from '../../../constants';
import EntityOverview from '../EntityOverview';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="mock-datetime" />,
}));
const mockQuerySearchProps = jest.fn();
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: (props: {
queryData?: { filter?: { expression?: string } };
initialExpression?: string;
}): JSX.Element => {
mockQuerySearchProps(props);
return <div data-testid="mock-query-search" />;
},
}));
type TestRecord = { name: string; meta?: Record<string, string> | null };
const mockFetchListData = jest.fn();
const mockTableColumns: TableColumnDef<TestRecord>[] = [
{
id: 'name',
header: 'Name',
accessorKey: 'name',
cell: ({ value }): string => String(value),
},
];
jest.mock('../../../Base/entity.registry', () => ({
getEntityConfig: (category: string) => ({
list: {
entity: category,
tableColumns: mockTableColumns,
fetchListData: mockFetchListData,
getRowKey: (record: TestRecord): string => record.name,
getItemKey: (record: TestRecord): string => record.name,
},
}),
}));
const NODE_ATTRIBUTES = {
'k8s.node.name': 'node-1',
'k8s.cluster.name': 'prod',
};
const LOCKED_EXPRESSION =
"k8s.cluster.name = 'prod' AND k8s.node.name = 'node-1'";
interface RenderOptions {
onUrlUpdate?: OnUrlUpdateFunction;
attributes?: Record<string, string>;
}
function overviewTree(
attributes: Record<string, string>,
queryClient: QueryClient,
onUrlUpdate?: OnUrlUpdateFunction,
): JSX.Element {
return (
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate}>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 800, itemHeight: 50 }}
>
<TooltipProvider>
<EntityOverview
category={InfraMonitoringEntity.NODES}
eventEntity="node"
attributes={attributes}
entity={{ nodeName: attributes['k8s.node.name'] }}
entityName={attributes['k8s.node.name']}
queryKeyPrefix="node"
/>
</TooltipProvider>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>
</QueryClientProvider>
</MemoryRouter>
);
}
function renderOverview({
onUrlUpdate,
attributes = NODE_ATTRIBUTES,
}: RenderOptions = {}): {
openAnotherResource: (next: Record<string, string>) => void;
} {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const { rerender } = render(
overviewTree(attributes, queryClient, onUrlUpdate),
);
return {
openAnotherResource: (next): void =>
rerender(overviewTree(next, queryClient, onUrlUpdate)),
};
}
describe('EntityOverview', () => {
beforeEach(() => {
mockQuerySearchProps.mockReset();
mockFetchListData.mockReset();
mockFetchListData.mockResolvedValue({
type: 'list',
records: [{ name: 'pod-1' }],
total: 1,
});
});
it('lists the first related resource scoped by the locked expression', async () => {
renderOverview();
await waitFor(() => {
expect(mockFetchListData).toHaveBeenCalled();
});
expect(mockFetchListData.mock.calls[0][0].filter.expression).toBe(
LOCKED_EXPRESSION,
);
await expect(
screen.findByTestId('related-entities-table-pods'),
).resolves.toBeInTheDocument();
});
it('keeps the locked scope out of the editable filter', async () => {
renderOverview();
// The chip leads with the relation and counts the scope behind it
const chip = await screen.findByTestId('overview-locked-scope');
expect(chip).toHaveTextContent("k8s.node.name = 'node-1'");
expect(chip).toHaveTextContent('+1');
// The editor is handed the user's own expression, never the locked scope
const lastProps = mockQuerySearchProps.mock.calls.at(-1)?.[0];
expect(lastProps.queryData.filter.expression).toBe('');
expect(lastProps.initialExpression).toBe(LOCKED_EXPRESSION);
});
it('opens the clicked resource in its own drawer', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn();
renderOverview({ onUrlUpdate });
const row = await screen.findByText('pod-1');
await user.click(row);
await waitFor(() => {
const params = onUrlUpdate.mock.calls.map((call) => call[0].searchParams);
expect(
params.some(
(searchParams) =>
searchParams.get('selectedItem') === 'pod-1' &&
searchParams.get('selectedItemCategory') === 'pods',
),
).toBe(true);
});
});
it("forgets the previous drawer's resource when another one opens", async () => {
const user = userEvent.setup();
const { openAnotherResource } = renderOverview();
await user.click(screen.getByTestId('overview-resource-select'));
await user.click(
await screen.findByTestId('overview-resource-option-containers'),
);
await expect(
screen.findByTestId('related-entities-table-containers'),
).resolves.toBeInTheDocument();
openAnotherResource({ ...NODE_ATTRIBUTES, 'k8s.node.name': 'node-2' });
// Back to pods, this node's default, rather than the containers just picked
await expect(
screen.findByTestId('related-entities-table-pods'),
).resolves.toBeInTheDocument();
});
it('says so when the resource relates to nothing', async () => {
renderOverview({ attributes: { 'os.type': 'linux' } });
await expect(
screen.findByText(/Nothing related to this Node/),
).resolves.toBeInTheDocument();
expect(mockFetchListData).not.toHaveBeenCalled();
});
it('switches the listed resource from the dropdown', async () => {
const user = userEvent.setup();
renderOverview();
await waitFor(() => {
expect(mockFetchListData).toHaveBeenCalled();
});
await user.click(screen.getByTestId('overview-resource-select'));
// Every resource related to a node is offered, the listed one included
await expect(
screen.findByTestId('overview-resource-option-pods'),
).resolves.toBeInTheDocument();
await user.click(
await screen.findByTestId('overview-resource-option-containers'),
);
await expect(
screen.findByTestId('related-entities-table-containers'),
).resolves.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,51 @@
import { useEffect, useRef } from 'react';
import { useQuerySearchOnRun } from 'components/QueryBuilderV2';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { parseAsString, useQueryState } from 'nuqs';
import { carryOverListFilters } from '../../Base/relations';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../../constants';
/**
* Seeds the editable half of the filter with whatever the list page was
* filtered by, reduced to the clauses the listed resource understands. Runs
* once per resource, so it never overwrites what the user types, and stands
* aside for a drawer opened from a link that already carries a filter.
*/
export function useCarriedOverFilters(
targetCategory: InfraMonitoringEntity,
): void {
const { currentQuery } = useQueryBuilder();
const listExpression =
currentQuery.builder.queryData[0]?.filter?.expression || '';
const querySearchOnRun = useQuerySearchOnRun();
const [urlExpression] = useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.OVERVIEW_EXPRESSION,
parseAsString,
);
const seededFor = useRef<string | null>(null);
const hadUrlExpressionOnMount = useRef<boolean | null>(null);
if (hadUrlExpressionOnMount.current === null) {
hadUrlExpressionOnMount.current = !!urlExpression;
}
useEffect(() => {
if (seededFor.current === targetCategory) {
return;
}
const isFirstResource = seededFor.current === null;
seededFor.current = targetCategory;
if (isFirstResource && hadUrlExpressionOnMount.current) {
return;
}
querySearchOnRun(carryOverListFilters(listExpression, targetCategory));
}, [targetCategory, listExpression, querySearchOnRun]);
}

View File

@@ -0,0 +1,113 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import APIError from 'types/api/error';
import {
K8sEntityData,
K8sEntityListConfig,
} from '../../Base/entity.config.types';
import { SelectedItemParams } from '../../hooks';
type FetchRelatedEntities = K8sEntityListConfig<
K8sEntityData,
string | SelectedItemParams
>['fetchListData'];
interface UseRelatedEntitiesParams {
/** Absent while the target category is still unresolved; the query stays idle */
fetchListData?: FetchRelatedEntities;
queryKey: string;
expression: string;
/** Time range in seconds — see useEntityDetailsTime */
timeRange: { startTime: number; endTime: number };
}
interface UseRelatedEntitiesResult {
records: K8sEntityData[];
total: number;
page: number;
limit: number;
setPage: (page: number) => void;
setLimit: (limit: number) => void;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error?: APIError | null;
endTimeBeforeRetention?: boolean;
refetch: () => void;
cancel: () => void;
}
export const RELATED_ENTITIES_PAGE_SIZE = 10;
const SECOND_IN_MS = 1000;
export function useRelatedEntities({
fetchListData,
queryKey,
expression,
timeRange,
}: UseRelatedEntitiesParams): UseRelatedEntitiesResult {
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(RELATED_ENTITIES_PAGE_SIZE);
useEffect(() => {
setPage(1);
}, [expression, queryKey]);
const reactQueryKey = useMemo(
() => [
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
page,
limit,
],
[queryKey, timeRange.startTime, timeRange.endTime, expression, page, limit],
);
const { data, isLoading, isFetching, isError, refetch } = useQuery({
queryKey: reactQueryKey,
queryFn: ({ signal }) =>
(fetchListData as FetchRelatedEntities)(
{
filter: { expression },
offset: (page - 1) * limit,
limit,
// The list APIs take milliseconds, useEntityDetailsTime gives seconds
start: timeRange.startTime * SECOND_IN_MS,
end: timeRange.endTime * SECOND_IN_MS,
},
signal,
),
enabled: !!fetchListData && !!expression.trim(),
});
const cancel = useCallback((): void => {
void queryClient.cancelQueries({ queryKey: reactQueryKey });
}, [queryClient, reactQueryKey]);
const handleRefetch = useCallback((): void => {
void refetch();
}, [refetch]);
return {
records: data?.records ?? data?.data ?? [],
total: data?.total ?? 0,
page,
limit,
setPage,
setLimit,
isLoading,
isFetching,
isError,
error: data?.error,
endTimeBeforeRetention: data?.endTimeBeforeRetention,
refetch: handleRefetch,
cancel,
};
}

View File

@@ -2,7 +2,7 @@ import { Container } from '@signozhq/icons';
import { InfraMonitoringEvents } from 'constants/events';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { CustomTab } from '../Base/K8sBaseDetails';
import { CustomTab } from '../Base/types';
import {
InfraMonitoringEntity,
podUtilizationByPodWidgetInfo,

View File

@@ -26,7 +26,10 @@
overflow-y: auto;
padding: var(--spacing-8);
margin: 0;
min-width: 900px;
// On a 13" screen the drawer itself is narrower than 900px, and a flat
// minimum there clips the right edge of every tab
box-sizing: border-box;
min-width: min(900px, 100%);
}
}
@@ -34,6 +37,29 @@
--button-padding: var(--spacing-2);
}
.backButton {
--button-padding: var(--spacing-2) var(--spacing-3);
flex: none;
white-space: nowrap;
}
// The kind reads as a label on the name, not as part of it
.entityKind {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
flex: none;
padding: var(--spacing-1) var(--spacing-3);
border-radius: 2px;
background: var(--l3-background);
color: var(--l2-foreground);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.title {
font-family: var(--periscope-font-family-mono);

View File

@@ -1,4 +1,7 @@
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import type {
InfraMonitoringEntity,
VIEWS,
} from 'container/InfraMonitoringK8sV2/constants';
import logEvent from 'api/common/logEvent';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isCustomTimeRange } from 'store/globalTime';
@@ -15,7 +18,7 @@ export function logInfraDrawerTimeRangeCustomizedEvent(
export function logInfraDrawerFilterCustomizedEvent(
entityType: InfraMonitoringEntity,
tab: 'metrics' | 'logs' | 'traces' | 'events' | 'pod_metrics',
tab: `${VIEWS}`,
expression: string,
filterSource: 'search' | 'logs',
): void {

View File

@@ -12,23 +12,11 @@ import {
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
ArrowUpDown,
ArrowUpToLine,
Bolt,
Box,
Boxes,
Computer,
Container,
FilePenLine,
Filter,
Group,
HardDrive,
Workflow,
} from '@signozhq/icons';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { DataSource } from 'types/common/queryBuilder';
import { CategoryIcon } from './Base/categoryIcons';
import { K8sDynamicList } from './Base/K8sDynamicList';
import {
GetClustersQuickFiltersConfig,
@@ -42,6 +30,7 @@ import {
GetStatefulsetsQuickFiltersConfig,
GetVolumesQuickFiltersConfig,
InfraMonitoringEntity,
K8S_CATEGORY_LABELS,
K8sCategories,
METRIC_NAMESPACE_BY_ENTITY,
} from './constants';
@@ -156,62 +145,62 @@ export default function InfraMonitoringK8s(): JSX.Element {
() => [
{
key: K8sCategories.CONTAINERS,
label: 'Containers',
icon: <Box size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.CONTAINERS],
icon: <CategoryIcon category={InfraMonitoringEntity.CONTAINERS} />,
config: GetContainersQuickFiltersConfig(),
},
{
key: K8sCategories.PODS,
label: 'Pods',
icon: <Container size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.PODS],
icon: <CategoryIcon category={InfraMonitoringEntity.PODS} />,
config: GetPodsQuickFiltersConfig(),
},
{
key: K8sCategories.NODES,
label: 'Nodes',
icon: <Workflow size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.NODES],
icon: <CategoryIcon category={InfraMonitoringEntity.NODES} />,
config: GetNodesQuickFiltersConfig(),
},
{
key: K8sCategories.NAMESPACES,
label: 'Namespaces',
icon: <FilePenLine size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.NAMESPACES],
icon: <CategoryIcon category={InfraMonitoringEntity.NAMESPACES} />,
config: GetNamespaceQuickFiltersConfig(),
},
{
key: K8sCategories.CLUSTERS,
label: 'Clusters',
icon: <Boxes size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.CLUSTERS],
icon: <CategoryIcon category={InfraMonitoringEntity.CLUSTERS} />,
config: GetClustersQuickFiltersConfig(),
},
{
key: K8sCategories.DEPLOYMENTS,
label: 'Deployments',
icon: <Computer size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.DEPLOYMENTS],
icon: <CategoryIcon category={InfraMonitoringEntity.DEPLOYMENTS} />,
config: GetDeploymentsQuickFiltersConfig(),
},
{
key: K8sCategories.JOBS,
label: 'Jobs',
icon: <Bolt size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.JOBS],
icon: <CategoryIcon category={InfraMonitoringEntity.JOBS} />,
config: GetJobsQuickFiltersConfig(),
},
{
key: K8sCategories.DAEMONSETS,
label: 'DaemonSets',
icon: <Group size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.DAEMONSETS],
icon: <CategoryIcon category={InfraMonitoringEntity.DAEMONSETS} />,
config: GetDaemonsetsQuickFiltersConfig(),
},
{
key: K8sCategories.STATEFULSETS,
label: 'StatefulSets',
icon: <ArrowUpDown size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.STATEFULSETS],
icon: <CategoryIcon category={InfraMonitoringEntity.STATEFULSETS} />,
config: GetStatefulsetsQuickFiltersConfig(),
},
{
key: K8sCategories.VOLUMES,
label: 'Volumes',
icon: <HardDrive size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.VOLUMES],
icon: <CategoryIcon category={InfraMonitoringEntity.VOLUMES} />,
config: GetVolumesQuickFiltersConfig(),
},
],

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -132,6 +132,7 @@ export const jobEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.JOBS,
eventCategory: InfraMonitoringEvents.Job,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'job',
getSelectedItemExpression: k8sJobGetSelectedItemExpression,
fetchEntityData,

View File

@@ -6,10 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import {
K8sDetailsCountConfig,
K8sDetailsMetadataConfig,
} from '../Base/K8sBaseDetails';
import { K8sDetailsCountConfig, K8sDetailsMetadataConfig } from '../Base/types';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -143,6 +143,7 @@ export const namespaceEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.NAMESPACES,
eventCategory: InfraMonitoringEvents.Namespace,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'namespace',
getSelectedItemExpression: k8sNamespaceGetSelectedItemExpression,
fetchEntityData,

View File

@@ -12,7 +12,7 @@ import {
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';

View File

@@ -129,6 +129,7 @@ export const nodeEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.NODES,
eventCategory: InfraMonitoringEvents.Node,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'node',
getSelectedItemExpression: k8sNodeGetSelectedItemExpression,
fetchEntityData,

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import {
buildEventsExpression,

View File

@@ -121,6 +121,7 @@ export const podEntityConfig: K8sEntityConfig<InframonitoringtypesPodRecordDTO>
details: {
category: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'pod',
getSelectedItemExpression: k8sPodGetSelectedItemExpression,
fetchEntityData,

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -132,6 +132,7 @@ export const statefulSetEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.STATEFULSETS,
eventCategory: InfraMonitoringEvents.StatefulSet,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'statefulSet',
getSelectedItemExpression: k8sStatefulSetGetSelectedItemExpression,
fetchEntityData,

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {

View File

@@ -127,6 +127,7 @@ export const volumeEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.VOLUMES,
eventCategory: InfraMonitoringEvents.Volume,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'volume',
getSelectedItemExpression: k8sVolumeGetSelectedItemExpression,
fetchEntityData,

View File

@@ -157,9 +157,11 @@ export enum VIEWS {
PROCESSES = 'processes',
EVENTS = 'events',
POD_METRICS = 'pod_metrics',
OVERVIEW = 'overview',
}
export const VIEW_TYPES = {
OVERVIEW: VIEWS.OVERVIEW,
METRICS: VIEWS.METRICS,
LOGS: VIEWS.LOGS,
TRACES: VIEWS.TRACES,
@@ -896,6 +898,41 @@ export const getInvalidValueTooltipText = (
attribute: string,
): string => `Some ${entity} do not have ${attribute}s.`;
export const K8S_CATEGORY_LABELS: Record<InfraMonitoringEntity, string> = {
[InfraMonitoringEntity.HOSTS]: 'Hosts',
[InfraMonitoringEntity.PODS]: 'Pods',
[InfraMonitoringEntity.NODES]: 'Nodes',
[InfraMonitoringEntity.NAMESPACES]: 'Namespaces',
[InfraMonitoringEntity.CLUSTERS]: 'Clusters',
[InfraMonitoringEntity.DEPLOYMENTS]: 'Deployments',
[InfraMonitoringEntity.STATEFULSETS]: 'StatefulSets',
[InfraMonitoringEntity.DAEMONSETS]: 'DaemonSets',
[InfraMonitoringEntity.CONTAINERS]: 'Containers',
[InfraMonitoringEntity.JOBS]: 'Jobs',
[InfraMonitoringEntity.VOLUMES]: 'Volumes',
};
/**
* Used where one record is named, like the drawer's back control. Cased the
* same way as the plural labels, so StatefulSet keeps its capital.
*/
export const K8S_CATEGORY_SINGULAR_LABELS: Record<
InfraMonitoringEntity,
string
> = {
[InfraMonitoringEntity.HOSTS]: 'Host',
[InfraMonitoringEntity.PODS]: 'Pod',
[InfraMonitoringEntity.NODES]: 'Node',
[InfraMonitoringEntity.NAMESPACES]: 'Namespace',
[InfraMonitoringEntity.CLUSTERS]: 'Cluster',
[InfraMonitoringEntity.DEPLOYMENTS]: 'Deployment',
[InfraMonitoringEntity.STATEFULSETS]: 'StatefulSet',
[InfraMonitoringEntity.DAEMONSETS]: 'DaemonSet',
[InfraMonitoringEntity.CONTAINERS]: 'Container',
[InfraMonitoringEntity.JOBS]: 'Job',
[InfraMonitoringEntity.VOLUMES]: 'Volume',
};
export const ENTITY_FILTER_PLACEHOLDERS: Record<InfraMonitoringEntity, string> =
{
[InfraMonitoringEntity.HOSTS]:
@@ -939,9 +976,11 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
PAGE_SIZE: 'pageSize',
EXPANDED: 'expanded',
SELECTED_ITEM: 'selectedItem',
SELECTED_ITEM_CATEGORY: 'selectedItemCategory',
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
SELECTED_ITEM_CONTAINER_NAME: 'selectedItemContainerName',
OVERVIEW_EXPRESSION: 'overviewExpression',
DETAIL_RELATIVE_TIME: 'detailRelativeTime',
DETAIL_START_TIME: 'detailStartTime',
DETAIL_END_TIME: 'detailEndTime',

View File

@@ -18,7 +18,6 @@ import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import {
DEFAULT_K8S_CATEGORY,
INFRA_MONITORING_K8S_PARAMS_KEYS,
VIEWS,
} from './constants';
import { orderBySchema, OrderBySchemaType } from './schemas';
@@ -85,10 +84,13 @@ export const useInfraMonitoringGroupBy = (): UseQueryStateReturn<
parseAsGroupBy.withDefault([]).withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringView = (): UseQueryStateReturn<string, string> =>
export const useInfraMonitoringView = (): UseQueryStateReturn<
string | null,
undefined
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW,
parseAsString.withDefault(VIEWS.METRICS).withOptions(defaultNuqsOptions),
parseAsString.withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringLogFilters = (): UseQueryStateReturn<
@@ -137,6 +139,8 @@ export const useInfraMonitoringCategory = (): UseQueryStateReturn<
export interface SelectedItemParams {
selectedItem: string | null;
/** Drawer category, when it differs from the list page category. */
category?: string | null;
clusterName?: string | null;
namespaceName?: string | null;
containerName?: string | null;
@@ -144,6 +148,7 @@ export interface SelectedItemParams {
const selectedItemParamsParsers = {
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: parseAsString,
@@ -165,6 +170,8 @@ export const useInfraMonitoringSelectedItemParams =
() => ({
selectedItem:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM] ?? null,
category:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY] ?? null,
clusterName:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME] ??
null,
@@ -183,6 +190,7 @@ export const useInfraMonitoringSelectedItemParams =
if (newParams === null) {
void setRawParams({
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: null,
@@ -193,6 +201,8 @@ export const useInfraMonitoringSelectedItemParams =
void setRawParams({
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]:
newParams.selectedItem ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY]:
newParams.category ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]:
newParams.clusterName ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]:

View File

@@ -5,22 +5,9 @@
}
.banner {
display: flex;
align-items: flex-start;
gap: var(--spacing-5);
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 30%, transparent);
background: color-mix(in srgb, var(--bg-amber-400) 8%, transparent);
color: var(--bg-amber-300, var(--bg-amber-400));
margin-top: var(--spacing-4);
}
.bannerIcon {
flex-shrink: 0;
margin-top: var(--spacing-1);
}
.error {
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Callout } from '@signozhq/ui/callout';
import { Typography } from '@signozhq/ui/typography';
import { TriangleAlert } from '@signozhq/icons';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
@@ -80,13 +80,10 @@ function UnpricedModelsTab(): JSX.Element {
return (
<div className={styles.unpricedModelsTab}>
<div className={styles.banner}>
<TriangleAlert size="sm" className={styles.bannerIcon} />
<Typography.Text as="span" size="small" color="warning">
Models detected in traces without pricing. Map each to a billing model or
create pricing so estimated cost can be computed.
</Typography.Text>
</div>
<Callout type="warning" size="small" showIcon className={styles.banner}>
Models detected in traces without pricing. Map each to a billing model or
create pricing so estimated cost can be computed.
</Callout>
{isError && (
<div className={styles.error}>

View File

@@ -10,6 +10,8 @@ import WarningPopover from 'components/WarningPopover/WarningPopover';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
@@ -347,57 +349,62 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="metrics-explorer-explore-container">
<div className="explore-header">
<div className="explore-header-left-actions">
<span>1 chart/query</span>
<Tooltip
open={disableOneChartPerQuery ? undefined : false}
title={oneChartPerQueryDisabledTooltip}
>
<Switch
value={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
<QuickFiltersLayout
showFilters
savedViewProps={{ source: SavedviewtypesSourceDTO.metrics }}
>
<div className="metrics-explorer-explore-container">
<div className="explore-header">
<div className="explore-header-left-actions">
<span>1 chart/query</span>
<Tooltip
open={disableOneChartPerQuery ? undefined : false}
title={oneChartPerQueryDisabledTooltip}
>
<Switch
value={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
/>
</Tooltip>
</div>
<div className="explore-header-right-actions">
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
<DateTimeSelector showAutoRefresh />
<RightToolbarActions
onStageRunQuery={(): void => handleRunQuery()}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
/>
</Tooltip>
</div>
</div>
<div className="explore-header-right-actions">
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
<DateTimeSelector showAutoRefresh />
<RightToolbarActions
onStageRunQuery={(): void => handleRunQuery()}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
<QueryBuilderV2
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
panelType={PANEL_TYPES.TIME_SERIES}
showFunctions={false}
version="v3"
/>
<div className="explore-content">
<TimeSeries
onFetchingStateChange={setIsLoadingQueries}
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
handleOpenMetricDetails={handleOpenMetricDetails}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
isCancelled={isCancelled}
exportDefaultQuery={exportDefaultQuery}
/>
</div>
</div>
<QueryBuilderV2
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
panelType={PANEL_TYPES.TIME_SERIES}
showFunctions={false}
version="v3"
/>
<div className="explore-content">
<TimeSeries
onFetchingStateChange={setIsLoadingQueries}
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
handleOpenMetricDetails={handleOpenMetricDetails}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
isCancelled={isCancelled}
exportDefaultQuery={exportDefaultQuery}
/>
</div>
</div>
</QuickFiltersLayout>
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}

View File

@@ -0,0 +1,79 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { render, screen, userEvent } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import { DataSourceDropdown } from '../DataSourceDropdown';
const TEST_ID = 'query-data-source-selector';
async function openDropdown(): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(TEST_ID);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
}
describe('DataSourceDropdown', () => {
// antd's virtual list renders only the first couple of options into jsdom, so
// each case asserts what the restriction admits and excludes, not the full list.
it('offers the signals beyond the current one when nothing restricts it', async () => {
render(
<DataSourceDropdown
data-testid={TEST_ID}
value={DataSource.METRICS}
onChange={jest.fn()}
/>,
);
await openDropdown();
await expect(
screen.findByRole('option', { name: 'Logs' }),
).resolves.toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Metrics' })).toBeInTheDocument();
});
it('offers only the signals the caller can visualize', async () => {
render(
<DataSourceDropdown
data-testid={TEST_ID}
value={DataSource.METRICS}
allowedDataSources={[TelemetrytypesSignalDTO.metrics]}
onChange={jest.fn()}
/>,
);
await openDropdown();
await expect(
screen.findByRole('option', { name: 'Metrics' }),
).resolves.toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Logs' }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Traces' }),
).not.toBeInTheDocument();
});
it('drops a signal that is not a data source a query can be built against', async () => {
render(
<DataSourceDropdown
data-testid={TEST_ID}
value={DataSource.LOGS}
allowedDataSources={[
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
TelemetrytypesSignalDTO[''],
]}
onChange={jest.fn()}
/>,
);
await openDropdown();
await expect(
screen.findByRole('option', { name: 'Logs' }),
).resolves.toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Traces' })).toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Metrics' }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,7 +1,9 @@
import { ChangeEvent, useCallback, useMemo, useState } from 'react';
import { Col, Input, Row, Select } from 'antd';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import BucketOptions from 'components/QueryBuilderV2/QueryV2/QueryAddOns/BucketOptions/BucketOptions';
import { LEGEND } from 'constants/global';
import { PANEL_TYPES } from 'constants/queryBuilder';
// ** Hooks
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
@@ -24,8 +26,12 @@ export function Formula({
query,
isQBV2,
}: FormulaProps): JSX.Element {
const { removeQueryBuilderEntityByIndex, handleSetFormulaData } =
useQueryBuilder();
const {
removeQueryBuilderEntityByIndex,
handleSetFormulaData,
panelType,
currentQuery,
} = useQueryBuilder();
const { handleChangeFormulaData } = useQueryOperations({
index,
@@ -73,6 +79,13 @@ export function Formula({
[handleChangeFormulaData],
);
const handleChangeBucketOptions = useCallback(
(value: IBuilderFormula['bucketOptions']) => {
handleChangeFormulaData('bucketOptions', value);
},
[handleChangeFormulaData],
);
const handleQBV2OrderByChange = useCallback(
(value: string) => {
const [columnName, order] = value.split(' ');
@@ -163,6 +176,17 @@ export function Formula({
</div>
</Col>
)}
{/* A heatmap draws its one enabled query, which is the formula when its
inputs are disabled — so the formula states its own bucket axis. */}
{isQBV2 && panelType === PANEL_TYPES.HEATMAP && (
<Col span={24}>
<BucketOptions
bucketOptions={formula.bucketOptions}
unit={currentQuery.unit}
onChange={handleChangeBucketOptions}
/>
</Col>
)}
</Row>
)}
</Row>

View File

@@ -0,0 +1,91 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { render, screen, userEvent } from 'tests/test-utils';
import type { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
import { Formula } from '../Formula';
const mockHandleChangeFormulaData = jest.fn();
let mockPanelType: PANEL_TYPES = PANEL_TYPES.HEATMAP;
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): Record<string, unknown> => ({
removeQueryBuilderEntityByIndex: jest.fn(),
handleSetFormulaData: jest.fn(),
panelType: mockPanelType,
currentQuery: { unit: undefined },
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
useQueryOperations: (): Record<string, unknown> => ({
handleChangeFormulaData: mockHandleChangeFormulaData,
}),
}));
jest.mock('../../QBEntityOptions/QBEntityOptions', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="qb-entity-options" />,
}));
function formula(overrides: Partial<IBuilderFormula> = {}): IBuilderFormula {
return {
queryName: 'F1',
expression: 'A',
legend: '',
disabled: false,
...overrides,
};
}
function renderFormula(overrides: Partial<IBuilderFormula> = {}): void {
render(
<Formula index={0} formula={formula(overrides)} query={{} as never} isQBV2 />,
);
}
describe('Formula bucket options', () => {
beforeEach(() => {
jest.clearAllMocks();
mockPanelType = PANEL_TYPES.HEATMAP;
});
it('offers a bucket axis on a heatmap', () => {
renderFormula();
expect(screen.getByTestId('bucket-options')).toBeInTheDocument();
});
it('is not dismissable — a formula has no add-on toggle bar to collapse into', () => {
renderFormula();
expect(screen.queryByTestId('bucket-options-close')).not.toBeInTheDocument();
});
it('offers no bucket axis on other panel types', () => {
mockPanelType = PANEL_TYPES.TIME_SERIES;
renderFormula();
expect(screen.queryByTestId('bucket-options')).not.toBeInTheDocument();
});
it('opens on the axis the formula already carries', () => {
renderFormula({
bucketOptions: { kind: 'log', spec: { scale: 0 } } as never,
});
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
expect(screen.getByRole('radio', { name: '0' })).toBeChecked();
});
it('writes the picked axis onto the formula', async () => {
const user = userEvent.setup();
renderFormula();
await user.click(screen.getByRole('radio', { name: '0' }));
expect(mockHandleChangeFormulaData).toHaveBeenCalledWith('bucketOptions', {
kind: 'log',
spec: { scale: 0 },
});
});
});

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