Compare commits

..

16 Commits

Author SHA1 Message Date
Naman Verma
c58f76f998 Merge branch 'main' into nv/exec-stats 2026-11-03 11:34:10 +05:30
Naman Verma
026369948b perf: divide cache buckets for a query into cells in a grid (#13333)
<!--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


https://app.notion.com/p/signoz/Dividing-Cache-Buckets-into-Cells-3fcfcc6bcd19819db159d2f7322c2dd6?source=copy_link

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

Closes https://github.com/SigNoz/pulse-pod/issues/527
2026-11-03 05:31:12 +00:00
Abhi kumar
b8fb5695ea feat(dashboards): add plugin schema for scatter plot panel (#13030)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Adds the `signoz/ScatterPlotPanel` kind, which draws one dot per group
placed by two aggregations, per the Scatter Plot PRD.
- `dimensions.x/y/size` reference value columns by the Table column key
(`A` or `A.<expression>`); `dimensions.color` is a group-by label. All
four are optional and the renderer picks defaults when they're empty.
- `formatting` reuses `TableFormatting`, so `columnUnits` carries over
when switching between Table and Scatter Plot.
- Axes are per-axis with a new `scale` enum (`auto` | `linear` | `log` |
`symlog`, default `auto`); the shared `Axes` type is unchanged.
- Allows every query kind, PromQL included. Public dashboards turn on
`formatTableResultForUI` for this kind so rows from all queries are
joined by group, as for Table.

#### Additional Information

- The generated TS schemas aren't in this PR: adding the kind breaks the
exhaustive panel-kind maps in the V2 dashboard code. The frontend PR
will regenerate them and register the panel.

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

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-11-02 20:46:04 +00:00
Vikrant Gupta
ff6e0a26fc feat(roles): pick the grant key in the telemetry selector wizard (#13226)
#### Description

- The role wizard's Key field becomes a dropdown of the supported
telemetry grant keys, `signoz.workspace.key.id`, `resource.service.name`
and `resource.deployment.environment.name`, instead of a fixed value. A
typed selector selects its key in the dropdown, and
`resource.signoz.workspace.key.id` folds to the bare workspace key as
the backend does.
- Covered by the Role Editor Storybook story (`Telemetry Key Dropdown`,
`Telemetry Key Scoped`).

#### Issues closed by this PR

Contributes to SigNoz/keystone-pod#73
2026-11-02 18:39:53 +00:00
Nikhil Mantri
a02ea41446 feat(authz): downtime schedule FGA (#13250)
#### Description

- Move `/api/v1/downtime_schedules` routes from role gates to
`CheckResources` with resource defs.
- Create and update check `rule:attach` on every id in `alertIds`.
Update diffs against the stored schedule: attach for added, detach for
removed.
- Migration 142 backfills planned-maintenance tuples plus attach and
detach on planned-maintenance and rule for admin and editor.
- Integration tests cover viewer, editor, admin, and a custom role
without rule attach or detach.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#289
Part of SigNoz/pulse-pod#355

#### Additional Information

Second of five stacked PRs splitting #12927. Stacked on #13249.

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-11-02 18:29:40 +00:00
Aditya Singh
c63f818789 feat(traces-explorer): fetch all span fields in the list view (#13275)
#### Description

- The traces explorer list view no longer sends the selected columns as
`selectFields`. The API now returns every span field for each row. The
span details drawer in the next PR uses this data, so it can open
without another request.
- Adding, removing or reordering a column no longer refetches the list.
- The table reads attribute and resource columns from the `attributes`
and `resource` maps on each row. It uses `getSpanAttribute`, the same
lookup as trace details.
- `getSpanAttribute` now keeps `false` values. Before, boolean `false`
attributes showed "-" in the table and dropped out of the trace details
hover previews. Color-by now treats `0` and `false` as real values, not
`unknown`.

#### Issues closed by this PR

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


#### Screenshots/Screen recording

No refetch on add/remove columns


https://github.com/user-attachments/assets/799b39b3-3594-441e-b5dc-0c8177c29afb

All fields fetched

<img width="1687" height="1000" alt="all fields fetched"
src="https://github.com/user-attachments/assets/af8dd374-6b30-40a5-92dc-caa07defc463"
/>



#### Additional Information

- Two backend gaps show "-" in some columns:
- `scope.name` and `scope.version`: the all-fields mode does not select
the `scope` column. Value suggestions for these fields are also empty.
The fix is to add `scope` to `ContextualSpanColumns`.
- Old camelCase column names (`durationNano`, `httpMethod`, ...) in
saved views and column preferences: the backend resolved them only when
they were in `selectFields`. The fix is a backend migration that renames
them. The workaround is to remove the column and add it again.
- Each row is larger, because the response carries all span fields.
- Export and add to dashboard still use the selected columns.
- The List view story rows now use the nested `resource` shape. The page
renders the same, so the existing story covers it.
- cc. @tewarig
2026-11-02 17:56:08 +00:00
Naman Verma
e39ad9e221 test: check how exec stats fail 2026-11-02 22:24:22 +05:30
Vikrant Gupta
6026e2372d feat(authz): support service and environment keys in telemetry selectors (#13036)
#### Description

- Telemetry grants can now scope on `resource.service.name` and
`resource.deployment.environment.name`, alongside
`signoz.workspace.key.id`.

#### Issues closed by this PR

Closes SigNoz/keystone-pod#73
2026-11-02 16:28:30 +00:00
Nikhil Soni
be83a3e464 feat(traces): return instrumentation scope in waterfall, flamegraph and list view (#13280)
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

- Waterfall spans and the empty-`selectFields` list view now return
`scope: {name, version, attributes}`, read from the `scope` JSON column.
- Flamegraph `selectFields` accept `fieldContext: scope`: `scope.name` /
`scope.version` resolve to the declared paths, any other name to a scope
attribute, the same way the query builder resolves them.
- Scope attributes are flat dotted keys, like span attributes.
- Span details drawer shows `scope`.

#### Issues closed by this PR

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

For Frontend changes https://github.com/SigNoz/events-pod/issues/230

#### Additional Information

- Stacked on #13274.
- API contract change: OpenAPI spec and frontend client regenerated in
their own commits.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:32:58 +00:00
Nikhil Soni
34b6cbb98e fix(saved-views): rename old camelCase trace select fields (#13326)
#### Description

- Support for camelCase trace field names (`durationNano`,
`serviceName`, `httpMethod`, …) in select fields is being removed.
- Adds migration 141, which renames them in traces saved views'
`spec.selectedFields` using the same mapping as `oldToNew`.
- Drops `fieldContext` on renamed entries: legacy columns carry
`attribute` from migration 114, which would make `service.name` resolve
to a span attribute. Without it the query builder resolves the field
itself.
- If the new name is already selected, the renamed entry is dropped.
Unparseable rows are logged and skipped.
- Prod backups: 52 traces views across 29 deployments still use these
names, mostly the old default columns. Tested on a restored backup by
running migrations 049–141 through the server.

#### Additional Information

- Logs views and old names in filters / orderBy / groupBy are not
touched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-11-02 10:24:17 +00:00
Gaurav Tewari
5acf61dd04 refactor(saved-views): key by source page instead of data source (#13296)
#### Description

- Key options menu, saved view options and preference slices by saved
view source page (`SavedviewtypesSourceDTO`) instead of the query's
`DataSource`.
- `PREFERENCE_SOURCE_PAGES` (logs, traces) is the one list of pages with
column and formatting preferences; the type and the
`isPreferenceSourcePage` guard come from it.
- Saved-view behavior on metrics and meter is unchanged: the hooks still
fall back to the logs options and drop them where the source has no
preferences.
- Tests use typed mocks (`jest.mocked`, `jest.MockedFunction`) and check
preference fixtures with `satisfies PreferenceContextValue`.

#### Issues closed by this PR

#### Screenshots / Screen Recordings

- [sbshot visual
diff](https://claude.ai/artifact/9yZWJ8t5J9vX65n43m2cK1): 188 Storybook
pairs (logs, live tail, traces, saved views, fields selector, metrics,
meter; dark + light) vs merge-base. 170 identical; 18 moved, all noise
(wall-clock mock timestamps, spinner frames, antialiasing).

#### Additional Information

- Review commit by commit: preferences → options menu → saved views. The
layers depend on each other, so only the last commit builds.
- `PreferenceContextValue` gains `ai_observability` in #13297; the
`useOptionsMenu` fixtures get the AI slice there.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-02 07:01:03 +00:00
Abhi kumar
53611631d6 feat(storybook): query builder component stories (#13325)
#### Description

- `Components/Query Builder`: the builder as each host mounts it (logs,
traces, metrics and meter explorers, V2 panel editor, logs alert), props
mirrored from each call site and the starting query seeded through the
route.
- A `QueryProbe` below the builder prints the `query_range` payload its
live state would send, so plays assert on the query instead of the DOM.
- `Components/Query Builder/Behaviour`: add-ons per panel kind and
signal, metric-type defaults (gauge → avg/avg, histogram → p90) and the
single-aggregation limit on Value panels.
- Shared msw handlers now answer `/api/v1/fields/values`,
`/api/v2/metrics`, `/api/v2/metrics/metadata` and the `ai_observability`
fields, so any story rendering the builder has them.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#663

#### Additional Information

- Filter suggestion loading/empty/error states are already covered by
the logs page stories, so they are not repeated here.
- The test runner reports existing a11y warnings in the builder's
markup; out of scope here.
2026-11-02 06:45:15 +00:00
Abhi kumar
1e052cbc64 refactor(dashboard): group panel type picker into components and hooks (#13170)
#### Description

- `PanelTypeSelectionModal/` was 20 flat files. Each component now lives
in `components/<Name>/` with its styles, and the picker-target hooks in
`hooks/`. The drawer, section-option `types`/`utils` and the test stay
at the root.
- `PanelTypeTile` gets its own stylesheet, split out of
`PanelTypeBrowser.module.scss`.
- No behaviour change. Outside the folder, only `PanelTypeSwitcher`'s
import path changes.
2026-11-02 06:42:02 +00:00
Nityananda Gohain
a621fb2b49 feat(aiobservability): read the messages the Langfuse SDK writes (#13331)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Langfuse SDK spans reached the thread with empty formatted messages:
the SDK writes them to `langfuse.observation.input/output`, which the
mapper never moved onto `gen_ai.*.messages`.
- `gen_ai.llm` v5 moves `langfuse.observation.input/output` onto
`gen_ai.input/output.messages` and copies `langfuse.observation.type`
onto `gen_ai.operation.name`, above every existing source. New spans
only.
- Formatter handles the shapes these spans carry: a superjson `{json}`
envelope, a single message object (incl. role-less `{tool_calls}` from
the LangChain handler), a bare list of Responses items (LiteLLM
`langfuse_otel` tool turns), and falls back to the raw output when the
OpenInference bucket is empty. Mixed-format warning dropped when one
side is generic or OpenInference.

  #### Additional Information

- Verified with unit tests, the shipped definition through the
in-process collector pipeline.
- Why Langfuse keeps its own keys instead of emitting semconv, checked
in the Langfuse repos:
- Docs ([OpenTelemetry
integration](https://langfuse.com/integrations/native/opentelemetry)):
`langfuse.*` attributes "always take precedence over the generic
OpenTelemetry conventions" and are "recommended for all users that are
manually instrumenting"; semconv is "still evolving", so OTel traces are
mapped into the Langfuse model.
- [Discussion #2509](https://github.com/orgs/langfuse/discussions/2509),
Sep 2024, maintainer: Langfuse features "not yet covered by the
conventions" and the risk of "breaking backwards compatibility once
conventions are in place" are the reasons for an own namespace.
- Ingestion (`OtelIngestionProcessor.ts`, `extractInputAndOutput`): own
keys first, then vendor keys, then semconv `gen_ai.*.messages`, then
events. Semconv is one vendor format among many.
- SDK sources: input and output are any JSON on any observation type
(span, agent, tool, generation, …), looser than `gen_ai.input.messages`.
- Still unmapped: `usage_details`, `cost_details`, `model.parameters`
are JSON objects; the mapper cannot extract fields, so pure Langfuse
spans have no tokens or cost.


Closes https://github.com/SigNoz/nerve-pod/issues/579
2026-11-01 07:54:17 +00:00
Vinicius Lourenço
72443c8267 chore(deps): bump axios to 1.21.0 (#13339)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Bump `axios` from 1.18.0 to 1.21.0 to fix the high-severity advisories
affecting `< 1.20.0`.
2026-11-01 05:35:32 +00:00
Vinicius Lourenço
b37231f31a fix(saved-views): remove loading effect from authz checks (#13287)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (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
<!--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

This basically does two fixes:
- Remove loading effect
- The effect was not looking good, this was implemented during the
migration of components.
- Remove read checks for each item
- We should not have loading for each item in the list, against our
guidelines for authz
- The list already returns the info needed in read, redundant to have
both checks
- Replace the button with a link
  - To improve UX of this views, they were suppose to act as links.

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

Closes https://github.com/SigNoz/uforge-pod/issues/6394

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

Normal view:
<img width="1680" height="1224"
alt="dark--components-saved-views-authz--no-read-open-view--blink (1)"
src="https://github.com/user-attachments/assets/68772cda-11f1-4798-8012-79bfe685cbcd"
/>

No read only:
<img width="1680" height="1224"
alt="dark--components-saved-views-overview--unsaved-changes--blink (1)"
src="https://github.com/user-attachments/assets/898ab59a-f870-4d7c-8c2a-e20843f98444"
/>

Long names:
<img width="1680" height="1224"
alt="dark--components-saved-views-overview--long-names--blink (1)"
src="https://github.com/user-attachments/assets/48a4bd61-8335-4047-8c2a-eca42c43653e"
/>

No read only 2:
<img width="1680" height="1224"
alt="dark--components-saved-views-authz--no-read--blink (1)"
src="https://github.com/user-attachments/assets/af04bb90-1ce7-4c36-818b-49e26933ea0b"
/>


Full diff:
https://claude.ai/artifact/S43YyxbMmCdtZLmEXVNsgm?sk=LXctCOiBiN5kGigap2M_JA
2026-10-30 16:53:33 +00:00
398 changed files with 15656 additions and 2652 deletions

View File

@@ -4224,6 +4224,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -5250,6 +5257,7 @@ components:
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -5265,6 +5273,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -5278,6 +5287,7 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -5384,6 +5394,21 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
properties:
kind:
description: The panel kind.
enum:
- signoz/ScatterPlotPanel
type: string
spec:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
description: The options for this panel kind. {} accepts every default.
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
@@ -5494,6 +5519,16 @@ components:
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
description: The time range the slices are computed over.
type: object
DashboardtypesPointDiameter:
maximum: 40
minimum: 2
nullable: true
type: integer
DashboardtypesPointOpacity:
maximum: 1
minimum: 0.1
nullable: true
type: number
DashboardtypesPostableDashboardV2:
properties:
generateName:
@@ -5748,6 +5783,161 @@ components:
required:
- queryValue
type: object
DashboardtypesScatterPlotAxes:
properties:
x:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
description: The axis dimensions.x is plotted along.
"y":
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
description: The axis dimensions.y is plotted along.
type: object
DashboardtypesScatterPlotAxis:
properties:
label:
description: Axis title. Empty draws none; the tooltip still names the column.
type: string
scale:
allOf:
- $ref: '#/components/schemas/DashboardtypesAxisScale'
description: auto (default) uses log, or symlog when values include zero
or negatives, when that spreads the dots clearly more than linear does;
otherwise linear. log cannot place a value at or below zero and leaves
that dot out; symlog is log-like but places it.
softMax:
description: The highest value the axis always shows; data above it still
extends the axis, as does a threshold on it. Null fits the data.
nullable: true
type: number
softMin:
description: The lowest value the axis always shows; data below it still
extends the axis, as does a threshold on it. Null fits the data. A log
axis ignores a bound at or below zero.
nullable: true
type: number
type: object
DashboardtypesScatterPlotChartAppearance:
properties:
points:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
description: Dot size and opacity.
type: object
DashboardtypesScatterPlotDimensions:
properties:
colorBy:
description: Group-by label names (e.g. k8s.namespace.name) whose combined
values color the dots and drive the legend. Empty colors by every group-by
label.
items:
type: string
nullable: true
type: array
sizeBy:
description: Value column key that scales each dot's area between chartAppearance.points.minSize
and maxSize. Empty draws every dot at chartAppearance.points.size, as
does a dot with no value.
type: string
x:
description: Value column key (queryName, or queryName.expression for a
multi-aggregation query) plotted on the x axis. Empty, or a key the result
lacks, uses the first value column.
type: string
"y":
description: Value column key plotted on the y axis. Empty, or a key the
result lacks, uses the first value column x does not use.
type: string
type: object
DashboardtypesScatterPlotPanelSpec:
properties:
axes:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
description: Title, bounds and scale of each axis.
chartAppearance:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
description: Dot drawing options.
dimensions:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
description: 'Which result columns place, size and color the dots. Each
dot is one row of the joined scalar result: one group.'
formatting:
allOf:
- $ref: '#/components/schemas/DashboardtypesTableFormatting'
description: Per-column units and decimal precision. The x and y columns'
units label their axes; every bound column's unit shows in the tooltip.
legend:
allOf:
- $ref: '#/components/schemas/DashboardtypesLegend'
description: Legend placement and series colors. A series is one color group,
labeled by its colorBy values joined with a comma, or All when nothing
is grouped.
thresholds:
description: Reference lines drawn at fixed values across the x or y axis.
items:
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
nullable: true
type: array
visualization:
allOf:
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
description: The time range the groups are computed over.
type: object
DashboardtypesScatterPlotPoints:
properties:
maxSize:
allOf:
- $ref: '#/components/schemas/DashboardtypesPointDiameter'
description: Diameter of the largest dot when dimensions.sizeBy is set,
in CSS pixels from 2 to 40. Null means 24.
minSize:
allOf:
- $ref: '#/components/schemas/DashboardtypesPointDiameter'
description: Diameter of the smallest dot when dimensions.sizeBy is set,
in CSS pixels from 2 to 40. Null means 4.
opacity:
allOf:
- $ref: '#/components/schemas/DashboardtypesPointOpacity'
description: Fill opacity of every dot, from 0.1 to 1. Null means 0.7.
size:
allOf:
- $ref: '#/components/schemas/DashboardtypesPointDiameter'
description: Diameter of every dot when dimensions.sizeBy is unset, in CSS
pixels from 2 to 40. Null means 6.
type: object
DashboardtypesScatterPlotThreshold:
properties:
axis:
allOf:
- $ref: '#/components/schemas/DashboardtypesThresholdAxis'
description: 'The axis the line marks: y (default) draws it horizontally,
x vertically. Unit converts against that axis''s column unit, and the
axis extends to keep the line in view.'
color:
description: 'The CSS color of the line, for example #F1575F, which matches
the SigNoz red preset; CSS named colors such as red also work.'
type: string
label:
description: The caption shown on the line.
type: string
unit:
description: The unit id of value, such as ms or percent. Empty means the
panel's unit, or the column's unit for a table threshold. Prefer the panel's
unit; a different unit works only when it converts to it, such as s against
a panel in ms.
type: string
value:
description: The value the line is drawn at, in unit.
format: double
type: number
required:
- value
- color
type: object
DashboardtypesSource:
enum:
- user
@@ -5914,6 +6104,11 @@ components:
- value
- name
type: object
DashboardtypesThresholdAxis:
enum:
- x
- "y"
type: string
DashboardtypesThresholdFormat:
enum:
- text
@@ -12131,6 +12326,8 @@ components:
additionalProperties:
type: string
type: object
scope:
$ref: '#/components/schemas/SpantypesScope'
spanId:
type: string
timestamp:
@@ -12147,6 +12344,7 @@ components:
- event
- attributes
- resource
- scope
type: object
SpantypesGettableFlamegraphTrace:
properties:
@@ -12387,6 +12585,16 @@ components:
nullable: true
type: array
type: object
SpantypesScope:
properties:
attributes:
additionalProperties: {}
type: object
name:
type: string
version:
type: string
type: object
SpantypesSpanAggregation:
properties:
aggregation:
@@ -12756,6 +12964,8 @@ components:
type: object
response_status_code:
type: string
scope:
$ref: '#/components/schemas/SpantypesScope'
span_id:
type: string
status_code:
@@ -15064,9 +15274,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- planned-maintenance:list
- tokenizer:
- VIEWER
- planned-maintenance:list
summary: List downtime schedules
tags:
- downtimeschedules
@@ -15121,9 +15331,13 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- planned-maintenance:create
- planned-maintenance:attach
- rule:attach
- tokenizer:
- EDITOR
- planned-maintenance:create
- planned-maintenance:attach
- rule:attach
summary: Create downtime schedule
tags:
- downtimeschedules
@@ -15168,9 +15382,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- planned-maintenance:delete
- tokenizer:
- EDITOR
- planned-maintenance:delete
summary: Delete downtime schedule
tags:
- downtimeschedules
@@ -15226,9 +15440,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- planned-maintenance:read
- tokenizer:
- VIEWER
- planned-maintenance:read
summary: Get downtime schedule by ID
tags:
- downtimeschedules
@@ -15283,9 +15497,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- planned-maintenance:update
- planned-maintenance:attach
- planned-maintenance:detach
- rule:attach
- rule:detach
- tokenizer:
- EDITOR
- planned-maintenance:update
- planned-maintenance:attach
- planned-maintenance:detach
- rule:attach
- rule:detach
summary: Update downtime schedule
tags:
- downtimeschedules

View File

@@ -17,7 +17,7 @@ Condition: `model`, `llm.input_messages` (OpenInference sets `llm.model_name` fr
| `gen_ai.response.model`, last source for the request model | vLLM, which writes only the response model |
| `ai.model.id`, `ai.model.provider`, `ai.response.model`, `ai.response.finishReason`, `ai.prompt.messages`, `ai.response.text`, `ai.response.toolCalls`, `ai.usage.promptTokens`, `ai.usage.completionTokens` | Vercel AI SDK v4, Mastra |
| `ai.usage.inputTokens`, `ai.usage.outputTokens`, `ai.usage.cachedInputTokens`, `ai.usage.reasoningTokens` | Vercel AI SDK v5 |
| `langfuse.observation.model.name`, `langfuse.session.id` | Langfuse SDK v3 |
| `langfuse.observation.model.name`, `langfuse.observation.input`, `langfuse.observation.output`, `langfuse.observation.type`, `langfuse.session.id` | Langfuse SDK v3 (Python) and v4 (JS), LiteLLM `langfuse_otel` callback |
| `session.id` | OpenInference, Langfuse, Claude Code |
| `gen_ai.client.session_id` | opentelemetry-hooks runner (Cursor, Copilot, Claude Code hooks) |
| `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` | Claude Code `claude_code.llm_request` spans ([docs](https://code.claude.com/docs/en/monitoring-usage#span-attributes)) |

View File

@@ -593,13 +593,12 @@
}
},
{
// The react-router allowlist: the facade itself, the route table, the router mount,
// `useSafeNavigate`, the history singleton and the test and story router mounts. Test files are
// The react-router allowlist: the facade itself, the route table, `useSafeNavigate`,
// the history singleton and the test and story router mounts. Test files are
// deliberately absent: they render at a route or use tests/router-mocks.
"files": [
"src/lib/router/**",
"src/AppRoutes/**",
"src/app/AppRouter.tsx",
"src/hooks/useSafeNavigate.ts",
"src/lib/history.ts",
"src/tests/router.tsx",

View File

@@ -57,6 +57,7 @@ const mockPaths = {
const mockTzDate = jest.fn(
(date: Date, _timezone: string) => new Date(date.getTime()),
);
const mockOrient = jest.fn();
// Mock uPlot constructor - this needs to be a proper constructor function
function MockUPlot(
@@ -70,6 +71,7 @@ function MockUPlot(
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
MockUPlot.orient = mockOrient;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;

View File

@@ -5,8 +5,6 @@
/**
* Adds custom matchers from the react testing library to all tests
*/
import { TextDecoder, TextEncoder } from 'node:util';
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect';
import 'jest-styled-components';
@@ -16,14 +14,6 @@ import { server } from './src/mocks-server/server';
import './src/styles.scss';
// Establish API mocking before all tests.
// react-router@7's entry point pulls in its server-runtime cookie signing,
// which builds a TextEncoder at module scope. jsdom ships neither encoder, so
// importing anything from the router throws before a test starts.
Object.assign(globalThis, {
TextEncoder: globalThis.TextEncoder ?? TextEncoder,
TextDecoder: globalThis.TextDecoder ?? TextDecoder,
});
// Mock window.matchMedia
window.matchMedia =
window.matchMedia ||

View File

@@ -70,7 +70,7 @@
"antd": "5.11.0",
"antd-table-saveas-excel": "2.2.1",
"antlr4": "4.13.2",
"axios": "1.18.0",
"axios": "1.21.0",
"babel-jest": "^29.6.4",
"chart.js": "3.9.1",
"chartjs-adapter-date-fns": "^2.0.0",
@@ -83,7 +83,7 @@
"dompurify": "3.4.15",
"event-source-polyfill": "1.0.31",
"eventemitter3": "5.0.1",
"history": "5.3.0",
"history": "4.10.1",
"html-to-image": "1.11.13",
"http-status-codes": "2.3.0",
"i18next": "^21.6.12",
@@ -117,7 +117,8 @@
"react-query": "3.39.3",
"react-redux": "^7.2.2",
"react-rnd": "^10.5.3",
"react-router": "7.18.4",
"react-router-dom": "^5.2.0",
"react-router-dom-v5-compat": "6.30.6",
"react-syntax-highlighter": "15.5.0",
"react-use": "^17.3.2",
"react-virtuoso": "4.0.3",
@@ -171,6 +172,7 @@
"@types/crypto-js": "4.2.2",
"@types/d3-hierarchy": "1.1.11",
"@types/event-source-polyfill": "^1.0.0",
"@types/history": "4.7.11",
"@types/jest": "30.0.0",
"@types/lodash-es": "^4.17.4",
"@types/node": "^22.20.5",
@@ -181,6 +183,7 @@
"@types/react-grid-layout": "^1.1.2",
"@types/react-redux": "^7.1.11",
"@types/react-resizable": "3.0.3",
"@types/react-router-dom": "^5.1.6",
"@types/react-syntax-highlighter": "15.5.13",
"@types/redux-mock-store": "1.0.4",
"@types/styled-components": "^5.1.4",

View File

@@ -54,8 +54,8 @@ await ruleTester({
errors: [{ message: MOCK }],
},
{
name: 'jest.doMock of react-router',
code: "jest.doMock('react-router');",
name: 'jest.doMock of react-router-dom-v5-compat',
code: "jest.doMock('react-router-dom-v5-compat');",
errors: [{ message: MOCK }],
},
{
@@ -74,9 +74,9 @@ await ruleTester({
errors: [{ message: FACADE }],
},
{
name: 'react-router-dom is flagged as the v7 re-export shim',
code: "import { Outlet } from 'react-router-dom';",
errors: [{ message: 'only a re-export shim' }],
name: 'react-router-dom-v5-compat',
code: "import { useNavigate } from 'react-router-dom-v5-compat';",
errors: [{ message: 'compat package is an implementation detail' }],
},
{
name: 'lib/history default import',

View File

@@ -5,15 +5,12 @@
* upgrade is a change to one directory instead of ~300 call sites. This rule
* keeps new call sites, and test mocks, from reaching past it.
*
* `react-router-dom` is still flagged even though the package is gone: on v7 it is a
* re-export shim over `react-router`, so reinstalling it would split the tree again.
*
* `history` (the package) is deliberately not flagged: it is a transitive concern of
* the version bump, not something the facade replaces.
*
* The allowlist (the facade itself, the route table, the router mount, `useSafeNavigate`,
* the history singleton and the test and story router mounts) is applied via overrides
* in .oxlintrc.json, not here, so the file list stays visible next to the severity.
* The allowlist (the facade itself, the route table, `useSafeNavigate`, the history
* singleton and the test and story router mounts) is applied via overrides in
* .oxlintrc.json, not here, so the file list stays visible next to the severity.
*/
import path from 'node:path';
@@ -32,8 +29,9 @@ const JEST_MODULE_METHODS = new Set([
]);
const MESSAGE_IDS = {
'react-router': 'router',
'react-router-dom': 'routerDom',
'react-router': 'v5Router',
'react-router-dom': 'v5Router',
'react-router-dom-v5-compat': 'v6Compat',
'lib/history': 'historySingleton',
'@/lib/history': 'historySingleton',
};
@@ -67,10 +65,10 @@ export default {
},
schema: [],
messages: {
router:
'Do not import react-router directly. Use the src/lib/router facade (useAppNavigate, useAppLocation, useAppParams, AppLink, Redirect, matchRoute) so a router upgrade stays contained.',
routerDom:
'Do not import react-router-dom. The app is on react-router; on v7 react-router-dom is only a re-export shim, and installing it puts two copies of the router in the tree. Use the src/lib/router facade.',
v5Router:
'Do not import react-router-dom directly. Use the src/lib/router facade (useAppNavigate, useAppLocation, useAppParams, AppLink, Redirect, matchRoute) so a router upgrade stays contained.',
v6Compat:
'Do not import react-router-dom-v5-compat directly. Use the src/lib/router facade instead; the compat package is an implementation detail of the migration and disappears with it.',
historySingleton:
'Do not import the lib/history singleton. Use the helpers in src/lib/router/navigation.ts, or useAppNavigate() to follow the nearest router. history loses basename handling once the router owns it.',
mock:

132
frontend/pnpm-lock.yaml generated
View File

@@ -24,6 +24,7 @@ overrides:
nanoid@<3.3.18: '>=3.3.18 <4'
prismjs@<1.30.0: '>=1.30.0 <2'
postcss-selector-parser@>=7.1.0 <7.1.3: '>=7.1.3 <8'
react-router@>=6.7.0 <6.30.6: '>=6.30.6 <7'
tmp@<0.2.6: '>=0.2.6 <0.3.0'
yaml@>=1.0.0 <1.10.3: '>=1.10.3 <2'
'@types/node': ^22.20.5
@@ -82,7 +83,7 @@ importers:
version: 0.4.0
'@signozhq/ui':
specifier: 0.2.0
version: 0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
version: 0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -126,8 +127,8 @@ importers:
specifier: 4.13.2
version: 4.13.2
axios:
specifier: 1.18.0
version: 1.18.0
specifier: 1.21.0
version: 1.21.0
babel-jest:
specifier: ^29.6.4
version: 29.6.4(@babel/core@7.29.7)
@@ -165,8 +166,8 @@ importers:
specifier: 5.0.1
version: 5.0.1
history:
specifier: 5.3.0
version: 5.3.0
specifier: 4.10.1
version: 4.10.1
html-to-image:
specifier: 1.11.13
version: 1.11.13
@@ -202,7 +203,7 @@ importers:
version: 12.4.13(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
nuqs:
specifier: 2.8.8
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
overlayscrollbars:
specifier: ^2.16.0
version: 2.16.0
@@ -266,9 +267,12 @@ importers:
react-rnd:
specifier: ^10.5.3
version: 10.5.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-router:
specifier: 7.18.4
version: 7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-router-dom:
specifier: ^5.2.0
version: 5.3.4(react@18.2.0)
react-router-dom-v5-compat:
specifier: 6.30.6
version: 6.30.6(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react@18.2.0)
react-syntax-highlighter:
specifier: 15.5.0
version: 15.5.0(react@18.2.0)
@@ -387,6 +391,9 @@ importers:
'@types/event-source-polyfill':
specifier: ^1.0.0
version: 1.0.1
'@types/history':
specifier: 4.7.11
version: 4.7.11
'@types/jest':
specifier: 30.0.0
version: 30.0.0
@@ -417,6 +424,9 @@ importers:
'@types/react-resizable':
specifier: 3.0.3
version: 3.0.3
'@types/react-router-dom':
specifier: ^5.1.6
version: 5.3.3
'@types/react-syntax-highlighter':
specifier: 15.5.13
version: 15.5.13
@@ -3318,6 +3328,10 @@ packages:
'@react-dnd/shallowequal@4.0.2':
resolution: {integrity: sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==}
'@remix-run/router@1.23.4':
resolution: {integrity: sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==}
engines: {node: '>=14.0.0'}
'@rolldown/binding-android-arm64@1.0.0-beta.53':
resolution: {integrity: sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3999,6 +4013,9 @@ packages:
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/history@4.7.11':
resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==}
'@types/hoist-non-react-statics@3.3.1':
resolution: {integrity: sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==}
@@ -4080,6 +4097,12 @@ packages:
'@types/react-resizable@3.0.3':
resolution: {integrity: sha512-W/QsUOZoXBAIBQNhNm95A5ohoaiUA874lWQytO2UP9dOjp5JHO9+a0cwYNabea7sA12ZDJnGVUFZxcNaNksAWA==}
'@types/react-router-dom@5.3.3':
resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==}
'@types/react-router@5.1.20':
resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==}
'@types/react-syntax-highlighter@15.5.13':
resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==}
@@ -4570,8 +4593,8 @@ packages:
resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
engines: {node: '>=4'}
axios@1.18.0:
resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==}
axios@1.21.0:
resolution: {integrity: sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==}
babel-jest@29.6.4:
resolution: {integrity: sha512-meLj23UlSLddj6PC+YTOFRgDAtjnZom8w/ACsrx0gtPtv5cJZk0A5Unk5bV4wixD7XaPCN1fQvpww8czkZURmw==}
@@ -5012,10 +5035,6 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
copy-anything@3.0.5:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
@@ -7496,7 +7515,7 @@ packages:
'@tanstack/react-router': ^1
next: '>=14.2.0'
react: '>=18.2.0 || ^19.0.0-0'
react-router: ^5 || ^6 || ^7
react-router: '>=6.30.6 <7'
react-router-dom: ^5 || ^6 || ^7
peerDependenciesMeta:
'@remix-run/react':
@@ -7517,7 +7536,7 @@ packages:
'@tanstack/react-router': ^1
next: '>=14.2.0'
react: '>=18.2.0 || ^19.0.0-0'
react-router: ^5 || ^6 || ^7 || ^8
react-router: '>=6.30.6 <7'
react-router-dom: ^5 || ^6 || ^7
peerDependenciesMeta:
'@remix-run/react':
@@ -8441,6 +8460,14 @@ packages:
react: '>=16.3.0'
react-dom: '>=16.3.0'
react-router-dom-v5-compat@6.30.6:
resolution: {integrity: sha512-I54amXpivD3V0r1BYUIj1qLOljXx+UpbLG63Bqbm3BdKH62z4H3znC0L7SyMTYAMEDZuUjDSlqtkBRG/OEDXBg==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router-dom: 4 || 5
react-router-dom@5.3.4:
resolution: {integrity: sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==}
peerDependencies:
@@ -8451,15 +8478,11 @@ packages:
peerDependencies:
react: '>=15'
react-router@7.18.4:
resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==}
engines: {node: '>=20.0.0'}
react-router@6.30.6:
resolution: {integrity: sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react: '>=16.8'
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
@@ -12905,6 +12928,8 @@ snapshots:
'@react-dnd/shallowequal@4.0.2': {}
'@remix-run/router@1.23.4': {}
'@rolldown/binding-android-arm64@1.0.0-beta.53':
optional: true
@@ -13137,7 +13162,7 @@ snapshots:
'@signozhq/icons@0.4.0': {}
'@signozhq/ui@0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)':
'@signozhq/ui@0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)':
dependencies:
'@base-ui/react': 1.7.0(@date-fns/tz@1.4.1)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@chenglou/pretext': 0.0.8
@@ -13161,7 +13186,7 @@ snapshots:
lodash-es: 4.18.1
motion: 11.18.2(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next-themes: 0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
nuqs: 2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
nuqs: 2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-compiler-runtime: 1.0.0(react@18.2.0)
react-day-picker: 9.9.0(react@18.2.0)
@@ -13613,6 +13638,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.2
'@types/history@4.7.11': {}
'@types/hoist-non-react-statics@3.3.1':
dependencies:
'@types/react': 18.0.26
@@ -13709,6 +13736,17 @@ snapshots:
dependencies:
'@types/react': 18.0.26
'@types/react-router-dom@5.3.3':
dependencies:
'@types/history': 4.7.11
'@types/react': 18.0.26
'@types/react-router': 5.1.20
'@types/react-router@5.1.20':
dependencies:
'@types/history': 4.7.11
'@types/react': 18.0.26
'@types/react-syntax-highlighter@15.5.13':
dependencies:
'@types/react': 18.0.26
@@ -14245,7 +14283,7 @@ snapshots:
axe-core@4.13.0: {}
axios@1.18.0:
axios@1.21.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.6
@@ -14750,8 +14788,6 @@ snapshots:
cookie@0.7.2: {}
cookie@1.1.1: {}
copy-anything@3.0.5:
dependencies:
is-what: 4.1.16
@@ -16262,8 +16298,7 @@ snapshots:
dependencies:
is-inside-container: 1.0.0
isarray@0.0.1:
optional: true
isarray@0.0.1: {}
isarray@1.0.0: {}
@@ -17758,20 +17793,20 @@ snapshots:
dependencies:
boolbase: 1.0.0
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0):
dependencies:
'@standard-schema/spec': 1.0.0
react: 18.2.0
optionalDependencies:
react-router: 7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-router: 6.30.6(react@18.2.0)
react-router-dom: 5.3.4(react@18.2.0)
nuqs@2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
nuqs@2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0):
dependencies:
'@standard-schema/spec': 1.1.0
react: 18.2.0
optionalDependencies:
react-router: 7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-router: 6.30.6(react@18.2.0)
react-router-dom: 5.3.4(react@18.2.0)
nwsapi@2.2.23: {}
@@ -18147,7 +18182,6 @@ snapshots:
path-to-regexp@1.9.0:
dependencies:
isarray: 0.0.1
optional: true
path-to-regexp@6.3.0: {}
@@ -18917,9 +18951,18 @@ snapshots:
react-draggable: 4.5.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
tslib: 2.6.2
react-router-dom-v5-compat@6.30.6(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react@18.2.0):
dependencies:
'@remix-run/router': 1.23.4
history: 5.3.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-router: 6.30.6(react@18.2.0)
react-router-dom: 5.3.4(react@18.2.0)
react-router-dom@5.3.4(react@18.2.0):
dependencies:
'@babel/runtime': 7.29.2
'@babel/runtime': 7.28.2
history: 4.10.1
loose-envify: 1.4.0
prop-types: 15.8.1
@@ -18927,11 +18970,10 @@ snapshots:
react-router: 5.3.4(react@18.2.0)
tiny-invariant: 1.3.3
tiny-warning: 1.0.3
optional: true
react-router@5.3.4(react@18.2.0):
dependencies:
'@babel/runtime': 7.29.2
'@babel/runtime': 7.28.2
history: 4.10.1
hoist-non-react-statics: 3.3.2
loose-envify: 1.4.0
@@ -18941,15 +18983,11 @@ snapshots:
react-is: 16.13.1
tiny-invariant: 1.3.3
tiny-warning: 1.0.3
optional: true
react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
react-router@6.30.6(react@18.2.0):
dependencies:
cookie: 1.1.1
'@remix-run/router': 1.23.4
react: 18.2.0
set-cookie-parser: 2.6.0
optionalDependencies:
react-dom: 18.2.0(react@18.2.0)
react-style-singleton@2.2.3(@types/react@18.0.26)(react@18.2.0):
dependencies:
@@ -20272,7 +20310,7 @@ snapshots:
wait-on@7.2.0:
dependencies:
axios: 1.18.0
axios: 1.21.0
joi: 17.13.7
lodash: 4.18.1
minimist: 1.2.8

View File

@@ -74,6 +74,11 @@ overrides:
# (postcss-selector-parser ^7.0.0)
# remove: blocked, 4.2.0 is latest and the range is open, so the floor is what pulls the fix
postcss-selector-parser@>=7.1.0 <7.1.3: '>=7.1.3 <8'
# via: @signozhq/ui > nuqs@2 (react-router ^6.4.0 || ^7)
# remove: blocked. GHSA-wrjc-x8rr-h8h6 and the deserializeErrors advisory are patched
# only in 7.18.0. Do NOT open the cap: react-router >=7 requires React 19 and breaks
# the app-wide CompatRouter, so those two moderates stay until the app moves to React 19
react-router@>=6.7.0 <6.30.6: '>=6.30.6 <7'
# via: msw@1.3.2 (devDep) > inquirer@8 > external-editor@3.1.0 (tmp ^0.0.33)
# remove: upgrade msw to >=2 (drops the inquirer/external-editor chain)
tmp@<0.2.6: '>=0.2.6 <0.3.0'

View File

@@ -1,4 +1,5 @@
import { ReactChild, useCallback, useMemo } from 'react';
import { matchPath, Redirect, useLocation } from 'react-router-dom';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { useListUsers } from 'api/generated/services/users';
@@ -8,9 +9,6 @@ import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { isEmpty } from 'lodash-es';
import { matchRoute } from 'lib/router/matchRoute';
import { Redirect } from 'lib/router/Redirect';
import { useAppLocation } from 'lib/router/useAppLocation';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
@@ -30,7 +28,7 @@ import routes, {
// eslint-disable-next-line sonarjs/cognitive-complexity
function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const location = useAppLocation();
const location = useLocation();
const { pathname } = location;
const {
org,
@@ -49,11 +47,10 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
() =>
new Map(
[...routes, LIST_LICENSES, SUPPORT_ROUTE].map((e) => {
const patterns = Array.isArray(e.path) ? e.path : [e.path];
const matches = patterns.some(
(pattern) => matchRoute(pathname, pattern) !== null,
);
return [matches ? 'current' : null, e];
const currentPath = matchPath(pathname, {
path: e.path,
});
return [currentPath === null ? null : 'current', e];
}),
),
[pathname],
@@ -245,7 +242,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
);
if (fromPathname) {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
return <Redirect to={fromPathname} replace={false} />;
return <Redirect to={fromPathname} />;
}
if (pathname !== ROUTES.SOMETHING_WENT_WRONG) {
return <Redirect to={ROUTES.HOME} />;
@@ -259,7 +256,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
);
if (fromPathname) {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
return <Redirect to={fromPathname} replace={false} />;
return <Redirect to={fromPathname} />;
}
return <Redirect to={ROUTES.HOME} />;
} else {

View File

@@ -1,6 +1,6 @@
import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router';
import { MemoryRouter, Route, Switch, useLocation } from 'react-router-dom';
import { act, render, screen, waitFor } from '@testing-library/react';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
@@ -237,17 +237,12 @@ function buildPrivateRouteTree(
<MemoryRouter initialEntries={[initialRoute]}>
<AppContext.Provider value={contextValue}>
<PrivateRoute>
<Routes>
<Route
path="*"
element={
<>
<div data-testid="children-rendered">Content</div>
<LocationDisplay />
</>
}
/>
</Routes>
<Switch>
<Route path="*">
<div data-testid="children-rendered">Content</div>
<LocationDisplay />
</Route>
</Switch>
</PrivateRoute>
</AppContext.Provider>
</MemoryRouter>

View File

@@ -1,73 +0,0 @@
import { lazy, Suspense, useState } from 'react';
import { Routes } from 'react-router';
import { act, render, screen } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { navigate } from 'lib/router/navigation';
import { TestRouter } from 'tests/router';
import { resetTestRoute } from 'tests/router-utils';
import { routeElements } from '../routeElements';
import type { AppRoutes } from '../routes';
let mounts = 0;
function Page(): JSX.Element {
const [mount] = useState(() => {
mounts += 1;
return mounts;
});
return <div data-testid="page">mount {mount}</div>;
}
// Two lazy wrappers of one module, like the logs explorer and pipelines routes.
const Explorer = lazy(async () => ({ default: Page }));
const Pipelines = lazy(async () => ({ default: Page }));
const routes: AppRoutes[] = [
{ path: ROUTES.LOGS, component: Explorer, key: 'LOGS', isPrivate: true },
{
path: [ROUTES.LOGS_PIPELINES, ROUTES.LOGS_INDEX_FIELDS],
component: Pipelines,
key: 'LOGS_PIPELINES',
isPrivate: true,
},
];
function renderRoutes(): void {
render(
<TestRouter initialRoute={ROUTES.LOGS}>
<Suspense fallback={<div data-testid="fallback" />}>
<Routes>{routeElements(routes)}</Routes>
</Suspense>
</TestRouter>,
);
}
describe('routeElements', () => {
beforeEach(() => {
mounts = 0;
});
afterEach(() => {
resetTestRoute();
});
it('remounts the page when another route entry resolves to the same component', async () => {
renderRoutes();
await expect(screen.findByText('mount 1')).resolves.toBeInTheDocument();
act(() => navigate(ROUTES.LOGS_PIPELINES));
await expect(screen.findByText('mount 2')).resolves.toBeInTheDocument();
});
it('keeps the page mounted across the patterns of one route entry', async () => {
renderRoutes();
act(() => navigate(ROUTES.LOGS_PIPELINES));
await expect(screen.findByText('mount 2')).resolves.toBeInTheDocument();
act(() => navigate(ROUTES.LOGS_INDEX_FIELDS));
expect(screen.getByTestId('page')).toHaveTextContent('mount 2');
});
});

View File

@@ -1,5 +1,6 @@
import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
import { Route, Routes } from 'react-router';
import { Route, Router, Switch } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
@@ -22,7 +23,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { StatusCodes } from 'http-status-codes';
import { IS_PROD } from 'lib/env';
import { getCurrentLocation, navigate, subscribe } from 'lib/router/navigation';
import history from 'lib/history';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import posthog from 'posthog-js';
import { useAppContext } from 'providers/App/App';
@@ -32,7 +33,6 @@ import { extractDomain } from 'utils/app';
import { Home } from './pageComponents';
import PrivateRoute from './Private';
import { routeElements } from './routeElements';
import defaultRoutes, {
AppRoutes,
LIST_LICENSES,
@@ -44,6 +44,12 @@ import {
SENTRY_IGNORE_ERRORS,
} from './sentryFilters';
const appRouter = (children: ReactNode): ReactNode => (
<Router history={history}>
<CompatRouter>{children}</CompatRouter>
</Router>
);
const appLayout = (children: ReactNode): ReactNode => (
<AppLayout>{children}</AppLayout>
);
@@ -68,16 +74,14 @@ function App(): JSX.Element {
const chatSupport = useChatSupport();
const { hostname } = window.location;
// Through the facade, not the raw history: the router owns the base path
// now, so `history.location.pathname` would still carry it.
const [pathname, setPathname] = useState(getCurrentLocation().pathname);
const [pathname, setPathname] = useState(history.location.pathname);
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const [isSentryInitialized, setIsSentryInitialized] = useState(false);
useEffect(() => {
return subscribe(({ location }) => {
return history.listen((location) => {
setPathname(location.pathname);
});
}, []);
@@ -394,7 +398,7 @@ function App(): JSX.Element {
// this needs to be on top of data missing error because if there is an error, data will never be loaded and it will
// move to indefinitive loading
if (userFetchError && pathname !== ROUTES.SOMETHING_WENT_WRONG) {
navigate(ROUTES.SOMETHING_WENT_WRONG, { replace: true });
history.replace(ROUTES.SOMETHING_WENT_WRONG);
}
// if all of the data is not set then return a spinner, this is required because there is some gap between loading states and data setting
@@ -410,6 +414,7 @@ function App(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<AppShell
router={appRouter}
overlays={
isLoggedInState && (
<>
@@ -422,11 +427,18 @@ function App(): JSX.Element {
<PrivateRoute>
<AppPageProviders layout={appLayout}>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Routes>
{routeElements(routes)}
<Route path="/" element={<Home />} />
<Route path="*" element={<NotFound />} />
</Routes>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route exact path="/" component={Home} />
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</AppPageProviders>
</PrivateRoute>

View File

@@ -1,20 +0,0 @@
import { Route } from 'react-router';
import type { AppRoutes } from './routes';
/**
* The page is keyed by its route entry. `<Routes>` renders the match unkeyed, so
* two lazy pages that resolve to the same component would otherwise reuse the
* previous route's instance. The patterns of one entry share the key.
*/
export function routeElements(routes: AppRoutes[]): JSX.Element[] {
return routes.flatMap(({ path, component: Component, nested, key }) =>
(Array.isArray(path) ? path : [path]).map((pattern) => (
<Route
key={pattern}
path={nested ? `${pattern}/*` : pattern}
element={<Component key={key} />}
/>
)),
);
}

View File

@@ -1,4 +1,4 @@
import type { ComponentType } from 'react';
import { RouteProps } from 'react-router-dom';
import ROUTES from 'constants/routes';
import {
@@ -58,25 +58,27 @@ const routes: AppRoutes[] = [
{
component: SignupPage,
path: ROUTES.SIGN_UP,
exact: true,
isPrivate: false,
key: 'SIGN_UP',
},
{
path: ROUTES.GET_STARTED_WITH_CLOUD,
nested: true,
exact: false,
component: OnboardingV2,
isPrivate: true,
key: 'GET_STARTED_WITH_CLOUD',
},
{
path: ROUTES.HOME,
exact: true,
component: Home,
isPrivate: true,
key: 'HOME',
},
{
path: ROUTES.ONBOARDING,
nested: true,
exact: false,
component: OrgOnboarding,
isPrivate: true,
key: 'ONBOARDING',
@@ -84,23 +86,27 @@ const routes: AppRoutes[] = [
{
component: LogsIndexToFields,
path: ROUTES.LOGS_INDEX_FIELDS,
exact: true,
isPrivate: true,
key: 'LOGS_INDEX_FIELDS',
},
{
component: ServicesTablePage,
path: ROUTES.APPLICATION,
exact: true,
isPrivate: true,
key: 'APPLICATION',
},
{
path: ROUTES.SERVICE_METRICS,
exact: true,
component: ServiceMetricsPage,
isPrivate: true,
key: 'SERVICE_METRICS',
},
{
path: ROUTES.SERVICE_TOP_LEVEL_OPERATIONS,
exact: true,
component: ServiceTopLevelOperationsPage,
isPrivate: true,
key: 'SERVICE_TOP_LEVEL_OPERATIONS',
@@ -109,252 +115,292 @@ const routes: AppRoutes[] = [
path: ROUTES.SERVICE_MAP,
component: ServiceMapPage,
isPrivate: true,
exact: true,
key: 'SERVICE_MAP',
},
{
path: ROUTES.TRACE_DETAIL,
exact: true,
component: TraceDetailV3,
isPrivate: true,
key: 'TRACE_DETAIL',
},
{
path: ROUTES.SETTINGS,
nested: true,
exact: false,
component: SettingsPage,
isPrivate: true,
key: 'SETTINGS',
},
{
path: ROUTES.USAGE_EXPLORER,
exact: true,
component: UsageExplorerPage,
isPrivate: true,
key: 'USAGE_EXPLORER',
},
{
path: ROUTES.ALL_DASHBOARD,
exact: true,
component: DashboardsListPage,
isPrivate: true,
key: 'ALL_DASHBOARD',
},
{
path: ROUTES.DASHBOARD,
exact: true,
component: DashboardPage,
isPrivate: true,
key: 'DASHBOARD',
},
{
path: ROUTES.PUBLIC_DASHBOARD,
nested: true,
exact: false,
component: PublicDashboardPage,
isPrivate: false,
key: 'PUBLIC_DASHBOARD',
},
{
path: ROUTES.DASHBOARD_PANEL_EDITOR,
exact: true,
component: DashboardPanelEditorPage,
isPrivate: true,
key: 'DASHBOARD_PANEL_EDITOR',
},
{
path: ROUTES.EDIT_ALERTS,
exact: true,
component: EditRulesPage,
isPrivate: true,
key: 'EDIT_ALERTS',
},
{
path: ROUTES.LIST_ALL_ALERT,
exact: true,
component: ListAllALertsPage,
isPrivate: true,
key: 'LIST_ALL_ALERT',
},
{
path: ROUTES.ALERTS_NEW,
exact: true,
component: CreateNewAlerts,
isPrivate: true,
key: 'ALERTS_NEW',
},
{
path: ROUTES.ALERT_HISTORY,
exact: true,
component: AlertHistory,
isPrivate: true,
key: 'ALERT_HISTORY',
},
{
path: ROUTES.ALERT_OVERVIEW,
exact: true,
component: AlertOverview,
isPrivate: true,
key: 'ALERT_OVERVIEW',
},
{
path: ROUTES.TRACES_EXPLORER,
exact: true,
component: TracesExplorer,
isPrivate: true,
key: 'TRACES_EXPLORER',
},
{
path: ROUTES.TRACES_FUNNELS,
exact: true,
component: TracesFunnels,
isPrivate: true,
key: 'TRACES_FUNNELS',
},
{
path: ROUTES.TRACES_FUNNELS_DETAIL,
exact: true,
component: TracesFunnelDetails,
isPrivate: true,
key: 'TRACES_FUNNELS_DETAIL',
},
{
path: ROUTES.CHANNELS_NEW,
exact: true,
component: ChannelsNew,
isPrivate: true,
key: 'CHANNELS_NEW',
},
{
path: ROUTES.CHANNELS_EDIT,
exact: true,
component: ChannelsEdit,
isPrivate: true,
key: 'CHANNELS_EDIT',
},
{
path: ROUTES.ALL_ERROR,
exact: true,
isPrivate: true,
component: AllErrors,
key: 'ALL_ERROR',
},
{
path: ROUTES.ERROR_DETAIL,
exact: true,
component: ErrorDetails,
isPrivate: true,
key: 'ERROR_DETAIL',
},
{
path: ROUTES.VERSION,
exact: true,
component: StatusPage,
isPrivate: true,
key: 'VERSION',
},
{
path: ROUTES.LOGS,
exact: true,
component: Logs,
key: 'LOGS',
isPrivate: true,
},
{
path: ROUTES.LOGS_PIPELINES,
exact: true,
component: PipelinePage,
key: 'LOGS_PIPELINES',
isPrivate: true,
},
{
path: ROUTES.LOGIN,
exact: true,
component: Login,
isPrivate: false,
key: 'LOGIN',
},
{
path: ROUTES.FORGOT_PASSWORD,
exact: true,
component: ForgotPassword,
isPrivate: false,
key: 'FORGOT_PASSWORD',
},
{
path: ROUTES.UN_AUTHORIZED,
exact: true,
component: UnAuthorized,
key: 'UN_AUTHORIZED',
isPrivate: true,
},
{
path: ROUTES.PASSWORD_RESET,
exact: true,
component: PasswordReset,
key: 'PASSWORD_RESET',
isPrivate: false,
},
{
path: ROUTES.SOMETHING_WENT_WRONG,
exact: true,
component: SomethingWentWrong,
key: 'SOMETHING_WENT_WRONG',
isPrivate: false,
},
{
path: ROUTES.WORKSPACE_LOCKED,
exact: true,
component: WorkspaceBlocked,
isPrivate: true,
key: 'WORKSPACE_LOCKED',
},
{
path: ROUTES.WORKSPACE_SUSPENDED,
exact: true,
component: WorkspaceSuspended,
isPrivate: true,
key: 'WORKSPACE_SUSPENDED',
},
{
path: ROUTES.WORKSPACE_ACCESS_RESTRICTED,
exact: true,
component: WorkspaceAccessRestricted,
isPrivate: true,
key: 'WORKSPACE_ACCESS_RESTRICTED',
},
{
path: ROUTES.INTEGRATIONS_DETAIL,
exact: true,
component: IntegrationsDetailsPage,
isPrivate: true,
key: 'INTEGRATIONS_DETAIL',
},
{
path: ROUTES.INTEGRATIONS,
exact: true,
component: Integrations,
isPrivate: true,
key: 'INTEGRATIONS',
},
{
path: ROUTES.MESSAGING_QUEUES_KAFKA,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_KAFKA',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_CELERY_TASK,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_CELERY_TASK',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_OVERVIEW,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_KAFKA_DETAIL,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_KAFKA_DETAIL',
isPrivate: true,
},
{
path: ROUTES.INFRASTRUCTURE_MONITORING_HOSTS,
exact: true,
component: InfrastructureMonitoring,
key: 'INFRASTRUCTURE_MONITORING_HOSTS',
isPrivate: true,
},
{
path: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
exact: true,
component: InfrastructureMonitoring,
key: 'INFRASTRUCTURE_MONITORING_KUBERNETES',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_EXPLORER,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER_VOLUME_CONTROL',
isPrivate: true,
@@ -362,48 +408,56 @@ const routes: AppRoutes[] = [
{
path: ROUTES.METER,
exact: true,
component: MeterExplorerPage,
key: 'METER',
isPrivate: true,
},
{
path: ROUTES.METER_EXPLORER,
exact: true,
component: MeterExplorerPage,
key: 'METER_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.API_MONITORING,
exact: true,
component: ApiMonitoring,
key: 'API_MONITORING',
isPrivate: true,
},
{
path: [ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT],
exact: true,
component: AIAssistantPage,
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_ATTRIBUTE_MAPPING',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_OVERVIEW,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_EXPLORER,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_CONFIGURATION,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_CONFIGURATION',
isPrivate: true,
@@ -412,6 +466,7 @@ const routes: AppRoutes[] = [
export const SUPPORT_ROUTE: AppRoutes = {
path: ROUTES.SUPPORT,
exact: true,
component: SupportPage,
key: 'SUPPORT',
isPrivate: true,
@@ -419,6 +474,7 @@ export const SUPPORT_ROUTE: AppRoutes = {
export const LIST_LICENSES: AppRoutes = {
path: ROUTES.LIST_LICENSES,
exact: true,
component: LicensePage,
isPrivate: true,
key: 'LIST_LICENSES',
@@ -450,15 +506,9 @@ export const ROUTES_NOT_TO_BE_OVERRIDEN: string[] = [
];
export interface AppRoutes {
component: ComponentType;
/** An array registers the same component under each pattern. */
path: string | string[];
/**
* The route renders its own child routes, so it matches as a prefix. v6
* matches the whole path by default, so the mount appends a `/*` splat for
* these and nothing for the rest.
*/
nested?: boolean;
component: RouteProps['component'];
path: RouteProps['path'];
exact: RouteProps['exact'];
isPrivate: boolean;
key: keyof typeof ROUTES;
}

View File

@@ -5584,6 +5584,12 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export enum DashboardtypesAxisScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -6716,6 +6722,205 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesHeatmapPanelSpecDTO;
}
/**
* The panel kind.
*/
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind {
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
export interface DashboardtypesScatterPlotAxisDTO {
/**
* @type string
* @description Axis title. Empty draws none; the tooltip still names the column.
*/
label?: string;
/**
* @description auto (default) uses log, or symlog when values include zero or negatives, when that spreads the dots clearly more than linear does; otherwise linear. log cannot place a value at or below zero and leaves that dot out; symlog is log-like but places it.
*/
scale?: DashboardtypesAxisScaleDTO;
/**
* @type number,null
* @description The highest value the axis always shows; data above it still extends the axis, as does a threshold on it. Null fits the data.
*/
softMax?: number | null;
/**
* @type number,null
* @description The lowest value the axis always shows; data below it still extends the axis, as does a threshold on it. Null fits the data. A log axis ignores a bound at or below zero.
*/
softMin?: number | null;
}
export interface DashboardtypesScatterPlotAxesDTO {
/**
* @description The axis dimensions.x is plotted along.
*/
x?: DashboardtypesScatterPlotAxisDTO;
/**
* @description The axis dimensions.y is plotted along.
*/
y?: DashboardtypesScatterPlotAxisDTO;
}
/**
* @minimum 2
* @maximum 40
* @nullable
*/
export type DashboardtypesPointDiameterDTO = number | null;
/**
* @minimum 0.1
* @maximum 1
* @nullable
*/
export type DashboardtypesPointOpacityDTO = number | null;
/**
* Diameter of the largest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 24.
*/
export type DashboardtypesScatterPlotPointsDTOMaxSize = DashboardtypesPointDiameterDTO | null;
/**
* Diameter of the smallest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 4.
*/
export type DashboardtypesScatterPlotPointsDTOMinSize = DashboardtypesPointDiameterDTO | null;
/**
* Fill opacity of every dot, from 0.1 to 1. Null means 0.7.
*/
export type DashboardtypesScatterPlotPointsDTOOpacity = DashboardtypesPointOpacityDTO | null;
/**
* Diameter of every dot when dimensions.sizeBy is unset, in CSS pixels from 2 to 40. Null means 6.
*/
export type DashboardtypesScatterPlotPointsDTOSize = DashboardtypesPointDiameterDTO | null;
export interface DashboardtypesScatterPlotPointsDTO {
/**
* @description Diameter of the largest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 24.
*/
maxSize?: DashboardtypesScatterPlotPointsDTOMaxSize;
/**
* @description Diameter of the smallest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 4.
*/
minSize?: DashboardtypesScatterPlotPointsDTOMinSize;
/**
* @description Fill opacity of every dot, from 0.1 to 1. Null means 0.7.
*/
opacity?: DashboardtypesScatterPlotPointsDTOOpacity;
/**
* @description Diameter of every dot when dimensions.sizeBy is unset, in CSS pixels from 2 to 40. Null means 6.
*/
size?: DashboardtypesScatterPlotPointsDTOSize;
}
export interface DashboardtypesScatterPlotChartAppearanceDTO {
/**
* @description Dot size and opacity.
*/
points?: DashboardtypesScatterPlotPointsDTO;
}
export interface DashboardtypesScatterPlotDimensionsDTO {
/**
* @type array,null
* @description Group-by label names (e.g. k8s.namespace.name) whose combined values color the dots and drive the legend. Empty colors by every group-by label.
*/
colorBy?: string[] | null;
/**
* @type string
* @description Value column key that scales each dot's area between chartAppearance.points.minSize and maxSize. Empty draws every dot at chartAppearance.points.size, as does a dot with no value.
*/
sizeBy?: string;
/**
* @type string
* @description Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty, or a key the result lacks, uses the first value column.
*/
x?: string;
/**
* @type string
* @description Value column key plotted on the y axis. Empty, or a key the result lacks, uses the first value column x does not use.
*/
y?: string;
}
export enum DashboardtypesThresholdAxisDTO {
x = 'x',
y = 'y',
}
export interface DashboardtypesScatterPlotThresholdDTO {
/**
* @description The axis the line marks: y (default) draws it horizontally, x vertically. Unit converts against that axis's column unit, and the axis extends to keep the line in view.
*/
axis?: DashboardtypesThresholdAxisDTO;
/**
* @type string
* @description The CSS color of the line, for example #F1575F, which matches the SigNoz red preset; CSS named colors such as red also work.
*/
color: string;
/**
* @type string
* @description The caption shown on the line.
*/
label?: string;
/**
* @type string
* @description The unit id of value, such as ms or percent. Empty means the panel's unit, or the column's unit for a table threshold. Prefer the panel's unit; a different unit works only when it converts to it, such as s against a panel in ms.
*/
unit?: string;
/**
* @type number
* @format double
* @description The value the line is drawn at, in unit.
*/
value: number;
}
export interface DashboardtypesScatterPlotPanelSpecDTO {
/**
* @description Title, bounds and scale of each axis.
*/
axes?: DashboardtypesScatterPlotAxesDTO;
/**
* @description Dot drawing options.
*/
chartAppearance?: DashboardtypesScatterPlotChartAppearanceDTO;
/**
* @description Which result columns place, size and color the dots. Each dot is one row of the joined scalar result: one group.
*/
dimensions?: DashboardtypesScatterPlotDimensionsDTO;
/**
* @description Per-column units and decimal precision. The x and y columns' units label their axes; every bound column's unit shows in the tooltip.
*/
formatting?: DashboardtypesTableFormattingDTO;
/**
* @description Legend placement and series colors. A series is one color group, labeled by its colorBy values joined with a comma, or All when nothing is grouped.
*/
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
* @description Reference lines drawn at fixed values across the x or y axis.
*/
thresholds?: DashboardtypesScatterPlotThresholdDTO[] | null;
/**
* @description The time range the groups are computed over.
*/
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO {
/**
* @enum signoz/ScatterPlotPanel
* @type string
* @description The panel kind.
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind;
/**
* @description The options for this panel kind. {} accepts every default.
*/
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -6726,7 +6931,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -8006,6 +8212,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
/**
* The RFC 6902 operations, applied in order against the dashboard's postable shape.
@@ -13264,6 +13471,23 @@ export type SpantypesFlamegraphSpanDTOAttributes = { [key: string]: unknown };
export type SpantypesFlamegraphSpanDTOResource = { [key: string]: string };
export type SpantypesScopeDTOAttributes = { [key: string]: unknown };
export interface SpantypesScopeDTO {
/**
* @type object
*/
attributes?: SpantypesScopeDTOAttributes;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
version?: string;
}
export interface SpantypesFlamegraphSpanDTO {
/**
* @type object
@@ -13299,6 +13523,7 @@ export interface SpantypesFlamegraphSpanDTO {
* @type object
*/
resource: SpantypesFlamegraphSpanDTOResource;
scope: SpantypesScopeDTO;
/**
* @type string
*/
@@ -13844,6 +14069,7 @@ export interface SpantypesWaterfallSpanDTO {
* @type string
*/
response_status_code?: string;
scope?: SpantypesScopeDTO;
/**
* @type string
*/

View File

@@ -21,6 +21,7 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
convertBuilderQueriesToV5,
mapPanelTypeToRequestType,
prepareQueryRangePayloadV5,
} from './prepareQueryRangePayloadV5';
@@ -904,6 +905,12 @@ describe('prepareQueryRangePayloadV5', () => {
});
});
describe('mapPanelTypeToRequestType', () => {
it('sends a scatter plot as scalar, like a table', () => {
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
});
});
describe('convertBuilderQueriesToV5 having normalization', () => {
const buildSpec = (having: unknown): MetricBuilderQuery => {
const [envelope] = convertBuilderQueriesToV5(

View File

@@ -51,6 +51,7 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
case PANEL_TYPES.BAR:
return 'time_series';
case PANEL_TYPES.TABLE:
case PANEL_TYPES.SCATTER:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return 'scalar';
@@ -274,6 +275,7 @@ export function createAggregation(
queryData.dataSource === DataSource.METRICS &&
panelType &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE);

View File

@@ -16,7 +16,6 @@ export interface AppProvidersProps {
store: Store;
queryClient: QueryClient;
appContext: AppLayer;
router: AppLayer;
searchParams: AppLayer;
}
@@ -28,34 +27,27 @@ export interface AppProvidersProps {
* A new provider belongs here only if it holds process-wide state that does not
* depend on the user, the license or the route. One that fetches on mount would
* fire unauthenticated from here; put it in `AppShell` or lower.
*
* `router` is the outermost layer because `searchParams` (nuqs) reads the
* router's `useNavigate` / `useSearchParams`. Everything below, including the
* boot spinner, therefore renders inside the router.
*/
function AppProviders({
children,
store,
queryClient,
appContext,
router,
searchParams,
}: AppProvidersProps): JSX.Element {
return (
<HelmetProvider>
{router(
searchParams(
<ThemeProvider>
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<GlobalTimeStoreAdapter />
{appContext(children)}
</Provider>
</QueryClientProvider>
</TimezoneProvider>
</ThemeProvider>,
),
{searchParams(
<ThemeProvider>
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<GlobalTimeStoreAdapter />
{appContext(children)}
</Provider>
</QueryClientProvider>
</TimezoneProvider>
</ThemeProvider>,
)}
</HelmetProvider>
);

View File

@@ -1,26 +0,0 @@
import { ReactNode } from 'react';
import { unstable_HistoryRouter as HistoryRouter } from 'react-router';
import history from 'lib/history';
import { getBasePath } from 'utils/basePath';
/**
* The outermost layer. It is above `NuqsAdapter` on purpose:
* `nuqs/adapters/react-router/v7` calls `useNavigate` and `useSearchParams`, so
* it only works inside a router.
*
* Transitions are off on purpose: under one React keeps the previous screen up
* instead of committing the Suspense fallback, so a route whose chunk is not
* cached yet renders no loader at all. Rendering pending UI under it needs
* `useNavigation()` and a data router.
*/
export function appRouter(children: ReactNode): ReactNode {
return (
<HistoryRouter
basename={getBasePath()}
history={history}
useTransitions={false}
>
{children}
</HistoryRouter>
);
}

View File

@@ -5,8 +5,11 @@ import { NotificationProvider } from 'hooks/useNotifications';
import { CmdKProvider } from 'providers/cmdKProvider';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { AppLayer } from './types';
export interface AppShellProps {
children: ReactNode;
router: AppLayer;
/** Mounted beside the routed content: the command palette and its siblings. */
overlays?: ReactNode;
}
@@ -16,26 +19,28 @@ export interface AppShellProps {
* fetches settle, above `PrivateRoute`, so it also covers the redirects and the
* not-found route, and it survives every navigation.
*
* A new provider belongs here if it needs the user and has to outlive the page:
* a global overlay, a shortcut host, anything one route opens and the next one
* keeps. The router itself is higher up, in `AppProviders`.
* A new provider belongs here if it needs the router or the user and has to
* outlive the page: a global overlay, a shortcut host, anything one route opens
* and the next one keeps.
*
* `ConfigProvider` reads `useThemeConfig`, which needs `ThemeProvider` above it,
* so the antd theme is settled here instead of by the caller.
*/
function AppShell({ children, overlays }: AppShellProps): JSX.Element {
function AppShell({ children, router, overlays }: AppShellProps): JSX.Element {
const themeConfig = useThemeConfig();
return (
<ConfigProvider theme={themeConfig}>
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
{overlays}
{children}
</ErrorModalProvider>
</NotificationProvider>
</CmdKProvider>
{router(
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
{overlays}
{children}
</ErrorModalProvider>
</NotificationProvider>
</CmdKProvider>,
)}
</ConfigProvider>
);
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { useAppLocation } from 'lib/router/useAppLocation';
import { Dot } from '@signozhq/icons';
import { Dot, Globe, Inbox, SquarePen } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import Noz from 'components/Noz/Noz';
@@ -16,7 +16,6 @@ import {
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { Globe, Inbox, SquarePen } from '@signozhq/icons';
import AnnouncementsModal from './AnnouncementsModal';
import FeedbackModal from './FeedbackModal';

View File

@@ -4,6 +4,7 @@ import { Button } from '@signozhq/ui/button';
import { Drawer, Tooltip } from 'antd';
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
@@ -147,7 +148,7 @@ function LogDetailInner({
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});

View File

@@ -121,7 +121,6 @@ exports[`Not Found page test should render Not Found page without errors 1`] = `
</div>
<a
class="c3"
data-discover="true"
href="/home"
tabindex="0"
>

View File

@@ -106,9 +106,17 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
[resolvedConfig],
);
const traceOperatorField = useMemo(
() =>
resolveQueryBuilderField(QueryBuilderField.TraceOperator, resolvedConfig),
[resolvedConfig],
);
const canShowTraceOperator = showTraceOperator && !traceOperatorField.hidden;
const isMultiQueryAllowed = useMemo(
() => !additionalQueries.hidden && (!isRawQuery || showTraceOperator),
[additionalQueries.hidden, showTraceOperator, isRawQuery],
() => !additionalQueries.hidden && (!isRawQuery || canShowTraceOperator),
[additionalQueries.hidden, canShowTraceOperator, isRawQuery],
);
const queryDataSources = useMemo(
@@ -146,13 +154,14 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
);
const hasTraceOperator = useMemo(
() => showTraceOperator && hasAtLeastOneTraceQuery && Boolean(traceOperator),
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
() =>
canShowTraceOperator && hasAtLeastOneTraceQuery && Boolean(traceOperator),
[canShowTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
);
const showQueryList = useMemo(
() => (!showOnlyWhereClause && !isRawQuery) || showTraceOperator,
[isRawQuery, showOnlyWhereClause, showTraceOperator],
() => (!showOnlyWhereClause && !isRawQuery) || canShowTraceOperator,
[isRawQuery, showOnlyWhereClause, canShowTraceOperator],
);
const showFormula = useMemo(() => {
@@ -168,8 +177,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
}, [formula.hidden, isRawQuery, currentDataSource]);
const showAddTraceOperator = useMemo(
() => showTraceOperator && !traceOperator && hasAtLeastOneTraceQuery,
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
() => canShowTraceOperator && !traceOperator && hasAtLeastOneTraceQuery,
[canShowTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
);
// Nothing left to add means no footer at all, rather than an empty bar under the
@@ -178,13 +187,13 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
() =>
(!additionalQueries.hidden || showFormula || showAddTraceOperator) &&
((!showOnlyWhereClause && !isRawQuery) ||
(currentDataSource === DataSource.TRACES && showTraceOperator)),
(currentDataSource === DataSource.TRACES && canShowTraceOperator)),
[
additionalQueries.hidden,
showFormula,
showAddTraceOperator,
isRawQuery,
showTraceOperator,
canShowTraceOperator,
showOnlyWhereClause,
currentDataSource,
],
@@ -222,7 +231,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
fieldsConfig={fieldsConfig}
allowedDataSources={queryDataSources}
isMultiQueryAllowed={isMultiQueryAllowed}
showTraceOperator={showTraceOperator}
showTraceOperator={canShowTraceOperator}
hasTraceOperator={hasTraceOperator}
version={version}
isAvailableToDisable={false}
@@ -247,7 +256,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
version={version}
isMultiQueryAllowed={isMultiQueryAllowed}
isAvailableToDisable={false}
showTraceOperator={showTraceOperator}
showTraceOperator={canShowTraceOperator}
hasTraceOperator={hasTraceOperator}
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}

View File

@@ -6,7 +6,8 @@ export type NonAddOnField =
| QueryBuilderField.StepInterval
| QueryBuilderField.Functions
| QueryBuilderField.Formula
| QueryBuilderField.AdditionalQueries;
| QueryBuilderField.AdditionalQueries
| QueryBuilderField.TraceOperator;
export type AddOnField = Exclude<QueryBuilderField, NonAddOnField>;

View File

@@ -18,6 +18,7 @@ import {
STEP_INTERVAL_PLACEHOLDER,
} from '../../queryBuilderFields.utils';
import { isScalarPanelType } from '../panelTypes';
import QueryAggregationSelect from './QueryAggregationSelect';
import './QueryAggregation.styles.scss';
@@ -45,10 +46,7 @@ function QueryAggregationOptions({
const isNonMetricSource =
dataSource === DataSource.TRACES || dataSource === DataSource.LOGS;
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
) {
if (isNonMetricSource && isScalarPanelType(panelType)) {
return { hidden: true, disabled: false, pinned: false };
}

View File

@@ -47,29 +47,23 @@ export function QuerySearchV2Provider({
store.getState().setInitialExpression(initialExpression);
}, [initialExpression, store]);
// The URL owns the expression, in both directions. A provider can outlive the
// navigation away from the page it belongs to (a route-driven tab keeps the
// leaving pane mounted until its animation ends), so treating a param that
// disappeared as something to restore republishes it onto the URL of the page
// being entered.
const isInitialized = useRef(false);
useEffect(() => {
const userExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
if (userExpression !== store.getState().committedExpression) {
store.getState().initializeFromUrl(userExpression);
if (!isInitialized.current && urlExpression) {
const cleanedExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
store.getState().initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [urlExpression, initialExpression, store]);
const publishedExpression = useRef(committedExpression);
useEffect(() => {
if (committedExpression === publishedExpression.current) {
return;
if (isInitialized.current || !urlExpression) {
setUrlExpression(committedExpression || null);
}
publishedExpression.current = committedExpression;
setUrlExpression(committedExpression || null);
}, [committedExpression, setUrlExpression]);
}, [committedExpression, setUrlExpression, urlExpression]);
useEffect(() => {
return (): void => {

View File

@@ -110,22 +110,6 @@ describe('QuerySearchExpressionProvider', () => {
expect(result.current.expression).toBe('status = 500');
});
it('should follow the URL when the param is dropped', () => {
mockUrlValue = 'status = 500';
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
expect(result.current.expression).toBe('status = 500');
mockSetQueryState.mockClear();
mockUrlValue = null;
rerender();
expect(result.current.expression).toBe('');
expect(mockSetQueryState).not.toHaveBeenCalledWith('status = 500');
});
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useExpression());

View File

@@ -9,7 +9,6 @@ import {
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import cx from 'classnames';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QBEntityOptions from 'container/QueryBuilder/components/QBEntityOptions/QBEntityOptions';
import { QueryProps } from 'container/QueryBuilder/type';
import SpanScopeSelector from 'container/QueryBuilder/filters/QueryBuilderSearchV2/SpanScopeSelector';
@@ -27,6 +26,7 @@ import {
resolveQueryBuilderField,
} from '../queryBuilderFields.utils';
import { isScalarPanelType } from './panelTypes';
import MetricsAggregateSection from './MerticsAggregateSection/MetricsAggregateSection';
import { MetricsSelect } from './MetricsSelect/MetricsSelect';
import QueryAddOns from './QueryAddOns/QueryAddOns';
@@ -112,11 +112,7 @@ export const QueryV2 = forwardRef(function QueryV2(
}
const showReduceTo = useMemo(
() =>
dataSource === DataSource.METRICS &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE),
() => dataSource === DataSource.METRICS && isScalarPanelType(panelType),
[dataSource, panelType],
);

View File

@@ -0,0 +1,13 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
/** Panels that reduce each query to one value per group over the whole window. */
const SCALAR_PANEL_TYPES: ReadonlySet<string> = new Set<string>([
PANEL_TYPES.TABLE,
PANEL_TYPES.SCATTER,
PANEL_TYPES.PIE,
PANEL_TYPES.VALUE,
]);
export function isScalarPanelType(panelType?: string | null): boolean {
return panelType != null && SCALAR_PANEL_TYPES.has(panelType);
}

View File

@@ -14,6 +14,17 @@ jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
const queryData = initialQueriesMap.traces.builder
.queryData[0] as IBuilderQuery;
function renderFor(panelType: PANEL_TYPES, dataSource: DataSource): void {
render(
<QueryAggregationOptions
dataSource={dataSource}
panelType={panelType}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
}
describe('QueryAggregationOptions step interval placeholder', () => {
it('shows the configured placeholder', () => {
render(
@@ -34,14 +45,27 @@ describe('QueryAggregationOptions step interval placeholder', () => {
});
it('falls back to Auto', () => {
render(
<QueryAggregationOptions
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TIME_SERIES}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByPlaceholderText('Auto')).toBeInTheDocument();
});
});
describe('QueryAggregationOptions step interval', () => {
it.each([PANEL_TYPES.TABLE, PANEL_TYPES.SCATTER, PANEL_TYPES.PIE])(
'is hidden for a %s panel over traces, which reduces the whole window',
(panelType) => {
renderFor(panelType, DataSource.TRACES);
expect(screen.queryByText('every')).not.toBeInTheDocument();
},
);
it('stays for a scatter plot over metrics, where it sets the series step', () => {
renderFor(PANEL_TYPES.SCATTER, DataSource.METRICS);
expect(screen.getByText('every')).toBeInTheDocument();
});
it('stays for a time series over traces', () => {
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByText('every')).toBeInTheDocument();
});
});

View File

@@ -20,6 +20,7 @@ export enum QueryBuilderField {
// Builder level
Formula = 'formula',
AdditionalQueries = 'additional_queries',
TraceOperator = 'trace_operator',
}
/** Per-query builder fields that can be kept equal across a panel's queries. */

View File

@@ -65,13 +65,13 @@ export function resolveQueryBuilderFields(
/**
* The surface a raw-row builder starts from, layered under a caller's own config.
* `AdditionalQueries` is deliberately absent — a raw trace builder still takes several
* queries when trace matching is on. Omit rather than Partial, so a field added to the
* enum has to be placed on one side.
* `AdditionalQueries` and `TraceOperator` are deliberately absent — a raw trace builder
* still takes several queries, and their trace operator, when trace matching is on.
* Omit rather than Partial, so a field added to the enum has to be placed on one side.
*/
export const RAW_QUERY_FIELDS: Omit<
Record<QueryBuilderField, QueryBuilderFieldRule>,
QueryBuilderField.AdditionalQueries
QueryBuilderField.AdditionalQueries | QueryBuilderField.TraceOperator
> = {
[QueryBuilderField.Aggregation]: { state: 'hidden' },
[QueryBuilderField.StepInterval]: { state: 'hidden' },

View File

@@ -0,0 +1,169 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderV2 } from '../QueryBuilderV2';
import {
addOnToggles,
appendToAggregation,
DEFAULT_ARGS,
expectFirstQuery,
PANEL_EDITOR_ARGS,
PANEL_EDITOR_PATH,
payloadIn,
routeWith,
selectMetric,
} from './QueryBuilderV2.stories.mocks';
import QueryProbe from './QueryProbe';
const METRICS_EXPLORER_ARGS = {
config: { initialDataSource: DataSource.METRICS, queryVariant: 'static' },
showFunctions: false,
} as const;
const metricsExplorerRoute = routeWith(
ROUTES.METRICS_EXPLORER_EXPLORER,
initialQueriesMap.metrics,
);
/**
* Builder rules that hold without a backend: which add-ons a panel kind
* offers, the aggregations a metric type defaults to, and input limits. The
* probe below the builder prints the payload each play asserts on.
*/
const meta = {
title: 'Components/Query Builder/Behaviour',
component: QueryBuilderV2,
tags: ['play'],
decorators: [withCanvas({ maxWidth: 1100 })],
args: DEFAULT_ARGS,
render: (args): JSX.Element => (
<>
<QueryBuilderV2 {...args} />
<QueryProbe />
</>
),
} satisfies Meta<typeof QueryBuilderV2>;
export default meta;
type Story = StoryObj<typeof meta>;
/** A Value panel offers only the legend add-on. */
export const LogsOnValuePanel: Story = {
args: { ...PANEL_EDITOR_ARGS, panelType: PANEL_TYPES.VALUE },
parameters: {
signoz: { route: routeWith(PANEL_EDITOR_PATH, initialQueriesMap.logs) },
},
play: async ({ canvasElement }) => {
await waitFor(() =>
expect(addOnToggles(canvasElement)).toStrictEqual(['legend_format']),
);
},
};
/** Metrics on a Table: reduce-to is offered; group by lives in the aggregation row. */
export const MetricsOnTablePanel: Story = {
args: { ...PANEL_EDITOR_ARGS, panelType: PANEL_TYPES.TABLE },
parameters: {
signoz: { route: routeWith(PANEL_EDITOR_PATH, initialQueriesMap.metrics) },
},
play: async ({ canvasElement }) => {
await waitFor(() =>
expect(addOnToggles(canvasElement)).toStrictEqual([
'having',
'order_by',
'limit',
'legend_format',
'reduce_to',
]),
);
},
};
/** Traces on a time series: every add-on, with group by as a toggle. */
export const TracesOnTimeSeriesPanel: Story = {
args: PANEL_EDITOR_ARGS,
parameters: {
signoz: { route: routeWith(PANEL_EDITOR_PATH, initialQueriesMap.traces) },
},
play: async ({ canvasElement }) => {
await waitFor(() =>
expect(addOnToggles(canvasElement)).toStrictEqual([
'group_by',
'having',
'order_by',
'limit',
'legend_format',
]),
);
},
};
/** Picking a gauge defaults to avg over time and avg across series. */
export const GaugeMetricDefaults: Story = {
args: METRICS_EXPLORER_ARGS,
parameters: { signoz: { route: metricsExplorerRoute } },
play: async ({ canvasElement }) => {
await selectMetric(canvasElement, 'container_cpu_utilization');
await expectFirstQuery(canvasElement, {
aggregations: [
expect.objectContaining({
metricName: 'container_cpu_utilization',
timeAggregation: 'avg',
spaceAggregation: 'avg',
}),
],
});
},
};
/** Picking a histogram defaults to the p90 across series. */
export const HistogramMetricDefaults: Story = {
args: METRICS_EXPLORER_ARGS,
parameters: { signoz: { route: metricsExplorerRoute } },
play: async ({ canvasElement }) => {
await selectMetric(canvasElement, 'signoz_latency_bucket');
await expectFirstQuery(canvasElement, {
aggregations: [
expect.objectContaining({
metricName: 'signoz_latency_bucket',
spaceAggregation: 'p90',
}),
],
});
},
};
/** A Value panel takes a single aggregation: a second one is not accepted. */
export const ValuePanelTakesOneAggregation: Story = {
args: { ...PANEL_EDITOR_ARGS, panelType: PANEL_TYPES.VALUE },
parameters: {
signoz: { route: routeWith(PANEL_EDITOR_PATH, initialQueriesMap.logs) },
},
play: async ({ canvasElement }) => {
await expectFirstQuery(canvasElement, {
aggregations: [{ expression: 'count()' }],
});
const { before, after } = appendToAggregation(
canvasElement,
', count_distinct(service.name)',
);
await expect(after).toBe(before);
await expectFirstQuery(canvasElement, {
aggregations: [{ expression: 'count()' }],
});
await expect(
payloadIn(canvasElement).queries[0].spec.aggregations,
).toHaveLength(1);
},
};

View File

@@ -0,0 +1,134 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { EditorView } from '@codemirror/view';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import type { QueryBuilderV2 } from '../QueryBuilderV2';
type QueryBuilderProps = Parameters<typeof QueryBuilderV2>[0];
/** The builder's provider reads its starting query from the URL, as every host seeds it. */
export const routeWith = (path: string, query: Query): string =>
`${path}?compositeQuery=${encodeURIComponent(JSON.stringify(query))}`;
export const meterQuery: Query = {
...initialQueriesMap.metrics,
builder: {
...initialQueriesMap.metrics.builder,
queryData: [
{ ...initialQueriesMap.metrics.builder.queryData[0], source: 'meter' },
],
},
};
/** `PanelEditorQueryBuilder`'s props, less the per-kind field config. */
export const PANEL_EDITOR_ARGS: Partial<QueryBuilderProps> = {
allowedDataSources: [
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
TelemetrytypesSignalDTO.metrics,
],
showTraceOperator: true,
signalSourceChangeEnabled: true,
savePreviousQuery: true,
};
export const PANEL_EDITOR_PATH = '/dashboard/story/panel/story';
interface ProbedQuery {
type: string;
spec: {
aggregations?: Record<string, unknown>[];
[key: string]: unknown;
};
}
/** The `compositeQuery` the story's `QueryProbe` prints for the builder's state. */
export const payloadIn = (
canvasElement: HTMLElement,
): { queries: ProbedQuery[] } =>
JSON.parse(
within(canvasElement).getByTestId('qb-payload').textContent ?? '{}',
) as { queries: ProbedQuery[] };
export const expectFirstQuery = async (
canvasElement: HTMLElement,
spec: Record<string, unknown>,
): Promise<void> => {
await waitFor(() =>
expect(payloadIn(canvasElement).queries[0]).toMatchObject({
type: 'builder_query',
spec: expect.objectContaining(spec),
}),
);
};
/** The add-on toggles query `A` offers, in order (`group_by`, `having`, …). */
export const addOnToggles = (canvasElement: HTMLElement): string[] =>
within(within(canvasElement).getByTestId('query-A'))
.queryAllByTestId(/^query-add-on-/)
.map((toggle) =>
(toggle.getAttribute('data-testid') ?? '').replace('query-add-on-', ''),
);
/** Picks `metricName` in query `A`'s metric selector; its list portals out. */
export const selectMetric = async (
canvasElement: HTMLElement,
metricName: string,
): Promise<void> => {
const field = within(canvasElement).getByTestId('metric-name-selector-0');
const input = within(field).getByRole('combobox');
await userEvent.click(input);
await userEvent.type(input, metricName);
const option = await waitFor(async () => {
const match = Array.from(
document.querySelectorAll<HTMLElement>(
'.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
),
).find((candidate) => candidate.textContent?.includes(metricName));
await expect(match).toBeDefined();
return match as HTMLElement;
});
await userEvent.click(option);
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
};
/**
* Appends `text` to query `A`'s aggregation editor and returns its document
* before and after. Dispatched as a CodeMirror transaction: `userEvent.type`
* strands the caret when the line redraws, and a transaction runs the editor's
* own filters, which is what an input limit is.
*/
export const appendToAggregation = (
canvasElement: HTMLElement,
text: string,
): { before: string; after: string } => {
const content = within(canvasElement)
.getByTestId('query-aggregation-editor')
.querySelector<HTMLElement>('.cm-content');
const view = content ? EditorView.findFromDOM(content) : null;
if (!view) {
throw new Error('aggregation editor not mounted');
}
const before = view.state.doc.toString();
view.dispatch({
changes: { from: before.length, insert: text },
selection: { anchor: before.length + text.length },
userEvent: 'input.type',
});
// Read synchronously: the document is the editor's own verdict, before
// React state catches up.
return { before, after: view.state.doc.toString() };
};
export const DEFAULT_ARGS: Partial<QueryBuilderProps> = {
panelType: PANEL_TYPES.TIME_SERIES,
version: 'v3',
};

View File

@@ -0,0 +1,145 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderV2 } from '../QueryBuilderV2';
import {
DEFAULT_ARGS,
expectFirstQuery,
meterQuery,
PANEL_EDITOR_ARGS,
PANEL_EDITOR_PATH,
routeWith,
} from './QueryBuilderV2.stories.mocks';
import QueryProbe from './QueryProbe';
/**
* The query builder as each host mounts it: the props come from that host's
* call site, the starting query from its route. The probe below the builder
* prints the `query_range` payload its state would send.
*/
const meta = {
title: 'Components/Query Builder',
component: QueryBuilderV2,
tags: ['play'],
decorators: [withCanvas({ maxWidth: 1100 })],
args: DEFAULT_ARGS,
render: (args): JSX.Element => (
<>
<QueryBuilderV2 {...args} />
<QueryProbe />
</>
),
} satisfies Meta<typeof QueryBuilderV2>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Logs explorer, graph view: a fixed logs signal with `count()`. */
export const LogsExplorer: Story = {
args: {
config: { initialDataSource: DataSource.LOGS, queryVariant: 'static' },
},
parameters: {
signoz: { route: routeWith(ROUTES.LOGS_EXPLORER, initialQueriesMap.logs) },
},
play: async ({ canvasElement }) => {
await expectFirstQuery(canvasElement, {
signal: 'logs',
aggregations: [{ expression: 'count()' }],
});
},
};
/** Traces explorer: a fixed traces signal, with trace matching offered. */
export const TracesExplorer: Story = {
args: {
config: { initialDataSource: DataSource.TRACES, queryVariant: 'static' },
showTraceOperator: true,
},
parameters: {
signoz: {
route: routeWith(ROUTES.TRACES_EXPLORER, initialQueriesMap.traces),
},
},
play: async ({ canvasElement }) => {
await expectFirstQuery(canvasElement, { signal: 'traces' });
},
};
/** Metrics explorer: a fixed metrics signal, functions hidden. */
export const MetricsExplorer: Story = {
args: {
config: { initialDataSource: DataSource.METRICS, queryVariant: 'static' },
showFunctions: false,
},
parameters: {
signoz: {
route: routeWith(
ROUTES.METRICS_EXPLORER_EXPLORER,
initialQueriesMap.metrics,
),
},
},
play: async ({ canvasElement }) => {
await expectFirstQuery(canvasElement, { signal: 'metrics' });
},
};
/** Meter explorer: metrics read from the meter source. */
export const MeterExplorer: Story = {
args: {
config: {
initialDataSource: DataSource.METRICS,
queryVariant: 'static',
signalSource: 'meter',
},
showFunctions: false,
},
parameters: {
signoz: { route: routeWith(ROUTES.METER_EXPLORER, meterQuery) },
},
play: async ({ canvasElement }) => {
await expectFirstQuery(canvasElement, { signal: 'metrics', source: 'meter' });
},
};
/**
* V2 panel editor: a data-source dropdown per query, several queries,
* formulas and trace matching.
*/
export const PanelEditor: Story = {
args: PANEL_EDITOR_ARGS,
parameters: {
signoz: {
route: routeWith(PANEL_EDITOR_PATH, initialQueriesMap.logs),
},
},
play: async ({ canvasElement }) => {
await expectFirstQuery(canvasElement, { signal: 'logs' });
await expect(
within(canvasElement).getByTestId('query-data-source-selector-0'),
).toBeVisible();
},
};
/** Logs alert: a fixed logs signal with functions, as the alert form mounts it. */
export const LogsAlert: Story = {
args: {
config: { initialDataSource: DataSource.LOGS, queryVariant: 'static' },
showFunctions: true,
signalSourceChangeEnabled: true,
},
parameters: {
signoz: { route: routeWith(ROUTES.ALERTS_NEW, initialQueriesMap.logs) },
},
play: async ({ canvasElement }) => {
await expectFirstQuery(canvasElement, { signal: 'logs' });
},
};

View File

@@ -0,0 +1,11 @@
.probe {
margin-top: 16px;
padding: 12px;
max-height: 320px;
overflow: auto;
border: 1px solid var(--l1-border);
border-radius: 4px;
font-size: 12px;
color: var(--l2-foreground);
background: var(--l1-background);
}

View File

@@ -0,0 +1,38 @@
import { useMemo } from 'react';
import { prepareQueryRangePayloadV5 } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import styles from './QueryProbe.module.scss';
/** Fixed, so the printed payload is the same on every render and capture. */
const WINDOW = { start: 1_700_000_000_000, end: 1_700_003_600_000 };
/**
* Prints the `compositeQuery` the builder's live state would send, so a play
* function asserts on the query rather than on the DOM that edits it.
*/
function QueryProbe(): JSX.Element {
const { currentQuery, panelType } = useQueryBuilder();
const payload = useMemo(() => {
try {
return prepareQueryRangePayloadV5({
query: currentQuery,
graphType: panelType ?? PANEL_TYPES.TIME_SERIES,
selectedTime: 'GLOBAL_TIME',
...WINDOW,
}).queryPayload.compositeQuery;
} catch (error) {
return { error: String(error) };
}
}, [currentQuery, panelType]);
return (
<pre className={styles.probe} data-testid="qb-payload">
{JSON.stringify(payload, null, 2)}
</pre>
);
}
export default QueryProbe;

View File

@@ -36,6 +36,7 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HEATMAP]: null,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -3,7 +3,6 @@ import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
@@ -337,6 +336,7 @@ export enum PANEL_TYPES {
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
SCATTER = 'scatter',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
@@ -591,7 +591,6 @@ export const listViewInitialTraceQuery: Query = {
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
offset: 0,
pageSize: 10,
selectColumns: defaultTraceSelectedColumns,
},
],
},

View File

@@ -18,11 +18,9 @@ import {
import ROUTES from 'constants/routes';
import { navigate } from 'lib/router/navigation';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import {
buildExplorerNavigationUrl,
findSavedViewInLists,
openSavedView,
openSavedViewByKey,
@@ -232,23 +230,6 @@ describe('findSavedViewInLists', () => {
});
});
describe('buildExplorerNavigationUrl', () => {
it('encodes composite query and view selectors', () => {
const url = buildExplorerNavigationUrl(
ROUTES.LOGS_EXPLORER,
{ queryType: 'builder' } as never,
{
[QueryParams.panelTypes]: PANEL_TYPES.LIST,
[QueryParams.viewKey]: 'view-1',
},
);
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain(`${QueryParams.compositeQuery}=`);
expect(url).toContain(`${QueryParams.viewKey}=`);
});
});
describe('openSavedView', () => {
beforeEach(() => {
mockedNavigate.mockClear();

View File

@@ -12,7 +12,7 @@ import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource'
import { SOURCEPAGE_VS_ROUTES } from 'container/SavedViews/constants';
import { navigate } from 'lib/router/navigation';
import { DataSource } from 'types/common/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
type SavedViewSourceHint = SavedViewSourcePage;
@@ -72,26 +72,6 @@ export function explorerRouteForSourcePage(
return SOURCEPAGE_VS_ROUTES[sourcePage] ?? null;
}
/**
* Builds an explorer URL the same way `redirectWithQueryBuilderData` does —
* without inheriting stale query params from the current page's `urlQuery`.
*/
export function buildExplorerNavigationUrl(
route: string,
query: Query,
searchParams: Record<string, unknown>,
): string {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
Object.entries(searchParams).forEach(([key, value]) => {
params.set(key, JSON.stringify(value));
});
return `${route}?${params.toString()}`;
}
export function openSavedView(view: SavedviewtypesSavedViewDTO): void {
const route = view.source ? explorerRouteForSourcePage(view.source) : null;
if (!route) {

View File

@@ -38,8 +38,7 @@ import { useAppContext } from 'providers/App/App';
import { UserPreference } from 'types/api/preferences/preference';
import { DataSource } from 'types/common/queryBuilder';
import { USER_ROLES } from 'types/roles';
import { isIngestionActive } from 'utils/app';
import { isModifierKeyPressed } from 'utils/app';
import { isIngestionActive, isModifierKeyPressed } from 'utils/app';
import { popupContainer } from 'utils/selectPopupContainer';
import crackerUrl from '@/assets/Icons/cracker.svg';

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
@@ -18,10 +18,10 @@ import {
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { initialQueriesMap } from 'constants/queryBuilder';
import K8sBaseDetails, {
K8sDetailsFilters,
} from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { DefaultQueryRedirect } from 'container/InfraMonitoringK8sV2/Base/DefaultQueryRedirect';
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
import StatusFilter from 'container/InfraMonitoringHostsV2/StatusFilter';
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
@@ -29,6 +29,9 @@ import {
InfraMonitoringEntity,
METRIC_NAMESPACE_BY_ENTITY,
} from 'container/InfraMonitoringK8sV2/constants';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import {
getHostMetricsQueryPayload,
@@ -58,6 +61,10 @@ import {
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const compositeQuery = useGetCompositeQueryParam();
const { redirectWithQueryBuilderData } = useQueryBuilder();
const isInitialized = useRef(false);
const selectedTime = useGlobalTimeStore((state) => state.selectedTime);
const getMinMaxTime = useGlobalTimeStore((state) => state.getMinMaxTime);
const { startUnixMilli, endUnixMilli } = useMemo(() => {
@@ -69,6 +76,28 @@ function Hosts(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTime, getMinMaxTime]);
useEffect(() => {
if (isInitialized.current) {
return;
}
isInitialized.current = true;
if (!compositeQuery) {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
redirectWithQueryBuilderData({
...defaultQuery,
builder: {
...defaultQuery.builder,
queryData: defaultQuery.builder.queryData.map((query) => ({
...query,
filter: { expression: '' },
filters: { items: [], op: 'AND' as const },
})),
},
});
}
}, [compositeQuery, redirectWithQueryBuilderData]);
const handleFilterVisibilityChange = (): void => {
setShowFilters(!showFilters);
};
@@ -197,7 +226,6 @@ function Hosts(): JSX.Element {
return (
<>
<DefaultQueryRedirect />
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (

View File

@@ -1,42 +0,0 @@
import { useEffect, useRef } from 'react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DataSource } from 'types/common/queryBuilder';
/**
* Puts the default query in the URL when the page mounts without one.
*
* Render it before the list: React runs sibling effects in tree order, and this
* navigation drops the nuqs writes still queued, such as the page size the list
* writes on mount.
*/
export function DefaultQueryRedirect(): null {
const compositeQuery = useGetCompositeQueryParam();
const { redirectWithQueryBuilderData } = useQueryBuilder();
const isInitialized = useRef(false);
useEffect(() => {
if (isInitialized.current) {
return;
}
isInitialized.current = true;
if (!compositeQuery) {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
redirectWithQueryBuilderData({
...defaultQuery,
builder: {
...defaultQuery.builder,
queryData: defaultQuery.builder.queryData.map((query) => ({
...query,
filter: { expression: '' },
filters: { items: [], op: 'AND' as const },
})),
},
});
}
}, [compositeQuery, redirectWithQueryBuilderData]);
return null;
}

View File

@@ -62,6 +62,7 @@ function K8sHeader({
const currentExpression =
currentQuery.builder.queryData[0]?.filter?.expression || '';
const finalExpression = newExpression ?? currentExpression;
void setCurrentPage(1);
const updatedQuery = {
...currentQuery,
@@ -88,8 +89,6 @@ function K8sHeader({
);
safeNavigate(`${location.pathname}?${newUrlQuery.toString()}`);
// After the navigation: a navigation drops the nuqs writes still queued.
void setCurrentPage(1);
void invalidateQueries();
if (finalExpression?.trim()) {

View File

@@ -4,8 +4,7 @@ import {
InframonitoringtypesClusterRecordDTO,
InframonitoringtypesNodeConditionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as Sentry from '@sentry/react';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
@@ -9,12 +9,14 @@ import {
QuickFilterCheckboxUseFieldApis,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { DataSource } from 'types/common/queryBuilder';
import { CategoryIcon } from './Base/categoryIcons';
import { DefaultQueryRedirect } from './Base/DefaultQueryRedirect';
import { K8sDynamicList } from './Base/K8sDynamicList';
import EntityStatusFilter from './Base/components/EntityStatusFilter/EntityStatusFilter';
import {
@@ -66,7 +68,9 @@ export default function InfraMonitoringK8s(): JSX.Element {
const [, setNodeReadinessFilter] = useInfraMonitoringNodeReadinessFilter();
const [, setContainerStatusFilter] = useInfraMonitoringContainerStatusFilter();
const compositeQuery = useGetCompositeQueryParam();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isInitialized = useRef(false);
const selectedTime = useGlobalTimeStore((state) => state.selectedTime);
const getMinMaxTime = useGlobalTimeStore((state) => state.getMinMaxTime);
@@ -93,6 +97,28 @@ export default function InfraMonitoringK8s(): JSX.Element {
[getUseFieldApis, selectedCategory],
);
useEffect(() => {
if (isInitialized.current) {
return;
}
isInitialized.current = true;
if (!compositeQuery) {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
redirectWithQueryBuilderData({
...defaultQuery,
builder: {
...defaultQuery.builder,
queryData: defaultQuery.builder.queryData.map((query) => ({
...query,
filter: { expression: '' },
filters: { items: [], op: 'AND' as const },
})),
},
});
}
}, [compositeQuery, redirectWithQueryBuilderData]);
useEffect(() => {
void logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
@@ -195,7 +221,14 @@ export default function InfraMonitoringK8s(): JSX.Element {
const handleCategorySelect = (key: string): void => {
if (key !== selectedCategory) {
// Navigate first: a navigation drops the nuqs writes still queued.
void setSelectedCategory(key as string);
void setOrderBy(null);
void setGroupBy(null);
void setCurrentPage(null);
setSelectedItemParams(null);
void setPodStatusFilter(null);
void setNodeReadinessFilter(null);
void setContainerStatusFilter(null);
redirectWithQueryBuilderData({
...currentQuery,
builder: {
@@ -209,14 +242,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
],
},
});
void setSelectedCategory(key as string);
void setOrderBy(null);
void setGroupBy(null);
void setCurrentPage(null);
setSelectedItemParams(null);
void setPodStatusFilter(null);
void setNodeReadinessFilter(null);
void setContainerStatusFilter(null);
}
};
@@ -245,7 +270,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<DefaultQueryRedirect />
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (

View File

@@ -12,9 +12,11 @@ import K8sGroupCell from '../Base/K8sGroupCell';
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
import { TextNoData, ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { NODE_CONDITION_COLORS, NODE_CONDITION_LABELS } from './utils';
import { Workflow } from '@signozhq/icons';

View File

@@ -1,213 +0,0 @@
import { usePreferredPageSizeStore } from 'components/TanStackTableView/usePreferredPageSize.store';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import K8sHeader from '../Base/K8sHeader';
import EntityDateTimeSelector from '../EntityDetailsUtils/EntityDateTimeSelector/EntityDateTimeSelector';
import {
InfraMonitoringEntity,
INFRA_MONITORING_K8S_PARAMS_KEYS,
K8sCategories,
} from '../constants';
import {
useInfraMonitoringCategory,
useInfraMonitoringSelectedItemParams,
} from '../hooks';
import InfraMonitoringK8s from '../InfraMonitoringK8s';
// The real hook: a navigation made while a nuqs write is queued drops that write,
// which is what these tests guard. The extension slips past jest.config.ts's mock.
jest.mock('hooks/useSafeNavigate', () =>
jest.requireActual('../../../hooks/useSafeNavigate.ts'),
);
jest.mock('components/QuickFilters/QuickFilters', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="quick-filters" />,
}));
// Keeps the list's own URL write on mount: the page size.
jest.mock('../Base/K8sDynamicList', () => {
const { useTableParams } = jest.requireActual('components/TanStackTableView');
function K8sDynamicList(): JSX.Element {
useTableParams(
{ page: 'page', limit: 'pageSize' },
{ page: 1, limit: 10, storageKey: 'k8s-containers' },
);
return <div data-testid="k8s-dynamic-list" />;
}
return { __esModule: true, K8sDynamicList, default: K8sDynamicList };
});
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: ({ onRun }: { onRun: (expression: string) => void }): JSX.Element => (
<button
type="button"
aria-label="Run filter"
data-testid="run-filter"
onClick={(): void => onRun("k8s.pod.name = 'pod-1'")}
/>
),
}));
jest.mock('lib/navigation', () => ({
getNavigationReferrer: (): string => 'direct',
}));
const route = ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES;
function DrawerHarness(): JSX.Element {
const [, setCategory] = useInfraMonitoringCategory();
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
return (
<>
<button
type="button"
aria-label="Select category"
data-testid="select-category"
onClick={(): void => {
void setCategory(K8sCategories.PODS);
}}
/>
<button
type="button"
aria-label="Open row"
data-testid="open-row"
onClick={(): void =>
setSelectedItemParams({
selectedItem: 'pod-1',
clusterName: null,
namespaceName: null,
})
}
/>
{selectedItemParams.selectedItem && (
<div data-testid="drawer">
<EntityDateTimeSelector
eventEntity="k8s"
category={InfraMonitoringEntity.PODS}
view="overview"
/>
</div>
)}
</>
);
}
function getUrlParam(key: string): string | null {
return new URLSearchParams(window.location.search).get(key);
}
describe('K8s URL writes queued by nuqs', () => {
afterEach(() => {
usePreferredPageSizeStore
.getState()
.setPreferredPageSize('k8s-containers', null);
});
it('opens the drawer on the first click after an earlier shallow write', async () => {
render(<DrawerHarness />, undefined, {
initialRoute: `${route}?relativeTime=15m`,
});
fireEvent.click(screen.getByTestId('select-category'));
await waitFor(() =>
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY)).toBe(
K8sCategories.PODS,
),
);
fireEvent.click(screen.getByTestId('open-row'));
await waitFor(() =>
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM)).toBe(
'pod-1',
),
);
expect(screen.getByTestId('drawer')).toBeInTheDocument();
});
it('switches category on the first click while a filter is applied', async () => {
const query = initialQueriesMap[DataSource.METRICS];
const filteredQuery = {
...query,
builder: {
...query.builder,
queryData: [
{
...query.builder.queryData[0],
filter: { expression: "k8s.pod.name = 'pod-1'" },
},
],
},
};
const params = new URLSearchParams({
[INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY]: K8sCategories.PODS,
compositeQuery: encodeURIComponent(JSON.stringify(filteredQuery)),
});
render(<InfraMonitoringK8s />, undefined, {
initialRoute: `${route}?${params.toString()}`,
});
fireEvent.click(
await screen.findByTestId(`category-${K8sCategories.CONTAINERS}`),
);
await waitFor(() => {
const compositeQuery = JSON.parse(
decodeURIComponent(getUrlParam('compositeQuery') ?? ''),
);
expect(compositeQuery.builder.queryData[0].filter.expression).toBe('');
});
await waitFor(() =>
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY)).toBe(
K8sCategories.CONTAINERS,
),
);
});
it('goes back to the first page when a filter runs', async () => {
render(
<K8sHeader
entity={InfraMonitoringEntity.PODS}
showAutoRefresh={false}
cancelQuery={jest.fn()}
/>,
undefined,
{ initialRoute: `${route}?relativeTime=30m&page=3` },
);
fireEvent.click(screen.getByTestId('run-filter'));
await waitFor(() => {
const compositeQuery = JSON.parse(
decodeURIComponent(getUrlParam('compositeQuery') ?? ''),
);
expect(compositeQuery.builder.queryData[0].filter.expression).toBe(
"k8s.pod.name = 'pod-1'",
);
});
await waitFor(() =>
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE)).toBe('1'),
);
});
it('keeps the page size the list writes while the default query loads', async () => {
usePreferredPageSizeStore
.getState()
.setPreferredPageSize('k8s-containers', 25);
render(<InfraMonitoringK8s />, undefined, { initialRoute: route });
await waitFor(() => expect(getUrlParam('compositeQuery')).not.toBeNull());
await waitFor(() =>
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE_SIZE)).toBe('25'),
);
});
});

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAppLocation } from 'lib/router/useAppLocation';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import FieldsSelector from 'components/FieldsSelector';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail';
@@ -54,7 +55,7 @@ function LiveLogsContainer({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});

View File

@@ -5,6 +5,7 @@ import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { toast } from '@signozhq/ui/sonner';
import { Card } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import ListLogView from 'components/Logs/ListLogView';
@@ -30,7 +31,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useEventSource } from 'providers/EventSource';
// interfaces
import { ILog } from 'types/api/logs/log';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { StringOperators } from 'types/common/queryBuilder';
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
import { getAbsoluteUrl } from 'utils/basePath';
@@ -68,7 +69,7 @@ function LiveLogsList({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: StringOperators.NOOP,
});

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { Button, Skeleton } from 'antd';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -16,7 +17,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { StringOperators } from 'types/common/queryBuilder';
import { withBasePath } from 'utils/basePath';
import { useContextLogData } from './useContextLogData';
@@ -45,7 +46,7 @@ function ContextLogRenderer({
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});

View File

@@ -5,6 +5,7 @@ import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { toast } from '@signozhq/ui/sonner';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
@@ -77,7 +78,7 @@ function LogsExplorerList({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator:
currentStagedQueryData?.aggregateOperator || StringOperators.NOOP,
});

View File

@@ -1,6 +1,7 @@
import { ReactNode, useState } from 'react';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import FieldsSelector from 'components/FieldsSelector';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
@@ -28,7 +29,7 @@ function LogsActionsContainer({
}): JSX.Element {
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});

View File

@@ -1,11 +1,20 @@
import { useQueries } from 'react-query';
import { renderHook } from '@testing-library/react';
import { createElement, ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import type { NotificationInstance } from 'antd/es/notification/interface';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { DataSource } from 'types/common/queryBuilder';
import {
FormattingOptions,
PreferenceContextValue,
PreferenceSlice,
} from 'providers/preferences/types';
import { renderHook } from 'tests/test-utils';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { FontSize } from '../types';
import useOptionsMenu from '../useOptionsMenu';
// Mock all dependencies
@@ -13,104 +22,118 @@ jest.mock('hooks/useNotifications');
jest.mock('providers/preferences/context/PreferenceContextProvider');
jest.mock('hooks/useUrlQueryData');
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueries: jest.fn(),
}));
const mockUseNotifications = jest.mocked(useNotifications);
const mockUsePreferenceContext = jest.mocked(usePreferenceContext);
const mockUseUrlQueryData = jest.mocked(useUrlQueryData);
const mockUseGetQueryKeySuggestions = jest.mocked(useGetQueryKeySuggestions);
type KeySuggestionsResult = ReturnType<typeof useGetQueryKeySuggestions>;
// The hook reads only `data.data.data.keys` and `isFetching`.
const keySuggestions = (
attributeKeys: Record<string, string>[],
): KeySuggestionsResult =>
({
data: { data: { data: { complete: true, keys: { attributeKeys } } } },
isFetching: false,
}) as unknown as KeySuggestionsResult;
const wrapper = ({ children }: { children: ReactNode }): JSX.Element =>
createElement(QueryClientProvider, { client: new QueryClient() }, children);
const toIds = (columns: TelemetryFieldKey[]): string[] =>
columns.map((column) => `${column.fieldContext}:${column.name}`);
describe('useOptionsMenu', () => {
const mockNotifications = { error: jest.fn(), success: jest.fn() };
const mockUpdateColumns = jest.fn();
const mockUpdateFormatting = jest.fn();
const mockRedirectWithQuery = jest.fn();
const mockNotifications = {
error: jest.fn() as jest.MockedFunction<NotificationInstance['error']>,
success: jest.fn() as jest.MockedFunction<NotificationInstance['success']>,
} as Partial<NotificationInstance> as NotificationInstance;
const mockUpdateColumns = jest.fn() as jest.MockedFunction<
PreferenceSlice['updateColumns']
>;
const mockUpdateFormatting = jest.fn() as jest.MockedFunction<
PreferenceSlice['updateFormatting']
>;
const mockRedirectWithQuery = jest.fn() as jest.MockedFunction<
(newQueryData: unknown) => void
>;
const slice = (
columns: TelemetryFieldKey[],
formatting: FormattingOptions,
): PreferenceSlice => ({
preferences: { columns, formatting },
loading: false,
error: null,
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
});
const rawFormatting: FormattingOptions = {
format: 'raw',
maxLines: 1,
fontSize: FontSize.SMALL,
};
beforeEach(() => {
jest.clearAllMocks();
(useNotifications as jest.Mock).mockReturnValue({
mockUseNotifications.mockReturnValue({
notifications: mockNotifications,
});
(usePreferenceContext as jest.Mock).mockReturnValue({
traces: {
preferences: {
columns: [],
formatting: {
format: 'raw',
maxLines: 1,
fontSize: 'small',
},
},
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
logs: {
preferences: {
columns: [],
formatting: {
format: 'raw',
maxLines: 1,
fontSize: 'small',
},
},
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
});
mockUsePreferenceContext.mockReturnValue({
[SavedviewtypesSourceDTO.traces]: slice([], rawFormatting),
[SavedviewtypesSourceDTO.logs]: slice([], rawFormatting),
} satisfies PreferenceContextValue);
(useUrlQueryData as jest.Mock).mockReturnValue({
mockUseUrlQueryData.mockReturnValue({
query: null,
queryData: undefined,
redirectWithQuery: mockRedirectWithQuery,
});
(useQueries as jest.Mock).mockReturnValue([]);
});
it('does not show isRoot or isEntryPoint in column options when dataSource is TRACES', () => {
it('does not show isRoot or isEntryPoint in column options when sourcePage is traces', () => {
// Mock the query key suggestions to return data including isRoot and isEntryPoint
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: [
{
name: 'isRoot',
signal: 'traces',
fieldDataType: 'bool',
fieldContext: '',
},
{
name: 'isEntryPoint',
signal: 'traces',
fieldDataType: 'bool',
fieldContext: '',
},
{
name: 'duration',
signal: 'traces',
fieldDataType: 'float64',
fieldContext: '',
},
{
name: 'serviceName',
signal: 'traces',
fieldDataType: 'string',
fieldContext: '',
},
],
},
},
mockUseGetQueryKeySuggestions.mockReturnValue(
keySuggestions([
{
name: 'isRoot',
signal: 'traces',
fieldDataType: 'bool',
fieldContext: '',
},
},
isFetching: false,
});
{
name: 'isEntryPoint',
signal: 'traces',
fieldDataType: 'bool',
fieldContext: '',
},
{
name: 'duration',
signal: 'traces',
fieldDataType: 'float64',
fieldContext: '',
},
{
name: 'serviceName',
signal: 'traces',
fieldDataType: 'string',
fieldContext: '',
},
]),
);
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.traces,
aggregateOperator: 'count',
}),
{ wrapper },
);
// Get the column options from the config
@@ -127,98 +150,38 @@ describe('useOptionsMenu', () => {
expect(optionNames).toContain('serviceName');
});
it('does not show body in column options when dataSource is METRICS', () => {
it('shows body in column options when sourcePage is logs', () => {
// Mock the query key suggestions to return data including body
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: [
{
name: 'body',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
{
name: 'status',
signal: 'metrics',
fieldDataType: 'int64',
fieldContext: '',
},
{
name: 'value',
signal: 'metrics',
fieldDataType: 'float64',
fieldContext: '',
},
],
},
},
mockUseGetQueryKeySuggestions.mockReturnValue(
keySuggestions([
{
name: 'body',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
},
isFetching: false,
});
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.METRICS,
aggregateOperator: 'count',
}),
{
name: 'level',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
{
name: 'timestamp',
signal: 'logs',
fieldDataType: 'int64',
fieldContext: '',
},
]),
);
// Get the column options from the config
const columnOptions = result.current.config.addColumn?.options ?? [];
const optionNames = columnOptions.map((option) => option.label);
// body should NOT be in the options
expect(optionNames).not.toContain('body');
// Other attributes should be present
expect(optionNames).toContain('status');
expect(optionNames).toContain('value');
});
it('does not show body in column options when dataSource is LOGS', () => {
// Mock the query key suggestions to return data including body
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: [
{
name: 'body',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
{
name: 'level',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
{
name: 'timestamp',
signal: 'logs',
fieldDataType: 'int64',
fieldContext: '',
},
],
},
},
},
},
isFetching: false,
});
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: 'count',
}),
{ wrapper },
);
// Get the column options from the config
@@ -234,15 +197,15 @@ describe('useOptionsMenu', () => {
});
describe('reorderSelectColumns / handleRemoveSelectedColumn — composite ID lookup', () => {
const baseFormatting = {
const baseFormatting: FormattingOptions = {
format: 'table',
maxLines: 1,
fontSize: 'small',
fontSize: FontSize.SMALL,
};
// Two entries share the same `name` but differ in `fieldContext` — the
// exact case composite IDs are meant to disambiguate.
const seedColumns = [
const seedColumns: TelemetryFieldKey[] = [
{
name: 'body',
fieldContext: 'log',
@@ -264,36 +227,26 @@ describe('useOptionsMenu', () => {
{
name: 'timestamp',
fieldContext: 'log',
fieldDataType: '',
signal: 'logs',
},
];
beforeEach(() => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: { data: { data: { keys: {} } } },
isFetching: false,
});
(usePreferenceContext as jest.Mock).mockReturnValue({
traces: {
preferences: { columns: [], formatting: baseFormatting },
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
logs: {
preferences: { columns: seedColumns, formatting: baseFormatting },
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
});
mockUseGetQueryKeySuggestions.mockReturnValue(keySuggestions([]));
mockUsePreferenceContext.mockReturnValue({
[SavedviewtypesSourceDTO.traces]: slice([], baseFormatting),
[SavedviewtypesSourceDTO.logs]: slice(seedColumns, baseFormatting),
} satisfies PreferenceContextValue);
});
it('reorders by composite IDs — preserves both same-name variants distinctly', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: 'count',
}),
{ wrapper },
);
// New order: [attribute:service.name, log:body, resource:service.name, log:timestamp]
@@ -306,12 +259,7 @@ describe('useOptionsMenu', () => {
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
const reordered = mockUpdateColumns.mock.calls[0][0];
expect(
reordered.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}:${c.name}`,
),
).toStrictEqual([
expect(toIds(reordered)).toStrictEqual([
'attribute:service.name',
'log:body',
'resource:service.name',
@@ -320,11 +268,13 @@ describe('useOptionsMenu', () => {
});
it('reorder ignores ids that are not columns (e.g. state-indicator) and skips unknown ids', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: 'count',
}),
{ wrapper },
);
result.current.config.addColumn?.onReorder([
@@ -338,12 +288,7 @@ describe('useOptionsMenu', () => {
const reordered = mockUpdateColumns.mock.calls[0][0];
// state-indicator + unknown.composite filtered; rest preserved in given order.
expect(
reordered.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}:${c.name}`,
),
).toStrictEqual([
expect(toIds(reordered)).toStrictEqual([
'log:timestamp',
'log:body',
'resource:service.name',
@@ -352,11 +297,13 @@ describe('useOptionsMenu', () => {
});
it('removes only the variant whose composite ID matches', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: 'count',
}),
{ wrapper },
);
// Removing 'resource:service.name' should drop ONLY the resource variant.
@@ -364,20 +311,21 @@ describe('useOptionsMenu', () => {
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
const remaining = mockUpdateColumns.mock.calls[0][0];
expect(
remaining.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}:${c.name}`,
),
).toStrictEqual(['log:body', 'attribute:service.name', 'log:timestamp']);
expect(toIds(remaining)).toStrictEqual([
'log:body',
'attribute:service.name',
'log:timestamp',
]);
});
it('removing by a non-matching composite ID is a no-op (filter returns the full list)', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: 'count',
}),
{ wrapper },
);
result.current.config.addColumn?.onRemove('unknown.composite');
@@ -390,35 +338,34 @@ describe('useOptionsMenu', () => {
describe('fieldsSelector.value drops legacy columns without a name', () => {
it('excludes entries missing name while keeping valid columns', () => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: { data: { data: { keys: {} } } },
isFetching: false,
});
(usePreferenceContext as jest.Mock).mockReturnValue({
traces: {
preferences: {
columns: [
{ name: 'body', fieldContext: 'log' },
{ key: 'legacy-key-no-name', fieldContext: 'log' },
{ name: 'timestamp', fieldContext: 'log' },
],
formatting: { format: 'table', maxLines: 1, fontSize: 'small' },
mockUseGetQueryKeySuggestions.mockReturnValue(keySuggestions([]));
mockUsePreferenceContext.mockReturnValue({
[SavedviewtypesSourceDTO.traces]: slice(
[
{ name: 'body', fieldContext: 'log' },
// A legacy stored column: `key` only, no `name`.
{
key: 'legacy-key-no-name',
fieldContext: 'log',
} as unknown as TelemetryFieldKey,
{ name: 'timestamp', fieldContext: 'log' },
],
{
format: 'table',
maxLines: 1,
fontSize: FontSize.SMALL,
},
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
logs: {
preferences: { columns: [], formatting: {} },
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
});
),
[SavedviewtypesSourceDTO.logs]: slice([], {}),
} satisfies PreferenceContextValue);
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.traces,
aggregateOperator: 'count',
}),
{ wrapper },
);
const fields = result.current.config.fieldsSelector?.value ?? [];

View File

@@ -1,4 +1,6 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import { PREFERENCE_SOURCE_PAGES } from 'providers/preferences/constants';
import { DataSource } from 'types/common/queryBuilder';
import { FontSize, OptionsQuery } from './types';
@@ -13,6 +15,14 @@ export const defaultOptionsQuery: OptionsQuery = {
fontSize: FontSize.SMALL,
};
export const SOURCE_PAGE_SIGNAL: Record<
(typeof PREFERENCE_SOURCE_PAGES)[number],
DataSource
> = {
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
};
export const EXCLUDED_COLUMNS: Record<DataSource, string[]> = {
[DataSource.TRACES]: ['body', 'isRoot', 'isEntryPoint'],
[DataSource.METRICS]: ['body'],

View File

@@ -1,6 +1,7 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueries } from 'react-query';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { TelemetryFieldKey } from 'api/v5/v5';
import { AxiosResponse } from 'axios';
@@ -11,6 +12,7 @@ import useUrlQueryData from 'hooks/useUrlQueryData';
import { has } from 'lodash-es';
import { AllTraceFilterKeyValue } from 'constants/traceFilterKeys';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { PreferenceSourcePage } from 'providers/preferences/types';
import {
QueryKeyRequestProps,
QueryKeySuggestionsResponseProps,
@@ -20,13 +22,13 @@ import {
FieldDataType,
SignalType,
} from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import {
defaultLogsSelectedColumns,
defaultOptionsQuery,
defaultTraceSelectedColumns,
EXCLUDED_COLUMNS,
SOURCE_PAGE_SIGNAL,
URL_OPTIONS,
} from './constants';
import {
@@ -40,7 +42,7 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
dataSource: DataSource;
sourcePage: PreferenceSourcePage;
aggregateOperator?: string;
initialOptions?: InitialOptions;
}
@@ -52,15 +54,14 @@ interface UseOptionsMenu {
}
const useOptionsMenu = ({
dataSource,
// aggregateOperator,
sourcePage,
initialOptions = {},
}: UseOptionsMenuProps): UseOptionsMenu => {
const signal = SOURCE_PAGE_SIGNAL[sourcePage];
const { notifications } = useNotifications();
const prefCtx = usePreferenceContext();
// TODO: send null to updateColumns and updateFormatting if dataSource is not logs or traces
const slice = dataSource === DataSource.TRACES ? prefCtx.traces : prefCtx.logs;
const { preferences, updateColumns, updateFormatting } = slice;
const { preferences, updateColumns, updateFormatting } =
usePreferenceContext()[sourcePage];
const [searchText, setSearchText] = useState<string>('');
const [isFocused, setIsFocused] = useState<boolean>(false);
@@ -68,10 +69,10 @@ const useOptionsMenu = ({
const initialQueryParamsV5: QueryKeyRequestProps = useMemo(
() => ({
signal: dataSource,
signal,
searchText: '',
}),
[dataSource],
[signal],
);
const { query: optionsQuery, redirectWithQuery: redirectWithOptionsData } =
@@ -127,7 +128,7 @@ const useOptionsMenu = ({
?.map((column) => attributesData.find(({ name }) => name === column))
.filter((e) => !!e) || []) as TelemetryFieldKey[];
if (dataSource === DataSource.TRACES) {
if (sourcePage === SavedviewtypesSourceDTO.traces) {
initialSelected = initialSelected
?.map((col) => {
if (col && Object.keys(AllTraceFilterKeyValue).includes(col?.name)) {
@@ -157,7 +158,7 @@ const useOptionsMenu = ({
isFetchedInitialAttributes,
initialOptions?.selectColumns,
initialAttributesResult,
dataSource,
sourcePage,
]);
const {
@@ -195,7 +196,7 @@ const useOptionsMenu = ({
fieldDataType: e.fieldDataType,
}));
}
if (dataSource === DataSource.TRACES) {
if (sourcePage === SavedviewtypesSourceDTO.traces) {
return defaultTraceSelectedColumns.map((e) => ({
...e,
name: e.name,
@@ -203,16 +204,16 @@ const useOptionsMenu = ({
}
return [];
}, [dataSource, searchedAttributesDataV5?.data.data.keys]);
}, [sourcePage, searchedAttributesDataV5?.data.data.keys]);
const initialOptionsQuery: OptionsQuery = useMemo(() => {
let defaultColumns: TelemetryFieldKey[] = defaultOptionsQuery.selectColumns;
if (dataSource === DataSource.TRACES) {
if (sourcePage === SavedviewtypesSourceDTO.traces) {
defaultColumns = defaultTraceSelectedColumns.map((e) => ({
...e,
name: e.name,
}));
} else if (dataSource === DataSource.LOGS) {
} else if (sourcePage === SavedviewtypesSourceDTO.logs) {
// eslint-disable-next-line sonarjs/no-identical-functions
defaultColumns = defaultLogsSelectedColumns.map((e) => ({
...e,
@@ -232,7 +233,7 @@ const useOptionsMenu = ({
...initialOptions,
selectColumns: finalSelectColumns,
};
}, [dataSource, initialOptions, initialSelectedColumns]);
}, [sourcePage, initialOptions, initialSelectedColumns]);
const selectedColumnKeys = useMemo(
() => preferences?.columns?.map(({ name }) => name) || [],
@@ -241,7 +242,7 @@ const useOptionsMenu = ({
const optionsFromAttributeKeys = useMemo(() => {
const filteredAttributeKeys = searchedAttributeKeys.filter((item) => {
const exclusions = EXCLUDED_COLUMNS[dataSource];
const exclusions = EXCLUDED_COLUMNS[signal];
if (exclusions) {
return !exclusions.includes(item.name);
}
@@ -249,7 +250,7 @@ const useOptionsMenu = ({
});
return getOptionsFromKeys(filteredAttributeKeys, selectedColumnKeys);
}, [dataSource, searchedAttributeKeys, selectedColumnKeys]);
}, [signal, searchedAttributeKeys, selectedColumnKeys]);
const handleRedirectWithOptionsData = useCallback(
(newQueryData: OptionsQuery) => {
@@ -285,7 +286,10 @@ const useOptionsMenu = ({
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType) !== columnKey,
);
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
if (
!newSelectedColumns?.length &&
sourcePage !== SavedviewtypesSourceDTO.logs
) {
notifications.error({
message: 'There must be at least one selected column',
});
@@ -294,7 +298,7 @@ const useOptionsMenu = ({
updateColumns(newSelectedColumns || []);
},
[dataSource, notifications, preferences, updateColumns],
[sourcePage, notifications, preferences, updateColumns],
);
const handleFormatChange = useCallback(

View File

@@ -146,13 +146,114 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
await expect(screen.findByText('promql/*')).resolves.toBeInTheDocument();
});
it('hardcodes the key and does not let it be edited', async () => {
it('defaults the key to signoz.workspace.key.id', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const keyInput = screen.getByTestId('wizard-key-input-logs-read');
expect(keyInput).toHaveValue('signoz.workspace.key.id');
expect(keyInput).toBeDisabled();
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
'signoz.workspace.key.id',
);
});
it('rewrites the selector when another key is picked', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.type(
screen.getByTestId('wizard-value-input-logs-read'),
'checkout',
);
await user.click(screen.getByTestId('wizard-key-select-logs-read'));
await user.click(
await screen.findByTestId(
'wizard-key-option-resource.service.name-logs-read',
),
);
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
'builder_query/resource.service.name/checkout',
);
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await expect(
screen.findByText('builder_query/resource.service.name/checkout'),
).resolves.toBeInTheDocument();
});
it('folds the resource prefix of the workspace key from a typed selector', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(
selectorInput,
'builder_query/resource.signoz.workspace.key.id/key-a',
);
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
'signoz.workspace.key.id',
);
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '2');
expect(selectorInput).toHaveValue(
'builder_query/signoz.workspace.key.id/key-a2',
);
});
it('accepts the resource-prefixed workspace key in the hint', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(
selectorInput,
'builder_query/resource.signoz.workspace.key.id/key-a',
);
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent(
'Allow signoz.workspace.key.id=key-a for Builder Query queries.',
);
});
it('selects the key from a typed selector', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(
selectorInput,
'builder_query/resource.deployment.environment.name/prod',
);
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
'resource.deployment.environment.name',
);
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue(
'prod',
);
});
it('hints when a typed selector uses an unsupported key', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/host.name/web-1');
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
'signoz.workspace.key.id',
);
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent('"host.name" is not a supported key.');
});
it('hides Key field for query types that do not support key scoping', async () => {
@@ -163,7 +264,7 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
await user.click(await screen.findByText('ClickHouse SQL'));
expect(
screen.queryByTestId('wizard-key-input-logs-read'),
screen.queryByTestId('wizard-key-select-logs-read'),
).not.toBeInTheDocument();
});
@@ -288,38 +389,18 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
expect(screen.getByRole('checkbox', { name: 'Any value' })).toBeChecked();
});
it('keeps the key input hardcoded when the selector uses another key', async () => {
it('restores the selected key in the selector once the value changes', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/service.name/frontend');
expect(screen.getByTestId('wizard-key-input-logs-read')).toHaveValue(
'signoz.workspace.key.id',
);
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent('Allow service.name=frontend for Builder Query queries.');
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toHaveAttribute(
'aria-disabled',
'true',
);
});
it('restores the hardcoded key in the selector once the value changes', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/service.name/frontend');
await user.type(selectorInput, 'builder_query/host.name/web-1');
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '2');
expect(selectorInput).toHaveValue(
'builder_query/signoz.workspace.key.id/frontend2',
'builder_query/signoz.workspace.key.id/web-12',
);
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toHaveAttribute(
'aria-disabled',

View File

@@ -30,17 +30,26 @@ export const QUERY_TYPES: readonly QueryTypeOption[] = [
export const DEFAULT_QUERY_TYPE: QueryTypeId = 'builder_query';
export const SUPPORTED_GRANT_KEY = 'signoz.workspace.key.id';
// mirrors telemetryGrantKeys in pkg/types/telemetrytypes/selector.go
export const SUPPORTED_GRANT_KEYS: readonly string[] = [
'signoz.workspace.key.id',
'resource.service.name',
'resource.deployment.environment.name',
];
export const DEFAULT_GRANT_KEY = 'signoz.workspace.key.id';
export const ANY_RESOURCE_VALUE = '*';
export interface SelectorDraft {
queryType: QueryTypeId;
key: string;
value: string;
}
export interface ParsedSelector {
queryType?: QueryTypeId;
key?: string;
value: string;
}

View File

@@ -15,7 +15,7 @@ import { Typography } from '@signozhq/ui/typography';
import {
ANY_RESOURCE_VALUE,
QUERY_TYPES,
SUPPORTED_GRANT_KEY,
SUPPORTED_GRANT_KEYS,
} from './TelemetrySelectorWizard.constants';
import { isQueryTypeAvailable } from './TelemetrySelectorWizard.utils';
import useTelemetrySelectorWizard from './useTelemetrySelectorWizard';
@@ -38,6 +38,7 @@ function TelemetrySelectorWizard({
open,
queryType,
selectedQueryType,
grantKey,
value,
selector,
isAnyResource,
@@ -46,6 +47,7 @@ function TelemetrySelectorWizard({
canAdd,
handleOpenChange,
handleQueryTypeChange,
handleKeyChange,
handleValueChange,
handleAnyResourceChange,
handleSelectorChange,
@@ -130,12 +132,22 @@ function TelemetrySelectorWizard({
<Typography as="label" weight="medium">
Key
</Typography>
<Input
value={SUPPORTED_GRANT_KEY}
readOnly
disabled
testId={`wizard-key-input-${testId}`}
/>
<Select value={grantKey} onChange={handleKeyChange}>
<SelectTrigger data-testid={`wizard-key-select-${testId}`}>
<SelectValue>{grantKey}</SelectValue>
</SelectTrigger>
<SelectContent withPortal={false} className={styles.selectContent}>
{SUPPORTED_GRANT_KEYS.map((supportedKey) => (
<SelectItem
key={supportedKey}
value={supportedKey}
testId={`wizard-key-option-${supportedKey}-${testId}`}
>
{supportedKey}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}

View File

@@ -2,13 +2,14 @@ import { AuthZResource } from 'lib/authz/hooks/useAuthZ/types';
import {
ANY_RESOURCE_VALUE,
DEFAULT_GRANT_KEY,
ParsedSelector,
QUERY_TYPES,
QueryTypeId,
QueryTypeOption,
SelectorDraft,
SelectorValidation,
SUPPORTED_GRANT_KEY,
SUPPORTED_GRANT_KEYS,
} from './TelemetrySelectorWizard.constants';
const METRIC_RESOURCES: ReadonlySet<AuthZResource> = new Set<AuthZResource>([
@@ -37,6 +38,20 @@ export function isAnyResourceValue(value: string): boolean {
return value.trim() === ANY_RESOURCE_VALUE;
}
export function isSupportedGrantKey(key: string): boolean {
return SUPPORTED_GRANT_KEYS.includes(key);
}
const RESOURCE_PREFIX = 'resource.';
// the backend folds resource.<key> to <key> when <key> is itself supported
function foldGrantKey(key: string): string {
const stripped = key.startsWith(RESOURCE_PREFIX)
? key.slice(RESOURCE_PREFIX.length)
: key;
return isSupportedGrantKey(stripped) ? stripped : key;
}
function splitSelector(selector: string): string[] {
const parts = selector.split('/');
@@ -47,14 +62,18 @@ function splitSelector(selector: string): string[] {
return [parts[0], parts[1], parts.slice(2).join('/')];
}
export function buildSelector({ queryType, value }: SelectorDraft): string {
export function buildSelector({
queryType,
key,
value,
}: SelectorDraft): string {
const trimmedValue = value.trim();
if (!supportsKeyScoping(queryType) || !trimmedValue) {
return `${queryType}/${ANY_RESOURCE_VALUE}`;
}
return `${queryType}/${SUPPORTED_GRANT_KEY}/${trimmedValue}`;
return `${queryType}/${key}/${trimmedValue}`;
}
export function parseSelector(selector: string): ParsedSelector {
@@ -62,6 +81,7 @@ export function parseSelector(selector: string): ParsedSelector {
return {
queryType: getQueryTypeOption(parts[0])?.id,
key: parts.length >= 3 ? foldGrantKey(parts[1]) : undefined,
value: parts.length >= 3 ? parts[2] : '',
};
}
@@ -101,7 +121,9 @@ export function validateSelector(selector: string): SelectorValidation {
}
return {
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/${SUPPORTED_GRANT_KEY}/<value>.`,
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/<key>/<value> with one of: ${SUPPORTED_GRANT_KEYS.join(
', ',
)}.`,
isError: false, // intentionally not an error
};
}
@@ -112,7 +134,8 @@ export function validateSelector(selector: string): SelectorValidation {
};
}
const [, key, value] = parts;
const [, rawKey, value] = parts;
const key = foldGrantKey(rawKey);
if (value === ANY_RESOURCE_VALUE) {
return {
@@ -128,6 +151,15 @@ export function validateSelector(selector: string): SelectorValidation {
};
}
if (!isSupportedGrantKey(key)) {
return {
message: `"${key}" is not a supported key. Use one of: ${SUPPORTED_GRANT_KEYS.join(
', ',
)}.`,
isError: false, // intentionally not an error
};
}
return {
message: `Allow ${key}=${value} for ${option.label} queries.`,
isError: false,
@@ -135,5 +167,5 @@ export function validateSelector(selector: string): SelectorValidation {
}
export function getDefaultSelector(queryType: QueryTypeId): string {
return buildSelector({ queryType, value: '' });
return buildSelector({ queryType, key: DEFAULT_GRANT_KEY, value: '' });
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react';
import {
ANY_RESOURCE_VALUE,
DEFAULT_GRANT_KEY,
DEFAULT_QUERY_TYPE,
QueryTypeId,
QueryTypeOption,
@@ -12,6 +13,7 @@ import {
getDefaultSelector,
getQueryTypeOption,
isAnyResourceValue,
isSupportedGrantKey,
parseSelector,
validateSelector,
} from './TelemetrySelectorWizard.utils';
@@ -24,6 +26,7 @@ interface UseTelemetrySelectorWizardResult {
open: boolean;
queryType: QueryTypeId;
selectedQueryType: QueryTypeOption | undefined;
grantKey: string;
value: string;
selector: string;
isAnyResource: boolean;
@@ -32,6 +35,7 @@ interface UseTelemetrySelectorWizardResult {
canAdd: boolean;
handleOpenChange: (nextOpen: boolean) => void;
handleQueryTypeChange: (value: string | string[]) => void;
handleKeyChange: (value: string | string[]) => void;
handleValueChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
handleAnyResourceChange: (checked: boolean) => void;
handleSelectorChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
@@ -44,6 +48,7 @@ function useTelemetrySelectorWizard({
}: UseTelemetrySelectorWizardParams): UseTelemetrySelectorWizardResult {
const [open, setOpen] = useState(false);
const [queryType, setQueryType] = useState<QueryTypeId>(DEFAULT_QUERY_TYPE);
const [grantKey, setGrantKey] = useState(DEFAULT_GRANT_KEY);
const [value, setValue] = useState('');
const [selector, setSelector] = useState(() =>
getDefaultSelector(DEFAULT_QUERY_TYPE),
@@ -58,10 +63,17 @@ function useTelemetrySelectorWizard({
const validation = useMemo(() => validateSelector(selector), [selector]);
const applyDraft = useCallback(
(nextQueryType: QueryTypeId, nextValue: string): void => {
(nextQueryType: QueryTypeId, nextKey: string, nextValue: string): void => {
setQueryType(nextQueryType);
setGrantKey(nextKey);
setValue(nextValue);
setSelector(buildSelector({ queryType: nextQueryType, value: nextValue }));
setSelector(
buildSelector({
queryType: nextQueryType,
key: nextKey,
value: nextValue,
}),
);
},
[],
);
@@ -71,23 +83,30 @@ function useTelemetrySelectorWizard({
const selected = (Array.isArray(next) ? next[0] : next) as QueryTypeId;
const keepsValue = getQueryTypeOption(selected)?.supportsKeyScoping ?? false;
applyDraft(selected, keepsValue ? value : '');
applyDraft(selected, grantKey, keepsValue ? value : '');
},
[applyDraft, value],
[applyDraft, grantKey, value],
);
const handleKeyChange = useCallback(
(next: string | string[]): void => {
applyDraft(queryType, Array.isArray(next) ? next[0] : next, value);
},
[applyDraft, queryType, value],
);
const handleValueChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
applyDraft(queryType, event.target.value);
applyDraft(queryType, grantKey, event.target.value);
},
[applyDraft, queryType],
[applyDraft, queryType, grantKey],
);
const handleAnyResourceChange = useCallback(
(checked: boolean): void => {
applyDraft(queryType, checked ? ANY_RESOURCE_VALUE : '');
applyDraft(queryType, grantKey, checked ? ANY_RESOURCE_VALUE : '');
},
[applyDraft, queryType],
[applyDraft, queryType, grantKey],
);
const handleSelectorChange = useCallback(
@@ -99,6 +118,9 @@ function useTelemetrySelectorWizard({
if (parsed.queryType) {
setQueryType(parsed.queryType);
}
if (parsed.key && isSupportedGrantKey(parsed.key)) {
setGrantKey(parsed.key);
}
setValue(parsed.value);
},
[],
@@ -109,6 +131,7 @@ function useTelemetrySelectorWizard({
if (!nextOpen) {
setQueryType(DEFAULT_QUERY_TYPE);
setGrantKey(DEFAULT_GRANT_KEY);
setValue('');
setSelector(getDefaultSelector(DEFAULT_QUERY_TYPE));
}
@@ -138,6 +161,7 @@ function useTelemetrySelectorWizard({
open,
queryType,
selectedQueryType,
grantKey,
value,
selector,
isAnyResource: isAnyResourceValue(value),
@@ -146,6 +170,7 @@ function useTelemetrySelectorWizard({
canAdd: !validation.isError,
handleOpenChange,
handleQueryTypeChange,
handleKeyChange,
handleValueChange,
handleAnyResourceChange,
handleSelectorChange,

View File

@@ -77,6 +77,11 @@
word-break: break-all;
}
.error {
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
}
.noFilters {
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);

View File

@@ -1,13 +1,8 @@
import { CalendarClock } from '@signozhq/icons';
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
import { useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { useTimezone } from 'providers/Timezone';
import { SavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { SAVED_VIEW_UPDATED_AT_FORMAT } from './constants';
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
import SavedViewHoverCardDetails from './SavedViewHoverCardDetails';
import { SavedViewHoverCardProps } from './types';
import styles from './SavedViewHoverCard.module.scss';
@@ -18,23 +13,11 @@ function SavedViewHoverCard({
onCardLeave,
onClose,
}: SavedViewHoverCardProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const view = target?.view;
const querySummaries = useMemo(
() => (view ? getSavedViewQuerySummaries(view) : []),
[view],
);
const hasQueryNames = querySummaries.length > 1;
const copyValue = querySummaries
.filter(({ expression }) => expression.length > 0)
.map(({ queryName, expression }) =>
hasQueryNames ? `${queryName}: ${expression}` : expression,
)
.join('\n');
const { allowed: canReadAll } = useAuthZ([SavedViewReadPermission]);
return (
<Popover
open={!!view}
open={!!target}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
@@ -55,71 +38,17 @@ function SavedViewHoverCard({
onOpenAutoFocus={(event): void => event.preventDefault()}
onCloseAutoFocus={(event): void => event.preventDefault()}
>
{view && (
{target && (
<div
className={styles.content}
onMouseEnter={onCardEnter}
onMouseLeave={onCardLeave}
data-testid="saved-view-hover-card"
>
<div className={styles.header}>
<Typography.Text className={styles.name}>
{view.spec.displayName}
</Typography.Text>
{copyValue && (
<CopyButton
value={copyValue}
ariaLabel="Copy query"
testId="saved-view-hover-card-copy"
/>
)}
</div>
<div className={styles.query} data-testid="saved-view-hover-card-query">
{querySummaries.length === 0 ? (
<Typography.Text className={styles.noFilters}>
No filters
</Typography.Text>
) : (
querySummaries.map(({ queryName, expression, metric }) => (
<div key={queryName} className={styles.queryItem}>
{hasQueryNames && (
<Typography.Text className={styles.queryName}>
{queryName}
</Typography.Text>
)}
{metric && (
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
)}
{expression && (
<Typography.Text className={styles.expression}>
<SavedViewHighlightedExpression expression={expression} />
</Typography.Text>
)}
</div>
))
)}
</div>
<div className={styles.updated}>
<Typography.Text className={styles.sectionTitle}>
Last updated
</Typography.Text>
{view.updatedBy && (
<Typography.Text className={styles.metaText}>
{view.updatedBy}
</Typography.Text>
)}
{view.updatedAt && (
<div className={styles.meta}>
<CalendarClock size={14} />
<Typography.Text className={styles.metaText}>
{formatTimezoneAdjustedTimestamp(
view.updatedAt,
SAVED_VIEW_UPDATED_AT_FORMAT,
)}
</Typography.Text>
</div>
)}
</div>
<SavedViewHoverCardDetails
viewId={target.viewId}
canReadAll={canReadAll}
/>
</div>
)}
</PopoverContent>

View File

@@ -0,0 +1,121 @@
import { useMemo } from 'react';
import { CalendarClock } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { useGetSavedView } from 'api/generated/services/saved-view';
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { useTimezone } from 'providers/Timezone';
import {
SAVED_VIEW_LOAD_FAILED_NAME,
SAVED_VIEW_UPDATED_AT_FORMAT,
} from './constants';
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
import SavedViewHoverCardSkeleton from './SavedViewHoverCardSkeleton';
import { SavedViewHoverCardDetailsProps } from './types';
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
import styles from './SavedViewHoverCard.module.scss';
function SavedViewHoverCardDetails({
viewId,
}: SavedViewHoverCardDetailsProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { data, isLoading, isError } = useGetSavedView({ id: viewId });
const view = data?.data;
const querySummaries = useMemo(
() => (view ? getSavedViewQuerySummaries(view) : []),
[view],
);
if (isLoading) {
return <SavedViewHoverCardSkeleton />;
}
if (isError || !view) {
return (
<Typography.Text
className={styles.error}
data-testid="saved-view-hover-card-error"
>
{SAVED_VIEW_LOAD_FAILED_NAME}
</Typography.Text>
);
}
const hasQueryNames = querySummaries.length > 1;
const copyValue = querySummaries
.filter(({ expression }) => expression.length > 0)
.map(({ queryName, expression }) =>
hasQueryNames ? `${queryName}: ${expression}` : expression,
)
.join('\n');
return (
<>
<div className={styles.header}>
<Typography.Text className={styles.name}>
{view.spec.displayName}
</Typography.Text>
{copyValue && (
<CopyButton
value={copyValue}
ariaLabel="Copy query"
testId="saved-view-hover-card-copy"
/>
)}
</div>
<div className={styles.query} data-testid="saved-view-hover-card-query">
{querySummaries.length === 0 ? (
<Typography.Text className={styles.noFilters}>No filters</Typography.Text>
) : (
querySummaries.map(({ queryName, expression, metric }) => (
<div key={queryName} className={styles.queryItem}>
{hasQueryNames && (
<Typography.Text className={styles.queryName}>
{queryName}
</Typography.Text>
)}
{metric && (
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
)}
{expression && (
<Typography.Text className={styles.expression}>
<SavedViewHighlightedExpression expression={expression} />
</Typography.Text>
)}
</div>
))
)}
</div>
<div className={styles.updated}>
<Typography.Text className={styles.sectionTitle}>
Last updated
</Typography.Text>
{view.updatedBy && (
<Typography.Text className={styles.metaText}>
{view.updatedBy}
</Typography.Text>
)}
{view.updatedAt && (
<div className={styles.meta}>
<CalendarClock size={14} />
<Typography.Text className={styles.metaText}>
{formatTimezoneAdjustedTimestamp(
view.updatedAt,
SAVED_VIEW_UPDATED_AT_FORMAT,
)}
</Typography.Text>
</div>
)}
</div>
</>
);
}
export default withAuthZContent(SavedViewHoverCardDetails, {
checks: ({ viewId, canReadAll }) =>
canReadAll ? [] : [buildSavedViewReadPermission(viewId)],
fallbackOnLoading: <SavedViewHoverCardSkeleton />,
});

View File

@@ -0,0 +1,13 @@
import { Skeleton } from '@signozhq/ui/skeleton';
function SavedViewHoverCardSkeleton(): JSX.Element {
return (
<Skeleton
active
paragraph={{ rows: 3, width: ['100%', '80%', '60%'] }}
testId="saved-view-hover-card-loading"
/>
);
}
export default SavedViewHoverCardSkeleton;

View File

@@ -54,9 +54,8 @@
&.isActive {
background-color: var(--l3-background);
.rowName {
--button-variant-ghost-color: var(--l1-foreground);
--button-variant-ghost-hover-color: var(--l1-foreground);
.rowLink {
color: var(--l1-foreground);
}
}
}
@@ -76,18 +75,32 @@
opacity: 1;
}
.rowName {
--button-height: 100%;
--button-padding: 0 var(--spacing-6);
--button-justify-content: flex-start;
--button-font-size: var(--periscope-font-size-base);
--button-variant-ghost-color: var(--l2-foreground);
--button-variant-ghost-hover-color: var(--l2-foreground);
--button-variant-ghost-hover-background-color: transparent;
.rowLink {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
height: 100%;
padding: 0 var(--spacing-6);
border-radius: var(--radius-1);
color: var(--l2-foreground);
text-decoration: none;
&:focus-visible {
outline: 1px solid var(--button-focus-ring);
outline-offset: -1px;
}
&:hover {
color: var(--l2-foreground-hover);
}
}
// nowrap fills the row before the ellipsis; line clamping alone stops at the last whole word.
.rowLinkLabel {
min-width: 0;
letter-spacing: -0.005em;
white-space: nowrap;
}
.loading {

View File

@@ -65,11 +65,11 @@ function SavedViewsList({
const rowHover = useMemo<SavedViewsListRowHoverProps>(
() => ({
onRowEnter: (view, row): void => {
onRowEnter: (viewId, row): void => {
const listTop = listRef.current?.getBoundingClientRect().top ?? 0;
const rowRect = row.getBoundingClientRect();
onHoverCardRowEnter({
view,
viewId,
anchorTop: rowRect.top - listTop,
anchorHeight: rowRect.height,
});

View File

@@ -1,10 +1,8 @@
import cx from 'classnames';
import { Undo2 } from '@signozhq/icons';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import SavedViewsRowButton from './SavedViewsRowButton';
import SavedViewsRowLink from './SavedViewsRowLink';
import SavedViewsRowMenu from './SavedViewsRowMenu';
import { SavedViewsListRowProps } from './types';
@@ -18,42 +16,17 @@ function SavedViewsListRow({
onAction,
hover,
}: SavedViewsListRowProps): JSX.Element {
const name = view.spec.displayName;
const readPermission = buildSavedViewReadPermission(view.id);
// Same cached check as the button; gates the hover card.
const { isLoading, deniedPermissions } = useAuthZ([readPermission]);
const canRead = !isLoading && deniedPermissions.length === 0;
return (
<div
className={cx(styles.row, { [styles.isActive]: isActive })}
data-testid="saved-views-row"
data-view-id={view.id}
onMouseEnter={(event): void => {
if (canRead) {
hover.onRowEnter(view, event.currentTarget);
}
}}
onMouseEnter={(event): void =>
hover.onRowEnter(view.id, event.currentTarget)
}
onMouseLeave={hover.onRowLeave}
>
<div className={styles.rowName}>
<AuthZButton
checks={[readPermission]}
size="md"
variant="ghost"
color="secondary"
width="100%"
textOverflow="ellipsis"
onClick={(): void => {
if (!isActive) {
onSelect(view);
}
}}
testId="saved-views-row-name"
>
{name}
</AuthZButton>
</div>
<SavedViewsRowLink view={view} isActive={isActive} onSelect={onSelect} />
{isActive && (
<SavedViewsRowButton
icon={<Undo2 size={14} />}

View File

@@ -5,7 +5,11 @@ import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import { SavedViewListPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import {
SavedViewListPermission,
SavedViewReadPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import SavedViewsIconButton from './SavedViewsIconButton';
import SavedViewsList from './SavedViewsList';
@@ -21,6 +25,8 @@ function SavedViewsPanel({
onClose?: () => void;
}): JSX.Element {
const [search, setSearch] = useState('');
// Batched with the list check, so the hover card reads it from cache.
useAuthZ([SavedViewReadPermission]);
return (
<div

View File

@@ -0,0 +1,57 @@
import { MouseEvent, useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { AppLink } from 'lib/router/AppLink';
import { useAppLocation } from 'lib/router/useAppLocation';
import { isModifierKeyPressed } from 'utils/app';
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
import { SavedViewsRowLinkProps } from './types';
import { getSavedViewPath } from './utils/getSavedViewPath';
import styles from './SavedViewsList.module.scss';
function SavedViewsRowLink({
view,
isActive,
onSelect,
}: SavedViewsRowLinkProps): JSX.Element {
const { pathname } = useAppLocation();
const timeSearch = useTimeSearchParams();
const path = useMemo(
() => getSavedViewPath(pathname, view, timeSearch),
[pathname, view, timeSearch],
);
// Opening in a new tab or window is left to the browser.
const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {
if (isModifierKeyPressed(event) || event.shiftKey) {
return;
}
event.preventDefault();
if (!isActive) {
onSelect(view);
}
};
return (
<AppLink
to={path}
className={styles.rowLink}
aria-current={isActive ? 'page' : undefined}
onClick={handleClick}
data-testid="saved-views-row-name"
>
<Typography.Text
as="span"
size="base"
weight="normal"
truncate={1}
className={styles.rowLinkLabel}
>
{view.spec.displayName}
</Typography.Text>
</AppLink>
);
}
export default SavedViewsRowLink;

View File

@@ -8,6 +8,8 @@ import {
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -16,7 +18,7 @@ import {
SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS,
} from '../constants';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import { API, mockSavedViewsApi } from './savedViewsApiMock';
import {
expectUsable,
explorerUrl,
@@ -139,9 +141,11 @@ function advance(ms: number): void {
});
}
function openCard(name: string): void {
// Resolves once the card has fetched its view.
async function openCard(name: string): Promise<HTMLElement> {
fireEvent.mouseEnter(row(name));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
return screen.findByTestId('saved-view-hover-card-query');
}
describe('SavedViewHoverCard', () => {
@@ -168,7 +172,8 @@ describe('SavedViewHoverCard', () => {
expect(card()).toBeNull();
advance(1);
expect(card()).toHaveTextContent('Errors');
expect(card()).toBeInTheDocument();
await waitFor(() => expect(card()).toHaveTextContent('Errors'));
});
it('does not open when the pointer leaves before the delay', async () => {
@@ -184,7 +189,7 @@ describe('SavedViewHoverCard', () => {
it('stays open while the pointer moves into the card', async () => {
await renderPanel();
openCard('Errors');
await openCard('Errors');
fireEvent.mouseLeave(row('Errors'));
fireEvent.mouseEnter(card() as HTMLElement);
@@ -198,7 +203,7 @@ describe('SavedViewHoverCard', () => {
it('closes on leaving the row and reopens for the next row after the delay', async () => {
await renderPanel();
openCard('Errors');
await openCard('Errors');
fireEvent.mouseLeave(row('Errors'));
fireEvent.mouseEnter(row('Slow spans'));
@@ -206,12 +211,12 @@ describe('SavedViewHoverCard', () => {
expect(card()).toBeNull();
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
expect(card()).toHaveTextContent('Slow spans');
await waitFor(() => expect(card()).toHaveTextContent('Slow spans'));
});
it('closes when the row menu is pressed', async () => {
await renderPanel();
openCard('Errors');
await openCard('Errors');
fireEvent.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
@@ -222,19 +227,17 @@ describe('SavedViewHoverCard', () => {
describe('content', () => {
it('shows the filter, the updater and the update time', async () => {
await renderPanel();
openCard('Errors');
const query = await openCard('Errors');
const content = card() as HTMLElement;
expect(
within(content).getByTestId('saved-view-hover-card-query'),
).toHaveTextContent('has_error = true');
expect(query).toHaveTextContent('has_error = true');
expect(content).toHaveTextContent('editor@signoz.io');
expect(content).toHaveTextContent('Sep 28, 2026');
});
it('copies the filter expression', async () => {
await renderPanel();
openCard('Errors');
await openCard('Errors');
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
@@ -243,9 +246,8 @@ describe('SavedViewHoverCard', () => {
it('names each query when there are several, and copies them per line', async () => {
await renderPanel();
openCard('Errors and slow');
const query = await openCard('Errors and slow');
const query = screen.getByTestId('saved-view-hover-card-query');
expect(query).toHaveTextContent('A');
expect(query).toHaveTextContent('B');
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
@@ -257,21 +259,74 @@ describe('SavedViewHoverCard', () => {
it('says there are no filters, with nothing to copy', async () => {
await renderPanel();
openCard('Everything');
const query = await openCard('Everything');
expect(screen.getByTestId('saved-view-hover-card-query')).toHaveTextContent(
'No filters',
);
expect(query).toHaveTextContent('No filters');
expect(screen.queryByTestId('saved-view-hover-card-copy')).toBeNull();
});
it('shows the metric of a metrics query', async () => {
await renderPanel(SavedviewtypesSourceDTO.metrics, DataSource.METRICS);
openCard('Request rate');
const query = await openCard('Request rate');
const query = screen.getByTestId('saved-view-hover-card-query');
expect(query).toHaveTextContent('app.requests · rate · sum');
expect(query).not.toHaveTextContent('No filters');
});
});
describe('fetching', () => {
it('shows the query from the view fetch, not from the list', async () => {
server.use(
rest.get(API, (_req, res, ctx) =>
res(
ctx.json({
status: 'success',
data: [{ ...errors, spec: { ...errors.spec, queries: [] } }],
}),
),
),
);
await renderPanel();
const query = await openCard('Errors');
expect(query).toHaveTextContent('has_error = true');
});
it('fetches the view again on every open', async () => {
let fetches = 0;
server.use(
rest.get(`${API}/:id`, (_req, res, ctx) => {
fetches += 1;
return res(ctx.json({ status: 'success', data: errors }));
}),
);
await renderPanel();
await openCard('Errors');
fireEvent.mouseLeave(row('Errors'));
advance(SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS);
expect(card()).toBeNull();
await openCard('Errors');
await waitFor(() => expect(fetches).toBe(2));
});
it('says the view failed to load when its fetch fails', async () => {
server.use(
rest.get(`${API}/:id`, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
await renderPanel();
fireEvent.mouseEnter(row('Errors'));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
await expect(
screen.findByTestId('saved-view-hover-card-error'),
).resolves.toHaveTextContent('View failed to load');
expect(screen.queryByTestId('saved-view-hover-card-copy')).toBeNull();
});
});
});

View File

@@ -1,6 +1,10 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import {
AuthtypesTransactionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
@@ -9,34 +13,42 @@ import {
buildSavedViewUpdatePermission,
SavedViewCreatePermission,
SavedViewListPermission,
SavedViewReadPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { DataSource } from 'types/common/queryBuilder';
import { SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS } from '../constants';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { API, mockSavedViewsApi } from './savedViewsApiMock';
import {
expectUsable,
explorerUrl,
makeView,
MENU_CHECK_TIMEOUT,
queryWith,
renderWithExplorerProviders,
urlParam,
viewUrl,
} from './savedViewsTestUtils';
const PATH = ROUTES.TRACES_EXPLORER;
const HOVER_CARD_TIMEOUT = { timeout: SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS * 3 };
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
function renderPanel(url = explorerUrl(PATH, {})): void {
renderWithExplorerProviders(
function renderPanel(
url = explorerUrl(PATH, {}),
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
@@ -68,6 +80,37 @@ async function openRowMenu(name: string): Promise<void> {
fireEvent.click(within(rowNamed(name)).getByTestId('saved-views-row-menu'));
}
// Records every check, answered by `isGranted`.
function recordChecks(
isGranted: (transaction: AuthtypesTransactionDTO) => boolean = (): boolean =>
true,
): AuthtypesTransactionDTO[] {
const checks: AuthtypesTransactionDTO[] = [];
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = (await req.json()) as AuthtypesTransactionDTO[];
checks.push(...payload);
return res(ctx.json(authzMockResponse(payload, payload.map(isGranted))));
}),
);
return checks;
}
const readSelectors = (checks: AuthtypesTransactionDTO[]): string[] =>
checks
.filter((transaction) => transaction.relation === 'read')
.map((transaction) => transaction.object.selector);
async function hoverRow(name: string): Promise<HTMLElement> {
await screen.findAllByTestId('saved-views-row');
fireEvent.mouseEnter(rowNamed(name));
return screen.findByTestId(
'saved-view-hover-card',
undefined,
HOVER_CARD_TIMEOUT,
);
}
// Resolves once the row's check is back, so its state is final.
async function settledRow(testId: string): Promise<HTMLElement> {
await waitFor(
@@ -98,21 +141,51 @@ describe('SavedViews - AuthZ', () => {
expect(screen.queryByTestId('saved-views-search')).toBeNull();
});
it('disables only the views it cannot read', async () => {
it('runs no read check per row', async () => {
mockSavedViewsApi([errors, slow]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
const checks = recordChecks();
renderPanel();
// The menu checks go out after any check the rows made on mount.
await openRowMenu('Errors');
await settledRow('saved-views-row-menu-item-delete');
expect(checks.map((transaction) => transaction.relation)).toContain('delete');
expect(readSelectors(checks)).toStrictEqual(['*']);
});
it('opens a view it cannot read, then the header says no access', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
const { history } = renderWithExplorerProviders(
<>
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
onToggleViews={jest.fn()}
/>
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
/>
</>,
explorerUrl(PATH, {}),
);
await screen.findAllByTestId('saved-views-row');
const nameOf = (view: string): HTMLElement =>
within(rowNamed(view)).getByTestId('saved-views-row-name');
const name = within(rowNamed('Errors')).getByTestId('saved-views-row-name');
expectUsable(name);
await userEvent.click(name);
await waitFor(() =>
expect(nameOf('Errors')).toHaveAttribute('aria-disabled', 'true'),
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify(errors.id),
),
);
await waitFor(() =>
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-busy'),
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
'No view access',
),
);
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-disabled', 'true');
});
it('keeps the row menu on a view it cannot read', async () => {
@@ -133,6 +206,58 @@ describe('SavedViews - AuthZ', () => {
).resolves.not.toHaveAttribute('data-disabled');
});
it('shows the denied callout in the hover card of a view it cannot read, without fetching it', async () => {
let fetches = 0;
mockSavedViewsApi([errors]);
server.use(
setupAuthzDeny(
SavedViewReadPermission,
buildSavedViewReadPermission(errors.id),
),
rest.get(`${API}/:id`, (_req, res, ctx) => {
fetches += 1;
return res(ctx.json({ status: 'success', data: errors }));
}),
);
renderPanel();
const card = await hoverRow('Errors');
await expect(
within(card).findByText(/not authorized/i),
).resolves.toBeInTheDocument();
expect(within(card).queryByTestId('saved-view-hover-card-query')).toBeNull();
expect(fetches).toBe(0);
});
it('skips the read check of the hovered view with read on every view', async () => {
mockSavedViewsApi([errors]);
const checks = recordChecks();
renderPanel();
const card = await hoverRow('Errors');
await expect(
within(card).findByTestId('saved-view-hover-card-query'),
).resolves.toHaveTextContent('has_error = true');
expect(readSelectors(checks)).toStrictEqual(['*']);
});
it('checks read on the hovered view without read on every view', async () => {
mockSavedViewsApi([errors]);
const checks = recordChecks(
(transaction) =>
transaction.relation !== 'read' || transaction.object.selector !== '*',
);
renderPanel();
const card = await hoverRow('Errors');
await expect(
within(card).findByTestId('saved-view-hover-card-query'),
).resolves.toHaveTextContent('has_error = true');
expect(readSelectors(checks)).toStrictEqual(['*', errors.id]);
});
it('disables creating without create', async () => {
mockSavedViewsApi([]);
server.use(setupAuthzDeny(SavedViewCreatePermission));

View File

@@ -1,10 +1,9 @@
import { useState } from 'react';
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '@signozhq/ui/button';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { act, screen, userEvent, waitFor } from 'tests/test-utils';
import SavedViewsHeader from '../SavedViewsHeader';
import { mockSavedViewsApi } from './savedViewsApiMock';
@@ -28,7 +27,7 @@ const errors = {
};
function Harness(): JSX.Element {
const { traces } = usePreferenceContext();
const traces = usePreferenceContext()[SavedviewtypesSourceDTO.traces];
const [headerKey, setHeaderKey] = useState(0);
const columns = traces.preferences?.columns ?? [];
@@ -37,7 +36,7 @@ function Harness(): JSX.Element {
<SavedViewsHeader
key={headerKey}
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
onOpenViews={jest.fn() as jest.MockedFunction<() => void>}
/>
<span data-testid="columns">
{columns.map((column) => column.name).join(',')}
@@ -84,13 +83,14 @@ describe('SavedViewsHeader columns', () => {
});
it('keeps edited columns when the view is fetched again', async () => {
const user = userEvent.setup();
mockSavedViewsApi([errors]);
await openView();
await userEvent.click(screen.getByTestId('add-column'));
await user.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('remount-header'));
await user.click(screen.getByTestId('remount-header'));
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(columnsText()).toBe('service.name,name,extra-2');
@@ -98,35 +98,37 @@ describe('SavedViewsHeader columns', () => {
});
it('discard puts the saved columns back', async () => {
const user = userEvent.setup();
mockSavedViewsApi([errors]);
await openView();
await userEvent.click(screen.getByTestId('add-column'));
await user.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-discard'));
await user.click(screen.getByTestId('saved-views-discard'));
await waitFor(() => expect(columnsText()).toBe('service.name,name'));
expect(isDirty()).toBe(false);
});
it('discard after an update goes back to the updated columns', async () => {
const user = userEvent.setup();
mockSavedViewsApi([errors]);
await openView();
await userEvent.click(screen.getByTestId('add-column'));
await user.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await user.click(screen.getByTestId('saved-views-save-changes'));
const update = await screen.findByTestId(
'saved-views-save-changes-item-update',
);
await waitFor(() => expectRowUsable(update), MENU_CHECK_TIMEOUT);
await userEvent.click(update);
await user.click(update);
await waitFor(() => expect(isDirty()).toBe(false));
await userEvent.click(screen.getByTestId('add-column'));
await user.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-discard'));
await user.click(screen.getByTestId('saved-views-discard'));
await waitFor(() => expect(columnsText()).toBe('service.name,name,extra-2'));
expect(isDirty()).toBe(false);

View File

@@ -1,9 +1,14 @@
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import {
fireEvent,
screen,
userEvent,
waitFor,
within,
} from 'tests/test-utils';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
@@ -14,6 +19,7 @@ import {
renderWithExplorerProviders,
urlParam,
viewUrl,
withGlobalTime,
} from './savedViewsTestUtils';
jest.mock('@signozhq/ui/sonner', () => ({
@@ -53,6 +59,9 @@ const row = (name: string): HTMLElement =>
.getAllByTestId('saved-views-row')
.find((element) => element.textContent?.includes(name)) as HTMLElement;
const rowName = (name: string): HTMLElement =>
within(row(name)).getByTestId('saved-views-row-name');
describe('SavedViewsPanel list', () => {
beforeEach(() => {
localStorage.clear();
@@ -85,9 +94,8 @@ describe('SavedViewsPanel list', () => {
const { history } = renderPanel();
await screen.findByTestId('saved-views-list');
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
await waitFor(() => expectUsable(name));
await userEvent.click(name);
await waitFor(() => expectUsable(rowName('Slow spans')));
await userEvent.click(rowName('Slow spans'));
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
@@ -96,6 +104,60 @@ describe('SavedViewsPanel list', () => {
);
});
it('links each row to its view in the current time range', async () => {
mockSavedViewsApi([mine, alsoMine]);
renderWithExplorerProviders(
withGlobalTime(
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
/>,
{ selectedTime: '1h' },
),
explorerUrl(PATH, {}),
);
await screen.findByTestId('saved-views-list');
const url = new URL(
rowName('Slow spans').getAttribute('href') as string,
window.location.origin,
);
expect(url.pathname).toBe(PATH);
expect(url.searchParams.get(QueryParams.viewKey)).toBe(
JSON.stringify(alsoMine.id),
);
expect(url.searchParams.get(QueryParams.panelTypes)).toBe(
JSON.stringify(alsoMine.spec.panelType),
);
expect(url.searchParams.has(QueryParams.compositeQuery)).toBe(true);
expect(url.searchParams.get(QueryParams.relativeTime)).toBe('1h');
});
it.each([
['Ctrl', { ctrlKey: true }],
['Cmd', { metaKey: true }],
['Shift', { shiftKey: true }],
])('leaves a %s click on a row to the browser', async (_key, modifiers) => {
mockSavedViewsApi([mine, alsoMine]);
const { history } = renderPanel();
await screen.findByTestId('saved-views-list');
const before = history.location.search;
let defaultPrevented: boolean | undefined;
const recordClick = (event: MouseEvent): void => {
defaultPrevented = event.defaultPrevented;
// jsdom cannot open a new tab.
event.preventDefault();
};
document.addEventListener('click', recordClick);
fireEvent.click(rowName('Slow spans'), modifiers);
document.removeEventListener('click', recordClick);
expect(defaultPrevented).toBe(false);
expect(history.location.search).toBe(before);
});
it('marks the open view and clears it from its row', async () => {
mockSavedViewsApi([mine, alsoMine]);
const { history } = renderPanel(viewUrl(PATH, mine));
@@ -120,9 +182,8 @@ describe('SavedViewsPanel list', () => {
await screen.findByTestId('saved-views-list');
const before = history.location.search;
await userEvent.click(
within(row('Errors')).getByTestId('saved-views-row-name'),
);
expect(rowName('Errors')).toHaveAttribute('aria-current', 'page');
await userEvent.click(rowName('Errors'));
expect(history.location.search).toBe(before);
});

View File

@@ -4,6 +4,7 @@ import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
@@ -16,6 +17,7 @@ import {
renderWithExplorerProviders,
urlParam,
viewUrl,
withGlobalTime,
} from './savedViewsTestUtils';
const mockCopy = jest.fn();
@@ -32,6 +34,8 @@ jest.mock('@signozhq/ui/sonner', () => ({
}));
const PATH = ROUTES.TRACES_EXPLORER;
const START_MS = 1700000000000;
const END_MS = 1700003600000;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const slow = makeView({
@@ -119,9 +123,22 @@ describe('SavedViewsPanel row menu', () => {
);
});
it('copies a link that opens the view with its query and tab', async () => {
it('copies a link that opens the view with its query, tab and time range', async () => {
mockSavedViewsApi([errors, slow]);
renderPanelWithHeader();
renderWithExplorerProviders(
withGlobalTime(
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
/>,
{
selectedTime: 'custom',
minTime: START_MS * NANO_SECOND_MULTIPLIER,
maxTime: END_MS * NANO_SECOND_MULTIPLIER,
},
),
explorerUrl(PATH, {}),
);
await chooseFromRowMenu('Slow spans', 'Copy link');
@@ -137,6 +154,9 @@ describe('SavedViewsPanel row menu', () => {
expect(
decodeURIComponent(link.searchParams.get(QueryParams.compositeQuery) ?? ''),
).toContain('duration_nano > 1000');
expect(link.searchParams.get(QueryParams.startTime)).toBe(String(START_MS));
expect(link.searchParams.get(QueryParams.endTime)).toBe(String(END_MS));
expect(link.searchParams.has(QueryParams.relativeTime)).toBe(false);
expect(toast.success).toHaveBeenCalledWith('Link copied', expect.anything());
});

View File

@@ -28,6 +28,7 @@ import { TestRouter } from 'tests/router';
import { getAppContextMock } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchParams';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
@@ -160,6 +161,23 @@ export function viewUrl(
const mockStore = configureStore([thunk]);
export function withGlobalTime(
ui: ReactElement,
globalTime: Partial<GlobalReducer>,
): ReactElement {
const state = store.getState();
return (
<Provider
store={mockStore({
...state,
globalTime: { ...state.globalTime, ...globalTime },
})}
>
{ui}
</Provider>
);
}
// What the explorer page does around the header: writes its default query to a
// bare url, and sets the provider's tab from the url (the provider alone seeds
// it from the raw, still JSON-quoted param).

View File

@@ -1,28 +1,16 @@
import { Copy, Link, PenLine, Trash2 } from '@signozhq/icons';
import {
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SavedviewtypesSchemaVersionDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import {
buildSavedViewDeletePermission,
buildSavedViewUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewRowActionConfig, SaveViewModalMode } from './types';
export const SAVED_VIEW_SCHEMA_VERSION = SavedviewtypesSchemaVersionDTO.v2;
// Only the logs and traces explorers have columns and formatting to save.
export const SAVED_VIEW_OPTIONS_DATA_SOURCE: Partial<
Record<SavedviewtypesSourceDTO, DataSource>
> = {
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
};
// What selecting a view writes to the url, so clearing it removes exactly these.
export const SAVED_VIEW_URL_PARAMS = [
QueryParams.viewKey,

View File

@@ -8,10 +8,9 @@ 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 { DataSource } from 'types/common/queryBuilder';
import { isPreferenceSourcePage } from 'providers/preferences/utils';
import { toAPIError } from 'utils/errorUtils';
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
import { UseActiveSavedViewResult } from '../types';
import { hasUnsavedViewChanges } from '../utils/hasUnsavedViewChanges';
@@ -21,9 +20,9 @@ export function useActiveSavedView(
const { viewKey } = useGetSavedViewParams();
const { stagedQuery, panelType } = useQueryBuilder();
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const hasOptions = isPreferenceSourcePage(source);
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
});
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
@@ -63,7 +62,7 @@ export function useActiveSavedView(
view,
stagedQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
options: optionsDataSource ? options : undefined,
options: hasOptions ? options : undefined,
});
}, [
view,
@@ -71,7 +70,7 @@ export function useActiveSavedView(
isStagedQueryCurrent,
stagedQuery,
panelType,
optionsDataSource,
hasOptions,
options,
]);

View File

@@ -20,13 +20,12 @@ import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useAppLocation } from 'lib/router/useAppLocation';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { DataSource } from 'types/common/queryBuilder';
import { usePreferenceSlice } from 'providers/preferences/context/usePreferenceSlice';
import { isPreferenceSourcePage } from 'providers/preferences/utils';
import { toAPIError } from 'utils/errorUtils';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import {
SAVED_VIEW_OPTIONS_DATA_SOURCE,
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
SAVED_VIEW_URL_PARAMS,
@@ -60,15 +59,11 @@ export function useSavedViewActions(
const { viewKey } = useGetSavedViewParams();
const logSavedViewEvent = useSavedViewLogEvent(source);
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const hasOptions = isPreferenceSourcePage(source);
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
});
const preferences = usePreferenceContext();
const preferencesSlice =
optionsDataSource === DataSource.TRACES
? preferences.traces
: preferences.logs;
const preferenceSlice = usePreferenceSlice(source);
const { mutateAsync: createSavedView, isLoading: isCreating } =
useCreateSavedView();
@@ -86,10 +81,10 @@ export function useSavedViewActions(
query: stagedQuery ?? currentQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
displayName,
options: optionsDataSource ? options : undefined,
options: hasOptions ? options : undefined,
color,
}),
[stagedQuery, currentQuery, panelType, optionsDataSource, options],
[stagedQuery, currentQuery, panelType, hasOptions, options],
);
const selectView = useCallback(
@@ -105,14 +100,14 @@ export function useSavedViewActions(
const revertView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
if (optionsDataSource) {
if (preferenceSlice) {
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
optionsDataSource,
source,
);
preferencesSlice.updateColumns(columns);
preferenceSlice.updateColumns(columns);
if (formatting) {
preferencesSlice.updateFormatting(formatting);
preferenceSlice.updateFormatting(formatting);
}
}
selectView(view);
@@ -120,7 +115,7 @@ export function useSavedViewActions(
[SavedViewEventKeys.ViewId]: view.id,
});
},
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
[source, preferenceSlice, selectView, logSavedViewEvent],
);
// The query builder redirects only ever add params, so the url is rebuilt

View File

@@ -12,11 +12,10 @@ import {
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import { QueryParams } from 'constants/query';
import { buildExplorerNavigationUrl } from 'container/AIAssistant/components/ActionsSection/utils/openSavedView';
import { useAppLocation } from 'lib/router/useAppLocation';
import { getAbsoluteUrl } from 'utils/basePath';
import { toAPIError } from 'utils/errorUtils';
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
import {
SAVED_VIEW_SCHEMA_VERSION,
@@ -24,7 +23,7 @@ import {
} from '../constants';
import { SavedViewEventKeys, SavedViewEvents } from '../events';
import { UseSavedViewRowActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { getSavedViewPath } from '../utils/getSavedViewPath';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
@@ -43,6 +42,7 @@ export function useSavedViewRowActions(
): UseSavedViewRowActionsResult {
const queryClient = useQueryClient();
const { pathname } = useAppLocation();
const timeSearch = useTimeSearchParams();
const [, copyToClipboard] = useCopyToClipboard();
const logSavedViewEvent = useSavedViewLogEvent(source);
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
@@ -52,17 +52,15 @@ export function useSavedViewRowActions(
const copyViewLink = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
[QueryParams.panelTypes]: view.spec.panelType,
[QueryParams.viewKey]: view.id,
});
copyToClipboard(getAbsoluteUrl(path));
copyToClipboard(
getAbsoluteUrl(getSavedViewPath(pathname, view, timeSearch)),
);
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.LinkCopied, {
[SavedViewEventKeys.ViewId]: view.id,
});
},
[pathname, copyToClipboard, logSavedViewEvent],
[pathname, timeSearch, copyToClipboard, logSavedViewEvent],
);
const copyViewId = useCallback(

View File

@@ -45,6 +45,7 @@ import {
savedViewsListResponse,
type SavedViewSource,
unreadableViewId,
withLongName,
} from './__story_mockdata__/savedViews';
import SavedViewsHeader from '../SavedViewsHeader';
@@ -73,7 +74,13 @@ const controls = {
unreadableView: toggleControl('Unreadable view', {
group: VIEW,
description:
'Denies `read` on the first view created by others alone, on top of the Access controls: its row greys out, and with Open view on `created-by-others` the header shows No view access.',
'Denies `read` on the first view created by others, and so on `saved-view:*`, on top of the Access controls: its row still opens it, and the header then shows No view access, as with Open view on `created-by-others`. Its hover card shows the permission callout in place of the query.',
value: false,
}),
viewPending: toggleControl('View fetch pending', {
group: VIEW,
description:
'Holds every `GET /saved_views/:id` open, so a hover card stays on its loading skeleton.',
value: false,
}),
createdByMe: countControl('Created by me', {
@@ -87,6 +94,12 @@ const controls = {
value: 4,
max: SAVED_VIEWS_MAX,
}),
longNames: toggleControl('Long names', {
group: LIST,
description:
'Lengthens every view name past the sidebar width, so rows and the open view name truncate.',
value: false,
}),
};
type SavedViewsValues = MockControlValues<typeof controls>;
@@ -99,7 +112,7 @@ const viewDenied = (
): boolean =>
transaction.relation === 'read' &&
transaction.object.resource.kind === 'saved-view' &&
transaction.object.selector === deniedId;
[deniedId, '*'].includes(transaction.object.selector);
const definition = defineStoryMocks({
controls,
@@ -107,7 +120,14 @@ const definition = defineStoryMocks({
values: SavedViewsValues & AuthzArgs,
response,
): RequestHandler[] => {
const { source, openView, createdByMe, createdByOthers } = values;
const {
source,
openView,
viewPending,
createdByMe,
createdByOthers,
longNames,
} = values;
// What the panel writes stays in this handler set until a control changes.
const created: SavedviewtypesSavedViewDTO[] = [];
@@ -118,8 +138,14 @@ const definition = defineStoryMocks({
view: SavedviewtypesSavedViewDTO,
): SavedviewtypesSavedViewDTO => ({ ...view, ...updated.get(view.id) });
const named = (
view: SavedviewtypesSavedViewDTO,
): SavedviewtypesSavedViewDTO => (longNames ? withLongName(view) : view);
const findView = (id: string): SavedviewtypesSavedViewDTO | undefined => {
const view = created.find((item) => item.id === id) ?? savedViewById(id);
const stored = savedViewById(id);
const view =
created.find((item) => item.id === id) ?? (stored && named(stored));
return view && !deleted.has(id) ? current(view) : undefined;
};
@@ -143,7 +169,7 @@ const definition = defineStoryMocks({
return {
...list,
data: [...(list.data ?? []), ...created]
data: [...(list.data ?? []).map(named), ...created]
.filter((view) => !deleted.has(view.id))
.map(current),
};
@@ -151,6 +177,10 @@ const definition = defineStoryMocks({
),
rest.get(`${API}/:id`, (req, res, ctx) => {
if (viewPending) {
return res(ctx.delay('infinite'));
}
if (openError) {
return res(ctx.status(openError.status), ctx.json(openError.body));
}
@@ -274,7 +304,9 @@ const isPending = (element: HTMLElement): boolean =>
element.hasAttribute('data-loading');
const isDisabled = (element: HTMLElement): boolean =>
element.hasAttribute('disabled') || element.hasAttribute('data-disabled');
element.hasAttribute('disabled') ||
element.hasAttribute('data-disabled') ||
element.getAttribute('aria-disabled') === 'true';
export const expectDisabled = (
find: FindElement,
@@ -302,6 +334,30 @@ export const rowName =
(): HTMLElement =>
within(row).getByTestId('saved-views-row-name');
const hoverRow = async (row: HTMLElement): Promise<HTMLElement> => {
await userEvent.hover(row);
return screen.findByTestId('saved-view-hover-card', undefined, UNTIL_LOADED);
};
const searchViews = async (
canvasElement: HTMLElement,
text: string,
): Promise<void> => {
const search = await within(canvasElement).findByTestId(
'saved-views-search',
undefined,
UNTIL_LOADED,
);
await userEvent.type(search, text);
};
const clickWhenEnabled = async (find: FindElement): Promise<void> => {
await expectDisabled(find, false);
await userEvent.click(find());
};
export const openRowMenu = async (row: HTMLElement): Promise<void> => {
await userEvent.click(within(row).getByTestId('saved-views-row-menu'));
await screen.findByRole('menu', undefined, UNTIL_LOADED);
@@ -327,3 +383,115 @@ export const openSaveChangesMenu = async (
export const saveChangesItem = (key: 'save-as-new' | 'update'): FindElement =>
byTestId(`saved-views-save-changes-item-${key}`);
type PlayContext = {
canvasElement: HTMLElement;
args: { source: SavedViewSource };
};
export const hoverFirstRow = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [row] = await findRows(canvasElement);
const card = await hoverRow(row);
await within(card).findByTestId(
'saved-view-hover-card-query',
undefined,
UNTIL_LOADED,
);
};
export const hoverFirstRowLoading = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [row] = await findRows(canvasElement);
const card = await hoverRow(row);
await within(card).findByTestId(
'saved-view-hover-card-loading',
undefined,
UNTIL_LOADED,
);
};
export const searchWithNoResults = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await searchViews(canvasElement, 'nothing like this');
await screen.findByTestId('saved-views-list-no-results');
};
export const openCreateModal = async (): Promise<void> => {
await clickWhenEnabled(byTestId('saved-views-create'));
await screen.findByTestId('save-view-modal');
};
export const openEditDetailsModal = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [row] = await findRows(canvasElement);
await openRowMenu(row);
await clickWhenEnabled(rowMenuItem('editDetails'));
await screen.findByTestId('save-view-modal');
};
export const openSaveAsNewModal = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openSaveChangesMenu(canvasElement);
await clickWhenEnabled(saveChangesItem('save-as-new'));
await screen.findByTestId('save-view-modal');
};
export const openDeleteDialog = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [row] = await findRows(canvasElement);
await openRowMenu(row);
await clickWhenEnabled(rowMenuItem('delete'));
await screen.findByTestId('delete-saved-view-confirm');
};
const findUnreadableRow = async ({
canvasElement,
args,
}: PlayContext): Promise<HTMLElement> => {
const rows = await findRows(canvasElement);
const deniedId = unreadableViewId(args.source);
return rows.find(
(row) => row.getAttribute('data-view-id') === deniedId,
) as HTMLElement;
};
export const hoverUnreadableRow = async (
context: PlayContext,
): Promise<void> => {
const card = await hoverRow(await findUnreadableRow(context));
await expect(
await within(card).findByText(/not authorized/i, undefined, UNTIL_LOADED),
).toBeVisible();
await expect(
within(card).queryByTestId('saved-view-hover-card-query'),
).toBeNull();
};
export const openUnreadableView = async (
context: PlayContext,
): Promise<void> => {
const { canvasElement } = context;
const denied = await findUnreadableRow(context);
await userEvent.click(rowName(denied)());
await expect(
await within(canvasElement).findByText(
'No view access',
undefined,
UNTIL_LOADED,
),
).toBeVisible();
};

View File

@@ -6,10 +6,17 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import SavedViewsPanel from '../SavedViewsPanel';
import {
findRows,
hoverFirstRow,
hoverFirstRowLoading,
openCreateModal,
openDeleteDialog,
openEditDetailsModal,
openRowMenu,
openSaveAsNewModal,
openSaveChangesMenu,
SavedViewsSidebar,
savedViewsMocks,
searchWithNoResults,
sidebarCanvas,
} from './SavedViews.stories.mocks';
@@ -78,3 +85,48 @@ export const SaveChangesMenu: Story = {
await openSaveChangesMenu(canvasElement);
},
};
/**
* Views whose names run past the sidebar, with one of them open: each row and
* the open view name truncate.
*/
export const LongNames: Story = {
args: { longNames: true, openView: 'created-by-me' },
};
/** The hover card of the first row: its query, who updated it and when. */
export const HoverCard: Story = {
play: hoverFirstRow,
};
/** The hover card while its view is still loading: a skeleton in place of the query. */
export const HoverCardLoading: Story = {
args: { viewPending: true },
play: hoverFirstRowLoading,
};
/** A search that matches no view: the list says so in place of the rows. */
export const SearchNoResults: Story = {
play: searchWithNoResults,
};
/** The + in the header opens Save view for the current query. */
export const CreateModal: Story = {
play: openCreateModal,
};
/** Edit details from a row's menu: the modal starts on the view's name. */
export const EditDetailsModal: Story = {
play: openEditDetailsModal,
};
/** Save as new view from the ✓ menu on unsaved changes. */
export const SaveAsNewModal: Story = {
args: { openView: 'unsaved-changes' },
play: openSaveAsNewModal,
};
/** Delete from a row's menu asks to confirm before the view goes. */
export const DeleteDialog: Story = {
play: openDeleteDialog,
};

View File

@@ -209,6 +209,18 @@ export const createdSavedView = (
spec,
});
const LONG_NAME_SUFFIX = ', every environment and region, last 30 days';
export const withLongName = (
view: SavedviewtypesSavedViewDTO,
): SavedviewtypesSavedViewDTO => ({
...view,
spec: {
...view.spec,
displayName: `${view.spec.displayName}${LONG_NAME_SUFFIX}`,
},
});
export const savedViewResponse = (
view: SavedviewtypesSavedViewDTO,
): GetSavedView200 => ({ status: 'success', data: view });

View File

@@ -4,14 +4,15 @@ import { expect, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { unreadableViewId } from '../__story_mockdata__/savedViews';
import {
byTestId,
expectDisabled,
expectPending,
findRows,
hoverUnreadableRow,
openRowMenu,
openSaveChangesMenu,
openUnreadableView,
rowMenuItem,
rowName,
saveChangesItem,
@@ -99,21 +100,18 @@ export const NoCreateSaveMenu: Story = {
};
/**
* `read` denied on one view made by someone else: its name is disabled and its
* hover card never opens, while every other view still opens.
* `read` denied on one view made by someone else: the list checks no `read`, so
* its row opens it like any other, and the header then says No view access.
*/
export const NoRead: Story = {
args: { unreadableView: true },
play: async ({ canvasElement, args }) => {
const rows = await findRows(canvasElement);
const deniedId = unreadableViewId(args.source);
play: openUnreadableView,
};
await Promise.all(
rows.map((row) =>
expectDisabled(rowName(row), row.getAttribute('data-view-id') === deniedId),
),
);
},
/** The hover card of the unreadable view: the permission callout in place of its query. */
export const NoReadHoverCard: Story = {
args: { unreadableView: true },
play: hoverUnreadableRow,
};
/** The unreadable view open from its link: the header names it No view access. */

View File

@@ -128,6 +128,11 @@ export interface SavedViewsListRowProps {
hover: SavedViewsListRowHoverProps;
}
export type SavedViewsRowLinkProps = Pick<
SavedViewsListRowProps,
'view' | 'isActive' | 'onSelect'
>;
export type SavedViewRowActionKey =
| 'copyLink'
| 'copyId'
@@ -181,7 +186,7 @@ export interface UseSavedViewRowActionsResult {
}
export interface SavedViewHoverCardTarget {
view: SavedviewtypesSavedViewDTO;
viewId: string;
anchorTop: number;
anchorHeight: number;
}
@@ -202,8 +207,14 @@ export interface SavedViewHoverCardProps {
onClose: () => void;
}
export interface SavedViewHoverCardDetailsProps {
viewId: string;
/** `read` is granted on every view, so the view needs no check of its own. */
canReadAll: boolean;
}
export interface SavedViewsListRowHoverProps {
onRowEnter: (view: SavedviewtypesSavedViewDTO, row: HTMLElement) => void;
onRowEnter: (viewId: string, row: HTMLElement) => void;
onRowLeave: () => void;
onMenuOpen: () => void;
}

View File

@@ -0,0 +1,34 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { makeView } from '../../__tests__/savedViewsTestUtils';
import { getSavedViewPath } from '../getSavedViewPath';
const view = makeView({ id: 'view-1', displayName: 'Errors' });
function paramsOf(path: string): URLSearchParams {
return new URL(path, window.location.origin).searchParams;
}
describe('getSavedViewPath', () => {
it('opens the view on the given explorer in the given time range', () => {
const path = getSavedViewPath(
ROUTES.TRACES_EXPLORER,
view,
'startTime=1700000000000&endTime=1700003600000',
);
const params = paramsOf(path);
expect(path.startsWith(`${ROUTES.TRACES_EXPLORER}?`)).toBe(true);
expect(params.get(QueryParams.viewKey)).toBe(JSON.stringify(view.id));
expect(params.get(QueryParams.startTime)).toBe('1700000000000');
expect(params.get(QueryParams.endTime)).toBe('1700003600000');
});
it('adds no time range when there is none', () => {
const path = getSavedViewPath(ROUTES.TRACES_EXPLORER, view, '');
expect(path.endsWith('&')).toBe(false);
expect(paramsOf(path).has(QueryParams.relativeTime)).toBe(false);
});
});

View File

@@ -0,0 +1,17 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
import { getSavedViewQuery } from './getSavedViewQuery';
export function getSavedViewPath(
pathname: string,
view: SavedviewtypesSavedViewDTO,
timeSearch: string,
): string {
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
[QueryParams.panelTypes]: view.spec.panelType,
[QueryParams.viewKey]: view.id,
});
return timeSearch ? `${path}&${timeSearch}` : path;
}

View File

@@ -1,4 +1,7 @@
import { SavedviewtypesSavedViewSpecDTO } from 'api/generated/services/sigNoz.schemas';
import {
SavedviewtypesSavedViewSpecDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import {
defaultLogsSelectedColumns,
@@ -7,7 +10,6 @@ import {
} from 'container/OptionsMenu/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { Preferences } from 'providers/preferences/types';
import { DataSource } from 'types/common/queryBuilder';
function withColumnNames(
columns: TelemetryFieldKey[] | undefined,
@@ -25,11 +27,11 @@ function withColumnNames(
// defaults filled in where the view has none.
export function getViewColumnsAndFormatting(
spec: SavedviewtypesSavedViewSpecDTO | undefined,
dataSource: DataSource,
sourcePage: SavedviewtypesSourceDTO,
): Preferences {
const selectedFields = spec?.selectedFields as TelemetryFieldKey[] | undefined;
if (dataSource === DataSource.LOGS) {
if (sourcePage === SavedviewtypesSourceDTO.logs) {
return {
columns: ensureLogsRequiredColumns(
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
@@ -42,7 +44,7 @@ export function getViewColumnsAndFormatting(
},
};
}
if (dataSource === DataSource.TRACES) {
if (sourcePage === SavedviewtypesSourceDTO.traces) {
return { columns: selectedFields || defaultTraceSelectedColumns };
}
return { columns: [] };

View File

@@ -7,6 +7,7 @@ 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> = {
@@ -79,7 +80,7 @@ export function hasUnsavedViewChanges({
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
dataSource,
toSavedViewSource(dataSource),
);
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
fields.map((field) => field.name ?? field.key);

View File

@@ -3,11 +3,9 @@ import { DataSource } from 'types/common/queryBuilder';
import { SavedViewSourcePage } from '../types';
// Explorers and the preferences module are keyed by DataSource (the signal),
// the api keys views by source page. Same values today, so this is the one
// place they meet. AI observability views will come with their own source and
// DataSource cannot tell them apart from traces, so preferences should move to
// source page at that point and this map goes with it.
// 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.
// TODO: remove in a follow-up once callers pass the source page directly.
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
{
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,

View File

@@ -1,9 +1,15 @@
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import {
initialQueriesMap,
initialQueryBuilderFormTraceOperatorValues,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import ListView from './index';
@@ -24,7 +30,7 @@ jest.mock('react-redux', () => {
});
// List columns come from the options menu (server-synced preferences). Pin them
// so the query fires and the expected columns render, independent of that API.
// so the expected columns render, independent of that API.
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
__esModule: true,
default: (): unknown => ({
@@ -35,6 +41,8 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
{ name: 'duration_nano', fieldContext: 'span' },
{ name: 'http_method', fieldContext: 'span' },
{ name: 'response_status_code', fieldContext: 'span' },
{ name: 'http.route', fieldContext: 'attribute' },
{ name: 'cache.hit', fieldContext: 'attribute' },
],
},
config: { addColumn: { onRemove: jest.fn() } },
@@ -48,19 +56,19 @@ const listRows = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
'service.name': 'frontend',
name: 'HTTP GET',
duration_nano: 55306000,
http_method: 'GET',
response_status_code: '200',
span_id: '772c4d29dd9076ac',
trace_id: '0000000000000000344ded1387b08a7e',
attributes: { 'http.route': '/api/cart', 'cache.hit': false },
resource: { 'service.name': 'frontend' },
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
'service.name': 'demo-app',
name: 'authenticate_check_db',
duration_nano: 790949390,
// empty status fields to assert the "-" cell
@@ -68,6 +76,8 @@ const listRows = [
response_status_code: '',
span_id: '5704353737b6778e',
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
attributes: {},
resource: { 'service.name': 'demo-app' },
},
},
];
@@ -84,7 +94,9 @@ const mockSuccess = (rows: unknown[] = listRows): void => {
);
};
const renderListView = (): ReturnType<typeof render> =>
const renderListView = (
stagedQuery: Query = initialQueriesMap.traces,
): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<ListView
@@ -98,8 +110,8 @@ const renderListView = (): ReturnType<typeof render> =>
initialRoute: '/traces-explorer',
queryBuilderOverrides: {
panelType: PANEL_TYPES.LIST,
stagedQuery: initialQueriesMap.traces,
currentQuery: initialQueriesMap.traces,
stagedQuery,
currentQuery: stagedQuery,
redirectWithQueryBuilderData: jest.fn(),
} as any,
},
@@ -118,6 +130,10 @@ describe('Traces ListView - Data Loaded', () => {
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
expect(screen.getByText('authenticate_check_db')).toBeInTheDocument();
// attribute column reads from the attributes map
expect(screen.getByText('/api/cart')).toBeInTheDocument();
expect(screen.getByText('false')).toBeInTheDocument();
// duration_nano renders in milliseconds
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
@@ -130,4 +146,37 @@ describe('Traces ListView - Data Loaded', () => {
// empty status fields render "-"
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
});
it('requests every span field instead of the selected columns', async () => {
let requestBody: QueryRangeRequestV5 | undefined;
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
requestBody = await req.json<QueryRangeRequestV5>();
return res(ctx.status(200), ctx.json(listResponse(listRows)));
}),
);
const selectColumns = [{ name: 'http.route', fieldContext: 'attribute' }];
const { builder } = initialQueriesMap.traces;
renderListView({
...initialQueriesMap.traces,
builder: {
...builder,
queryData: [{ ...builder.queryData[0], selectColumns }],
queryTraceOperator: [
{
...initialQueryBuilderFormTraceOperatorValues,
expression: 'A',
selectColumns,
},
],
},
} as Query);
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
const queries = requestBody?.compositeQuery.queries ?? [];
expect(queries).toHaveLength(2);
queries.forEach(({ spec }) => {
expect(spec).not.toHaveProperty('selectFields');
});
});
});

View File

@@ -11,6 +11,7 @@ import {
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
@@ -86,7 +87,7 @@ function ListView({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
dataSource: DataSource.TRACES,
sourcePage: SavedviewtypesSourceDTO.traces,
aggregateOperator: 'count',
initialOptions: {
selectColumns: defaultSelectedColumns,
@@ -99,23 +100,20 @@ function ListView({
const paginationConfig =
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
[stagedQuery, orderBy],
);
// Stable sorted-name signature for the queryKey.
// - Drag updates selectColumns; raw queryKey would churn on reorder.
// - Trace API fetches only listed columns → add/remove must refetch.
// - Sorted-name signature: stable on reorder, changes on add/remove.
const selectColumnsSignature = useMemo(
() =>
(options?.selectColumns ?? [])
.map((c) => c.name)
.sort()
.join(','),
[options?.selectColumns],
);
// Empty selectFields makes the API return every span field.
const requestQuery = useMemo(() => {
const query = getListViewQuery(
stagedQuery || initialQueriesMap.traces,
orderBy,
);
query.builder.queryData.forEach((queryData) => {
queryData.selectColumns = [];
});
query.builder.queryTraceOperator?.forEach((queryTraceOperator) => {
queryTraceOperator.selectColumns = [];
});
return query;
}, [stagedQuery, orderBy]);
const queryKey = useMemo(
() => [
@@ -126,14 +124,12 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
],
[
stagedQuery,
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
],
@@ -154,7 +150,6 @@ function ListView({
},
tableParams: {
pagination: paginationConfig,
selectColumns: options?.selectColumns,
},
},
// ENTITY_VERSION_V4,
@@ -163,10 +158,7 @@ function ListView({
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading &&
!!stagedQuery &&
panelType === PANEL_TYPES.LIST &&
!!options?.selectColumns?.length,
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
},
);

View File

@@ -1,6 +1,7 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { TIMESTAMP_FIELD_NAMES } from './constants';
import FieldCell from './FieldCell';
@@ -16,7 +17,10 @@ export function getFieldColumn(
return {
id: buildCompositeKey(name, fieldContext, fieldDataType),
header: name,
accessorFn: (row): unknown => row[name],
// Span rows nest attribute/resource fields in maps; trace rows are flat.
accessorFn: (row): unknown =>
row[name] ??
getSpanAttribute(row as Parameters<typeof getSpanAttribute>[0], name),
enableMove: !isTimestamp,
enableRemove: !isTimestamp,
canBeHidden: !isTimestamp,

View File

@@ -28,6 +28,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
TRACE: false,
BAR: true,
AREA: true,
SCATTER: false,
PIE: false,
HISTOGRAM: false,
HEATMAP: false,

View File

@@ -16,6 +16,7 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.TRACE]: null,
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,

View File

@@ -1,64 +0,0 @@
import { ReactNode } from 'react';
import { renderHook } from '@testing-library/react';
import { TestRouter } from 'tests/router';
// jest.config.ts maps every `useSafeNavigate` import to a mock; the extension
// slips past that mapping.
const { useSafeNavigate } = jest.requireActual<
typeof import('hooks/useSafeNavigate')
>('../useSafeNavigate.ts');
function wrapper({ children }: { children: ReactNode }): JSX.Element {
return <TestRouter initialRoute="/page?a=1">{children}</TestRouter>;
}
describe('useSafeNavigate same-URL check', () => {
let pushState: jest.SpyInstance;
let replaceState: jest.SpyInstance;
beforeEach(() => {
pushState = jest.spyOn(window.history, 'pushState');
replaceState = jest.spyOn(window.history, 'replaceState');
});
afterEach(() => {
jest.restoreAllMocks();
});
function render(): ReturnType<typeof useSafeNavigate>['safeNavigate'] {
const { result } = renderHook(() => useSafeNavigate(), { wrapper });
pushState.mockClear();
replaceState.mockClear();
return result.current.safeNavigate;
}
it('navigates to a different URL', () => {
const safeNavigate = render();
safeNavigate('/page?a=2');
expect(pushState).toHaveBeenCalledTimes(1);
expect(window.location.search).toBe('?a=2');
});
it('skips the URL the router is on', () => {
const safeNavigate = render();
safeNavigate('/page?a=1');
expect(pushState).not.toHaveBeenCalled();
expect(replaceState).not.toHaveBeenCalled();
});
it('skips the URL a shallow write published ahead of the router', () => {
const safeNavigate = render();
// A nuqs write: it reaches the address bar but not the router.
window.history.replaceState(window.history.state, '', '/page?a=1&b=1');
replaceState.mockClear();
safeNavigate('/page?a=1&b=1');
expect(pushState).not.toHaveBeenCalled();
expect(replaceState).not.toHaveBeenCalled();
});
});

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