Compare commits

...

13 Commits

Author SHA1 Message Date
Naman Verma
fe45b9dcae Merge branch 'main' into nv/exec-stats 2026-11-06 00:51:46 +05:30
Naman Verma
63ee60a40c fix(querier): stop cache refreshes from inflating exec stats 2026-11-06 00:51:03 +05:30
Gaurav Tewari
9a541c851f feat:ai o11y saved views (#13297)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--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 saved views to the AI observability explorer.
- New `ai_observability` preference slice. Columns are stored per panel
in localStorage (`AI_OBSERVABILITY_OPTIONS`); the slice holds only the
open panel's `{ columns, panelType }`. Only the Traces tab keeps columns
today.
- The Traces tab reads columns through `useOptionsMenu`, like the logs
and traces lists. Defaults are a fixed list
(`TRACE_VIEW_DEFAULT_COLUMNS`) matching the backend's trace aggregates,
so the table no longer waits for the keys API.
- The URL carries the open panel's columns in `options.selectColumns`;
logs and traces ignore it on the AI explorer route.
- Fixes saved views opening on the wrong tab: the explorer now reads the
tab from the URL For AI View

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### 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



https://github.com/user-attachments/assets/daab1db3-b8df-435a-8252-d6bb35a86e99


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

- Review commit by commit: preferences → saved views → trace view →
explorer wiring → tab fix → discard and loading fixes. Each commit
builds.
- Columns picked on the Traces tab before this PR were kept in the
table's column store; they are ignored now, so those users see the
defaults once. Column widths are kept.
- The AI Explorer story is not updated in this PR.
- SB Shot -
https://claude.ai/artifact/R77CFZKS71pSxNLV4ZhpxY?sk=dM6jybUP456zVTcW9xrz8Q
- Check to have a summary of comment raised and closed with greptile
https://github.com/SigNoz/signoz/pull/13297#issuecomment-6508458226

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-05 16:53:57 +00:00
Nikhil Mantri
e0802b8274 feat(authz): rule state history FGA (#13252)
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

- Move `/api/v2/rules/{id}/history/*` routes from role gates to
`CheckResources`.
- `stats` and `overall_status` check `rule:read` on the id.
- `timeline` and `top_contributors` check `rule:read` on the id plus
read on the telemetry resources the rule's query touches, so a broad
telemetry grant alone cannot read another rule's firing history.
- `filter_keys` and `filter_values` check `rule:read` on the id plus
list and read on the field resources of the signals the rule queries.
The field kinds come from the same query walker `query_range` uses,
mapped telemetry kind to field kind.
- Integration tests cover each gate with and without the grant. No
migration, rule tuples come from #13251.

#### Issues closed by this PR

Part of SigNoz/pulse-pod#286
Part of SigNoz/pulse-pod#355

#### Additional Information

Fourth of five stacked PRs splitting #12927. Stacked on #13251.
2026-11-05 15:42:36 +00:00
Srikanth Chekuri
add47f6516 fix(telemetrystore): return 400 for five more ClickHouse SQL mistakes (#13444)
- Five ClickHouse errors that user SQL causes returned 500. They now
return 400 with a suggestion, as #13328 does for 170, 288, and 407: 386
(no common type), 215 (column neither in GROUP BY nor in an aggregate),
184 (aggregate where none is allowed, mostly an alias that shadows a
column in WHERE), 190 (arrays of different sizes), 427 (a regular
expression RE2 cannot compile).
- Code 48 stays a 500. A correlated subquery over a distributed table
raises it, but the code covers every unimplemented feature, so a mapping
could hide a fault in generated SQL.
- 215 and 386 can also come from a builder bug; that is the trade-off
#13328 made.
- `test_clickhouse_user_mistake_is_bad_request` raises each code with a
real query, plus a builder `REGEXP` filter.
2026-11-05 15:17:03 +00:00
Gaurav Tewari
2d3c4fd7d2 fix(ai-assistant): disable open actions for the page already open (#13370)
#### Description
- Clicking a Noz "Open …" action (e.g. "Open alert") while already on
that resource re-navigated to the same page; the page re-initialised and
the Noz chat jumped up.
- Open-resource chips are now disabled, with a "You're already viewing
this page" tooltip, when their target is the current page. Elsewhere
they navigate as before.
- The alert Overview tab counts as the alert's page, since it renders
the same editor as `/alerts/edit`; the History tab keeps the button
enabled.
- Saved-view chips are intentionally never disabled: reopening one
reapplies the saved query, resetting unsaved explorer edits.

#### Issues closed by this PR


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

#### Screenshots / Screen Recordings

Before - 


https://github.com/user-attachments/assets/56983c3a-cc75-4290-88b6-cdfc893f5984


Now - 


https://github.com/user-attachments/assets/63507ea7-f2e5-44ae-b722-771188cb17e3



#### Additional Information
<img width="648" height="329" alt="image"
src="https://github.com/user-attachments/assets/656855d9-64c5-47ea-ab49-2ea917c247d4"
/>

in Main 3 tests fails , i have added 2 extra test cases for the base
folow. lmk if we should remove them.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-05 13:17:39 +00:00
Abhi kumar
8f86b59822 feat(dashboard): group small pie slices into an automatic Other slice (#13428)
#### Description

Groups small pie slices into one automatic **Other** slice, so a pie
with many groups stays readable.

- A slice under 1% of the total joins Other, and the pie draws at most
15 slices; Other needs at least 2 members and is always the last arc.
- Automatic, with no panel setting.
- Legend lists every series; hiding or showing one regroups Other from
the series left showing.
- Hovering Other lists its members in the timeseries-style tooltip;
clicking pins it, and clicking a member drills into that series through
the existing slice drilldown.
- Storybook: `Pages/Dashboards/Detail/Panels/PieOther`.



https://github.com/user-attachments/assets/143626a2-7e8a-48ac-a9cd-742f0fcd18ec



#### Issues closed by this PR

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

#### Additional Information

- V2 dashboards only; V1 pie is unchanged.
2026-11-05 11:34:01 +00:00
ashu8912
e53d16157b test(e2e): scroll drivers with the mouse wheel (#13433)
#### Description

- Drivers now scroll with the mouse wheel through a new `wheelTo` helper
(`drivers/scroll.ts`) instead of `scrollIntoView` /
`scrollIntoViewIfNeeded`. Scripted scrolling moves even an `overflow:
hidden` box a user can't scroll, so the channel form clipping passed e2e
while users couldn't reach the fields.
- `wheelTo` wheels over the target's visible scroll container until the
target is centred and not covered by a sticky header, then asserts
`toBeInViewport()`. If a turn doesn't move the target, it fails with "a
container clips it instead of scrolling".
- oxlint now rejects `scrollIntoView` and `scrollIntoViewIfNeeded` in
`tests/e2e`, and `AGENTS.md` adds the rule.
- The panel handle's `scrollIntoView()` is renamed to `wheelIntoView()`.
- The channel form (`fillField`) and the alert rule form (threshold
channels, labels, advanced options, evaluation settings) wheel to lower
controls before acting, so a clipped form fails the existing specs, not
only the dedicated regression spec.

#### Additional Information

- Other `click()` / `fill()` calls still use Playwright's scripted
scroll; the wheel is applied where content can sit below the fold.
- Verified on Chromium only.


Closes: https://github.com/SigNoz/platform-pod/issues/3338
2026-11-05 11:10:34 +00:00
Ashwin Bhatkal
83ebe26877 ci(e2e): run the suite on 10 shards (#13395)
#### Description

- The e2e suite now runs on 10 shards instead of 4. Since
[#13189](https://github.com/SigNoz/signoz/pull/13189) the suite is about
twice as long, so each of the 4 shards spends 16 to 20 min in tests and
a PR waits 23 to 27 min for e2e.
- Each shard also pays about 7 min of fixed setup, so 10 shards should
bring the slowest one to about 16 to 17 min. Past 10, every 2 extra
shards save only about 1.5 min.
2026-11-05 11:04:21 +00:00
Aditya Singh
33d2ff917f feat(explorer): make the traces and logs explorer toolbars and tables match (#13411)
#### Description

- The traces and logs explorers now look the same above and around the
table. Both toolbars use the same row height, spacing and controls.
- The traces Options control is now an icon button, the same as the logs
one.
- The logs Options menu now uses the design system buttons and number
input. Its rows no longer fill on hover. The logs Options button no
longer turns blue on hover.
- Max lines per row now clamps a typed value to 1–10. Before, it ignored
1 and accepted 2.5.
- The logs list now reaches the bottom of the page.
- The logs list and table now use Inter, the same as traces. Only the
raw view keeps the mono font.
- The traces and logs table rows are now 30px tall, the row height in
the design.

#### Issues closed by this PR

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

#### Screenshots

https://claude.ai/artifact/6zsvLZFYYzfFrPy9r6Xq9F?sk=ymS3KLyoZjWMf2axjY1-ng

#### Additional Information

- Stacked on #13392.
- The traces styles moved to CSS modules. The LLM Observability explorer
renders the same global classes, so global rules from one page could
restyle the other.
2026-11-05 10:22:09 +00:00
Naman Verma
29a393e47e Merge branch 'main' into nv/exec-stats 2026-11-05 10:51:11 +05:30
Naman Verma
c58f76f998 Merge branch 'main' into nv/exec-stats 2026-11-03 11:34:10 +05:30
Naman Verma
e39ad9e221 test: check how exec stats fail 2026-11-02 22:24:22 +05:30
124 changed files with 3950 additions and 1360 deletions

View File

@@ -67,7 +67,7 @@ jobs:
matrix:
project:
- chromium
shard: [1, 2, 3, 4]
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
needs: changes
if: needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
@@ -162,7 +162,7 @@ jobs:
- name: playwright-test
run: |
cd tests/e2e && \
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/10
- name: teardown-stack
if: always()
run: |

View File

@@ -28287,6 +28287,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28295,9 +28301,15 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs-field:list
- traces-field:list
- metrics-field:list
- tokenizer:
- VIEWER
- rule:read
- logs-field:list
- traces-field:list
- metrics-field:list
summary: Get rule history filter keys
tags:
- rules
@@ -28465,6 +28477,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28473,9 +28491,15 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs-field:read
- traces-field:read
- metrics-field:read
- tokenizer:
- VIEWER
- rule:read
- logs-field:read
- traces-field:read
- metrics-field:read
summary: Get rule history filter values
tags:
- rules
@@ -28548,9 +28572,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- tokenizer:
- VIEWER
- rule:read
summary: Get rule overall status timeline
tags:
- rules
@@ -28620,9 +28644,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- tokenizer:
- VIEWER
- rule:read
summary: Get rule history stats
tags:
- rules
@@ -28704,6 +28728,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28712,9 +28742,19 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Get rule history timeline
tags:
- rules
@@ -28779,6 +28819,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28787,9 +28833,19 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Get top contributors to rule firing
tags:
- rules

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ export enum LOCALSTORAGE {
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
AI_OBSERVABILITY_PANEL_COLUMNS = 'AI_OBSERVABILITY_PANEL_COLUMNS',
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',

View File

@@ -48,18 +48,19 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { getOpenResourceTarget } from './utils/getOpenResourceTarget';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { isOnResourcePage } from './utils/isOnResourcePage';
import { openSavedViewByKey } from './utils/openSavedView';
import {
isSavedViewOpenAction,
resolveOpenResourceType,
resolveResourceId,
resolveSavedViewSourceHint,
} from './utils/resolveOpenResource';
import { ResourceType, resourceRoute } from './utils/resourceRoute';
import { ResourceType } from './utils/resourceRoute';
import styles from './ActionsSection.module.scss';
@@ -397,7 +398,7 @@ export default function ActionsSection({
actions,
messageId,
}: ActionsSectionProps): JSX.Element | null {
const { pathname } = useAppLocation();
const { pathname, search } = useAppLocation();
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
@@ -493,23 +494,17 @@ export default function ActionsSection({
return;
}
const resourceType = resolveOpenResourceType(action);
const resourceId = resolveResourceId(action);
if (!resourceType || !resourceId) {
return;
}
const path = resourceRoute(resourceType, resourceId);
if (!path) {
const target = getOpenResourceTarget(action);
if (!target) {
return;
}
void logEvent(AIAssistantEvents.ResourceOpened, {
threadId,
messageId,
targetModule: targetModuleForResource(resourceType),
resourceId,
targetModule: targetModuleForResource(target.resourceType),
resourceId: target.resourceId,
});
navigate(path);
navigate(target.path);
};
const handleClick = (key: string, action: MessageActionDTO): void => {
@@ -607,6 +602,10 @@ export default function ActionsSection({
// is still surfaced visually via the suffix pill below.
const tooltip = isError ? result.error : (action.tooltip ?? undefined);
const openTarget = getOpenResourceTarget(action);
const isViewingResource =
openTarget !== null &&
isOnResourcePage(openTarget.path, { pathname, search });
let icon: JSX.Element;
if (isSuccess) {
@@ -619,12 +618,16 @@ export default function ActionsSection({
const chip = (
<Button
disabledTooltip="This action already ran"
disabledTooltip={
isViewingResource
? "You're already viewing this page"
: 'This action already ran'
}
variant="outlined"
color="secondary"
size="sm"
onClick={(): void => handleClick(key, action)}
disabled={isSuccess}
disabled={isSuccess || isViewingResource}
loading={isLoading}
aria-label={action.label}
testId={`ai-action-chip-${action.kind}`}

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from 'react-query';
import { useAppSearchParams } from 'lib/router/useAppSearchParams';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
@@ -85,8 +86,9 @@ function Explorer(): JSX.Element {
setIsLoadingQueries(false);
}, [queryClient]);
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
const selectedView = useMemo(
() => getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
[searchParams, panelTypesFromUrl],
);
const [warning, setWarning] = useState<Warning | undefined>();
@@ -115,8 +117,6 @@ function Explorer(): JSX.Element {
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
setSelectedView(view);
handleExplorerTabChange(
explorerViewToPanelType[view],
querySearchParameters,
@@ -190,6 +190,7 @@ function Explorer(): JSX.Element {
testId="llm-observability-explorer"
showFilters={isOpen}
onToggleFilters={(): void => setOpen(!isOpen)}
savedViewProps={{ source: SavedviewtypesSourceDTO.ai_observability }}
quickFilterProps={{
className: 'qf-traces-explorer',
quickFilterSource: QuickFiltersSource.AI_OBSERVABILITY,

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { Dispatch, SetStateAction, useCallback } from 'react';
import { useAppNavigate } from 'lib/router/useAppNavigate';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import type { ColumnSizingState } from '@tanstack/react-table';
import TanStackTable from 'components/TanStackTableView';
import type {
CellTypographySize,
@@ -31,6 +32,8 @@ export type TracesTableProps = {
isFilterApplied: boolean;
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
onColumnRemove?: (columnId: string) => void;
columnSizing?: ColumnSizingState;
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
cellTypographySize?: CellTypographySize;
};
@@ -48,6 +51,8 @@ function TracesTable({
isFilterApplied,
onColumnOrderChange,
onColumnRemove,
columnSizing,
onColumnSizingChange,
cellTypographySize = 'medium',
}: TracesTableProps): JSX.Element {
const navigate = useAppNavigate();
@@ -98,6 +103,8 @@ function TracesTable({
cellTypographySize={cellTypographySize}
onColumnOrderChange={onColumnOrderChange}
onColumnRemove={onColumnRemove}
columnSizing={columnSizing}
onColumnSizingChange={onColumnSizingChange}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
getRowTestId={(row): string => `traces-table-row-${row.id}`}
@@ -113,6 +120,8 @@ TracesTable.defaultProps = {
respectColumnOrder: false,
onColumnOrderChange: undefined,
onColumnRemove: undefined,
columnSizing: undefined,
onColumnSizingChange: undefined,
cellTypographySize: 'medium',
};

View File

@@ -11,13 +11,16 @@ const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
// An aggregate outside the default order starts hidden, so the persisted
// defaults are observable.
// An aggregate that starts hidden, so the persisted defaults are observable.
const COLUMNS = buildTraceViewColumns([
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'unlisted_aggregate' },
]);
]).map((column) =>
column.id === 'unlisted_aggregate'
? { ...column, defaultVisibility: false }
: column,
);
function RaceHarness(): JSX.Element {
const [columnsReady, setColumnsReady] = useState(false);

View File

@@ -12,11 +12,19 @@ import { QueryKey } from 'react-query';
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { ColumnSizingState } from '@tanstack/react-table';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import {
setColumnSizing,
useColumnSizing,
} from 'components/TanStackTableView/useColumnStore';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { getTraceLink } from '../ListView/utils';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
@@ -40,8 +48,11 @@ import {
TRACE_VIEW_ORDER_BY_EXTRA_FIELDS,
} from '../constants';
import { getListViewQuery } from '../explorerUtils';
import { PER_PAGE_OPTIONS } from './configs';
import { useTraceViewColumns } from './useTraceViewColumns';
import {
buildTraceViewColumns,
PER_PAGE_OPTIONS,
REQUIRED_FIELDS,
} from './configs';
import styles from './TracesView.module.scss';
interface TracesViewProps {
@@ -65,14 +76,37 @@ function TracesView({
[stagedQuery],
);
const {
columns,
selectedFields,
onFieldsChange,
requiredFields,
isLoading: isColumnsLoading,
canPersistColumns,
} = useTraceViewColumns();
const { options, config } = useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.ai_observability,
});
const tableColumns = useMemo(
() => buildTraceViewColumns(options.selectColumns),
[options.selectColumns],
);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(cols.map((column) => column.id));
},
[config],
);
// The column store keeps widths only; the picked columns live in the slice.
const columnSizing = useColumnSizing(
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS,
);
const handleColumnSizingChange = useCallback<
Dispatch<SetStateAction<ColumnSizingState>>
>(
(updater) => {
setColumnSizing(
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS,
typeof updater === 'function' ? updater(columnSizing) : updater,
);
},
[columnSizing],
);
const {
selectedTime: globalSelectedTime,
@@ -174,24 +208,6 @@ function TracesView({
[currentQuery, redirectWithQueryBuilderData],
);
// Without the full column set there is no pool to pick from, so the control is dropped.
const fieldsSelectorConfig = useMemo(
() =>
canPersistColumns
? { fieldsSelector: { value: selectedFields, onFieldsChange } }
: null,
[canPersistColumns, selectedFields, onFieldsChange],
);
// Rendering the pool unfiltered would surface columns the defaults keep hidden.
const tableColumns = useMemo(
() =>
canPersistColumns
? columns
: columns.filter((column) => column.defaultVisibility !== false),
[canPersistColumns, columns],
);
return (
<div className={styles.container}>
<div className={styles.actionsContainer}>
@@ -215,11 +231,11 @@ function TracesView({
isLoading={isLoading}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
config={fieldsSelectorConfig}
config={config}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_COLUMN_EXTRA_FIELDS}
requiredFields={requiredFields}
requiredFields={REQUIRED_FIELDS}
/>
</div>
</div>
@@ -227,15 +243,13 @@ function TracesView({
<TracesTable
data={rows}
columns={tableColumns}
columnStorageKey={
canPersistColumns
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
: undefined
}
respectColumnOrder
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
columnSizing={columnSizing}
onColumnSizingChange={handleColumnSizingChange}
panelType="TRACE"
getRowHref={getTraceLink}
isLoading={isLoading || isColumnsLoading}
isLoading={isLoading}
isFetching={isFetching}
isError={isError}
error={error}

View File

@@ -0,0 +1,182 @@
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent } from 'tests/test-utils';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { TRACE_VIEW_DEFAULT_COLUMNS } from '../../constants';
import type { TracesTableProps } from '../../TracesTable/TracesTable';
import TracesView from '../TracesView';
jest.mock('../../TracesTable/TracesTable', () => ({
__esModule: true,
default: ({
columns,
onColumnOrderChange,
onColumnRemove,
onColumnSizingChange,
}: TracesTableProps): JSX.Element => (
<div>
<div data-testid="table-columns">
{columns.map((column) => String(column.header)).join(',')}
</div>
<button
data-testid="reverse-columns"
type="button"
onClick={(): void => onColumnOrderChange?.([...columns].reverse())}
>
reverse
</button>
<button
data-testid="remove-total-tokens"
type="button"
onClick={(): void => onColumnRemove?.('trace:total_tokens:float64')}
>
remove
</button>
<button
data-testid="resize-trace-id"
type="button"
onClick={(): void => onColumnSizingChange?.({ trace_id: 240 })}
>
resize
</button>
</div>
),
}));
const PANEL_COLUMNS_KEY = LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS;
const SIZING_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
const TOTAL_TOKENS = {
name: 'total_tokens',
fieldContext: 'trace',
fieldDataType: 'float64',
};
const storedColumnNames = (): string[] | undefined => {
const raw = localStorage.getItem(PANEL_COLUMNS_KEY);
return raw
? (JSON.parse(raw)[PANEL_TYPES.TRACE] as { name: string }[]).map(
(field) => field.name,
)
: undefined;
};
const shownColumns = (): string[] =>
(screen.getByTestId('table-columns').textContent ?? '').split(',');
const renderTracesView = (): ReturnType<typeof render> =>
render(
<TracesView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
/>,
{},
{
initialRoute: '/llm-observability/traces',
queryBuilderOverrides: {
panelType: PANEL_TYPES.TRACE,
stagedQuery: initialQueryAIWithType,
currentQuery: initialQueryAIWithType,
} as never,
},
);
describe('TracesView columns', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: { type: 'trace', data: {} } })),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('shows the default columns while nothing is picked, storing nothing', () => {
renderTracesView();
expect(shownColumns()).toStrictEqual(
TRACE_VIEW_DEFAULT_COLUMNS.map((field) => field.name),
);
expect(localStorage.getItem(PANEL_COLUMNS_KEY)).toBeNull();
});
it('shows trace_id first when a stored list lacks it', () => {
localStorage.setItem(
PANEL_COLUMNS_KEY,
JSON.stringify({ [PANEL_TYPES.TRACE]: [TOTAL_TOKENS] }),
);
renderTracesView();
expect(shownColumns()).toStrictEqual(['trace_id', 'total_tokens']);
});
it('stores the order a column drag leaves behind', async () => {
const user = userEvent.setup();
localStorage.setItem(
PANEL_COLUMNS_KEY,
JSON.stringify({
[PANEL_TYPES.TRACE]: [{ name: 'trace_id' }, TOTAL_TOKENS],
}),
);
renderTracesView();
await user.click(screen.getByTestId('reverse-columns'));
expect(storedColumnNames()).toStrictEqual(['total_tokens', 'trace_id']);
expect(shownColumns()).toStrictEqual(['total_tokens', 'trace_id']);
});
it('keeps a dragged trace_id in place when the stored list lacks it', async () => {
const user = userEvent.setup();
localStorage.setItem(
PANEL_COLUMNS_KEY,
JSON.stringify({ [PANEL_TYPES.TRACE]: [TOTAL_TOKENS] }),
);
renderTracesView();
await user.click(screen.getByTestId('reverse-columns'));
expect(storedColumnNames()).toStrictEqual(['total_tokens', 'trace_id']);
expect(shownColumns()).toStrictEqual(['total_tokens', 'trace_id']);
});
it('drops a column removed from the table', async () => {
const user = userEvent.setup();
localStorage.setItem(
PANEL_COLUMNS_KEY,
JSON.stringify({
[PANEL_TYPES.TRACE]: [{ name: 'trace_id' }, TOTAL_TOKENS],
}),
);
renderTracesView();
await user.click(screen.getByTestId('remove-total-tokens'));
expect(storedColumnNames()).toStrictEqual(['trace_id']);
expect(shownColumns()).toStrictEqual(['trace_id']);
});
it('keeps column widths in the column store', async () => {
const user = userEvent.setup();
renderTracesView();
await user.click(screen.getByTestId('resize-trace-id'));
expect(
useColumnStore.getState().tables[SIZING_KEY].columnSizing,
).toStrictEqual({ trace_id: 240 });
expect(localStorage.getItem(PANEL_COLUMNS_KEY)).toBeNull();
});
});

View File

@@ -12,8 +12,7 @@ import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import TracesView from '../TracesView';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const PANEL_COLUMNS_KEY = LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS;
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
const FIELD_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`;
@@ -84,9 +83,9 @@ const mockFieldKeysFailure = (): void => {
);
};
const persistedState = (): { hiddenColumnIds: string[] } | null => {
const raw = localStorage.getItem(PERSISTED_KEY);
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
const storedOptions = (): Record<string, { name: string }[]> | null => {
const raw = localStorage.getItem(PANEL_COLUMNS_KEY);
return raw ? (JSON.parse(raw) as Record<string, { name: string }[]>) : null;
};
const renderTracesView = (): ReturnType<typeof render> =>
@@ -121,16 +120,16 @@ describe('TracesView column persistence', () => {
// Rows are virtualised, so a mounted table stands in for "rows arrived".
const findTable = (): Promise<HTMLElement> => screen.findByRole('table');
it('seeds the persisted defaults once the field keys arrive', async () => {
it('shows the default columns once the field keys arrive, storing nothing', async () => {
mockFieldKeys(['llm_call_count', 'tool_call_count']);
renderTracesView();
await findTable();
await waitFor(() => {
expect(persistedState()?.hiddenColumnIds).toStrictEqual([]);
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
});
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
expect(storedOptions()).toBeNull();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
@@ -140,20 +139,19 @@ describe('TracesView column persistence', () => {
await findTable();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(persistedState()).toBeNull();
expect(storedOptions()).toBeNull();
});
it('drops the column picker when the field keys fail', async () => {
it('keeps the column picker when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
});
it('renders the display-only columns when the field keys fail', async () => {
it('renders the default columns when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
@@ -162,22 +160,27 @@ describe('TracesView column persistence', () => {
expect(screen.getByText('root_span_name')).toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.getByText('input')).toBeInTheDocument();
expect(screen.queryByText('llm_call_count')).not.toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
it('leaves an existing selection untouched while the field keys fail', async () => {
it('shows a stored selection, in its order, while the field keys fail', async () => {
const existing = {
hiddenColumnIds: ['trace:tool_call_count:float64', 'input', 'output'],
columnOrder: ['trace_id', 'resource:service.name'],
columnSizing: {},
[PANEL_TYPES.TRACE]: [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'trace_id' },
],
};
localStorage.setItem(PERSISTED_KEY, JSON.stringify(existing));
localStorage.setItem(PANEL_COLUMNS_KEY, JSON.stringify(existing));
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(persistedState()).toStrictEqual(existing);
const headers = screen
.getAllByRole('columnheader')
.map((header) => header.textContent);
expect(headers).toStrictEqual(['service.name', 'trace_id']);
expect(storedOptions()).toStrictEqual(existing);
});
});

View File

@@ -1,302 +0,0 @@
/* eslint-disable no-restricted-syntax */
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { useTraceViewColumns } from '../useTraceViewColumns';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const AGGREGATE_KEYS = [
'llm_call_count',
'tool_call_count',
'distinct_tool_count',
'input_tokens',
'output_tokens',
'total_tokens',
'estimated_total_cost',
'max_llm_duration_nano',
];
const fieldNames = (fields: TelemetryFieldKey[]): string[] =>
fields.map((field) => field.name);
const columnNames = (columns: { header?: unknown }[]): string[] =>
columns.map((column) => column.header as string);
function wrapper({ children }: { children: ReactNode }): JSX.Element {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
const seenAI: URLSearchParams[] = [];
const mockAggregateKeys = (names: string[]): void => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(req, res, ctx) => {
seenAI.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
names.map((name) => [
name,
[
{
name,
fieldContext: TelemetrytypesFieldContextDTO.trace,
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
},
],
]),
),
},
}),
);
},
),
);
};
const renderColumns = async (): Promise<
ReturnType<typeof renderHook<ReturnType<typeof useTraceViewColumns>, unknown>>
> => {
const rendered = renderHook(() => useTraceViewColumns(), { wrapper });
await waitFor(() => {
expect(rendered.result.current.isLoading).toBe(false);
});
return rendered;
};
describe('useTraceViewColumns', () => {
beforeEach(() => {
seenAI.length = 0;
useColumnStore.getState().tables = {};
localStorage.clear();
mockAggregateKeys(AGGREGATE_KEYS);
});
it('reads the aggregates from the trace context of the keys endpoint', async () => {
await renderColumns();
expect(seenAI).toHaveLength(1);
expect(seenAI[0]?.get('searchText')).toBe('');
expect(seenAI[0]?.get('fieldContext')).toBe(
TelemetrytypesFieldContextDTO.trace,
);
});
it('pools the hardcoded display-only columns with the endpoint aggregates', async () => {
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
]);
});
it('selects only the default-visible columns on first render', async () => {
const { result } = await renderColumns();
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
]);
});
it('keeps a newly reported aggregate hidden until it is picked', async () => {
mockAggregateKeys(['brand_new_aggregate']);
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toContain('brand_new_aggregate');
expect(fieldNames(result.current.selectedFields)).not.toContain(
'brand_new_aggregate',
);
});
describe('when the keys fetch fails', () => {
beforeEach(() => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
});
it('does not persist defaults', async () => {
await renderColumns();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(
localStorage.getItem(`@signoz/table-columns/${STORAGE_KEY}`),
).toBeNull();
});
it('reports the column state as not persistable', async () => {
const { result } = await renderColumns();
expect(result.current.canPersistColumns).toBe(false);
});
it('ignores a selection change instead of persisting a partial set', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }]);
});
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
});
it('seeds the defaults once a later fetch succeeds', async () => {
const { unmount } = await renderColumns();
unmount();
mockAggregateKeys(AGGREGATE_KEYS);
const { result } = await renderColumns();
expect(result.current.canPersistColumns).toBe(true);
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
]);
});
});
it('hides the columns dropped from the selection', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([
{ name: 'trace_id' },
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'total_tokens',
]);
});
it('shows a column added back from the pool', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }]);
});
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }, { name: 'input' }]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'input',
]);
});
it('keeps the trace id column even when the selection drops it', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'span_count' }]);
});
expect(fieldNames(result.current.selectedFields)).toContain('trace_id');
expect(result.current.requiredFields).toStrictEqual(['trace_id']);
});
it('persists the selection order', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'total_tokens',
'trace_id',
'service.name',
]);
expect(
useColumnStore.getState().tables[STORAGE_KEY].columnOrder,
).toStrictEqual([
'trace:total_tokens:float64',
'trace_id',
'resource:service.name',
]);
});
});

View File

@@ -3,52 +3,17 @@ import type { TableColumnDef } from 'components/TanStackTableView/types';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
import { TRACE_ID_COLUMN_ID } from '../constants';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
/** Always present: it is the row's link to the trace, but it can be reordered. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
/** Fallback order, until the user drags a column; unlisted fields keep the order the keys endpoint returns them in. */
const DEFAULT_COLUMN_ORDER = [
TRACE_ID_COLUMN_ID,
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
];
const orderRank = (field: TelemetryFieldKey): number => {
const index = DEFAULT_COLUMN_ORDER.indexOf(field.name);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
};
export const sortByDefaultOrder = (
fields: TelemetryFieldKey[],
): TelemetryFieldKey[] =>
[...fields].sort((a, b) => orderRank(a) - orderRank(b));
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied at first init, since the store persists hidden ids. */
const DEFAULT_VISIBLE_FIELDS = new Set(DEFAULT_COLUMN_ORDER);
export const REQUIRED_FIELDS: readonly string[] = [TRACE_ID_COLUMN_ID];
export const buildTraceViewColumns = (
fields: TelemetryFieldKey[],
): TableColumnDef<TracesTableRow>[] =>
fields.map((field) => ({
...getFieldColumn(field),
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
enableMove: true,
enableRemove: field.name !== TRACE_ID_COLUMN_ID,

View File

@@ -1,127 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { mergeExtraFields } from 'utils/extraFields';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
hideColumn,
initializeFromDefaults,
setColumnOrder,
showColumn,
useColumnOrder,
useHiddenColumnIds,
} from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import {
TRACE_VIEW_BUILDER_QUERY_TYPE,
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
TRACE_VIEW_FIELD_KEYS,
} from '../constants';
import {
buildTraceViewColumns,
sortByDefaultOrder,
TRACE_ID_COLUMN_ID,
} from './configs';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
const columnIdOf = (field: TelemetryFieldKey): string =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
interface UseTraceViewColumns {
columns: TableColumnDef<TracesTableRow>[];
selectedFields: TelemetryFieldKey[];
onFieldsChange: (next: TelemetryFieldKey[]) => void;
requiredFields: readonly string[];
isLoading: boolean;
/** False until the keys fetch lands; a partial set must not reach the persisted store. */
canPersistColumns: boolean;
}
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
export function useTraceViewColumns(): UseTraceViewColumns {
const {
data: fetchedFields = [],
isFetched,
isSuccess,
} = useFieldKeysSuggestion(
{
...TRACE_VIEW_FIELD_KEYS,
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: '',
},
TRACE_VIEW_BUILDER_QUERY_TYPE,
);
const availableFields = useMemo(
() =>
sortByDefaultOrder(
mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
),
[fetchedFields],
);
const columns = useMemo(
() => buildTraceViewColumns(availableFields),
[availableFields],
);
// Defaults from a partial column set would persist as the user's own choice.
useEffect(() => {
if (isSuccess) {
initializeFromDefaults(STORAGE_KEY, columns);
}
}, [isSuccess, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
const selectedFields = useMemo(() => {
const hidden = new Set(hiddenColumnIds);
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
return availableFields
.filter((field) => !hidden.has(columnIdOf(field)))
.sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
(orderIndex.get(columnIdOf(b)) ?? Infinity),
);
}, [availableFields, hiddenColumnIds, columnOrder]);
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => {
if (!isSuccess) {
return;
}
const keptIds = new Set(next.map(columnIdOf));
columns.forEach((column) => {
if (keptIds.has(column.id) || column.id === TRACE_ID_COLUMN_ID) {
showColumn(STORAGE_KEY, column.id);
} else {
hideColumn(STORAGE_KEY, column.id);
}
});
// Columns missing from the order sort last, so the visible ones suffice.
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
},
[columns, isSuccess],
);
return {
columns,
selectedFields,
onFieldsChange,
requiredFields: [TRACE_ID_COLUMN_ID],
isLoading: !isFetched,
canPersistColumns: isSuccess,
};
}

View File

@@ -0,0 +1,46 @@
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { renderWithExplorerProviders } from 'container/SavedViews/__tests__/savedViewsTestUtils';
import { act, screen } from 'tests/test-utils';
import Explorer from '../Explorer';
jest.mock('../ListView/ListView', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="ai-list-view" />,
}));
jest.mock('../TableView/TableView', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="ai-table-view" />,
}));
const explorerUrlWithPanel = (panelType: PANEL_TYPES): string =>
`${ROUTES.AI_OBSERVABILITY_EXPLORER}?${QueryParams.panelTypes}=${encodeURIComponent(
JSON.stringify(panelType),
)}`;
describe('AI observability Explorer', () => {
it('shows the view of the panel type in the url', () => {
renderWithExplorerProviders(
<Explorer />,
explorerUrlWithPanel(PANEL_TYPES.LIST),
);
expect(screen.getByTestId('ai-list-view')).toBeInTheDocument();
});
it('follows the url when the panel type changes, as opening a saved view does', () => {
const { history } = renderWithExplorerProviders(
<Explorer />,
explorerUrlWithPanel(PANEL_TYPES.LIST),
);
act(() => {
history.push(explorerUrlWithPanel(PANEL_TYPES.TABLE));
});
expect(screen.getByTestId('ai-table-view')).toBeInTheDocument();
expect(screen.queryByTestId('ai-list-view')).not.toBeInTheDocument();
});
});

View File

@@ -4,6 +4,9 @@ import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
/** Always present: it is the row's link to the trace, but it can be reordered. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
export const TOOLBAR_VIEWS = {
trace: {
name: 'trace',
@@ -57,6 +60,81 @@ export const TRACE_VIEW_COLUMN_EXTRA_FIELDS: TelemetryFieldKey[] = [
{ name: 'output' },
] as TelemetryFieldKey[];
/**
* Shown, in this order, until the user picks columns. The aggregates are the
* keys the keys endpoint returns for builder_ai_query; the rest match
* TRACE_VIEW_COLUMN_EXTRA_FIELDS, so both build the same column ids.
*/
export const TRACE_VIEW_DEFAULT_COLUMNS: TelemetryFieldKey[] = [
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'root_span_name' },
{
name: 'estimated_total_cost',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{ name: 'trace_duration_nano' },
{ name: 'span_count' },
{
name: 'total_tokens',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{
name: 'input_tokens',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{
name: 'output_tokens',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{
name: 'distinct_tool_count',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{
name: 'llm_call_count',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{
name: 'tool_call_count',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{ name: 'start_time' },
{ name: 'end_time' },
{ name: 'error_count' },
{ name: 'input' },
{ name: 'output' },
{
name: 'max_llm_duration_nano',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
];
export const ensureAiRequiredColumns = (
columns: TelemetryFieldKey[],
panelType: PANEL_TYPES,
): TelemetryFieldKey[] =>
panelType !== PANEL_TYPES.TRACE ||
columns.some((column) => column.name === TRACE_ID_COLUMN_ID)
? columns
: [{ name: TRACE_ID_COLUMN_ID }, ...columns];
export const TRACE_VIEW_FIELD_KEYS = {
fieldContext: TelemetrytypesFieldContextDTO.trace,
} as const;

View File

@@ -0,0 +1,3 @@
.logsTable {
--tanstack-table-row-height: 30px;
}

View File

@@ -2,7 +2,6 @@
flex: 1;
display: flex;
flex-direction: column;
font-family: 'Space Mono', monospace;
font-size: 14px;
font-weight: 400;
line-height: 18px;
@@ -19,7 +18,6 @@
thead {
tr {
th {
font-family: 'Space Mono', monospace;
font-size: 12px;
color: white !important;
@@ -37,7 +35,6 @@
}
td {
font-family: 'Space Mono', monospace;
font-size: 12px;
}
@@ -57,7 +54,6 @@
tbody {
tr {
td {
font-family: 'Space Mono', monospace;
font-size: 12px;
padding: 4px !important;

View File

@@ -48,6 +48,7 @@ import {
isTraceToLogsQuery,
} from './utils';
import styles from './LogsExplorerList.module.scss';
import './LogsExplorerList.style.scss';
function Footer(): JSX.Element {
@@ -202,6 +203,7 @@ function LogsExplorerList({
return (
<TanStackTable<ILog>
ref={ref as React.Ref<TanStackTableHandle>}
className={styles.logsTable}
columns={logsColumns}
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
respectColumnOrder={false}

View File

@@ -1,5 +1,4 @@
.logs-explorer-views-container {
margin-bottom: 24px;
flex: 1;
min-height: 0;
display: flex;

View File

@@ -87,6 +87,7 @@ describe('useOptionsMenu', () => {
mockUsePreferenceContext.mockReturnValue({
[SavedviewtypesSourceDTO.traces]: slice([], rawFormatting),
[SavedviewtypesSourceDTO.logs]: slice([], rawFormatting),
[SavedviewtypesSourceDTO.ai_observability]: slice([], {}),
} satisfies PreferenceContextValue);
mockUseUrlQueryData.mockReturnValue({
@@ -236,6 +237,7 @@ describe('useOptionsMenu', () => {
mockUsePreferenceContext.mockReturnValue({
[SavedviewtypesSourceDTO.traces]: slice([], baseFormatting),
[SavedviewtypesSourceDTO.logs]: slice(seedColumns, baseFormatting),
[SavedviewtypesSourceDTO.ai_observability]: slice([], {}),
} satisfies PreferenceContextValue);
});
@@ -357,6 +359,7 @@ describe('useOptionsMenu', () => {
},
),
[SavedviewtypesSourceDTO.logs]: slice([], {}),
[SavedviewtypesSourceDTO.ai_observability]: slice([], {}),
} satisfies PreferenceContextValue);
const { result } = renderHook(

View File

@@ -21,6 +21,7 @@ export const SOURCE_PAGE_SIGNAL: Record<
> = {
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
[SavedviewtypesSourceDTO.ai_observability]: DataSource.TRACES,
};
export const EXCLUDED_COLUMNS: Record<DataSource, string[]> = {

View File

@@ -8,7 +8,10 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { isPreferenceSourcePage } from 'providers/preferences/utils';
import {
hasPreferences,
isPreferenceSourcePage,
} from 'providers/preferences/utils';
import { toAPIError } from 'utils/errorUtils';
import { UseActiveSavedViewResult } from '../types';
@@ -20,9 +23,12 @@ export function useActiveSavedView(
const { viewKey } = useGetSavedViewParams();
const { stagedQuery, panelType } = useQueryBuilder();
const hasOptions = isPreferenceSourcePage(source);
const viewPanelType = panelType ?? PANEL_TYPES.LIST;
const hasOptions = hasPreferences(source, viewPanelType);
const { options } = useOptionsMenu({
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
sourcePage: isPreferenceSourcePage(source)
? source
: SavedviewtypesSourceDTO.logs,
});
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
@@ -61,16 +67,18 @@ export function useActiveSavedView(
return hasUnsavedViewChanges({
view,
stagedQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
panelType: viewPanelType,
options: hasOptions ? options : undefined,
sourcePage: source,
});
}, [
view,
isError,
isStagedQueryCurrent,
stagedQuery,
panelType,
viewPanelType,
hasOptions,
source,
options,
]);

View File

@@ -21,7 +21,10 @@ import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useAppLocation } from 'lib/router/useAppLocation';
import { usePreferenceSlice } from 'providers/preferences/context/usePreferenceSlice';
import { isPreferenceSourcePage } from 'providers/preferences/utils';
import {
hasPreferences,
isPreferenceSourcePage,
} from 'providers/preferences/utils';
import { toAPIError } from 'utils/errorUtils';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
@@ -59,9 +62,12 @@ export function useSavedViewActions(
const { viewKey } = useGetSavedViewParams();
const logSavedViewEvent = useSavedViewLogEvent(source);
const hasOptions = isPreferenceSourcePage(source);
const viewPanelType = panelType ?? PANEL_TYPES.LIST;
const hasOptions = hasPreferences(source, viewPanelType);
const { options } = useOptionsMenu({
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
sourcePage: isPreferenceSourcePage(source)
? source
: SavedviewtypesSourceDTO.logs,
});
const preferenceSlice = usePreferenceSlice(source);
@@ -79,12 +85,12 @@ export function useSavedViewActions(
(displayName: string, color?: string): SavedviewtypesSavedViewSpecDTO =>
toSavedViewSpec({
query: stagedQuery ?? currentQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
panelType: viewPanelType,
displayName,
options: hasOptions ? options : undefined,
color,
}),
[stagedQuery, currentQuery, panelType, hasOptions, options],
[stagedQuery, currentQuery, viewPanelType, hasOptions, options],
);
const selectView = useCallback(
@@ -101,11 +107,11 @@ export function useSavedViewActions(
const revertView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
if (preferenceSlice) {
const { columns, formatting } = getViewColumnsAndFormatting(
const { columns, formatting, panelType } = getViewColumnsAndFormatting(
view.spec,
source,
);
preferenceSlice.updateColumns(columns);
preferenceSlice.updateColumns(columns, panelType);
if (formatting) {
preferenceSlice.updateFormatting(formatting);
}

View File

@@ -32,6 +32,7 @@ export interface HasUnsavedViewChangesArgs {
stagedQuery: Query | null;
panelType: PANEL_TYPES;
options?: SavedViewOptions;
sourcePage: SavedviewtypesSourceDTO;
}
export interface UseLastUsedViewResult {

View File

@@ -1,6 +1,7 @@
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSavedViewSpecDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
initialQueriesMap,
@@ -120,6 +121,7 @@ describe('hasUnsavedViewChanges', () => {
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.traces,
options: { ...options, selectColumns: [columns[0]] },
}),
).toBe(true);
@@ -139,6 +141,7 @@ describe('hasUnsavedViewChanges', () => {
view: bare,
stagedQuery: stagedFrom(bare, DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.traces,
options: { ...options, selectColumns: defaultTraceSelectedColumns },
}),
).toBe(false);
@@ -163,6 +166,7 @@ describe('hasUnsavedViewChanges', () => {
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
options: { selectColumns: shownColumns, ...formatting },
}),
).toBe(false);
@@ -174,6 +178,7 @@ describe('hasUnsavedViewChanges', () => {
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
options: { selectColumns: shownColumns, ...formatting, maxLines: 5 },
}),
).toBe(true);
@@ -193,6 +198,7 @@ describe('hasUnsavedViewChanges', () => {
view: bare,
stagedQuery: stagedFrom(bare, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
options: {
selectColumns: defaultLogsSelectedColumns,
format: 'table',
@@ -236,6 +242,7 @@ describe('hasUnsavedViewChanges', () => {
view: olderView,
stagedQuery: stagedFrom(olderView, DataSource.METRICS),
panelType: PANEL_TYPES.TIME_SERIES,
sourcePage: SavedviewtypesSourceDTO.metrics,
}),
).toBe(false);
});
@@ -283,6 +290,7 @@ describe('hasUnsavedViewChanges', () => {
view: storedView,
stagedQuery: stagedWithTag,
panelType: PANEL_TYPES.TIME_SERIES,
sourcePage: SavedviewtypesSourceDTO.metrics,
}),
).toBe(false);
});
@@ -322,6 +330,7 @@ describe('hasUnsavedViewChanges', () => {
view: noOrder,
stagedQuery: stagedFrom(noOrder, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
}),
).toBe(false);
});
@@ -337,6 +346,7 @@ describe('hasUnsavedViewChanges', () => {
view: noOrder,
stagedQuery: sorted,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
}),
).toBe(true);
@@ -346,6 +356,7 @@ describe('hasUnsavedViewChanges', () => {
view: saved,
stagedQuery: stagedFrom(saved, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
}),
).toBe(false);
});
@@ -362,6 +373,7 @@ describe('hasUnsavedViewChanges', () => {
view: legacy,
stagedQuery: stagedFrom(legacy, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
}),
).toBe(false);
});
@@ -373,6 +385,7 @@ describe('hasUnsavedViewChanges', () => {
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.traces,
options: { ...options, ...formatting, maxLines: 5 },
}),
).toBe(false);

View File

@@ -3,12 +3,18 @@ import {
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import {
DEFAULT_PANEL_TYPE,
ensureAiRequiredColumns,
} from 'container/LLMObservability/Explorer/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import getAiObservabilityLoaderConfig from 'providers/preferences/configs/aiObservabilityLoaderConfig';
import { Preferences } from 'providers/preferences/types';
function withColumnNames(
@@ -47,5 +53,18 @@ export function getViewColumnsAndFormatting(
if (sourcePage === SavedviewtypesSourceDTO.traces) {
return { columns: selectedFields || defaultTraceSelectedColumns };
}
if (sourcePage === SavedviewtypesSourceDTO.ai_observability) {
const panelType =
(spec?.panelType as unknown as PANEL_TYPES | undefined) ??
DEFAULT_PANEL_TYPE;
return {
columns: ensureAiRequiredColumns(
selectedFields ||
getAiObservabilityLoaderConfig(panelType).default().columns,
panelType,
),
panelType,
};
}
return { columns: [] };
}

View File

@@ -1,13 +1,14 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import {
SavedviewtypesSourceDTO,
TelemetrytypesFieldContextDTO,
} from 'api/generated/services/sigNoz.schemas';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import isEqual from 'lodash-es/isEqual';
import { DataSource } from 'types/common/queryBuilder';
import { HasUnsavedViewChangesArgs } from '../types';
import { getSavedViewQuery } from './getSavedViewQuery';
import { toSavedViewPanelType } from './toSavedViewSpec';
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
import { toSavedViewSource } from './toSavedViewSource';
// Legacy context names the server stores under their v5 name.
const FIELD_CONTEXT_ALIASES: Record<string, TelemetrytypesFieldContextDTO> = {
@@ -51,6 +52,7 @@ export function hasUnsavedViewChanges({
stagedQuery,
panelType,
options,
sourcePage,
}: HasUnsavedViewChangesArgs): boolean {
if (!stagedQuery) {
return false;
@@ -77,10 +79,9 @@ export function hasUnsavedViewChanges({
return false;
}
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
toSavedViewSource(dataSource),
sourcePage,
);
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
fields.map((field) => field.name ?? field.key);
@@ -88,7 +89,7 @@ export function hasUnsavedViewChanges({
return true;
}
if (dataSource !== DataSource.LOGS || !formatting) {
if (sourcePage !== SavedviewtypesSourceDTO.logs || !formatting) {
return false;
}
return (

View File

@@ -3,8 +3,8 @@ import { DataSource } from 'types/common/queryBuilder';
import { SavedViewSourcePage } from '../types';
// The views API files each view under a source page, but callers only know the
// signal they query: a builder query's dataSource, or the AI assistant's hint.
// The AI assistant names a view's source by DataSource (the signal), the api
// keys views by source page.
// TODO: remove in a follow-up once callers pass the source page directly.
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
{

View File

@@ -1,14 +0,0 @@
.container {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.3rem;
margin: 8px 0;
}
.optionsTrigger {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}

View File

@@ -1,12 +1,12 @@
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Settings } from '@signozhq/icons';
import { SlidersVertical } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import FieldsSelector from 'components/FieldsSelector';
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
import { DataSource } from 'types/common/queryBuilder';
import styles from './Controls.module.scss';
function TraceExplorerControls({
config,
}: TraceExplorerControlsProps): JSX.Element | null {
@@ -17,15 +17,23 @@ function TraceExplorerControls({
return null;
}
const label = t('options_menu.options');
return (
<div className={styles.container}>
<div
className={styles.optionsTrigger}
onClick={(): void => setIsFieldsSelectorOpen(true)}
>
{t('options_menu.options')}
<Settings size="md" />
</div>
<>
<Tooltip title={label}>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={label}
testId="periscope-btn-options-traces"
onClick={(): void => setIsFieldsSelectorOpen(true)}
>
<SlidersVertical size={14} />
</Button>
</Tooltip>
<FieldsSelector
isOpen={isFieldsSelectorOpen}
title="Edit columns"
@@ -34,7 +42,7 @@ function TraceExplorerControls({
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.TRACES}
/>
</div>
</>
);
}

View File

@@ -7,6 +7,32 @@
min-height: 400px;
}
.controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 4px;
min-height: 40px;
padding: 4px 8px;
border-bottom: 1px solid var(--l1-border);
}
.orderBy {
display: flex;
align-items: center;
gap: 8px;
}
.orderByLabel {
display: flex;
align-items: center;
gap: 4px;
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 16px;
}
.drawerScope {
display: contents;
}

View File

@@ -1,34 +0,0 @@
.trace-explorer-controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
.order-by-container {
display: flex;
align-items: center;
gap: 8px;
.order-by-label {
color: var(--muted-foreground);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
display: flex;
align-items: center;
gap: 4px;
}
.order-by-select {
width: 100px;
.ant-select-selector {
border: none;
box-shadow: none;
background-color: transparent;
}
}
}
}

View File

@@ -1,5 +1,5 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import ListView from './index';
@@ -60,7 +60,7 @@ const verifyControlsVisibility = (): void => {
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
// Options menu (settings button) - check for translation key or actual text
expect(screen.getByText(/options_menu.options|options/i)).toBeInTheDocument();
expect(screen.getByTestId('periscope-btn-options-traces')).toBeInTheDocument();
};
describe('Traces ListView - Error and Empty States', () => {
@@ -148,8 +148,8 @@ describe('Traces ListView - Error and Empty States', () => {
expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1);
// Options menu should be clickable
const optionsButton = screen.getByText(/options_menu.options|options/i);
expect(optionsButton).toBeInTheDocument();
await userEvent.click(screen.getByTestId('periscope-btn-options-traces'));
await expect(screen.findByText('Edit columns')).resolves.toBeInTheDocument();
});
it('allows interaction with controls in empty state', async () => {
@@ -164,8 +164,8 @@ describe('Traces ListView - Error and Empty States', () => {
expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1);
// Options menu should be clickable
const optionsButton = screen.getByText(/options_menu.options|options/i);
expect(optionsButton).toBeInTheDocument();
await userEvent.click(screen.getByTestId('periscope-btn-options-traces'));
await expect(screen.findByText('Edit columns')).resolves.toBeInTheDocument();
});
});
});

View File

@@ -40,8 +40,6 @@ import { defaultSelectedColumns, TIMESTAMP_FIELD } from './configs';
import { useListViewInfiniteQuery } from './useListViewInfiniteQuery';
import { getTraceLink } from './utils';
import './ListView.styles.scss';
import styles from './ListView.module.scss';
interface ListViewProps {
@@ -176,10 +174,10 @@ function ListView({
return (
<div className={styles.container}>
<div className="trace-explorer-controls">
<div className={styles.controls}>
{headerActions}
<div className="order-by-container">
<div className="order-by-label">
<div className={styles.orderBy}>
<div className={styles.orderByLabel}>
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>

View File

@@ -6,6 +6,7 @@
}
.tracesTable {
--tanstack-table-row-height: 30px;
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l3-background-60);
}

View File

@@ -10,6 +10,16 @@
.actionsContainer {
display: flex;
min-height: 40px;
padding: 4px 8px;
border-bottom: 1px solid var(--l1-border);
justify-content: space-between;
align-items: center;
}
.controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 4px;
}

View File

@@ -158,7 +158,7 @@ function TracesView({
</Typography.Link>
</Typography>
<div className="trace-explorer-controls">
<div className={styles.controls}>
{headerActions}
<DownloadOptionsMenu
dataSource={DataSource.TRACES}

View File

@@ -28,6 +28,23 @@
}
}
.itemButton {
display: block;
width: 100%;
padding: 0;
border: none;
border-radius: var(--radius);
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
&:hover {
background: var(--l2-background-hover);
}
}
.listLightMode {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);

View File

@@ -1,4 +1,10 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import {
useCallback,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import cx from 'classnames';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -22,11 +28,14 @@ const LIST_VERTICAL_PADDING = 16;
interface TooltipListProps {
id: string;
content: TooltipContentItem[];
/** Makes each row a button. */
onItemClick?: (index: number, event: ReactMouseEvent) => void;
}
export default function TooltipList({
id,
content,
onItemClick,
}: TooltipListProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const isScrollEventTriggered = useRef(false);
@@ -62,9 +71,23 @@ export default function TooltipList({
onScroll={handleScroll}
style={{ height }}
totalListHeightChanged={setTotalListHeight}
itemContent={(_, item): JSX.Element => (
<TooltipItem item={item} isItemActive={item.isHighlighted === true} />
)}
itemContent={(index, item): JSX.Element => {
const row = (
<TooltipItem item={item} isItemActive={item.isHighlighted === true} />
);
return onItemClick ? (
<button
type="button"
className={Styles.itemButton}
data-testid="uplot-tooltip-item-button"
onClick={(event): void => onItemClick(index, event)}
>
{row}
</button>
) : (
row
);
}}
/>
</div>
);

View File

@@ -26,34 +26,7 @@
}
.pieChartTooltip {
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
background-color: var(--l2-background) !important;
border: 1px solid var(--l2-border) !important;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.pieChartTooltipContent {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.pieChartIndicator {
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: 8px;
display: inline-block;
}
.pieChartTooltipValue {
font-weight: bold;
margin-top: 4px;
}
// width/height are set inline from the computed chart dimensions; border-box

View File

@@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Group } from '@visx/group';
import { Pie as VisxPie } from '@visx/shape';
import { defaultStyles, useTooltip, useTooltipInPortal } from '@visx/tooltip';
import { useTooltipInPortal } from '@visx/tooltip';
import cx from 'classnames';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -14,14 +14,15 @@ import { PieChartProps, PieSlice } from 'lib/visualization/charts/types';
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
import PieArc from 'lib/visualization/charts/Pie/PieArc';
import PieCenterLabel from 'lib/visualization/charts/Pie/PieCenterLabel';
import PieArc from 'lib/visualization/charts/Pie/components/PieArc/PieArc';
import { groupPieSlices } from 'lib/visualization/charts/Pie/utils/groupPieSlices';
import PieCenterLabel from 'lib/visualization/charts/Pie/components/PieCenterLabel/PieCenterLabel';
import PieTooltipContent from 'lib/visualization/charts/Pie/components/PieTooltip/PieTooltipContent';
import styles from 'lib/visualization/charts/Pie/Pie.module.scss';
import { PieTooltipData } from 'lib/visualization/charts/Pie/types';
import {
getDonutGeometry,
getFillColor,
} from 'lib/visualization/charts/Pie/utils';
import { usePieTooltip } from 'lib/visualization/charts/Pie/hooks/usePieTooltip';
import { getFillColor } from 'lib/visualization/charts/Pie/utils/colors';
import { getDonutGeometry } from 'lib/visualization/charts/Pie/utils/geometry';
import { compareSlices } from 'lib/visualization/charts/Pie/utils/slices';
/**
* Donut chart rendered with @visx. Splits its area into chart + legend with the
@@ -56,20 +57,29 @@ export default function Pie({
[data, hiddenKeys],
);
// Hidden slices drop out so the remaining arcs + centre total recompute.
// Hidden slices drop out before grouping, so Other and the centre total recompute.
const visibleData = useMemo(
() => data.filter((slice) => !hiddenKeys.has(slice.label)),
[data, hiddenKeys],
() =>
groupPieSlices(
data.filter((slice) => !hiddenKeys.has(slice.label)),
isDarkMode,
),
[data, hiddenKeys, isDarkMode],
);
const {
tooltipOpen,
tooltipLeft,
tooltipTop,
tooltipData,
hideTooltip,
showTooltip,
} = useTooltip<PieTooltipData>();
// A series folded into Other highlights the Other arc.
const drawnColorByLabel = useMemo(
() =>
new Map(
visibleData.flatMap((slice) =>
(slice.groupedMembers ?? [slice]).map((entry): [string, string] => [
entry.label,
slice.color,
]),
),
),
[visibleData],
);
const { containerRef, TooltipInPortal } = useTooltipInPortal({
scroll: true,
@@ -112,34 +122,64 @@ export default function Pie({
[visibleData],
);
const labelColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400;
const activeColor =
focusedSeriesIndex !== null ? data[focusedSeriesIndex].color : null;
const handleSliceEnter = useCallback(
(slice: PieSlice, centroidX: number, centroidY: number): void => {
showTooltip({
tooltipData: {
label: slice.label,
value: getYAxisFormattedValue(
slice.value.toString(),
yAxisUnit || 'none',
decimalPrecision,
),
color: slice.color,
},
tooltipTop: centroidY + height / 2,
tooltipLeft: centroidX + width / 2,
});
setFocusedKey(slice.label);
},
[showTooltip, setFocusedKey, yAxisUnit, decimalPrecision, height, width],
const formatValue = useCallback(
(value: number): string =>
getYAxisFormattedValue(
value.toString(),
yAxisUnit || 'none',
decimalPrecision,
),
[yAxisUnit, decimalPrecision],
);
const handleSliceLeave = useCallback((): void => {
hideTooltip();
setFocusedKey(null);
}, [hideTooltip, setFocusedKey]);
const {
tooltipOpen,
tooltipTop,
tooltipLeft,
tooltipData,
isPinned,
unpin,
handleSliceHover,
handleSliceLeave,
handleSliceClick,
handleMemberClick,
} = usePieTooltip({
slices: visibleData,
chartRef: wrapperRef,
formatValue,
setFocusedKey,
onSliceClick,
});
const labelColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400;
const hoveredColor = tooltipOpen ? tooltipData?.slice.color : undefined;
const activeColor =
hoveredColor ??
(focusedSeriesIndex !== null
? (drawnColorByLabel.get(labels[focusedSeriesIndex]) ?? null)
: null);
// The tooltip re-renders Pie on every mouse move; a stable element lets React skip the legend.
const legend = useMemo(
() => (
<Legend
items={legendItems}
position={position}
averageLegendWidth={averageLegendWidth}
showSearch={showLegendSearch}
focusedSeriesIndex={focusedSeriesIndex}
onAction={onLegendAction}
/>
),
[
legendItems,
position,
averageLegendWidth,
showLegendSearch,
focusedSeriesIndex,
onLegendAction,
],
);
if (!data.length) {
return (
@@ -169,6 +209,7 @@ export default function Pie({
<VisxPie
data={visibleData}
pieValue={(slice: PieSlice): number => slice.value}
pieSort={compareSlices}
outerRadius={radius}
innerRadius={innerRadius}
padAngle={0.01}
@@ -193,9 +234,9 @@ export default function Pie({
decimalPrecision={decimalPrecision}
labelColor={labelColor}
fill={getFillColor(arc.data.color, activeColor)}
onEnter={handleSliceEnter}
onHover={handleSliceHover}
onLeave={handleSliceLeave}
onClick={onSliceClick}
onClick={handleSliceClick}
/>
))
}
@@ -216,19 +257,19 @@ export default function Pie({
top={tooltipTop}
left={tooltipLeft}
className={styles.pieChartTooltip}
style={{
...defaultStyles,
color: labelColor,
}}
unstyled
applyPositionStyle
style={{ pointerEvents: isPinned ? 'auto' : 'none' }}
data-pie-tooltip=""
>
<div
className={styles.pieChartIndicator}
style={{ background: tooltipData.color }}
<PieTooltipContent
id={id ?? ''}
data={tooltipData}
isPinned={isPinned}
canDrilldown={!!onSliceClick}
onMemberClick={handleMemberClick}
onUnpin={unpin}
/>
<div className={styles.pieChartTooltipContent}>
<span>{tooltipData.label}</span>
<span className={styles.pieChartTooltipValue}>{tooltipData.value}</span>
</div>
</TooltipInPortal>
)}
</div>
@@ -241,14 +282,7 @@ export default function Pie({
height: legendHeight,
}}
>
<Legend
items={legendItems}
position={position}
averageLegendWidth={averageLegendWidth}
showSearch={showLegendSearch}
focusedSeriesIndex={focusedSeriesIndex}
onAction={onLegendAction}
/>
{legend}
</div>
</div>
);

View File

@@ -3,8 +3,9 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { LegendItem } from 'lib/uPlotV2/config/types';
import { PieSlice } from 'lib/visualization/charts/types';
import { PieChartProps, PieSlice } from 'lib/visualization/charts/types';
import Pie from 'lib/visualization/charts/Pie/Pie';
import { userEvent } from 'tests/test-utils';
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: jest.fn().mockReturnValue({ width: 400, height: 300 }),
@@ -14,22 +15,43 @@ jest.mock('components/Graph/yAxisConfig', () => ({
getYAxisFormattedValue: jest.fn((value: string) => value),
}));
// VirtuosoGrid only renders a window in jsdom; render every item so we can
// assert on legend entries.
let mockLegendRenders = 0;
// Virtuoso only renders a window in jsdom; render every item so we can
// assert on legend entries and tooltip rows.
jest.mock('react-virtuoso', () => ({
Virtuoso: ({
data,
itemContent,
'data-testid': testId,
}: {
data: unknown[];
itemContent: (index: number, item: unknown) => React.ReactNode;
'data-testid'?: string;
}): JSX.Element => (
<div data-testid={testId}>
{data.map((item, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={index}>{itemContent(index, item)}</div>
))}
</div>
),
VirtuosoGrid: ({
data,
itemContent,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
}): JSX.Element => (
<div data-testid="virtuoso-grid">
{data.map((item, index) => (
<div key={item.seriesIndex ?? index}>{itemContent(index, item)}</div>
))}
</div>
),
}): JSX.Element => {
mockLegendRenders += 1;
return (
<div data-testid="virtuoso-grid">
{data.map((item, index) => (
<div key={item.seriesIndex ?? index}>{itemContent(index, item)}</div>
))}
</div>
);
},
}));
const DATA: PieSlice[] = [
@@ -40,10 +62,16 @@ const DATA: PieSlice[] = [
function renderPie(
props: Partial<React.ComponentProps<typeof Pie>> = {},
): void {
render(<Pie data={DATA} isDarkMode={false} data-testid="pie" {...props} />);
): ReturnType<typeof render> {
return render(
<Pie data={DATA} isDarkMode={false} data-testid="pie" {...props} />,
);
}
const mockSliceClick = (): jest.MockedFunction<
NonNullable<PieChartProps['onSliceClick']>
> => jest.fn();
describe('Pie', () => {
it('renders the "No data" state for empty data', () => {
render(<Pie data={[]} isDarkMode={false} data-testid="pie" />);
@@ -124,4 +152,224 @@ describe('Pie', () => {
// One slice hidden → one fewer arc drawn.
expect(svg.querySelectorAll('path')).toHaveLength(1);
});
it('hints at drill-down on a plain slice only when clickable', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { unmount } = renderPie({ onSliceClick: mockSliceClick() });
const arc = (): Element =>
screen.getByTestId('pie').querySelectorAll('svg g g')[0];
await user.hover(arc());
expect(screen.getByText('Click to drilldown')).toBeInTheDocument();
unmount();
renderPie();
await user.hover(arc());
expect(screen.queryByTestId('pie-tooltip-footer')).toBeNull();
});
it('does not re-render the legend while the tooltip follows the cursor', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPie();
const arc = screen.getByTestId('pie').querySelectorAll('svg g g')[0];
await user.hover(arc);
const rendersAfterEnter = mockLegendRenders;
// user-event leaves clientX/Y at 0 in jsdom, so cursor moves go through fireEvent.
fireEvent.mouseMove(arc, { clientX: 20, clientY: 20 });
fireEvent.mouseMove(arc, { clientX: 30, clientY: 30 });
expect(mockLegendRenders).toBe(rendersAfterEnter);
});
describe('grouped slice', () => {
// Each member is under 1% of the 2078 total, so the 12 fold into Other.
const members: PieSlice[] = Array.from({ length: 12 }, (_, index) => ({
label: `svc-${index}`,
value: 12 - index,
color: '#123456',
}));
const GROUPED: PieSlice[] = [
{ label: 'frontend', value: 1000, color: '#aa0000' },
{ label: 'cart', value: 600, color: '#00aa00' },
{ label: 'checkout', value: 400, color: '#0000aa' },
...members,
];
const arcs = (testId = 'pie'): NodeListOf<SVGPathElement> =>
(
screen.getByTestId(testId).querySelector('svg') as SVGElement
).querySelectorAll('path');
const arcGroups = (testId = 'pie'): NodeListOf<SVGGElement> =>
(
screen.getByTestId(testId).querySelector('svg') as SVGElement
).querySelectorAll('g g');
const tooltipList = (): HTMLElement =>
screen.getByTestId('uplot-tooltip-list');
const tooltip = (): HTMLElement =>
document.querySelector('[data-pie-tooltip]') as HTMLElement;
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
user = userEvent.setup({ pointerEventsCheck: 0 });
});
const hoverOther = async (testId = 'pie'): Promise<SVGGElement> => {
const otherArc = arcGroups(testId)[3];
await user.hover(otherArc);
return otherArc;
};
it('draws one Other arc but lists every series in the legend', () => {
renderPie({ data: GROUPED });
expect(arcs()).toHaveLength(4);
expect(screen.getByText('Other (12)')).toBeInTheDocument();
const legend = screen.getByTestId('virtuoso-grid');
expect(within(legend).getByText('svc-11')).toBeInTheDocument();
expect(within(legend).queryByText(/^Other/)).toBeNull();
});
it('highlights the Other arc when a series inside it is hovered in the legend', async () => {
renderPie({ data: GROUPED });
await user.hover(screen.getByTestId('legend-item-5'));
const fills = [...arcs()].map((arc) => arc.getAttribute('fill'));
expect(fills[0]).toBe('rgba(170, 0, 0, 0.4)');
expect(fills[3]).not.toMatch(/^rgba/);
});
it('highlights a real series named Other, not the grouped slice', async () => {
renderPie({
data: [
...GROUPED.slice(0, 3),
{ label: 'Other', value: 500, color: '#00aaaa' },
...members,
],
});
await user.hover(screen.getByTestId('legend-item-3'));
const fills = [...arcs()].map((arc) => arc.getAttribute('fill'));
expect(fills[3]).toBe('#00aaaa');
expect(fills[fills.length - 1]).toMatch(/^rgba/);
});
it('regroups Other from the series left showing', async () => {
renderPie({ data: GROUPED });
// Isolate one member, then add a second back: Other can't hold just one.
await user.click(screen.getByTestId('legend-item-3'));
expect(arcs()).toHaveLength(1);
await user.click(screen.getByTestId('legend-item-0'));
await user.click(screen.getByTestId('legend-item-4'));
expect(arcs()).toHaveLength(3);
expect(screen.queryByText(/^Other/)).toBeNull();
});
it('lists every member in the hover tooltip, not yet clickable', async () => {
renderPie({ data: GROUPED, onSliceClick: mockSliceClick() });
await hoverOther();
expect(tooltipList().children).toHaveLength(12);
expect(within(tooltipList()).getByText('svc-11')).toBeInTheDocument();
expect(
screen.getByText('Click to pin, then pick a series'),
).toBeInTheDocument();
expect(screen.queryByTestId('uplot-tooltip-item-button')).toBeNull();
expect(screen.queryByTestId('uplot-tooltip-status')).toBeNull();
});
it('follows the cursor until pinned', async () => {
renderPie({ data: GROUPED, onSliceClick: mockSliceClick() });
const otherArc = await hoverOther();
// user-event leaves clientX/Y at 0 in jsdom, so cursor moves go through fireEvent.
fireEvent.mouseMove(otherArc, { clientX: 100, clientY: 50 });
const first = tooltip().getAttribute('style');
fireEvent.mouseMove(otherArc, { clientX: 140, clientY: 90 });
const moved = tooltip().getAttribute('style');
expect(moved).not.toBe(first);
await user.click(otherArc);
fireEvent.mouseMove(otherArc, { clientX: 10, clientY: 10 });
expect(tooltip().getAttribute('style')).toBe(moved);
});
it('pins on click, then drills into the clicked member', async () => {
const onSliceClick = mockSliceClick();
renderPie({ data: GROUPED, onSliceClick });
const otherArc = await hoverOther();
await user.click(otherArc);
await user.unhover(otherArc);
expect(onSliceClick).not.toHaveBeenCalled();
expect(screen.getByTestId('uplot-tooltip-status')).toBeInTheDocument();
expect(screen.getByText('12 series')).toBeInTheDocument();
expect(screen.getByText('Click a series to drilldown')).toBeInTheDocument();
jest
.spyOn(tooltip(), 'getBoundingClientRect')
.mockReturnValue({ left: 40, top: 70 } as DOMRect);
await user.click(within(tooltipList()).getByText('svc-3'));
// The drill-down menu opens where the pinned tooltip was, not at the row.
expect(onSliceClick).toHaveBeenCalledWith(members[3], { x: 40, y: 70 });
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
});
it('unpins on Escape, a click outside and the Unpin button', async () => {
renderPie({ data: GROUPED, onSliceClick: mockSliceClick() });
await user.click(await hoverOther());
await user.keyboard('{Escape}');
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
await user.click(await hoverOther());
await user.click(document.body);
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
await user.click(await hoverOther());
await user.click(screen.getByTestId('pie-tooltip-unpin'));
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
});
it('unpins when a click lands on another pie', async () => {
render(
<>
<Pie data={GROUPED} isDarkMode={false} data-testid="pie" />
<Pie data={DATA} isDarkMode={false} data-testid="other-pie" />
</>,
);
await user.click(await hoverOther());
expect(tooltipList()).toBeInTheDocument();
await user.click(arcGroups('other-pie')[0]);
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
});
it('drops the pinned tooltip when new data arrives', async () => {
const { rerender } = renderPie({ data: GROUPED });
await user.click(await hoverOther());
expect(tooltipList()).toBeInTheDocument();
rerender(<Pie data={[...GROUPED]} isDarkMode={false} data-testid="pie" />);
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
});
it('pins without drill-down targets when drill-down is off', async () => {
renderPie({ data: GROUPED });
await user.click(await hoverOther());
expect(screen.queryByTestId('uplot-tooltip-item-button')).toBeNull();
expect(screen.getByTestId('pie-tooltip-unpin')).toBeInTheDocument();
});
});
});

View File

@@ -4,7 +4,8 @@ import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { PieSlice } from 'lib/visualization/charts/types';
import { getArcGeometry } from 'lib/visualization/charts/Pie/utils';
import { getArcGeometry } from 'lib/visualization/charts/Pie/utils/geometry';
import { getSliceDisplayLabel } from 'lib/visualization/charts/Pie/utils/slices';
// Slices below this share of the total don't get a leader label (too cramped).
const MIN_LABEL_SHARE = 0.03;
@@ -14,7 +15,7 @@ interface PieArcProps {
slice: PieSlice;
/** SVG path `d` for the arc, from the visx pie generator. */
arcPath: string;
/** Arc centroid `[x, y]`, used to anchor the leader line and tooltip. */
/** Arc centroid `[x, y]`, used to anchor the leader line. */
centroid: [number, number];
startAngle: number;
endAngle: number;
@@ -26,7 +27,8 @@ interface PieArcProps {
labelColor: string;
/** Resolved fill (already dimmed if another slice is active). */
fill: string;
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
/** Fired on enter and on every move, so the tooltip can follow the cursor. */
onHover: (slice: PieSlice, event: ReactMouseEvent<SVGGElement>) => void;
onLeave: () => void;
onClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
}
@@ -34,7 +36,7 @@ interface PieArcProps {
/**
* A single donut slice: the arc path plus, for non-tiny slices, a leader line
* out to an external label + value. Pure presentation — interaction is
* delegated to the `onEnter`/`onLeave`/`onClick` callbacks.
* delegated to the `onHover`/`onLeave`/`onClick` callbacks.
*/
export default function PieArc({
slice,
@@ -48,11 +50,12 @@ export default function PieArc({
decimalPrecision,
labelColor,
fill,
onEnter,
onHover,
onLeave,
onClick,
}: PieArcProps): JSX.Element {
const { label, value } = slice;
const { value } = slice;
const label = getSliceDisplayLabel(slice);
const [centroidX, centroidY] = centroid;
const { labelX, labelY, lineEndX, lineEndY, textAnchor } = getArcGeometry(
startAngle,
@@ -71,7 +74,9 @@ export default function PieArc({
return (
<g
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
data-pie-arc=""
onMouseEnter={(event): void => onHover(slice, event)}
onMouseMove={(event): void => onHover(slice, event)}
onMouseLeave={onLeave}
onClick={(event): void => onClick?.(slice, event)}
>

View File

@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { PieSlice } from 'lib/visualization/charts/types';
import PieArc from 'lib/visualization/charts/Pie/PieArc';
import PieArc from 'lib/visualization/charts/Pie/components/PieArc/PieArc';
jest.mock('components/Graph/yAxisConfig', () => ({
// Echo the raw value so assertions are deterministic.
@@ -11,12 +11,12 @@ jest.mock('components/Graph/yAxisConfig', () => ({
const SLICE: PieSlice = { label: 'frontend', value: 50, color: '#f00' };
function renderArc(props: Partial<React.ComponentProps<typeof PieArc>> = {}): {
onEnter: jest.Mock;
onHover: jest.Mock;
onLeave: jest.Mock;
onClick: jest.Mock;
container: HTMLElement;
} {
const onEnter = jest.fn();
const onHover = jest.fn();
const onLeave = jest.fn();
const onClick = jest.fn();
const { container } = render(
@@ -31,14 +31,14 @@ function renderArc(props: Partial<React.ComponentProps<typeof PieArc>> = {}): {
totalValue={100}
labelColor="#fff"
fill="#f00"
onEnter={onEnter}
onHover={onHover}
onLeave={onLeave}
onClick={onClick}
{...props}
/>
</svg>,
);
return { onEnter, onLeave, onClick, container };
return { onHover, onLeave, onClick, container };
}
describe('PieArc', () => {
@@ -69,12 +69,14 @@ describe('PieArc', () => {
expect(screen.getByText('a-really-lon...')).toBeInTheDocument();
});
it('fires onEnter with the slice + centroid, and onLeave / onClick', () => {
const { onEnter, onLeave, onClick, container } = renderArc();
it('fires onHover with the slice on enter and move, and onLeave / onClick', () => {
const { onHover, onLeave, onClick, container } = renderArc();
const g = container.querySelector('g') as SVGGElement;
fireEvent.mouseEnter(g);
expect(onEnter).toHaveBeenCalledWith(SLICE, 10, 20);
fireEvent.mouseMove(g);
expect(onHover).toHaveBeenCalledTimes(2);
expect(onHover).toHaveBeenCalledWith(SLICE, expect.anything());
fireEvent.mouseLeave(g);
expect(onLeave).toHaveBeenCalledTimes(1);

View File

@@ -1,7 +1,7 @@
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { getScaledFontSize } from 'lib/visualization/charts/Pie/utils';
import { getScaledFontSize } from 'lib/visualization/charts/Pie/utils/geometry';
interface PieCenterLabelProps {
/** Sum of the visible slice values, shown in the donut hole. */

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import PieCenterLabel from 'lib/visualization/charts/Pie/PieCenterLabel';
import PieCenterLabel from 'lib/visualization/charts/Pie/components/PieCenterLabel/PieCenterLabel';
jest.mock('components/Graph/yAxisConfig', () => ({
getYAxisFormattedValue: jest.fn(),

View File

@@ -0,0 +1,39 @@
// Mirrors the uPlot tooltip container (uPlotV2/components/Tooltip).
.body {
display: flex;
flex-direction: column;
width: 360px;
max-width: 80vw;
font-family: 'Inter';
font-size: 12px;
-webkit-font-smoothing: antialiased;
color: var(--l2-foreground);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
.pinned {
border-color: var(--ring);
}
.headerRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: var(--spacing-8) var(--spacing-8) 0;
font-size: var(--font-size-sm);
font-weight: 500;
}
.activeItem {
padding: var(--spacing-4);
}
.divider {
display: block;
height: 1px;
background-color: var(--l2-border);
}

View File

@@ -0,0 +1,71 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import cx from 'classnames';
import TooltipItem from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem';
import TooltipList from 'lib/uPlotV2/components/Tooltip/components/TooltipList/TooltipList';
import TooltipPinnedBadge from 'lib/uPlotV2/components/Tooltip/components/TooltipPinnedBadge/TooltipPinnedBadge';
import { PieTooltipData } from 'lib/visualization/charts/Pie/types';
import PieTooltipFooter from 'lib/visualization/charts/Pie/components/PieTooltip/PieTooltipFooter';
import styles from 'lib/visualization/charts/Pie/components/PieTooltip/PieTooltip.module.scss';
interface PieTooltipContentProps {
/** Widget id, for the list's analytics. */
id: string;
data: PieTooltipData;
/** Pinned by a click on a grouped slice: its members become drill-down targets. */
isPinned: boolean;
canDrilldown: boolean;
onMemberClick: (index: number, event: ReactMouseEvent) => void;
onUnpin: () => void;
}
/** Hovered slice's label and value, in the timeseries tooltip's look; a grouped slice also lists its members. */
export default function PieTooltipContent({
id,
data,
isPinned,
canDrilldown,
onMemberClick,
onUnpin,
}: PieTooltipContentProps): JSX.Element {
const { slice, groupedMembers } = data;
const canDrillMember = isPinned && canDrilldown;
const showFooter = !!groupedMembers || canDrilldown;
return (
<div
className={cx(styles.body, {
[styles.pinned]: isPinned,
})}
>
{isPinned && (
<div className={styles.headerRow}>
<span>{groupedMembers?.length} series</span>
<TooltipPinnedBadge />
</div>
)}
<div className={styles.activeItem}>
<TooltipItem item={slice} isItemActive />
</div>
{groupedMembers && (
<>
<span className={styles.divider} />
<TooltipList
id={id}
content={groupedMembers}
onItemClick={canDrillMember ? onMemberClick : undefined}
/>
</>
)}
{showFooter && (
<PieTooltipFooter
isGroup={!!groupedMembers}
isPinned={isPinned}
canDrilldown={canDrilldown}
onUnpin={onUnpin}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { MousePointerClick, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Kbd } from '@signozhq/ui/kbd';
import footerStyles from 'lib/visualization/panels/components/TooltipFooter.module.scss';
interface PieTooltipFooterProps {
isGroup: boolean;
isPinned: boolean;
canDrilldown: boolean;
onUnpin: () => void;
}
/** The timeseries tooltip footer's look, with the pie's click-to-pin hints. */
export default function PieTooltipFooter({
isGroup,
isPinned,
canDrilldown,
onUnpin,
}: PieTooltipFooterProps): JSX.Element {
let clickHint = canDrilldown ? 'Click to drilldown' : null;
if (isGroup) {
clickHint = canDrilldown
? 'Click to pin, then pick a series'
: 'Click to pin the tooltip';
}
if (isPinned) {
clickHint = canDrilldown ? 'Click a series to drilldown' : null;
}
return (
<div className={footerStyles.footer} data-testid="pie-tooltip-footer">
<div className={footerStyles.hintList}>
{clickHint && (
<div className={footerStyles.hint} data-active="false">
<Kbd>
<MousePointerClick size={12} />
</Kbd>
<span>{clickHint}</span>
</div>
)}
{isPinned && (
<div className={footerStyles.hint} data-active="false">
<span>Press</span>
<Kbd>Esc</Kbd>
<span>to unpin</span>
</div>
)}
</div>
{isPinned && (
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={onUnpin}
aria-label="Unpin tooltip"
testId="pie-tooltip-unpin"
prefix={<X size={10} />}
>
<span>Unpin</span>
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,205 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type MouseEvent as ReactMouseEvent,
type RefObject,
} from 'react';
import { useTooltip } from '@visx/tooltip';
import { PieChartProps, PieSlice } from 'lib/visualization/charts/types';
import { PieTooltipData } from 'lib/visualization/charts/Pie/types';
import { getSliceDisplayLabel } from 'lib/visualization/charts/Pie/utils/slices';
// Keeps the cursor off the tooltip so it never steals the hover.
const TOOLTIP_CURSOR_OFFSET = 12;
interface UsePieTooltipArgs {
/** The drawn slices, Other included. */
slices: PieSlice[];
/** The chart's root: a click on its own arcs resolves the pin, one anywhere else unpins. */
chartRef: RefObject<HTMLElement>;
formatValue: (value: number) => string;
setFocusedKey: (key: string | null) => void;
onSliceClick: PieChartProps['onSliceClick'];
}
interface UsePieTooltipResult {
tooltipOpen: boolean;
tooltipTop?: number;
tooltipLeft?: number;
tooltipData?: PieTooltipData;
isPinned: boolean;
unpin: () => void;
handleSliceHover: (
slice: PieSlice,
event: ReactMouseEvent<SVGGElement>,
) => void;
handleSliceLeave: () => void;
handleSliceClick: (slice: PieSlice, event: ReactMouseEvent) => void;
handleMemberClick: (index: number, event: ReactMouseEvent) => void;
}
/**
* The Pie tooltip: follows the cursor over an arc, and pins on a grouped slice's click so its
* members can be drilled into. Esc or a click outside unpins.
*/
export function usePieTooltip({
slices,
chartRef,
formatValue,
setFocusedKey,
onSliceClick,
}: UsePieTooltipArgs): UsePieTooltipResult {
const {
tooltipOpen,
tooltipLeft,
tooltipTop,
tooltipData,
hideTooltip,
showTooltip,
} = useTooltip<PieTooltipData>();
// A grouped slice's click pins its tooltip so its members can be drilled into.
const [pinnedSlice, setPinnedSlice] = useState<PieSlice | null>(null);
const unpin = useCallback((): void => {
setPinnedSlice(null);
hideTooltip();
setFocusedKey(null);
}, [hideTooltip, setFocusedKey]);
// New data replaces the pinned slice, whose members may no longer be drawn.
useEffect(() => {
if (pinnedSlice && !slices.includes(pinnedSlice)) {
unpin();
}
}, [slices, pinnedSlice, unpin]);
useEffect(() => {
if (!pinnedSlice) {
return undefined;
}
const onPointerDown = (event: PointerEvent): void => {
const target = event.target as Element | null;
// This chart's arc clicks resolve the pin themselves (see handleSliceClick).
const isOwnArc =
!!target?.closest('[data-pie-arc]') && !!chartRef.current?.contains(target);
if (isOwnArc || target?.closest('[data-pie-tooltip]')) {
return;
}
unpin();
};
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
unpin();
}
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return (): void => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [pinnedSlice, unpin, chartRef]);
// Built once per data change, not on every mouse move.
const tooltipDataBySlice = useMemo(
() =>
new Map<PieSlice, PieTooltipData>(
slices.map((slice) => [
slice,
{
slice: {
label: getSliceDisplayLabel(slice),
value: slice.value,
tooltipValue: formatValue(slice.value),
color: slice.color,
isActive: true,
},
groupedMembers: slice.groupedMembers?.map((member) => ({
label: member.label,
value: member.value,
tooltipValue: formatValue(member.value),
color: member.color,
isActive: false,
})),
},
]),
),
[slices, formatValue],
);
const handleSliceHover = useCallback(
(slice: PieSlice, event: ReactMouseEvent<SVGGElement>): void => {
if (pinnedSlice) {
return;
}
const svgRect = event.currentTarget.ownerSVGElement?.getBoundingClientRect();
showTooltip({
tooltipData: tooltipDataBySlice.get(slice),
tooltipTop: event.clientY - (svgRect?.top ?? 0) + TOOLTIP_CURSOR_OFFSET,
tooltipLeft: event.clientX - (svgRect?.left ?? 0) + TOOLTIP_CURSOR_OFFSET,
});
setFocusedKey(slice.label);
},
[pinnedSlice, showTooltip, setFocusedKey, tooltipDataBySlice],
);
const handleSliceLeave = useCallback((): void => {
if (pinnedSlice) {
return;
}
hideTooltip();
setFocusedKey(null);
}, [pinnedSlice, hideTooltip, setFocusedKey]);
const handleSliceClick = useCallback(
(slice: PieSlice, event: ReactMouseEvent): void => {
if (slice.groupedMembers) {
if (pinnedSlice === slice) {
unpin();
} else {
setPinnedSlice(slice);
}
return;
}
if (pinnedSlice) {
unpin();
}
onSliceClick?.(slice, { x: event.clientX, y: event.clientY });
},
[pinnedSlice, unpin, onSliceClick],
);
const handleMemberClick = useCallback(
(index: number, event: ReactMouseEvent): void => {
const member = pinnedSlice?.groupedMembers?.[index];
const tooltipRect = (event.currentTarget as Element)
.closest('[data-pie-tooltip]')
?.getBoundingClientRect();
unpin();
if (member) {
onSliceClick?.(member, {
x: tooltipRect?.left ?? event.clientX,
y: tooltipRect?.top ?? event.clientY,
});
}
},
[pinnedSlice, unpin, onSliceClick],
);
return {
tooltipOpen,
tooltipTop,
tooltipLeft,
tooltipData,
isPinned: !!pinnedSlice,
unpin,
handleSliceHover,
handleSliceLeave,
handleSliceClick,
handleMemberClick,
};
}

View File

@@ -4,6 +4,8 @@
* (PieSlice, PieChartProps) live in the parent charts/types.ts.
*/
import type { TooltipContentItem } from 'lib/uPlotV2/components/types';
export interface ScaledFontSizeArgs {
text: string;
baseSize: number;
@@ -39,7 +41,7 @@ export interface ParsedRgb {
/** Resolved tooltip payload shown when a slice is hovered. */
export interface PieTooltipData {
label: string;
value: string;
color: string;
slice: TooltipContentItem;
/** Grouped slice only: every member, largest first. */
groupedMembers?: TooltipContentItem[];
}

View File

@@ -0,0 +1,35 @@
import {
getFillColor,
lightenColor,
} from 'lib/visualization/charts/Pie/utils/colors';
describe('Pie colors', () => {
describe('lightenColor', () => {
it('converts a #rrggbb hex to rgba at the given opacity', () => {
expect(lightenColor('#ff0000', 0.4)).toBe('rgba(255, 0, 0, 0.4)');
});
it('accepts hex without a leading #', () => {
expect(lightenColor('00ff00', 0.4)).toBe('rgba(0, 255, 0, 0.4)');
});
it('returns the original colour when it is not parseable hex', () => {
expect(lightenColor('rgba(0,0,0,1)', 0.4)).toBe('rgba(0,0,0,1)');
expect(lightenColor('red', 0.4)).toBe('red');
});
});
describe('getFillColor', () => {
it('returns the colour unchanged when nothing is active', () => {
expect(getFillColor('#ff0000', null)).toBe('#ff0000');
});
it('returns the colour unchanged for the active slice', () => {
expect(getFillColor('#ff0000', '#ff0000')).toBe('#ff0000');
});
it('dims non-active slices to 40% opacity', () => {
expect(getFillColor('#00ff00', '#ff0000')).toBe('rgba(0, 255, 0, 0.4)');
});
});
});

View File

@@ -1,12 +1,10 @@
import {
getArcGeometry,
getDonutGeometry,
getFillColor,
getScaledFontSize,
lightenColor,
} from 'lib/visualization/charts/Pie/utils';
} from 'lib/visualization/charts/Pie/utils/geometry';
describe('Pie utils', () => {
describe('Pie geometry', () => {
describe('getDonutGeometry', () => {
it('keeps the label anchor inside the box (reserves room for leader labels)', () => {
const { radius } = getDonutGeometry(400, 300);
@@ -98,33 +96,4 @@ describe('Pie utils', () => {
expect(g.textAnchor).toBe('end');
});
});
describe('lightenColor', () => {
it('converts a #rrggbb hex to rgba at the given opacity', () => {
expect(lightenColor('#ff0000', 0.4)).toBe('rgba(255, 0, 0, 0.4)');
});
it('accepts hex without a leading #', () => {
expect(lightenColor('00ff00', 0.4)).toBe('rgba(0, 255, 0, 0.4)');
});
it('returns the original colour when it is not parseable hex', () => {
expect(lightenColor('rgba(0,0,0,1)', 0.4)).toBe('rgba(0,0,0,1)');
expect(lightenColor('red', 0.4)).toBe('red');
});
});
describe('getFillColor', () => {
it('returns the colour unchanged when nothing is active', () => {
expect(getFillColor('#ff0000', null)).toBe('#ff0000');
});
it('returns the colour unchanged for the active slice', () => {
expect(getFillColor('#ff0000', '#ff0000')).toBe('#ff0000');
});
it('dims non-active slices to 40% opacity', () => {
expect(getFillColor('#00ff00', '#ff0000')).toBe('rgba(0, 255, 0, 0.4)');
});
});
});

View File

@@ -0,0 +1,129 @@
import type { PieSlice } from 'lib/visualization/charts/types';
import {
groupPieSlices,
OTHER_SLICE_LABEL,
} from 'lib/visualization/charts/Pie/utils/groupPieSlices';
const slice = (label: string, value: number): PieSlice => ({
label,
value,
color: '#000000',
queryName: 'A',
});
const group = (slices: PieSlice[]): PieSlice[] => groupPieSlices(slices, true);
describe('groupPieSlices', () => {
it('folds a long tail into a trailing Other, keeping kept-slice order', () => {
const slices = [
slice('b', 400),
slice('tail1', 2),
slice('a', 500),
slice('tail2', 4),
slice('tail3', 1),
];
const result = group(slices);
expect(result.map((s) => [s.label, s.value])).toStrictEqual([
['b', 400],
['a', 500],
[OTHER_SLICE_LABEL, 7],
]);
expect(result[2].groupedMembers?.map((s) => s.label)).toStrictEqual([
'tail2',
'tail1',
'tail3',
]);
expect(result[2].queryName).toBeUndefined();
});
it('caps a flat pie at 14 slices + Other', () => {
const slices = Array.from({ length: 30 }, (_, i) => slice(`s${i}`, 100 - i));
const result = group(slices);
expect(result).toHaveLength(15);
expect(result.slice(0, 14).map((s) => s.label)).toStrictEqual(
slices.slice(0, 14).map((s) => s.label),
);
expect(result[14].label).toBe(OTHER_SLICE_LABEL);
expect(result[14].groupedMembers).toHaveLength(16);
});
it('keeps exactly 15 large slices ungrouped', () => {
const slices = Array.from({ length: 15 }, (_, i) => slice(`s${i}`, 10));
expect(group(slices)).toBe(slices);
});
it('leaves a single small slice under its own name', () => {
const slices = [slice('a', 500), slice('b', 400), slice('tiny', 1)];
expect(group(slices)).toBe(slices);
});
it('keeps a dominant Other at its true size and conserves the total', () => {
const slices = [
slice('a', 300),
...Array.from({ length: 400 }, (_, i) => slice(`t${i}`, 1)),
];
const result = group(slices);
expect(result.map((s) => s.label)).toStrictEqual(['a', OTHER_SLICE_LABEL]);
expect(result[1].value).toBe(400);
expect(result[1].groupedMembers).toHaveLength(400);
});
it('keeps tiny slices grouped past 15 series', () => {
const slices = [
slice('a', 3000),
slice('b', 2000),
slice('c', 1000),
...Array.from({ length: 13 }, (_, i) => slice(`t${i}`, 1)),
];
const result = group(slices);
expect(result.map((s) => s.label)).toStrictEqual([
'a',
'b',
'c',
OTHER_SLICE_LABEL,
]);
expect(result[3].groupedMembers).toHaveLength(13);
});
it('keeps 14 slices when every slice is under the share threshold', () => {
// 200 × 0.5%: grouping by share alone would leave one 100% Other.
const slices = Array.from({ length: 200 }, (_, i) => slice(`s${i}`, 5));
const result = group(slices);
expect(result.slice(0, 14).map((s) => s.label)).toStrictEqual(
slices.slice(0, 14).map((s) => s.label),
);
expect(result[14].label).toBe(OTHER_SLICE_LABEL);
expect(result[14].groupedMembers).toHaveLength(186);
expect(result[14].value).toBe(930);
});
it('breaks ties by input order', () => {
const slices = Array.from({ length: 20 }, (_, i) => slice(`s${i}`, 5));
const result = group(slices);
expect(result.slice(0, 14).map((s) => s.label)).toStrictEqual(
slices.slice(0, 14).map((s) => s.label),
);
expect(result[14].groupedMembers?.map((s) => s.label)).toStrictEqual(
slices.slice(14).map((s) => s.label),
);
});
it('returns an empty pie unchanged', () => {
expect(group([])).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,26 @@
import type { PieSlice } from 'lib/visualization/charts/types';
import { compareSlices } from 'lib/visualization/charts/Pie/utils/slices';
describe('Pie slices', () => {
describe('compareSlices', () => {
it('orders largest first with the grouped slice last', () => {
const slice = (label: string, value: number, grouped = false): PieSlice => ({
label,
value,
color: '#000000',
...(grouped ? { groupedMembers: [] } : {}),
});
const slices = [
slice('small', 1),
slice('Other', 500, true),
slice('big', 100),
];
expect([...slices].sort(compareSlices).map((s) => s.label)).toStrictEqual([
'big',
'small',
'Other',
]);
});
});
});

View File

@@ -0,0 +1,41 @@
import { ParsedRgb } from 'lib/visualization/charts/Pie/types';
// Parses `#rrggbb` into its components. Returns null for anything else (e.g. an
// already-rgba string), letting callers fall back to the original colour.
function hexToRgb(color: string): ParsedRgb | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
/**
* Returns an rgba() string for `color` at the given opacity. Used to dim the
* non-hovered slices. Falls back to the original colour if it can't be parsed.
*/
export function lightenColor(color: string, opacity: number): string {
const rgb = hexToRgb(color);
if (!rgb) {
return color;
}
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
}
/**
* Resolves the fill for a slice given the currently-hovered slice colour:
* everything but the active slice dims to 40% opacity. With nothing hovered
* (`activeColor === null`) every slice keeps its full colour.
*/
export function getFillColor(
color: string,
activeColor: string | null,
): string {
if (activeColor === null) {
return color;
}
return activeColor === color ? color : lightenColor(color, 0.4);
}

View File

@@ -1,12 +1,8 @@
/**
* Pure presentation helpers for the Pie chart. Kept out of the component file
* so the renderer stays declarative (per the one-component-per-file rule).
*/
/** Donut, label and font sizing for the Pie chart. */
import {
ArcGeometry,
DonutGeometry,
ParsedRgb,
ScaledFontSizeArgs,
} from 'lib/visualization/charts/Pie/types';
@@ -77,43 +73,3 @@ export function getArcGeometry(
textAnchor: Math.sin(angle) > 0 ? 'start' : 'end',
};
}
// Parses `#rrggbb` into its components. Returns null for anything else (e.g. an
// already-rgba string), letting callers fall back to the original colour.
function hexToRgb(color: string): ParsedRgb | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
/**
* Returns an rgba() string for `color` at the given opacity. Used to dim the
* non-hovered slices. Falls back to the original colour if it can't be parsed.
*/
export function lightenColor(color: string, opacity: number): string {
const rgb = hexToRgb(color);
if (!rgb) {
return color;
}
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
}
/**
* Resolves the fill for a slice given the currently-hovered slice colour:
* everything but the active slice dims to 40% opacity. With nothing hovered
* (`activeColor === null`) every slice keeps its full colour.
*/
export function getFillColor(
color: string,
activeColor: string | null,
): string {
if (activeColor === null) {
return color;
}
return activeColor === color ? color : lightenColor(color, 0.4);
}

View File

@@ -0,0 +1,40 @@
import { Color } from '@signozhq/design-tokens';
import type { PieSlice } from 'lib/visualization/charts/types';
export const OTHER_SLICE_LABEL = 'Other';
const MIN_SHARE = 0.01;
const MAX_SLICES = 15;
const MIN_OTHER_MEMBERS = 2;
/**
* Folds slices at or under `MIN_SHARE`, plus any past `MAX_SLICES` (Other included), into one
* trailing "Other" slice. When no slice clears `MIN_SHARE`, the largest `MAX_SLICES - 1` stay so
* the pie never collapses into a single Other. Kept slices keep their order. No-op when fewer
* than two would fold.
*/
export function groupPieSlices(
slices: PieSlice[],
isDarkMode: boolean,
): PieSlice[] {
const total = slices.reduce((sum, slice) => sum + slice.value, 0);
const sorted = [...slices].sort((a, b) => b.value - a.value);
const aboveShare = sorted.filter(
(slice) => slice.value / total > MIN_SHARE,
).length;
const keptCount = Math.min(aboveShare || MAX_SLICES - 1, MAX_SLICES - 1);
const groupedMembers = sorted.slice(keptCount);
if (groupedMembers.length < MIN_OTHER_MEMBERS) {
return slices;
}
const kept = new Set(sorted.slice(0, keptCount));
const other: PieSlice = {
label: OTHER_SLICE_LABEL,
value: groupedMembers.reduce((sum, slice) => sum + slice.value, 0),
color: isDarkMode ? Color.BG_SLATE_50 : Color.BG_VANILLA_400,
groupedMembers,
};
return [...slices.filter((slice) => kept.has(slice)), other];
}

View File

@@ -0,0 +1,15 @@
import type { PieSlice } from 'lib/visualization/charts/types';
/** Grouped slices read "Other (n)"; `label` stays the key for colours and visibility. */
export function getSliceDisplayLabel(slice: PieSlice): string {
return slice.groupedMembers
? `${slice.label} (${slice.groupedMembers.length})`
: slice.label;
}
/** Arc order: largest first, with a grouped slice always last so it closes the circle. */
export function compareSlices(a: PieSlice, b: PieSlice): number {
return (
Number(!!a.groupedMembers) - Number(!!b.groupedMembers) || b.value - a.value
);
}

View File

@@ -1,5 +1,4 @@
import uPlot from 'uplot';
import type { MouseEvent as ReactMouseEvent } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import {
@@ -180,6 +179,8 @@ export interface PieSlice {
queryName?: string;
/** Group-by key→value of the slice's source row, used to build drill-down filters. */
labels?: Record<string, string>;
/** Slices folded into this one, largest first. Present only on the "Other" slice. */
groupedMembers?: PieSlice[];
}
/**
@@ -200,8 +201,11 @@ export interface PieChartProps {
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
*/
id?: string;
/** Fired when a slice's arc is clicked; carries the DOM event for popover positioning. */
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
/**
* Fired when a slice is clicked, with the viewport point to anchor the drill-down popover:
* the cursor for an arc, the pinned tooltip's corner for a grouped slice's member.
*/
onSliceClick?: (slice: PieSlice, anchor: { x: number; y: number }) => void;
'data-testid'?: string;
}

View File

@@ -1,8 +1,4 @@
import {
useCallback,
useMemo,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { useCallback, useMemo } from 'react';
import type { DashboardtypesPieChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Pie from 'lib/visualization/charts/Pie/Pie';
import type { PieSlice } from 'lib/visualization/charts/types';
@@ -75,14 +71,14 @@ function PiePanelRenderer({
);
const handleSliceClick = useCallback(
(slice: PieSlice, event: ReactMouseEvent): void => {
(slice: PieSlice, coordinates: { x: number; y: number }): void => {
if (!onClick) {
return;
}
const payload = enrichPieClick({
slice,
builderQueries,
coordinates: { x: event.clientX, y: event.clientY },
coordinates,
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {

View File

@@ -38,6 +38,7 @@ import {
PANEL_KINDS,
PANEL_RESULTS,
PANELS_DASHBOARD_ID,
PIE_SLICES,
SPAN_GAPS,
STACK_MODES,
TEXT_ALIGNS,
@@ -63,6 +64,7 @@ import type {
PanelKind,
PanelResult,
PanelUnit,
PieSlices,
SpanGaps,
TextSample,
YScale,
@@ -199,6 +201,13 @@ export const panelsMocks = defineStoryMocks({
value: 4,
max: MAX_SERIES,
}),
pieSlices: choiceControl<PieSlices>('Pie slices', {
group: DATA,
description:
'`even` steps down per service (Series sets how many); `long-tail` answers 24 services, the smallest 10 folding into Other.',
options: PIE_SLICES,
value: 'even',
}),
gaps: choiceControl<Gaps>('Gaps', {
group: DATA,
description:
@@ -267,6 +276,7 @@ export const panelsMocks = defineStoryMocks({
listRows: values.listRows,
listJsonBody: values.listJsonBody,
gaps: values.gaps,
pieSlices: values.pieSlices,
});
}),
),

View File

@@ -1,6 +1,5 @@
import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import {
DashboardtypesHeatmapYScaleDTO as HeatmapYScale,
DashboardtypesLegendPositionDTO as LegendPosition,
@@ -16,9 +15,13 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import DashboardPage from '../../DashboardPage';
import { panelsMocks } from './Panels.stories.mocks';
import { expectPanel } from './panelPlay/expectPanel';
import { filterLegend, searchTable } from './panelPlay/interactions';
import { walkListPages } from './panelPlay/list';
import {
playLegendRight,
playListJsonBody,
playListPaginated,
playPanel,
playTableSearch,
} from './panelPlay/plays';
type PanelsArgs = PageStoryArgs<typeof panelsMocks>;
@@ -49,53 +52,49 @@ export default meta;
type Story = StoryObj<PanelsArgs>;
const play: Story['play'] = async ({ canvasElement, args }) => {
await expectPanel(canvasElement, args);
};
/** A time series panel: p99 latency, one line per service, with a bottom legend. */
export const Default: Story = { play };
export const Default: Story = { play: playPanel };
/** One bar series per service over time, side by side. */
export const Bar: Story = {
args: { kind: 'Bar' },
play,
play: playPanel,
};
/** The services' bars stacked on one another at every moment. */
export const BarStacked: Story = {
args: { kind: 'Bar', stack: StackMode.normal },
play,
play: playPanel,
};
/** The services as filled areas, drawn independently. */
export const Area: Story = {
args: { kind: 'Area' },
play,
play: playPanel,
};
/** The services' areas stacked on one another, each band filling to the one below. */
export const AreaStacked: Story = {
args: { kind: 'Area', stack: StackMode.normal },
play,
play: playPanel,
};
/** The stacked areas rescaled so every moment sums to 100%, each band its service's share. */
export const AreaPercent: Story = {
args: { kind: 'Area', stack: StackMode.percent },
play,
play: playPanel,
};
/** How the latency values distribute across buckets, one bar set per service. */
export const Histogram: Story = {
args: { kind: 'Histogram' },
play,
play: playPanel,
};
/** The histogram binned 50 apart, every bin starting on a multiple of 50. */
export const HistogramBucketWidth: Story = {
args: { kind: 'Histogram', bucketWidth: '50' },
play,
play: playPanel,
};
/**
@@ -104,25 +103,34 @@ export const HistogramBucketWidth: Story = {
*/
export const Heatmap: Story = {
args: { kind: 'Heatmap' },
play,
play: playPanel,
};
/** The heatmap's bucket rows laid out on a log scale. */
export const HeatmapLog: Story = {
args: { kind: 'Heatmap', heatmapScale: HeatmapYScale.log },
play,
play: playPanel,
};
/** The heatmap's colour bar pinned to a fixed 5–250 count range. */
export const HeatmapCountRange: Story = {
args: { kind: 'Heatmap', heatmapCounts: 'fixed' },
play,
play: playPanel,
};
/** Each service's share of traffic as a donut, with the total in the middle. */
export const Pie: Story = {
args: { kind: 'Pie' },
play,
play: playPanel,
};
/**
* 24 services of falling traffic: the smallest 10 fold into one grey "Other (10)" slice,
* which lists them on hover; click pins the list to drill into any of them.
*/
export const PieOther: Story = {
args: { kind: 'Pie', pieSlices: 'long-tail' },
play: playPanel,
};
/**
@@ -131,13 +139,13 @@ export const Pie: Story = {
*/
export const Number: Story = {
args: { kind: 'Number', thresholds: true },
play,
play: playPanel,
};
/** The value's query matched nothing, which the Number panel draws as its own no-data state. */
export const NumberNoData: Story = {
args: { kind: 'Number', result: 'empty' },
play,
play: playPanel,
};
/**
@@ -146,7 +154,7 @@ export const NumberNoData: Story = {
*/
export const Table: Story = {
args: { kind: 'Table', thresholds: true, columnUnits: true },
play,
play: playPanel,
};
/**
@@ -155,16 +163,13 @@ export const Table: Story = {
*/
export const TableSearch: Story = {
args: { kind: 'Table' },
play: async ({ canvasElement, args }): Promise<void> => {
await expectPanel(canvasElement, args);
await searchTable(canvasElement, args);
},
play: playTableSearch,
};
/** The latest log lines, newest first, on a single page. */
export const List: Story = {
args: { kind: 'List' },
play,
play: playPanel,
};
/**
@@ -173,16 +178,7 @@ export const List: Story = {
*/
export const ListJsonBody: Story = {
args: { kind: 'List', listJsonBody: true },
play: async ({ canvasElement, args }): Promise<void> => {
await expectPanel(canvasElement, args);
const [first, , third] = within(canvasElement)
.getByTestId('list-panel-renderer')
.querySelectorAll<HTMLElement>('tbody tr.ant-table-row');
await expect(within(first).getByText(/^log line 1:/)).toBeInTheDocument();
await expect(
within(third).getByText(/^\{"message":"log line 3:/),
).toBeInTheDocument();
},
play: playListJsonBody,
};
/**
@@ -191,28 +187,25 @@ export const ListJsonBody: Story = {
*/
export const ListPaginated: Story = {
args: { kind: 'List', listRows: 60 },
play: async ({ canvasElement, args }): Promise<void> => {
await expectPanel(canvasElement, args);
await walkListPages(canvasElement, args);
},
play: playListPaginated,
};
/** A Markdown runbook: heading, list, link and code block. */
export const Text: Story = {
args: { kind: 'Text' },
play,
play: playPanel,
};
/** The time series on a logarithmic y axis. */
export const LogScale: Story = {
args: { yScale: 'log' },
play,
play: playPanel,
};
/** Dashed lines with a marker on every sample. */
export const LineStyle: Story = {
args: { lineStyle: LineStyleDTO.dashed, showPoints: true },
play,
play: playPanel,
};
/**
@@ -221,7 +214,7 @@ export const LineStyle: Story = {
*/
export const SpanGaps: Story = {
args: { gaps: 'hole', spanGaps: 'break-10m' },
play,
play: playPanel,
};
/**
@@ -230,26 +223,23 @@ export const SpanGaps: Story = {
*/
export const LegendRight: Story = {
args: { legend: LegendPosition.right },
play: async ({ canvasElement, args }): Promise<void> => {
await expectPanel(canvasElement, args);
await filterLegend(canvasElement, args);
},
play: playLegendRight,
};
/** The dashed SLO line across the time series, with its label. */
export const Thresholds: Story = {
args: { thresholds: true },
play,
play: playPanel,
};
/** The query ran and matched nothing, which the panel draws as its no-data state. */
export const NoData: Story = {
args: { result: 'empty' },
play,
play: playPanel,
};
/** Data that came back with a warning, flagged in the panel header. */
export const Warning: Story = {
args: { result: 'warning' },
play,
play: playPanel,
};

View File

@@ -67,6 +67,11 @@ export const LINE_STYLES = Object.values(LineStyle);
export const GAPS = ['none', 'hole'] as const;
export const PIE_SLICES = ['even', 'long-tail'] as const;
/** Slices `long-tail` answers with: 14 drawn, the other 10 grouped (the pie draws at most 15). */
export const LONG_TAIL_SLICES = 24;
export const SPAN_GAPS = ['bridge', 'break-10m'] as const;
/** Points cut out of every series by `hole`: 12 of the 30, a gap past the 10m break. */

View File

@@ -26,13 +26,14 @@ import {
HEATMAP_BUCKETS,
HEATMAP_COLUMNS,
HOLE,
LONG_TAIL_SLICES,
MAX_SERIES,
NUMBER_VALUE,
QUERY_NAME,
SERVICES,
TABLE_SECOND_COLUMN,
} from './constants';
import type { Gaps, PanelRequest, PanelResponseArgs } from './types';
import type { Gaps, PanelRequest, PanelResponseArgs, PieSlices } from './types';
export const serviceAt = (index: number): string =>
index < SERVICES.length
@@ -42,6 +43,14 @@ export const serviceAt = (index: number): string =>
export const TABLE_VALUES = (count: number): number[] =>
Array.from({ length: count }, (_unused, index) => 3800 - index * 480);
/** The pie's slice values: `even` steps down per series, `long-tail` falls off like a power law. */
export const PIE_VALUES = (slices: PieSlices, count: number): number[] =>
slices === 'even'
? TABLE_VALUES(count)
: Array.from({ length: LONG_TAIL_SLICES }, (_unused, index) =>
Math.round(42000 / (index + 1) ** 1.35),
);
const TABLE_SECOND_VALUES = (count: number): number[] =>
Array.from({ length: count }, (_unused, index) => 2000 + index * 600);
@@ -192,6 +201,7 @@ export const panelQueryResponse = ({
listRows,
listJsonBody,
gaps,
pieSlices,
window,
offset,
limit,
@@ -209,7 +219,10 @@ export const panelQueryResponse = ({
return queryRangeV5ScalarTableResponse({
groupBy: ['service.name'],
aggregations: [QUERY_NAME],
rows: TABLE_VALUES(series).map((value, index) => [serviceAt(index), value]),
rows: PIE_VALUES(pieSlices, series).map((value, index) => [
serviceAt(index),
value,
]),
warning,
});
case 'Table': {

View File

@@ -20,6 +20,7 @@ import type {
HEATMAP_COUNT_RANGES,
PANEL_KINDS,
PANEL_RESULTS,
PIE_SLICES,
SPAN_GAPS,
TEXT_SAMPLES,
UNITS,
@@ -44,6 +45,8 @@ export type Gaps = (typeof GAPS)[number];
export type SpanGaps = (typeof SPAN_GAPS)[number];
export type PieSlices = (typeof PIE_SLICES)[number];
/** The controls that shape the dashboard document. */
export interface PanelArgs {
kind: PanelKind;
@@ -79,4 +82,5 @@ export interface PanelResponseArgs extends PanelRequest {
listRows: number;
listJsonBody: boolean;
gaps: Gaps;
pieSlices: PieSlices;
}

View File

@@ -0,0 +1,42 @@
import { expect, within } from 'storybook/test';
import type { PanelPlayArgs } from './args';
import { expectPanel } from './expectPanel';
import { filterLegend, searchTable } from './interactions';
import { walkListPages } from './list';
type PanelPlay = (context: {
canvasElement: HTMLElement;
args: PanelPlayArgs;
}) => Promise<void>;
/** Asserts what the panel kind draws. */
export const playPanel: PanelPlay = async ({ canvasElement, args }) => {
await expectPanel(canvasElement, args);
};
export const playTableSearch: PanelPlay = async ({ canvasElement, args }) => {
await expectPanel(canvasElement, args);
await searchTable(canvasElement, args);
};
export const playListJsonBody: PanelPlay = async ({ canvasElement, args }) => {
await expectPanel(canvasElement, args);
const [first, , third] = within(canvasElement)
.getByTestId('list-panel-renderer')
.querySelectorAll<HTMLElement>('tbody tr.ant-table-row');
await expect(within(first).getByText(/^log line 1:/)).toBeInTheDocument();
await expect(
within(third).getByText(/^\{"message":"log line 3:/),
).toBeInTheDocument();
};
export const playListPaginated: PanelPlay = async ({ canvasElement, args }) => {
await expectPanel(canvasElement, args);
await walkListPages(canvasElement, args);
};
export const playLegendRight: PanelPlay = async ({ canvasElement, args }) => {
await expectPanel(canvasElement, args);
await filterLegend(canvasElement, args);
};

View File

@@ -6,12 +6,16 @@ import {
TABLE_THRESHOLD_VALUE,
} from '../__story_mockdata__/constants';
import {
PIE_VALUES,
serviceIndex,
TABLE_VALUES,
} from '../__story_mockdata__/panelResponses';
import { PANEL_LOAD, type PanelPlayArgs } from './args';
// `long-tail` draws 14 slices and folds the other 10 into Other.
const LONG_TAIL_KEPT = 14;
export const expectPie = async (
root: HTMLElement,
args: PanelPlayArgs,
@@ -19,9 +23,18 @@ export const expectPie = async (
const chart = await within(root).findByTestId('pie-chart', {}, PANEL_LOAD);
// The legend's icons are SVGs too; the donut is the first one.
const donut = chart.querySelector('svg');
await expect(donut?.querySelectorAll('g > path')).toHaveLength(args.series);
const values = PIE_VALUES(args.pieSlices, args.series);
const isGrouped = args.pieSlices === 'long-tail';
await expect(donut?.querySelectorAll('g > path')).toHaveLength(
isGrouped ? LONG_TAIL_KEPT + 1 : values.length,
);
if (isGrouped) {
await expect(
within(chart).getByText(`Other (${values.length - LONG_TAIL_KEPT})`),
).toBeInTheDocument();
}
// The centre shows the slices' sum, formatted as the panel formats values.
const total = TABLE_VALUES(args.series).reduce((sum, value) => sum + value, 0);
const total = values.reduce((sum, value) => sum + value, 0);
await expect(
donut?.querySelector('text[text-anchor="middle"]')?.textContent,
).toBe(getYAxisFormattedValue(String(total), args.unit).replace(/\s+/g, ''));

View File

@@ -168,12 +168,14 @@ describe('TracesExplorer -', () => {
),
);
const { getByText } = renderWithTracesExplorerRouter(<TracesExplorer />);
renderWithTracesExplorerRouter(<TracesExplorer />);
await screen.findByText(FILTER_SERVICE_NAME);
await screen.findByText('demo-app');
expect(getByText('options_menu.options')).toBeInTheDocument();
expect(
screen.getByTestId('periscope-btn-options-traces'),
).toBeInTheDocument();
// the list scrolls instead of paging
expect(screen.queryByTestId('pagination-page-size')).not.toBeInTheDocument();

View File

@@ -1,6 +1,9 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import { LOCALSTORAGE } from 'constants/localStorage';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { TRACE_VIEW_DEFAULT_COLUMNS } from 'container/LLMObservability/Explorer/constants';
import { LogViewMode, FontSize } from 'container/OptionsMenu/types';
import {
defaultLogsSelectedColumns,
@@ -9,12 +12,15 @@ import {
import { render, screen, userEvent } from 'tests/test-utils';
import { usePreferenceContext } from '../context/PreferenceContextProvider';
import { PreferenceSourcePage } from '../types';
import { PanelColumns, PreferenceSourcePage } from '../types';
const ROUTE_LOGS = '/logs';
const ROUTE_TRACES = '/traces';
const TESTID_LOGS = 'logs';
const TESTID_TRACES = 'traces';
const ROUTE_AI = '/ai-observability';
const AI_DEFAULT_COUNT = String(TRACE_VIEW_DEFAULT_COLUMNS.length);
const TESTID_AI = 'ai';
type LogsLocalOptions = {
selectColumns?: TelemetryFieldKey[];
@@ -57,9 +63,9 @@ function Consumer({
type="button"
onClick={(): void => {
const newCols: TelemetryFieldKey[] =
sourcePage === SavedviewtypesSourceDTO.traces
? (defaultTraceSelectedColumns.slice(0, 1) as TelemetryFieldKey[])
: (defaultLogsSelectedColumns.slice(0, 1) as TelemetryFieldKey[]);
sourcePage === SavedviewtypesSourceDTO.logs
? (defaultLogsSelectedColumns.slice(0, 1) as TelemetryFieldKey[])
: (defaultTraceSelectedColumns.slice(0, 1) as TelemetryFieldKey[]);
slice.updateColumns(newCols);
}}
>
@@ -436,4 +442,226 @@ describe('PreferencesProvider integration', () => {
}
});
});
describe('AI observability traces', () => {
const renderAiConsumer = (): void => {
render(
<Consumer
sourcePage={SavedviewtypesSourceDTO.ai_observability}
testIdPrefix={TESTID_AI}
/>,
undefined,
{ initialRoute: ROUTE_AI },
);
};
it('starts with the default trace view columns', () => {
renderAiConsumer();
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
AI_DEFAULT_COUNT,
);
});
it('loads its own localStorage, not the traces list one', () => {
setLocalStorageJSON(LOCALSTORAGE.TRACES_LIST_OPTIONS, {
selectColumns: [{ name: 'traces.list.col' }],
});
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
[PANEL_TYPES.TRACE]: [{ name: 'ai.col.a' }, { name: 'ai.col.b' }],
});
renderAiConsumer();
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('3');
});
const URL_COLUMNS = [{ name: 'ai.url.a' }, { name: 'ai.url.b' }];
const renderAtUrl = (
pathname: string,
params: Record<string, unknown>,
renderConsumer: () => void,
): void => {
const search = new URLSearchParams(
Object.entries(params).map(([key, value]) => [key, JSON.stringify(value)]),
).toString();
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
writable: true,
value: { ...originalLocation, pathname, search: `?${search}` },
});
renderConsumer();
Object.defineProperty(window, 'location', {
writable: true,
value: originalLocation,
});
};
const renderAiAtUrl = (params: Record<string, unknown>): void =>
renderAtUrl(ROUTES.AI_OBSERVABILITY_EXPLORER, params, renderAiConsumer);
it('ignores the url options on other explorers', () => {
renderAtUrl(
ROUTE_TRACES,
{ options: { selectColumns: URL_COLUMNS } },
renderAiConsumer,
);
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
AI_DEFAULT_COUNT,
);
});
it('loads the url columns for the panel in panelTypes', () => {
renderAiAtUrl({
options: { selectColumns: URL_COLUMNS },
panelTypes: PANEL_TYPES.TRACE,
});
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('3');
});
it('files the url columns under the panel in panelTypes', () => {
renderAiAtUrl({
options: { selectColumns: URL_COLUMNS },
panelTypes: PANEL_TYPES.LIST,
});
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
AI_DEFAULT_COUNT,
);
});
it('prefers its localStorage over the url options', () => {
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
[PANEL_TYPES.TRACE]: [{ name: 'ai.local.col' }],
});
renderAiAtUrl({
options: { selectColumns: URL_COLUMNS },
panelTypes: PANEL_TYPES.TRACE,
});
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('2');
});
it('keeps its url columns out of the traces slice', () => {
renderAtUrl(
ROUTES.AI_OBSERVABILITY_EXPLORER,
{
options: { selectColumns: URL_COLUMNS },
panelTypes: PANEL_TYPES.TRACE,
},
(): void => {
render(
<Consumer
sourcePage={SavedviewtypesSourceDTO.traces}
testIdPrefix={TESTID_TRACES}
/>,
undefined,
{ initialRoute: ROUTE_AI },
);
},
);
expect(screen.getByTestId('traces-columns-len')).toHaveTextContent(
String(defaultTraceSelectedColumns.length),
);
});
it('drops stored columns without a name or from another signal', () => {
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
[PANEL_TYPES.TRACE]: [
{ name: 'ai.col.a' },
{ name: 'logs.col', signal: 'logs' },
{ key: 'no.name' },
{},
'not-a-column',
],
});
renderAiConsumer();
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('2');
});
it('ignores a stored panel that is not a list', () => {
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
[PANEL_TYPES.TRACE]: 'ai.col.a',
});
renderAiConsumer();
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
AI_DEFAULT_COUNT,
);
});
it('falls back to the url options when every stored column is invalid', () => {
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
[PANEL_TYPES.TRACE]: [{ name: 'logs.col', signal: 'logs' }],
});
renderAiAtUrl({
options: { selectColumns: URL_COLUMNS },
panelTypes: PANEL_TYPES.TRACE,
});
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('3');
});
it('loads the columns of the panel open in the url', () => {
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
[PANEL_TYPES.TRACE]: [{ name: 'ai.trace.a' }, { name: 'ai.trace.b' }],
[PANEL_TYPES.LIST]: [{ name: 'ai.list.col' }],
});
render(
<Consumer
sourcePage={SavedviewtypesSourceDTO.ai_observability}
testIdPrefix={TESTID_AI}
/>,
undefined,
{
initialRoute: `${ROUTE_AI}?panelTypes=${encodeURIComponent(
JSON.stringify(PANEL_TYPES.LIST),
)}`,
},
);
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('1');
});
it('keeps each panel apart in its localStorage', () => {
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
[PANEL_TYPES.LIST]: [{ name: 'ai.list.col' }],
});
renderAiConsumer();
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
AI_DEFAULT_COUNT,
);
});
it('updateColumns writes its own localStorage key in direct mode', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderAiConsumer();
await user.click(screen.getByTestId('ai-update-columns'));
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('2');
expect(
getLocalStorageJSON<PanelColumns>(
LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS,
)?.[PANEL_TYPES.TRACE],
).toHaveLength(1);
expect(
getLocalStorageJSON<TracesLocalOptions>(LOCALSTORAGE.TRACES_LIST_OPTIONS),
).toBeNull();
});
});
});

View File

@@ -0,0 +1,41 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import getAiObservabilityUpdaterConfig from '../configs/aiObservabilityUpdaterConfig';
import { PreferenceMode } from '../types';
const columns = [{ name: 'total_tokens' }] as TelemetryFieldKey[];
describe('aiObservabilityUpdaterConfig in saved view mode', () => {
it('stores the columns under the open panel', () => {
const setSavedViewPreferences = jest.fn();
const { updateColumns } = getAiObservabilityUpdaterConfig(
jest.fn(),
setSavedViewPreferences,
PANEL_TYPES.TRACE,
);
updateColumns(columns, PreferenceMode.SAVED_VIEW);
expect(setSavedViewPreferences).toHaveBeenCalledWith({
columns,
panelType: PANEL_TYPES.TRACE,
});
});
it('stores the columns under the given panel', () => {
const setSavedViewPreferences = jest.fn();
const { updateColumns } = getAiObservabilityUpdaterConfig(
jest.fn(),
setSavedViewPreferences,
PANEL_TYPES.LIST,
);
updateColumns(columns, PreferenceMode.SAVED_VIEW, PANEL_TYPES.TRACE);
expect(setSavedViewPreferences).toHaveBeenCalledWith({
columns,
panelType: PANEL_TYPES.TRACE,
});
});
});

View File

@@ -1,8 +1,11 @@
import { useGetSavedView } from 'api/generated/services/saved-view';
import {
SavedviewtypesPanelTypeDTO,
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { TRACE_VIEW_DEFAULT_COLUMNS } from 'container/LLMObservability/Explorer/constants';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
@@ -197,6 +200,64 @@ describe('usePreferenceSync in saved view mode', () => {
);
});
it('adds trace_id to AI trace selectedFields and uses the defaults when absent', () => {
mockViews([
makeView('ai-with-fields', SavedviewtypesSourceDTO.ai_observability, {
panelType: SavedviewtypesPanelTypeDTO.trace,
selectedFields: [{ name: 'total_tokens' }],
}),
makeView('ai-without-fields', SavedviewtypesSourceDTO.ai_observability, {
panelType: SavedviewtypesPanelTypeDTO.trace,
}),
]);
const withFields = renderHook(() =>
usePreferenceSync({
mode: PreferenceMode.SAVED_VIEW,
sourcePage: SavedviewtypesSourceDTO.ai_observability,
savedViewId: 'ai-with-fields',
}),
);
const withoutFields = renderHook(() =>
usePreferenceSync({
mode: PreferenceMode.SAVED_VIEW,
sourcePage: SavedviewtypesSourceDTO.ai_observability,
savedViewId: 'ai-without-fields',
}),
);
expect(withFields.result.current.preferences).toStrictEqual({
columns: [{ name: 'trace_id' }, { name: 'total_tokens' }],
panelType: PANEL_TYPES.TRACE,
});
expect(withoutFields.result.current.preferences).toStrictEqual({
columns: TRACE_VIEW_DEFAULT_COLUMNS,
panelType: PANEL_TYPES.TRACE,
});
});
it('gives the open panel its defaults when the AI view was saved on another panel', () => {
mockViews([
makeView('ai-graph', SavedviewtypesSourceDTO.ai_observability, {
panelType: SavedviewtypesPanelTypeDTO.graph,
}),
]);
const { result } = renderHook(() =>
usePreferenceSync({
mode: PreferenceMode.SAVED_VIEW,
sourcePage: SavedviewtypesSourceDTO.ai_observability,
panelType: PANEL_TYPES.TRACE,
savedViewId: 'ai-graph',
}),
);
expect(result.current.preferences).toStrictEqual({
columns: TRACE_VIEW_DEFAULT_COLUMNS,
panelType: PANEL_TYPES.TRACE,
});
});
it('uses defaults while the saved view is not loaded', () => {
mockViews([makeView('other', SavedviewtypesSourceDTO.logs, {})]);
@@ -212,4 +273,21 @@ describe('usePreferenceSync in saved view mode', () => {
ensureLogsRequiredColumns(defaultLogsSelectedColumns),
);
});
it('uses the AI trace defaults while the saved view is not loaded', () => {
mockViews([]);
const { result } = renderHook(() =>
usePreferenceSync({
mode: PreferenceMode.SAVED_VIEW,
sourcePage: SavedviewtypesSourceDTO.ai_observability,
panelType: PANEL_TYPES.TRACE,
savedViewId: 'missing',
}),
);
expect(result.current.preferences).toStrictEqual({
columns: TRACE_VIEW_DEFAULT_COLUMNS,
panelType: PANEL_TYPES.TRACE,
});
});
});

View File

@@ -114,6 +114,7 @@ describe('usePreferenceUpdater', () => {
expect(mockLogsUpdateColumns).toHaveBeenCalledWith(
newColumns,
PreferenceMode.DIRECT,
undefined,
);
expect(mockTracesUpdateColumns).not.toHaveBeenCalled();
expect(setReSync).toHaveBeenCalledWith(true);
@@ -177,6 +178,7 @@ describe('usePreferenceUpdater', () => {
expect(mockTracesUpdateColumns).toHaveBeenCalledWith(
newColumns,
PreferenceMode.DIRECT,
undefined,
);
expect(mockLogsUpdateColumns).not.toHaveBeenCalled();
expect(setReSync).toHaveBeenCalledWith(true);

View File

@@ -1,20 +1,47 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { isPreferenceSourcePage } from '../utils';
import { hasPreferences, isPreferenceSourcePage } from '../utils';
describe('isPreferenceSourcePage', () => {
it.each([SavedviewtypesSourceDTO.logs, SavedviewtypesSourceDTO.traces])(
'is true for %s',
it.each([
SavedviewtypesSourceDTO.logs,
SavedviewtypesSourceDTO.traces,
SavedviewtypesSourceDTO.ai_observability,
])('is true for %s', (source) => {
expect(isPreferenceSourcePage(source)).toBe(true);
});
it.each([SavedviewtypesSourceDTO.metrics, SavedviewtypesSourceDTO.meter])(
'is false for %s',
(source) => {
expect(isPreferenceSourcePage(source)).toBe(true);
expect(isPreferenceSourcePage(source)).toBe(false);
},
);
});
it.each([
SavedviewtypesSourceDTO.metrics,
SavedviewtypesSourceDTO.meter,
SavedviewtypesSourceDTO.ai_observability,
])('is false for %s', (source) => {
expect(isPreferenceSourcePage(source)).toBe(false);
describe('hasPreferences', () => {
it('is true for every panel of the logs and traces explorers', () => {
expect(
hasPreferences(SavedviewtypesSourceDTO.traces, PANEL_TYPES.TRACE),
).toBe(true);
expect(hasPreferences(SavedviewtypesSourceDTO.logs, PANEL_TYPES.LIST)).toBe(
true,
);
});
it('is true only for the AI observability panels that keep columns', () => {
expect(
hasPreferences(SavedviewtypesSourceDTO.ai_observability, PANEL_TYPES.TRACE),
).toBe(true);
expect(
hasPreferences(SavedviewtypesSourceDTO.ai_observability, PANEL_TYPES.LIST),
).toBe(false);
});
it('is false for source pages without a slice', () => {
expect(
hasPreferences(SavedviewtypesSourceDTO.metrics, PANEL_TYPES.LIST),
).toBe(false);
});
});

View File

@@ -0,0 +1,101 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import { TelemetryFieldKey } from 'api/v5/v5';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
DEFAULT_PANEL_TYPE,
TRACE_VIEW_DEFAULT_COLUMNS,
} from 'container/LLMObservability/Explorer/constants';
import { PanelColumns } from '../types';
import { isAiObservabilityExplorerRoute } from '../utils';
/**
* Validates if a column is valid for the AI observability explorer
* Needs a name; a column from another signal would fail the trace query
*/
const isValidAiColumn = (column: unknown): column is TelemetryFieldKey => {
if (!column || typeof column !== 'object') {
return false;
}
const { name, signal } = column as { name?: unknown; signal?: unknown };
return (
typeof name === 'string' && name !== '' && (!signal || signal === 'traces')
);
};
// Keeps the valid columns of each panel; a panel left empty falls back to the
// defaults, like one that was never stored.
const toPanels = (value: unknown): PanelColumns => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.entries(value).reduce<PanelColumns>(
(panels, [panelType, columns]) => {
if (!Array.isArray(columns)) {
return panels;
}
const validColumns = columns.filter(isValidAiColumn);
return validColumns.length
? { ...panels, [panelType as PANEL_TYPES]: validColumns }
: panels;
},
{},
);
};
// Stored as { [panelType]: columns }.
export const readStoredPanels = (): PanelColumns => {
try {
return toPanels(
JSON.parse(
getLocalStorageKey(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS) || '{}',
),
);
} catch {
return {};
}
};
// The url keeps the open panel's columns in `options.selectColumns`, and
// `panelTypes` says which panel they belong to.
const readUrlPanels = (): PanelColumns => {
if (!isAiObservabilityExplorerRoute()) {
return {};
}
const urlParams = new URLSearchParams(window.location.search);
try {
const options = JSON.parse(urlParams.get('options') || '{}');
const panelTypeParam = urlParams.get(QueryParams.panelTypes);
const panelType: PANEL_TYPES = panelTypeParam
? JSON.parse(panelTypeParam)
: DEFAULT_PANEL_TYPE;
return toPanels({ [panelType]: options?.selectColumns });
} catch {
return {};
}
};
type AiObservabilityLoaderConfig = {
local: () => { columns: TelemetryFieldKey[] };
url: () => { columns: TelemetryFieldKey[] };
default: () => { columns: TelemetryFieldKey[] };
priority: readonly ['local', 'url', 'default'];
};
// --- AI OBSERVABILITY preferences loader config ---
// Each panel keeps its own columns, so the config is built for one panel.
const getAiObservabilityLoaderConfig = (
panelType: PANEL_TYPES,
): AiObservabilityLoaderConfig => ({
local: () => ({ columns: readStoredPanels()[panelType] ?? [] }),
url: () => ({ columns: readUrlPanels()[panelType] ?? [] }),
// Only the trace panel keeps columns today.
default: () => ({
columns: panelType === PANEL_TYPES.TRACE ? TRACE_VIEW_DEFAULT_COLUMNS : [],
}),
priority: ['local', 'url', 'default'] as const,
});
export default getAiObservabilityLoaderConfig;

View File

@@ -0,0 +1,55 @@
import { Dispatch, SetStateAction } from 'react';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { TelemetryFieldKey } from 'api/v5/v5';
import { LOCALSTORAGE } from 'constants/localStorage';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { defaultOptionsQuery } from 'container/OptionsMenu/constants';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { PreferenceMode, Preferences } from '../types';
import { readStoredPanels } from './aiObservabilityLoaderConfig';
// --- AI OBSERVABILITY preferences updater config ---
const getAiObservabilityUpdaterConfig = (
redirectWithOptionsData: (options: OptionsQuery) => void,
setSavedViewPreferences: Dispatch<SetStateAction<Preferences | null>>,
panelType: PANEL_TYPES | undefined,
): {
updateColumns: (
newColumns: TelemetryFieldKey[],
mode: string,
columnsPanelType?: PANEL_TYPES,
) => void;
updateFormatting: () => void;
} => ({
updateColumns: (newColumns, mode, columnsPanelType = panelType): void => {
if (!columnsPanelType) {
return;
}
if (mode === PreferenceMode.SAVED_VIEW) {
setSavedViewPreferences({
columns: newColumns,
panelType: columnsPanelType,
});
}
if (mode === PreferenceMode.DIRECT) {
const panels = {
...readStoredPanels(),
[columnsPanelType]: newColumns,
};
redirectWithOptionsData({
...defaultOptionsQuery,
selectColumns: newColumns,
});
setLocalStorageKey(
LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS,
JSON.stringify(panels),
);
}
},
updateFormatting: (): void => {}, // no-op for ai observability
});
export default getAiObservabilityUpdaterConfig;

View File

@@ -7,6 +7,7 @@ import { FontSize } from 'container/OptionsMenu/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FormattingOptions } from '../types';
import { isAiObservabilityExplorerRoute } from '../utils';
/**
* Validates if a column is valid for Logs Explorer
@@ -53,6 +54,9 @@ const logsLoaders = {
columns: BaseAutocompleteData[];
formatting: FormattingOptions;
} => {
if (isAiObservabilityExplorerRoute()) {
return { columns: [], formatting: undefined } as any;
}
const urlParams = new URLSearchParams(window.location.search);
try {
const options = JSON.parse(urlParams.get('options') || '{}');

View File

@@ -5,6 +5,8 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { isAiObservabilityExplorerRoute } from '../utils';
/**
* Validates if a column is valid for Traces Explorer
* Filters out Logs-specific columns that would cause query failures
@@ -41,6 +43,9 @@ const tracesLoaders = {
url: (): {
columns: BaseAutocompleteData[];
} => {
if (isAiObservabilityExplorerRoute()) {
return { columns: [] };
}
const urlParams = new URLSearchParams(window.location.search);
try {
const options = JSON.parse(urlParams.get('options') || '{}');

View File

@@ -1,7 +1,14 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
// Only the logs and traces explorers keep column and formatting preferences.
// Source pages that keep column and formatting preferences.
export const PREFERENCE_SOURCE_PAGES = [
SavedviewtypesSourceDTO.logs,
SavedviewtypesSourceDTO.traces,
SavedviewtypesSourceDTO.ai_observability,
] as const;
// The AI observability panels that keep their own columns.
export const AI_OBSERVABILITY_COLUMN_PANELS: PANEL_TYPES[] = [
PANEL_TYPES.TRACE,
];

View File

@@ -12,6 +12,7 @@ import {
PreferenceMode,
} from 'providers/preferences/types';
import { useAiObservabilityPreferenceSync } from '../sync/useAiObservabilityPreferenceSync';
import { usePreferenceSync } from '../sync/usePreferenceSync';
const PreferenceContext = createContext<PreferenceContextValue | undefined>(
@@ -47,12 +48,18 @@ export function PreferenceContextProvider({
sourcePage: SavedviewtypesSourceDTO.traces,
});
const aiObservabilitySlice = useAiObservabilityPreferenceSync({
mode: savedViewId ? PreferenceMode.SAVED_VIEW : PreferenceMode.DIRECT,
savedViewId: savedViewId || undefined,
});
const value = useMemo<PreferenceContextValue>(
() => ({
[SavedviewtypesSourceDTO.logs]: logsSlice,
[SavedviewtypesSourceDTO.traces]: tracesSlice,
[SavedviewtypesSourceDTO.ai_observability]: aiObservabilitySlice,
}),
[logsSlice, tracesSlice],
[logsSlice, tracesSlice, aiObservabilitySlice],
);
return (

View File

@@ -0,0 +1,17 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import getAiObservabilityLoaderConfig from '../configs/aiObservabilityLoaderConfig';
import { Preferences } from '../types';
// The defaults of one panel, for source pages whose panels each keep their own
// columns; undefined for the others.
export function getPanelDefaults(
sourcePage: SavedviewtypesSourceDTO,
panelType: PANEL_TYPES,
): Preferences | undefined {
if (sourcePage !== SavedviewtypesSourceDTO.ai_observability) {
return undefined;
}
return { ...getAiObservabilityLoaderConfig(panelType).default(), panelType };
}

View File

@@ -1,9 +1,12 @@
import { useEffect, useState } from 'react';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ensureAiRequiredColumns } from 'container/LLMObservability/Explorer/constants';
import { ensureLogsRequiredColumns } from 'container/OptionsMenu/constants';
import { has } from 'lodash-es';
import getAiObservabilityLoaderConfig from '../configs/aiObservabilityLoaderConfig';
import logsLoaderConfig from '../configs/logsLoaderConfig';
import tracesLoaderConfig from '../configs/tracesLoaderConfig';
import { FormattingOptions, Preferences, PreferenceSourcePage } from '../types';
@@ -66,17 +69,37 @@ function tracesPreferencesLoader(): {
return { columns };
}
const PREFERENCE_LOADERS: Record<PreferenceSourcePage, () => Preferences> = {
// Loads the open panel only. Same priority as the other loaders: localStorage,
// then the url, then the defaults.
function aiObservabilityPreferencesLoader(
panelType?: PANEL_TYPES,
): Preferences {
if (!panelType) {
return { columns: [] };
}
const { columns } = preferencesLoader<Preferences>(
getAiObservabilityLoaderConfig(panelType),
);
return { columns: ensureAiRequiredColumns(columns, panelType), panelType };
}
const PREFERENCE_LOADERS: Record<
PreferenceSourcePage,
(panelType?: PANEL_TYPES) => Preferences
> = {
[SavedviewtypesSourceDTO.logs]: logsPreferencesLoader,
[SavedviewtypesSourceDTO.traces]: tracesPreferencesLoader,
[SavedviewtypesSourceDTO.ai_observability]: aiObservabilityPreferencesLoader,
};
export function usePreferenceLoader({
sourcePage,
panelType,
reSync,
setReSync,
}: {
sourcePage: PreferenceSourcePage;
panelType?: PANEL_TYPES;
reSync: boolean;
setReSync: (value: boolean) => void;
}): {
@@ -85,17 +108,24 @@ export function usePreferenceLoader({
error: Error | null;
} {
const loadSlicePreferences = PREFERENCE_LOADERS[sourcePage];
const [preferences, setPreferences] = useState<Preferences | null>(
loadSlicePreferences,
const [preferences, setPreferences] = useState<Preferences | null>(() =>
loadSlicePreferences(panelType),
);
const [error, setError] = useState<Error | null>(null);
// Switching panels loads the new panel's columns in the same render.
const [loadedPanelType, setLoadedPanelType] = useState(panelType);
if (loadedPanelType !== panelType) {
setLoadedPanelType(panelType);
setPreferences(loadSlicePreferences(panelType));
}
useEffect((): void => {
function loadPreferences(): void {
setError(null);
try {
setPreferences(loadSlicePreferences());
setPreferences(loadSlicePreferences(panelType));
} catch (e) {
setError(e as Error);
} finally {
@@ -110,7 +140,7 @@ export function usePreferenceLoader({
if (reSync) {
loadPreferences();
}
}, [loadSlicePreferences, reSync, setReSync]);
}, [loadSlicePreferences, panelType, reSync, setReSync]);
return { preferences, loading: false, error };
}

View File

@@ -0,0 +1,34 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { DEFAULT_PANEL_TYPE } from 'container/LLMObservability/Explorer/constants';
import useUrlQuery from 'hooks/useUrlQuery';
import { PreferenceMode, PreferenceSlice } from '../types';
import { usePreferenceSync } from './usePreferenceSync';
export function useAiObservabilityPreferenceSync({
mode,
savedViewId,
}: {
mode: PreferenceMode;
savedViewId: string | undefined;
}): PreferenceSlice {
const params = useUrlQuery();
let panelType = DEFAULT_PANEL_TYPE;
const panelTypesParam = params.get(QueryParams.panelTypes);
if (panelTypesParam) {
try {
panelType = JSON.parse(panelTypesParam);
} catch (e) {
console.error(e);
}
}
return usePreferenceSync({
mode,
savedViewId,
sourcePage: SavedviewtypesSourceDTO.ai_observability,
panelType,
});
}

View File

@@ -1,9 +1,11 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useGetSavedView } from 'api/generated/services/saved-view';
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getViewColumnsAndFormatting } from 'container/SavedViews/utils/getViewColumnsAndFormatting';
import { getPanelDefaults } from '../loader/getPanelDefaults';
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
import {
FormattingOptions,
@@ -16,16 +18,22 @@ import { usePreferenceUpdater } from '../updater/usePreferenceUpdater';
export function usePreferenceSync({
mode,
sourcePage,
panelType,
savedViewId,
}: {
mode: PreferenceMode;
sourcePage: PreferenceSourcePage;
// The open panel, for source pages whose panels each keep their own columns.
panelType?: PANEL_TYPES;
savedViewId: string | undefined;
}): {
preferences: Preferences | null;
loading: boolean;
error: Error | null;
updateColumns: (newColumns: TelemetryFieldKey[]) => void;
updateColumns: (
newColumns: TelemetryFieldKey[],
panelType?: PANEL_TYPES,
) => void;
updateFormatting: (newFormatting: FormattingOptions) => void;
} {
const isSavedViewMode = mode === PreferenceMode.SAVED_VIEW && !!savedViewId;
@@ -58,22 +66,36 @@ export function usePreferenceSync({
const [reSync, setReSync] = useState(false);
const { preferences, loading, error } = usePreferenceLoader({
sourcePage,
panelType,
reSync,
setReSync,
});
const { updateColumns, updateFormatting } = usePreferenceUpdater({
sourcePage,
panelType,
mode,
preferences,
setReSync,
setSavedViewPreferences,
});
// A view saved on another panel has no columns for the open one, so that
// panel starts from its defaults.
const viewPreferences = useMemo(
() =>
savedViewPreferences?.panelType &&
panelType &&
savedViewPreferences.panelType !== panelType
? (getPanelDefaults(sourcePage, panelType) ?? savedViewPreferences)
: savedViewPreferences,
[savedViewPreferences, panelType, sourcePage],
);
return {
preferences:
mode === PreferenceMode.SAVED_VIEW && savedViewId
? savedViewPreferences
? viewPreferences
: preferences,
loading,
error,

View File

@@ -1,4 +1,5 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { LogViewMode, FontSize } from 'container/OptionsMenu/types';
import { PREFERENCE_SOURCE_PAGES } from '../constants';
@@ -10,11 +11,17 @@ export enum PreferenceMode {
export type PreferenceSourcePage = (typeof PREFERENCE_SOURCE_PAGES)[number];
export type PanelColumns = Partial<Record<PANEL_TYPES, TelemetryFieldKey[]>>;
export interface PreferenceSlice {
preferences: Preferences | null;
loading: boolean;
error: Error | null;
updateColumns: (newColumns: TelemetryFieldKey[]) => void;
// `panelType` defaults to the open panel.
updateColumns: (
newColumns: TelemetryFieldKey[],
panelType?: PANEL_TYPES,
) => void;
updateFormatting: (newFormatting: FormattingOptions) => void;
}
@@ -33,4 +40,7 @@ export interface FormattingOptions {
export interface Preferences {
columns: TelemetryFieldKey[];
formatting?: FormattingOptions;
// Set by source pages whose panels each keep their own columns: the panel
// these columns belong to.
panelType?: PANEL_TYPES;
}

View File

@@ -1,6 +1,7 @@
import { Dispatch, SetStateAction } from 'react';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
defaultOptionsQuery,
URL_OPTIONS,
@@ -8,6 +9,7 @@ import {
import { OptionsQuery } from 'container/OptionsMenu/types';
import useUrlQueryData from 'hooks/useUrlQueryData';
import getAiObservabilityUpdaterConfig from '../configs/aiObservabilityUpdaterConfig';
import getLogsUpdaterConfig from '../configs/logsUpdaterConfig';
import getTracesUpdaterConfig from '../configs/tracesUpdaterConfig';
import { FormattingOptions, Preferences, PreferenceSourcePage } from '../types';
@@ -16,10 +18,15 @@ const getUpdaterConfig = (
preferences: Preferences | null,
redirectWithOptionsData: (options: OptionsQuery) => void,
setSavedViewPreferences: Dispatch<SetStateAction<Preferences | null>>,
panelType: PANEL_TYPES | undefined,
): Record<
PreferenceSourcePage,
{
updateColumns: (newColumns: TelemetryFieldKey[], mode: string) => void;
updateColumns: (
newColumns: TelemetryFieldKey[],
mode: string,
panelType?: PANEL_TYPES,
) => void;
updateFormatting: (newFormatting: FormattingOptions, mode: string) => void;
}
> => ({
@@ -32,22 +39,32 @@ const getUpdaterConfig = (
redirectWithOptionsData,
setSavedViewPreferences,
),
[SavedviewtypesSourceDTO.ai_observability]: getAiObservabilityUpdaterConfig(
redirectWithOptionsData,
setSavedViewPreferences,
panelType,
),
});
export function usePreferenceUpdater({
sourcePage,
panelType,
mode,
preferences,
setReSync,
setSavedViewPreferences,
}: {
sourcePage: PreferenceSourcePage;
panelType?: PANEL_TYPES;
mode: string;
preferences: Preferences | null;
setReSync: Dispatch<SetStateAction<boolean>>;
setSavedViewPreferences: Dispatch<SetStateAction<Preferences | null>>;
}): {
updateColumns: (newColumns: TelemetryFieldKey[]) => void;
updateColumns: (
newColumns: TelemetryFieldKey[],
panelType?: PANEL_TYPES,
) => void;
updateFormatting: (newFormatting: FormattingOptions) => void;
} {
const { redirectWithQuery: redirectWithOptionsData } =
@@ -56,11 +73,15 @@ export function usePreferenceUpdater({
preferences,
redirectWithOptionsData,
setSavedViewPreferences,
panelType,
)[sourcePage];
return {
updateColumns: (newColumns: TelemetryFieldKey[]): void => {
updater.updateColumns(newColumns, mode);
updateColumns: (
newColumns: TelemetryFieldKey[],
columnsPanelType?: PANEL_TYPES,
): void => {
updater.updateColumns(newColumns, mode, columnsPanelType);
setReSync(true);
},
updateFormatting: (newFormatting: FormattingOptions): void => {

View File

@@ -1,6 +1,12 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { withBasePath } from 'utils/basePath';
import { PREFERENCE_SOURCE_PAGES } from './constants';
import {
AI_OBSERVABILITY_COLUMN_PANELS,
PREFERENCE_SOURCE_PAGES,
} from './constants';
import { PreferenceSourcePage } from './types';
export function isPreferenceSourcePage(
@@ -10,3 +16,21 @@ export function isPreferenceSourcePage(
PREFERENCE_SOURCE_PAGES as readonly SavedviewtypesSourceDTO[]
).includes(sourcePage);
}
export function hasPreferences(
sourcePage: SavedviewtypesSourceDTO,
panelType: PANEL_TYPES,
): boolean {
if (sourcePage === SavedviewtypesSourceDTO.ai_observability) {
return AI_OBSERVABILITY_COLUMN_PANELS.includes(panelType);
}
return isPreferenceSourcePage(sourcePage);
}
// The url `options` param belongs to the explorer that is open, so a link to
// one explorer never seeds another explorer's slice.
export function isAiObservabilityExplorerRoute(): boolean {
return (
window.location.pathname === withBasePath(ROUTES.AI_OBSERVABILITY_EXPLORER)
);
}

View File

@@ -4,7 +4,10 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/gorilla/mux"
@@ -13,7 +16,7 @@ import (
func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/rules/{id}/history/stats", handler.New(
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryStats),
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryStats, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleHistoryStats",
Tags: []string{"rules"},
@@ -24,13 +27,21 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}/history/timeline", handler.New(
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryTimeline),
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryTimeline, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleHistoryTimeline",
Tags: []string{"rules"},
@@ -40,14 +51,27 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
Response: new(rulestatehistorytypes.GettableRuleStateTimeline),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}, handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}/history/top_contributors", handler.New(
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryContributors),
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryContributors, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleHistoryTopContributors",
Tags: []string{"rules"},
@@ -57,14 +81,27 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
Response: new([]rulestatehistorytypes.GettableRuleStateHistoryContributor),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}, handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}/history/filter_keys", handler.New(
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryFilterKeys),
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryFilterKeys, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleHistoryFilterKeys",
Tags: []string{"rules"},
@@ -74,14 +111,27 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
Response: new(telemetrytypes.GettableFieldKeys),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, buildFieldScopes(coretypes.VerbList)...)),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}, handler.TelemetryResourceDef{
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
Resources: ruler.NewRuleQueryFieldResources(provider.rulerService),
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}/history/filter_values", handler.New(
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryFilterValues),
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryFilterValues, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleHistoryFilterValues",
Tags: []string{"rules"},
@@ -91,14 +141,27 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
Response: new(telemetrytypes.GettableFieldValues),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, buildFieldScopes(coretypes.VerbRead)...)),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}, handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
Resources: ruler.NewRuleQueryFieldResources(provider.rulerService),
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}/history/overall_status", handler.New(
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryOverallStatus),
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryOverallStatus, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleHistoryOverallStatus",
Tags: []string{"rules"},
@@ -109,10 +172,26 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}
func buildFieldScopes(verb coretypes.Verb) []string {
return []string{
coretypes.ResourceMetaResourceLogsField.Scope(verb),
coretypes.ResourceMetaResourceTracesField.Scope(verb),
coretypes.ResourceMetaResourceMetricsField.Scope(verb),
}
}

View File

@@ -26,8 +26,9 @@ const bucketCacheCellSteps = 60
type BucketCache interface {
// cached portion + list of gaps to fetch
GetMissRanges(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step) (cached *qbtypes.Result, missing []*qbtypes.TimeRange)
// store fresh buckets for future hits
Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result)
// store fresh buckets for future hits; only the ranges in the map were
// queried for fresh, the rest of it came from the cache
Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result, execStatsForQueriedTimeRanges map[qbtypes.TimeRange]qbtypes.ExecStats)
}
// bucketCache implements the BucketCache interface.
@@ -116,7 +117,7 @@ func (bc *bucketCache) GetMissRanges(
}
// Merge buckets into a single result
mergedResult := bc.mergeBuckets(ctx, relevantBuckets, cachedWarnings)
mergedResult := bc.mergeBuckets(ctx, relevantBuckets, cachedWarnings, startMs, endMs)
// Filter the merged result to only include values within the requested time range
mergedResult = bc.filterResultToTimeRange(mergedResult, q, startMs, endMs, stepMs)
@@ -125,7 +126,7 @@ func (bc *bucketCache) GetMissRanges(
}
// Put stores fresh query results in the cache.
func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result) {
func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result, execStatsForQueriedTimeRanges map[qbtypes.TimeRange]qbtypes.ExecStats) {
// Get query window
startMs, endMs := q.Window()
@@ -188,7 +189,7 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
// Convert trimmed result to buckets, one per grid cell it spans
cellWidthMs := bucketCacheCellSteps * stepMs
freshBuckets := bc.resultToBuckets(ctx, trimmedResult, cachableStartMs, cachableEndMs, cellWidthMs)
freshBuckets := bc.resultToBuckets(ctx, trimmedResult, cachableStartMs, cachableEndMs, cellWidthMs, execStatsForQueriedTimeRanges)
cellKeys := make([]string, len(freshBuckets))
cellEntries := make([]cachetypes.Cacheable, len(freshBuckets))
@@ -210,7 +211,7 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
cellEntry = cellEntries[i].(*qbtypes.CachedData)
}
cellBuckets, changed := bc.mergeFreshBucketIntoCell(ctx, cellEntry.Buckets, freshBucket)
cellBuckets, changed := bc.mergeFreshBucketIntoCell(ctx, cellEntry.Buckets, freshBucket, execStatsForQueriedTimeRanges)
if !changed {
continue
}
@@ -463,7 +464,7 @@ func (bc *bucketCache) filterRelevantBuckets(buckets []*qbtypes.CachedBucket, st
}
// mergeBuckets combines multiple cached buckets into a single result.
func (bc *bucketCache) mergeBuckets(ctx context.Context, buckets []*qbtypes.CachedBucket, warnings []string) *qbtypes.Result {
func (bc *bucketCache) mergeBuckets(ctx context.Context, buckets []*qbtypes.CachedBucket, warnings []string, startMs, endMs uint64) *qbtypes.Result {
if len(buckets) == 0 {
return &qbtypes.Result{}
}
@@ -471,12 +472,13 @@ func (bc *bucketCache) mergeBuckets(ctx context.Context, buckets []*qbtypes.Cach
// All buckets should have the same type
resultType := buckets[0].Type
// Aggregate stats
// A bucket can reach past the window; only the part inside it is served
var totalStats qbtypes.ExecStats
for _, bucket := range buckets {
totalStats.RowsScanned += bucket.Stats.RowsScanned
totalStats.BytesScanned += bucket.Stats.BytesScanned
totalStats.DurationMS += bucket.Stats.DurationMS
servedStats := bucket.ProrateStatsToWindow(startMs, endMs)
totalStats.RowsScanned += servedStats.RowsScanned
totalStats.BytesScanned += servedStats.BytesScanned
totalStats.DurationMS += servedStats.DurationMS
}
// Merge values based on type
@@ -674,7 +676,7 @@ func (bc *bucketCache) isEmptyResult(result *qbtypes.Result) (isEmpty bool, isFi
}
// resultToBuckets converts a query result into time-based buckets.
func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Result, startMs, endMs, cellWidthMs uint64) []*qbtypes.CachedBucket {
func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Result, startMs, endMs, cellWidthMs uint64, execStatsForQueriedTimeRanges map[qbtypes.TimeRange]qbtypes.ExecStats) []*qbtypes.CachedBucket {
// Check if result is empty
isEmpty, isFiltered := bc.isEmptyResult(result)
@@ -686,7 +688,7 @@ func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Resu
}
// Empty filtered results still become buckets, so the range is not re-queried
perCellBuckets, err := result.SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs)
perCellBuckets, err := result.SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs, execStatsForQueriedTimeRanges)
if err != nil {
bc.logger.ErrorContext(ctx, "failed to convert result to buckets", errors.Attr(err))
return nil
@@ -698,7 +700,7 @@ func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Resu
// and reports whether the cell changed: a bucket already covering the fresh
// one leaves it as is, buckets inside the fresh one are replaced, buckets
// overlapping or edge to edge with it are unioned, and the rest are kept apart.
func (bc *bucketCache) mergeFreshBucketIntoCell(ctx context.Context, cellBuckets []*qbtypes.CachedBucket, freshBucket *qbtypes.CachedBucket) ([]*qbtypes.CachedBucket, bool) {
func (bc *bucketCache) mergeFreshBucketIntoCell(ctx context.Context, cellBuckets []*qbtypes.CachedBucket, freshBucket *qbtypes.CachedBucket, execStatsForQueriedTimeRanges map[qbtypes.TimeRange]qbtypes.ExecStats) ([]*qbtypes.CachedBucket, bool) {
keptBuckets := make([]*qbtypes.CachedBucket, 0, len(cellBuckets)+1)
touchingBuckets := []*qbtypes.CachedBucket{}
for _, cellBucket := range cellBuckets {
@@ -711,7 +713,7 @@ func (bc *bucketCache) mergeFreshBucketIntoCell(ctx context.Context, cellBuckets
keptBuckets = append(keptBuckets, cellBucket)
}
}
keptBuckets = append(keptBuckets, bc.unionBuckets(ctx, freshBucket, touchingBuckets))
keptBuckets = append(keptBuckets, bc.unionBuckets(ctx, freshBucket, touchingBuckets, execStatsForQueriedTimeRanges))
slices.SortFunc(keptBuckets, func(a, b *qbtypes.CachedBucket) int {
return cmp.Compare(a.StartMs, b.StartMs)
@@ -722,7 +724,16 @@ func (bc *bucketCache) mergeFreshBucketIntoCell(ctx context.Context, cellBuckets
// unionBuckets widens the fresh bucket by the buckets it touches. Buckets
// inside it are replaced outright, so only a genuine partial overlap decodes
// anything.
func (bc *bucketCache) unionBuckets(ctx context.Context, freshBucket *qbtypes.CachedBucket, touchingBuckets []*qbtypes.CachedBucket) *qbtypes.CachedBucket {
func (bc *bucketCache) unionBuckets(ctx context.Context, freshBucket *qbtypes.CachedBucket, touchingBuckets []*qbtypes.CachedBucket, execStatsForQueriedTimeRanges map[qbtypes.TimeRange]qbtypes.ExecStats) *qbtypes.CachedBucket {
// Data queried again takes the new query's cost, which the fresh bucket
// carries; a touching bucket keeps its cost only for what was not queried again
for _, touchingBucket := range touchingBuckets {
keptStats := touchingBucket.ProrateStatsToUnqueried(execStatsForQueriedTimeRanges)
freshBucket.Stats.RowsScanned += keptStats.RowsScanned
freshBucket.Stats.BytesScanned += keptStats.BytesScanned
freshBucket.Stats.DurationMS += keptStats.DurationMS
}
bucketsToMerge := []*qbtypes.CachedBucket{freshBucket}
for _, touchingBucket := range touchingBuckets {
if !freshBucket.Contains(touchingBucket) {
@@ -743,14 +754,11 @@ func (bc *bucketCache) unionBuckets(ctx context.Context, freshBucket *qbtypes.Ca
EndMs: freshBucket.EndMs,
Type: freshBucket.Type,
Value: value,
Stats: qbtypes.ExecStats{StepIntervals: freshBucket.Stats.StepIntervals},
Stats: freshBucket.Stats,
}
for _, mergedBucket := range bucketsToMerge {
unionBucket.StartMs = min(unionBucket.StartMs, mergedBucket.StartMs)
unionBucket.EndMs = max(unionBucket.EndMs, mergedBucket.EndMs)
unionBucket.Stats.RowsScanned += mergedBucket.Stats.RowsScanned
unionBucket.Stats.BytesScanned += mergedBucket.Stats.BytesScanned
unionBucket.Stats.DurationMS += mergedBucket.Stats.DurationMS
}
return unionBucket
}

View File

@@ -30,7 +30,7 @@ func BenchmarkBucketCache_GetMissRanges(b *testing.B) {
endMs: uint64((i + 1) * 10000),
}
result := createBenchmarkResult(query.startMs, query.endMs, 1000)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
}
// Create test queries with varying cache hit patterns
@@ -121,7 +121,7 @@ func BenchmarkBucketCache_Put(b *testing.B) {
for i := 0; i < b.N; i++ {
for j := 0; j < tc.numQueries; j++ {
bc.Put(ctx, orgID, queries[j], qbtypes.Step{Duration: 1000 * time.Millisecond}, results[j])
bc.Put(ctx, orgID, queries[j], qbtypes.Step{Duration: 1000 * time.Millisecond}, results[j], map[qbtypes.TimeRange]qbtypes.ExecStats{{From: queries[j].startMs, To: queries[j].endMs}: results[j].Stats})
}
}
})
@@ -259,7 +259,7 @@ func BenchmarkBucketCache_ConcurrentOperations(b *testing.B) {
endMs: uint64((i + 1) * 10000),
}
result := createBenchmarkResult(query.startMs, query.endMs, 1000)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
}
b.ResetTimer()
@@ -284,7 +284,7 @@ func BenchmarkBucketCache_ConcurrentOperations(b *testing.B) {
endMs: uint64((i + 1) * 10000),
}
result := createBenchmarkResult(query.startMs, query.endMs, 1000)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
case 2: // Partial read
query := &mockQuery{
fingerprint: fmt.Sprintf("concurrent-query-%d", i%100),

View File

@@ -59,7 +59,7 @@ func TestBucketCacheStepAlignment(t *testing.T) {
}
// Put result in cache
bc.Put(ctx, orgID, query, step, result)
bc.Put(ctx, orgID, query, step, result, nil)
// Get cached data
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
@@ -109,7 +109,7 @@ func TestBucketCacheNoStepInterval(t *testing.T) {
}
// Should cache the entire range when step is 0
bc.Put(ctx, orgID, query, step, result)
bc.Put(ctx, orgID, query, step, result, nil)
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
assert.NotNil(t, cached)

View File

@@ -156,7 +156,7 @@ func TestBucketCache_Put_And_Get(t *testing.T) {
}
// Store in cache
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
// Wait a bit for cache to be written
time.Sleep(10 * time.Millisecond)
@@ -189,7 +189,7 @@ func TestBucketCache_PartialHit(t *testing.T) {
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 1000, 3000, 1000),
}
bc.Put(context.Background(), valuer.UUID{}, query1, qbtypes.Step{Duration: 1000 * time.Millisecond}, result1)
bc.Put(context.Background(), valuer.UUID{}, query1, qbtypes.Step{Duration: 1000 * time.Millisecond}, result1, nil)
// Wait for cache write
time.Sleep(10 * time.Millisecond)
@@ -225,7 +225,7 @@ func TestBucketCache_MultipleBuckets(t *testing.T) {
bc.Put(context.Background(), valuer.UUID{}, query1, qbtypes.Step{Duration: 100 * time.Millisecond}, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 1000, 2000, 100),
})
}, nil)
query2 := &mockQuery{
fingerprint: "test-query",
@@ -235,7 +235,7 @@ func TestBucketCache_MultipleBuckets(t *testing.T) {
bc.Put(context.Background(), valuer.UUID{}, query2, qbtypes.Step{Duration: 100 * time.Millisecond}, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 3000, 4000, 100),
})
}, nil)
// Wait for cache writes
time.Sleep(10 * time.Millisecond)
@@ -280,7 +280,7 @@ func TestBucketCache_FluxInterval(t *testing.T) {
}
// This should not be cached due to flux interval
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
// Wait a bit
time.Sleep(10 * time.Millisecond)
@@ -358,7 +358,7 @@ func TestBucketCache_MergeTimeSeriesResults(t *testing.T) {
{Series: []*qbtypes.TimeSeries{series1}},
},
},
})
}, nil)
// Cache second part
query2 := &mockQuery{
@@ -374,7 +374,7 @@ func TestBucketCache_MergeTimeSeriesResults(t *testing.T) {
{Series: []*qbtypes.TimeSeries{series2}},
},
},
})
}, nil)
// Wait for cache writes
time.Sleep(10 * time.Millisecond)
@@ -441,7 +441,7 @@ func TestBucketCache_RawData(t *testing.T) {
Value: rawData,
}
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
time.Sleep(10 * time.Millisecond)
cached, missing := bc.GetMissRanges(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
@@ -481,7 +481,7 @@ func TestBucketCache_ScalarData(t *testing.T) {
Value: scalarData,
}
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
time.Sleep(10 * time.Millisecond)
cached, missing := bc.GetMissRanges(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
@@ -509,7 +509,7 @@ func TestBucketCache_EmptyFingerprint(t *testing.T) {
Value: createTestTimeSeries("A", 1000, 5000, 1000),
}
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
time.Sleep(10 * time.Millisecond)
// Should still be able to retrieve
@@ -564,7 +564,7 @@ func TestBucketCache_ConcurrentAccess(t *testing.T) {
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries(fmt.Sprintf("Q%d", id), query.startMs, query.endMs, 100),
}
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 100 * time.Microsecond}, result)
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 100 * time.Microsecond}, result, nil)
done <- true
}(i)
}
@@ -624,7 +624,7 @@ func TestBucketCache_GetMissRanges_FluxInterval(t *testing.T) {
},
}
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, cachedResult)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, cachedResult, nil)
// Get miss ranges
cached, missing := bc.GetMissRanges(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
@@ -686,7 +686,7 @@ func TestBucketCache_Put_FluxIntervalTrimming(t *testing.T) {
}
// Put the result
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
// Retrieve cached data
cached, missing := bc.GetMissRanges(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
@@ -756,7 +756,7 @@ func TestBucketCache_Put_EntireRangeInFluxInterval(t *testing.T) {
}
// Put the result - should not cache anything
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
// Try to get cached data - should have no cached data
cached, missing := bc.GetMissRanges(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
@@ -862,7 +862,7 @@ func TestBucketCache_EmptyDataHandling(t *testing.T) {
}
// Put the result
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, tt.result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, tt.result, nil)
// Wait a bit for cache to be written
time.Sleep(10 * time.Millisecond)
@@ -927,7 +927,7 @@ func TestBucketCache_PartialValues(t *testing.T) {
}
// Put the result
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
// Wait for cache to be written
time.Sleep(10 * time.Millisecond)
@@ -997,7 +997,7 @@ func TestBucketCache_AllPartialValues(t *testing.T) {
}
// Put the result
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
// Wait for cache to be written
time.Sleep(10 * time.Millisecond)
@@ -1058,7 +1058,7 @@ func TestBucketCache_FilteredCachedResults(t *testing.T) {
}
// Cache the wide range
bc.Put(ctx, orgID, query1, qbtypes.Step{Duration: 1000 * time.Millisecond}, result1)
bc.Put(ctx, orgID, query1, qbtypes.Step{Duration: 1000 * time.Millisecond}, result1, nil)
time.Sleep(10 * time.Millisecond)
// Now query for a smaller range (2000-3500ms)
@@ -1125,7 +1125,7 @@ func TestBucketCache_PromQLKeepsTheValueAtTheWindowEnd(t *testing.T) {
}},
}},
},
})
}, nil)
time.Sleep(10 * time.Millisecond)
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
@@ -1291,7 +1291,7 @@ func TestBucketCache_PartialValueDetection(t *testing.T) {
}
// Put the result
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
time.Sleep(10 * time.Millisecond)
// Get cached data
@@ -1345,7 +1345,7 @@ func TestBucketCache_PartialValueDetection(t *testing.T) {
}
// Put the result
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
time.Sleep(10 * time.Millisecond)
// Get cached data
@@ -1397,7 +1397,7 @@ func TestBucketCache_PartialValueDetection(t *testing.T) {
}
// Put the result
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
time.Sleep(10 * time.Millisecond)
// Get cached data
@@ -1454,7 +1454,7 @@ func TestBucketCache_NoCache(t *testing.T) {
}
// Put the result in cache
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
time.Sleep(10 * time.Millisecond)
// Verify data is cached
@@ -1475,11 +1475,12 @@ func TestBucketCachePutSlicesAtCellBoundaries(t *testing.T) {
// a 1s step gives 60s cells, so 30s to 150s spans three of them
query := &mockQuery{fingerprint: "sliced-query", startMs: 30000, endMs: 150000}
bc.Put(ctx, orgID, query, step, &qbtypes.Result{
result := &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 30000, 150000, 1000),
Stats: qbtypes.ExecStats{RowsScanned: 120, BytesScanned: 1200, DurationMS: 12},
})
}
bc.Put(ctx, orgID, query, step, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: 30000, To: 150000}: result.Stats})
type bucketShape struct {
cell, startMs, endMs, rowsScanned uint64
@@ -1539,7 +1540,7 @@ func TestBucketCachePutAbsorbsWritesWithinACell(t *testing.T) {
name: "OverlappingWrites_BecomeOne",
firstWindow: qbtypes.TimeRange{From: 1000, To: 4000},
secondWindow: qbtypes.TimeRange{From: 3000, To: 6000},
expectedBuckets: []bucketShape{{1000, 6000, 6}},
expectedBuckets: []bucketShape{{1000, 6000, 5}},
expectedMissingRanges: []*qbtypes.TimeRange{},
expectedValueCount: 5,
},
@@ -1570,12 +1571,13 @@ func TestBucketCachePutAbsorbsWritesWithinACell(t *testing.T) {
for _, window := range []qbtypes.TimeRange{testCase.firstWindow, testCase.secondWindow} {
query := &mockQuery{fingerprint: "absorb-query", startMs: window.From, endMs: window.To}
bc.Put(ctx, orgID, query, step, &qbtypes.Result{
result := &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", window.From, window.To, 1000),
// one row per second, so a bucket's stats read as the seconds it was scanned for
// one row per second, so a bucket's stats read as the seconds of data it holds
Stats: qbtypes.ExecStats{RowsScanned: (window.To - window.From) / 1000},
})
}
bc.Put(ctx, orgID, query, step, result, map[qbtypes.TimeRange]qbtypes.ExecStats{window: result.Stats})
}
query := &mockQuery{
@@ -1609,7 +1611,7 @@ func TestBucketCachePutLeavesAnUnchangedCellAlone(t *testing.T) {
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 1000, 10000, 1000),
Warnings: []string{"from the wide write"},
})
}, nil)
// a write already covered by the cell's bucket must not rewrite the entry,
// which shows as its warnings never arriving
@@ -1618,7 +1620,7 @@ func TestBucketCachePutLeavesAnUnchangedCellAlone(t *testing.T) {
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 3000, 5000, 1000),
Warnings: []string{"from the inside write"},
})
}, nil)
var cellEntry qbtypes.CachedData
require.NoError(t, bc.cache.Get(ctx, orgID, bc.generateCacheKey(wide, 0), &cellEntry))

View File

@@ -823,7 +823,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
return nil, err
}
// Store in cache for future use
q.bucketCache.Put(ctx, orgID, query, step, result)
q.bucketCache.Put(ctx, orgID, query, step, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: startMs, To: endMs}: result.Stats})
return result, nil
}
}
@@ -879,16 +879,19 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
if err != nil {
return nil, err
}
q.bucketCache.Put(ctx, orgID, query, step, result)
startMs, endMs := query.Window()
q.bucketCache.Put(ctx, orgID, query, step, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: startMs, To: endMs}: result.Stats})
return result, nil
}
}
// Calculate total stats and filter out nil results
validResults := make([]*qbtypes.Result, 0, len(freshResults))
for _, result := range freshResults {
execStatsForQueriedTimeRanges := make(map[qbtypes.TimeRange]qbtypes.ExecStats, len(freshResults))
for i, result := range freshResults {
if result != nil {
validResults = append(validResults, result)
execStatsForQueriedTimeRanges[*missingRanges[i]] = result.Stats
totalStats.RowsScanned += result.Stats.RowsScanned
totalStats.BytesScanned += result.Stats.BytesScanned
totalStats.DurationMS += result.Stats.DurationMS
@@ -903,7 +906,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
mergedResult.Stats.DurationMS += totalStats.DurationMS
// Store merged result in cache
q.bucketCache.Put(ctx, orgID, query, step, mergedResult)
q.bucketCache.Put(ctx, orgID, query, step, mergedResult, execStatsForQueriedTimeRanges)
return mergedResult, nil
}

View File

@@ -2,10 +2,13 @@ package ruler
import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -159,6 +162,55 @@ func orgIDFromContext(ctx context.Context) (valuer.UUID, error) {
return valuer.NewUUID(claims.OrgID)
}
func NewRuleQueryTelemetryResources(rulerService Ruler) coretypes.ResourceExtractor {
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
body, err := fetchStoredCompositeQuery(ec, rulerService)
if err != nil {
return nil, err
}
return querybuilder.QueryRangeResources(coretypes.ExtractorContext{Request: ec.Request, RequestBody: body})
}
}
func NewRuleQueryFieldResources(rulerService Ruler) coretypes.ResourceExtractor {
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
body, err := fetchStoredCompositeQuery(ec, rulerService)
if err != nil {
return nil, err
}
return mapFieldResources(body)
}
}
func fetchStoredCompositeQuery(ec coretypes.ExtractorContext, rulerService Ruler) ([]byte, error) {
if ec.Request == nil {
return nil, nil
}
ruleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return nil, err
}
stored, err := rulerService.GetRule(ec.Request.Context(), ruleID)
if err != nil {
return nil, err
}
return json.Marshal(map[string]any{"compositeQuery": stored.RuleCondition.CompositeQuery})
}
func mapFieldResources(body []byte) ([]coretypes.ResourceWithID, error) {
telemetry, err := querybuilder.QueryRangeResources(coretypes.ExtractorContext{RequestBody: body})
if err != nil {
return nil, err
}
return ruletypes.NewFieldResources(telemetry)
}
func diffLinks(stored, incoming []string) (attached, detached []string) {
storedSet := make(map[string]struct{}, len(stored))
for _, id := range stored {

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