Compare commits

..

31 Commits

Author SHA1 Message Date
Yunus M
f41dc64d65 feat: add unit tests for error handling and retry functionality 2026-06-30 16:28:07 +05:30
Yunus M
934cf08774 feat: implement error handling and retry mechanism for assistant messages 2026-06-30 16:15:46 +05:30
Vinicius Lourenço
fc83f91058 feat(invite-members): add reusable component for invite members (#11872)
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
Release Drafter / update_release_draft (push) Has been cancelled
* feat(invite-members): add hook for invite members component

* feat(invite-members): add component to handle invite member logic

* test(invite-members): add tests for the component
2026-06-30 01:37:20 +00:00
Vikrant Gupta
0d47f02100 fix(authz): validate transactionGroups in UpdateRole like CreateRole (#11898)
UpdatableRole.UnmarshalJSON unmarshalled transactionGroups directly into
the slice, bypassing the verb/resource/kind/selector validation that
CreateRole applies via NewTransactionGroups. Parse the field through
NewTransactionGroups so update enforces the same constraints, while
keeping transactionGroups required (omitted/null still rejected).
2026-06-29 20:30:25 +00:00
Vinicius Lourenço
810bf5d9a0 fix(notification-channels): edit not persisting any information (#11888)
* fix(edit-alerts): not persisting any information on save

* refactor(channels-edit): use matchPath instead of regex
2026-06-29 18:48:46 +00:00
Abhi kumar
7d8a00ab8c fix(uplotV2): tooltip list clips last row and over-scrolls (#11883)
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
Release Drafter / update_release_draft (push) Has been cancelled
The Virtuoso scroller height was set to min(totalListHeight, 300), but the
scroll viewport adds 8px top + 8px bottom padding that totalListHeight does
not account for. The box was therefore ~16px shorter than its content,
clipping the last row and showing a scrollbar even when every row would fit.

Add the viewport's vertical padding back into the computed height so it only
scrolls once content genuinely exceeds the max height.
2026-06-29 17:58:01 +00:00
Gaurav Tewari
348fca1b62 feat(llm-pricing): listing page + table (2/5) (#11760)
* feat(llm-pricing): add model pricing foundation (route, permission, page shell)

* feat(llm-pricing): add listing page and table

* chore(llm-pricing): drop search + source filters from list request

The list API does not honour the q (search) and source params yet, so
the controls did nothing. Remove the search input and source dropdown
along with the params we sent, and trim useModelPricingFilters to the
URL-backed page state that pagination still needs. Currency dropdown,
tabs, table and pagination are unchanged. Filters will return once the
backend supports them.

* refactor(llm-pricing): extract getRelativeTime helper in utils

Pull the relative-time formatting out of getRelativeLastSeen into a
small local getRelativeTime helper. Kept feature-local (not in the
shared utils/timeUtils) so the LLM pricing module owns its own dayjs
config; the local relativeTime extend stays for test self-sufficiency.

* refactor(llm-pricing): drop dead NaN guard in formatPricePerMillion

Pricing fields are typed as required numbers and JSON can't carry NaN,
so Number.isNaN was unreachable. Keep the null/undefined guard as API
defensiveness (toFixed on a missing value would crash the row). Also
trims the now-redundant dayjs.extend comment.

* refactor(llm-pricing): centralize constants and shared types

Extract PAGE_SIZE, PAGE_KEY, COLUMN_COUNT and CURRENCY_OPTIONS into a
new constants.ts, and move the ModelPricingFilters contract into
types.ts. Component prop interfaces stay colocated with their
components, matching the convention in the drawer PR.

* refactor(llm-pricing): use nuqs for list pagination URL state

Replace the hand-rolled useHistory + URLSearchParams plumbing in
useModelPricingFilters with nuqs useQueryState, matching the convention
used by the dashboards, alerts and k8s list pages. Behaviour is
unchanged: parseAsInteger.withDefault(1) keeps ?page=1 out of the URL
and history:'replace' avoids polluting the back-stack.

* refactor(llm-pricing): inline pagination, drop useModelPricingFilters

The hook had shrunk to a one-line nuqs wrapper after search/source were
removed, so inline the useQueryState call into the container and remove
the hook file plus the now-unused ModelPricingFilters type. When the
filters return (once the API honours them) they can move back into a
dedicated hook.

* feat(llm-pricing): disable currency selector (USD-only for now)

Only USD is priced today, so render the currency SelectSimple in a
disabled state pinned to USD. A disabled select can't fire onChange, so
the currency useState is dead — drop it (and the now-unused useState
import).

* refactor(llm-pricing): render model costs inside its tab + tab URL param

The listing was rendered outside the Tabs, so the tab was decorative.
Move all model-cost content (currency control, list query, table,
pagination, footer) into a ModelCostsTab component rendered as the
'Model costs' tab's children, and drive the active tab from a 'tab' URL
query param (nuqs). The container is now just the page shell. Unpriced
models stays a disabled placeholder for a later PR.

* style(llm-pricing): target @signozhq table slots, drop dead antd/leftover rules

The component uses @signozhq/ui Table/Tabs (Radix-based), not antd, so the
.ant-table-* and .ant-tabs-nav selectors never matched — the intended
uppercase/muted header styling wasn't applied. Retarget header/cell rules to
[data-slot='table-head'|'table-cell'] (no !important needed). Also remove dead
rules left over from the removed search/source/add UI (.filters-bar__search,
__source, __add, .page-header__actions) and the unused .source-badge--auto/
--override modifiers.

* fix(llm-pricing): constrain currency dropdown width, drop tab URL param

- Currency SelectSimple stretched to fill the filters bar; give it a fixed
  160px width (min-width couldn't cap the trigger).
- Model costs is the only enabled tab for now, so use Tabs defaultValue
  instead of a URL-backed param. Removes the nuqs tab state plus the now-unused
  TAB_KEYS/TAB_QUERY_KEY constants and TabKey type.

* chore: self review changes

* fix: add skeleton loading

* refactor: self review changes

* refactor: initial prop

* fix: update styling

* fix: add comments in utils

* fix: route thing

* refactor: migrate to css moduel

* refactor: migrate to css module

* refactor: migrate to tanstack table

* docs: clarify price precision comment

* chore: remove comment

* refactor: shell

* fix: add key to route

* feat: add flags

* chore: additional refactor

* chore: add commet in utis

* refactor: types and other things

* refactor: types and other things

* refactor: update routes

* chore: remove usd selector for now

* fix: layout shift

* refactor: styles

* refactor: typography component

* refactor: more changes

* refactor: typograhy

* chore: sync table

* chore: remove extra comment

* chore: use typograpgy test in table config

* fix: llm pricing listing

* fix: update missing styles

* chore: update edit and delete options

* chore: remove divider

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-29 16:19:33 +00:00
Yunus M
83e0e974fe fix: include query metadata in alert edit and new contexts (#11891) 2026-06-29 15:37:07 +00:00
Himanshu RW
10217274b8 fix(render): add missing Content-Type header in Error() response (#11890) 2026-06-29 15:03:37 +00:00
Tushar Vats
7c3ac5b221 chore: json tags for error struct (#11886)
* chore: json tags for error struct

* fix: udpate unit tests
2026-06-29 12:17:00 +00:00
Naman Verma
c1d40d7359 fix: schema fixes based on UI and MCP integration review (#11718)
* fix: change schema properties based on UI integration review

* fix: check that panels referred in layouts exist

* chore: extract out validate panels method

* test: add test for missing spec prefix in layout

* fix: reject dashbaords that have vars with the same name

* fix: add additional error info on patch application error

* fix: add validations to list variable that text variable has

* fix: replicate text variable spec in signoz to make name required

* chore: replicate variable.sort into signoz

* chore: remove unsupported enum values (causing errors right now)

* chore: fix variable sort type errors

* fix: add back enum values

* fix: reject single-element list default when allowMultiple is false in list variables

* fix: remove unused import

* fix: make display required

* chore: make queries non-nullable

* fix: properly define default value and datasource plugin spec's api specs

* fix: promote variable defaultValue to a named oneOf component

The list variable defaultValue was an inline string | []string oneOf,
which downstream codegen can't canonicalize: tfplugingen-openapi rejects
the inline scalar-or-array multi-type, and oapi-codegen has no named type
to attach the union's Marshal/UnmarshalJSON to.

Shape the vendored variable.DefaultValue as the named VariableDefaultValue
oneOf via a reflector InterceptSchema hook and let defaultValue $ref it,
instead of overriding the property inline. Regenerate the OpenAPI spec and
frontend client accordingly.

* refactor: move VariableDefaultValue oneOf into dashboardtypes

Define VariableDefaultValue in dashboardtypes as a subclass of the perses
variable.DefaultValue and attach the string | []string oneOf via its own
PrepareJSONSchema, instead of shaping the perses type from an openapi.go
InterceptSchema hook. This keeps the union's schema next to its type.

The named component is now DashboardtypesVariableDefaultValue; regenerate
the OpenAPI spec and frontend client accordingly.

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
Co-authored-by: grandwizard28 <vibhupandey28@gmail.com>
2026-06-29 12:02:33 +00:00
Nityananda Gohain
c5c1913f97 fix(querier): pad clamped time range for trace_id-filtered logs (#11800)
* fix(querier): pad clamped time range for trace_id-filtered logs

* chore: use a struct instead

* chore: more cleanup
2026-06-29 11:19:39 +00:00
Naman Verma
5ab6636863 feat: add api to fetch v2 dashboards for a metric name (#11784)
* feat: add api to fetch v2 dashboards for a metric name

* chore: switch to query param

* chore: generate API specs

* chore: use proper struct in return type of GetByMetricNamesV2

* chore: add method for escaping like patterns in sqlstore formatter

* fix: use only one db call in GetByMetricNamesV2

* chore: dont use type alias for list of references
2026-06-29 10:24:23 +00:00
Aditya Singh
3680cc7779 refactor(trace-details): remove dead Trace Details V2 code (#11805)
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
Release Drafter / update_release_draft (push) Has been cancelled
* feat(trace-details): move no-data component from v2 code to v3 before v2 cleanup

* feat(trace-details): move span logs out from v2 to v3 before cleanup

* feat(trace-details): move events  out from v2 to v3 before cleanup

* feat(trace-details): remove usage of getTraceV2 from V3 code

* fix(trace-details): fix serviceName path in trace funnel

* feat(trace-details): remove Trace Details V2 page and its module import

* feat(trace-details): remove unused trace details v2 code

* feat(trace-details): fix failing test
2026-06-29 08:19:14 +00:00
Aditya Singh
7cfedc46c4 refactor(trace-details): migrate SpanLogs/Events/NoData + funnel to V3 (pre-V2-cleanup) (#11855)
* feat(trace-details): move no-data component from v2 code to v3 before v2 cleanup

* feat(trace-details): move span logs out from v2 to v3 before cleanup

* feat(trace-details): move events  out from v2 to v3 before cleanup

* feat(trace-details): remove usage of getTraceV2 from V3 code

* fix(trace-details): fix serviceName path in trace funnel

* feat(trace-details): move no-data component from v2 code to v3 before v2 cleanup

* feat(trace-details): move span logs out from v2 to v3 before cleanup

* feat(trace-details): move events  out from v2 to v3 before cleanup

* feat(trace-details): remove usage of getTraceV2 from V3 code

* fix(trace-details): fix serviceName path in trace funnel

* feat(trace-details): fix css style conventions

* feat(trace-details): events code refactor

* feat(trace-details): fix tests
2026-06-29 07:54:58 +00:00
Gaurav Tewari
69ba25d82e feat(llm-pricing): foundation — route, permission & page shell (1/5) (#11750)
* feat(llm-pricing): add model pricing foundation (route, permission, page shell)

* refactor: migrate to css moduel

* refactor: shell

* fix: add key to route

* feat: add flags

* refactor: update routes

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-29 05:56:37 +00:00
Yunus M
e42159f257 feat: enhance context picker with empty state handling and prefill functionality (#11869)
* feat: enhance context picker with empty state handling and prefill functionality

* feat: implement empty state CTA styling and testing for context picker
2026-06-29 05:51:06 +00:00
Ashwin Bhatkal
1e887dc9c9 chore(e2eci): source Playwright browsers from official image (#11789)
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
Release Drafter / update_release_draft (push) Has been cancelled
* chore(e2eci): source Playwright browsers from official image

Replace the runtime `pnpm playwright install` (which fetches browser
binaries from the Playwright CDN and intermittently times out) with a
copy out of the official `mcr.microsoft.com/playwright:v1.57.0-noble`
image. The job stays on ubuntu-latest so the testcontainers-based
backend bring-up is untouched; only OS deps are still installed via apt
(`install-deps`), which does not hit the flaky CDN.

Bump @playwright/test from the 1.57.0 alpha to stable 1.57.0 so the
client version matches the image tag (browser builds line up).

Closes #11772

* fix(e2e): open panel ⋮ menu via click, not hover (#11881)
2026-06-28 12:15:55 +00:00
Vinicius Lourenço
7646aabb2b fix(member-settings): sidenav button not redirecting to correct invite members modal (#11871)
Some checks failed
build-staging / prepare (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
2026-06-26 17:18:19 +00:00
Vinicius Lourenço
18851afb6d refactor(onboarding): drop old page (#11860) 2026-06-26 17:16:24 +00:00
Srikanth Chekuri
d5221a6ff3 feat(metrics): add per-metric label-reduction rules (#11849)
* chore(metric-reduction): scaffold metric volume control API (types, routes, stubs)

* chore: generate spec

* chore: fix required and generate frontend types

* chore(metric-reduction): add module implementation (#11857)
2026-06-26 17:16:04 +00:00
Vikrant Gupta
19712c3579 feat(authdomain): support custom roles in SSO group mapping (#11858)
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
Release Drafter / update_release_draft (push) Has been cancelled
* feat(authdomain): support custom roles in SSO group mapping

Role mappings now reference SigNoz roles by name (custom or managed)
instead of the legacy ADMIN/EDITOR/VIEWER enum. Legacy values sent by a
client are normalized to their managed names (signoz-admin/editor/viewer),
and a migration normalizes existing stored mappings.

- normalize legacy role names on unmarshal; resolve all matched roles on
  SSO callback (union of matched groups, else default, else viewer)
- validate referenced roles exist on auth domain create/update
- block deleting a role referenced by any auth domain mapping, naming the
  referencing domains (OnBeforeRoleDelete now also passes the role name)
- migration 096 to rewrite legacy names in existing auth_domain mappings

* refactor(sqlmigration): decode SSO role mapping into a typed struct

Decode the roleMapping object into a small frozen struct instead of
poking at json.RawMessage per field. The top-level config stays a raw
map so the other config fields are preserved untouched.

* fix(authdomain): validate SSO role attribute claim against existing roles

When the role mapping uses the IDP role attribute, the claimed role is
assigned only if it exists in the org (resolved case-insensitively, with
legacy ADMIN/EDITOR/VIEWER mapped to their managed names); otherwise the
mapping falls through to group mappings and the default role. This lets
custom roles be assigned via the attribute and avoids failing login on an
unknown claim.

Also normalize legacy role names case-insensitively and fold the
single-use managed-role lookup into NormalizeRoleName.
2026-06-26 14:26:43 +00:00
ElyesLaaribi
e31683be11 fix(metrics-explorer): resolve pagination overlap in table (#11812)
* fix: resolve pagination overlap in metrics explorer table

* fix: add padding-bottom to metrics table container per review feedback
2026-06-26 13:59:04 +00:00
Tushar Vats
1700ad06e6 chore: refactor error response + normalize ClickHouse telemetrystore errors (#11740)
* chore: mark required and nullable in json tag, renamed methods, and added more functionality

* fix: unit test

* fix: cast clickhouse exceptions

* fix: go mod tidy

* fix: telemetrystore now returns explicit base errors

* fix: typo

* fix: added all changes

* fix: added nil check

* fix: update test files

* fix: addressed comments

* fix: change errors and suggestions to be non-nullable
2026-06-26 13:44:38 +00:00
Vikrant Gupta
ee3b45b80d feat(statsreporter): track role counts (#11866)
Register the authz provider as a stats collector and emit per-org
role counts (role.count, role.custom.count, role.managed.count) using
the same dynamic key pattern as authdomain.
2026-06-26 13:24:59 +00:00
Vinicius Lourenço
4771e30c03 fix(keyboard-shortcut): monaco-editor should not trigger shortcut (#11848) 2026-06-26 12:22:03 +00:00
Pandey
e933fa74c7 feat(querybuilder): type untyped value fields in v5 openapi schema (#11850)
* feat(querybuilder): type untyped value fields in v5 openapi schema

The query-builder v5 types FunctionArg, VariableItem and Label carry a
`Value any` field that the reflector rendered as an untyped `{}` in the
OpenAPI spec, and QueryEnvelope left an untyped `spec: {}` on its oneOf
base. Add PrepareJSONSchema methods that document the real wire contract:

- FunctionArg.value  -> oneOf [number, string]
- VariableItem.value -> oneOf [string, number, boolean, array<scalar>]
- Label.value        -> oneOf [string, number, boolean]
- QueryEnvelope      -> drop the duplicate base properties so only the
                        typed oneOf-of-$ref variants remain

The Go fields stay `any`; this only shapes the generated schema, so the
runtime decode paths and the existing wire format are unchanged.

* chore(api): regenerate openapi spec and frontend client

Regenerates docs/api/openapi.yml (`generate openapi`) and the orval
frontend client (`generate:api`) for the v5 schema typing. The four
value/spec fields are now typed instead of untyped `{}`, and the
QueryEnvelope DTO collapses from a union of `& { spec?: unknown }`
intersections into a clean discriminated union.

* feat(querybuilder): expose builder_join variant and nullable variable value

- Uncomment the QueryEnvelope builder_join variant so the discriminated
  union includes joins (the runtime UnmarshalJSON already decodes them),
  and type QueryBuilderJoin.aggregations (a []any holding trace/log/metric
  aggregations) as a oneOf of the concrete aggregation schemas instead of
  an untyped {}.
- Mark VariableItem.value nullable: the frontend sends null for a dynamic
  variable whose "ALL" option is selected.

Go field types are unchanged; this only shapes the generated schema.

* chore(api): regenerate spec and frontend client for join + nullable value

Regenerates docs/api/openapi.yml and the orval frontend client. The
QueryEnvelope union gains the builder_join variant (with typed
aggregations), and VariableItem.value is now string | number | boolean |
array | null.

* refactor(querybuilder): extract join aggregations into a named JoinAggregation

The previous inline `items.oneOf` on QueryBuilderJoin.aggregations isn't
mappable by code generators — tfplugingen-openapi rejects a oneOf buried
inline in array items ("schema composition is currently not supported"),
and skaff's detectOneOfRewrites only rewrites top-level component schemas.

Introduce a named JoinAggregation element type exposing the trace/log/metric
shapes via JSONSchemaOneOf, so the schema becomes a named oneOf-of-$ref
component (which generators flatten into one object with three optional
sub-objects) instead of an inline union. The runtime value stays opaque and
the wire shape is unchanged via transparent Marshal/UnmarshalJSON.

* chore(api): regenerate spec and frontend client for JoinAggregation

QueryBuilderJoin.aggregations items now reference the named
Querybuildertypesv5JoinAggregation oneOf component.

* feat(querybuilder): add an OpenAPI discriminator to the QueryEnvelope union

Collapse the three signal-specific builder_query schema variants into a single
queryEnvelopeBuilder whose aggregations are a trace/log/metric union
(BuilderAggregation), so `type` maps 1:1 to one variant. Tag QueryEnvelope with
x-signoz-discriminator (propertyName: type); attachDiscriminators promotes it to
a real OpenAPI 3 discriminator so oapi-codegen emits ValueByDiscriminator and
generated clients can dispatch the union mechanically.

Runtime decoding is unchanged — UnmarshalJSON still dispatches builder queries
by signal. The generated frontend client and its dashboard-v2 consumers need a
follow-up to adopt the discriminated union.

* chore(api): regenerate openapi spec for the QueryEnvelope discriminator

Frontend client regeneration is deferred to the follow-up that adapts the
dashboard-v2 query consumers to the discriminated union.

* chore(api): regenerate frontend client for the QueryEnvelope discriminator

Matches the discriminator added in 120de27c8 and the openapi spec in
4d00c0f09: QueryEnvelopeDTO is now a discriminated union keyed on `type`,
with the builder variant collapsed and BuilderAggregation typed.

WIP: the dashboard-v2 queryV5 consumers (buildQueryRangeRequest,
persesQueryAdapters, prepareScalarTables) don't yet compile against the
discriminated union, so a whole-project `tsgo --noEmit` fails until the
follow-up commit adapts them. Committed with --no-verify intentionally.

* feat(querybuilder): make the QueryEnvelope discriminator generator-friendly

Two fixes so oapi-codegen/skaff can dispatch the QueryEnvelope union:
- Pin each variant's `type` to a plain-string enum (it was a $ref to the typed
  QueryType enum, which made oapi-codegen's `v.Type = "builder_query"`
  assignment fail to compile).
- Replace the builder variant's aggregation union with a signal-discriminated
  builderQuerySpec — a oneOf of QueryBuilderQuery[Trace|Log|Metric] keyed on the
  existing (already plain-string-pinned) `signal` field. The three aggregation
  structs stay separate, mirroring dashboardtypes.BuilderQuerySpec.

builder_join's aggregations stay a discriminator-less oneOf (JoinAggregation)
for now — deferred, since a join has no signal to discriminate on.

* chore(api): regenerate openapi spec for the QueryEnvelope discriminator fix

QueryEnvelope now has a plain-string `type` discriminator; builder_query nests a
signal-discriminated BuilderQuerySpec. Frontend client regeneration is deferred
to the single FE pass.

* chore(api): regenerate frontend client for the QueryEnvelope discriminator fix

Matches the plain-string two-level discriminator (cf5b2556e / openapi
1da75be19): QueryEnvelopeDTO discriminates on `type`; builder_query nests a
signal-discriminated BuilderQuerySpecDTO over the three QueryBuilderQuery[T].

WIP: the dashboard-v2 queryV5 consumers (buildQueryRangeRequest,
persesQueryAdapters, prepareScalarTables) still don't compile against the
discriminated union, so a whole-project `tsgo --noEmit` fails until the single
FE pass adapts them. Committed with --no-verify intentionally.

* fix(querybuilder): make QueryEnvelope discriminator `type` required so apitypes compile

The plain-string pinning was the wrong lever — the blocker is the pointer, not
the enum's underlying type. An optional discriminator field renders as `*T`, and
oapi-codegen's From<Variant> assigns the discriminator string literal directly,
which only compiles on a non-pointer field. Mark `type` required:"true" on each
variant (renders non-pointer, like dashboardtypes_layout's required `kind`), and
drop the pinQueryType plain-string override. The QueryType enum is unchanged.

* chore(api): regenerate openapi spec for the required `type` discriminator

QueryEnvelope variants now mark `type` required so the generated apitypes
compile. Frontend client regeneration follows.

* chore(api): regenerate frontend client for the required `type` discriminator

Matches the required-`type` fix (65afd890f / openapi b8d528666): QueryEnvelope
variants mark `type` required so the apitypes compile.

WIP: the dashboard-v2 queryV5 consumers still don't compile against the
discriminated union; a whole-project `tsgo --noEmit` fails until the single FE
pass adapts them. Committed with --no-verify intentionally.

* chore(querybuilder): defer builder_join until it has a proper aggregation discriminator

The join aggregation oneOf (trace/log/metric) has no discriminator — trace and
log aggregations are byte-identical, and a join carries no `signal` to dispatch
on (unlike a builder query) — so code generators can't map it. Comment out
JoinAggregation (with a TODO for when full join support lands), revert
QueryBuilderJoin.Aggregations to []any, and drop the builder_join variant from
QueryEnvelope's discriminated union (oneOf + `type` mapping). Runtime decoding of
builder_join is unchanged.

* chore(api): regenerate openapi spec — builder_join deferred out of the union

* chore(api): regenerate frontend client — builder_join deferred

Matches ee6228946 / openapi ac1255f7b: the builder_join variant (and its
JoinAggregation/QueryBuilderJoin DTOs) are dropped from QueryEnvelopeDTO while
joins are deferred.

WIP: the dashboard-v2 queryV5 consumers still don't compile against the
discriminated union; a whole-project `tsgo --noEmit` fails until the single FE
pass adapts them. Committed with --no-verify intentionally.

* fix(dashboards-v2): adapt queryV5 consumers to the discriminated QueryEnvelope union

The generated QueryEnvelopeDTO is now a discriminated union — orval splits `type`
into per-variant enums — so comparing/constructing against the shared
QueryTypeDTO no longer type-checks. Route the type/spec logic through the
hand-rolled QueryEnvelope model (plain-string `type`, typed `spec`) and cast to
the generated DTO at the wire boundary (reusing the existing toMapperEnvelopes
bridge). No behavior change; the queryV5 tests pass.

* refactor(dashboards-v2): compare envelope discriminator via generated enums

Replace the string-literal `type` checks with the generated per-variant
discriminator enums (Querybuildertypesv5QueryEnvelope{Builder,PromQL,ClickHouseSQL}DTOType).
They compare directly against `envelope.type` (no cast) and narrow the union,
so no magic strings and no hand-rolled QueryEnvelope routing for the type check.

* refactor(dashboards-v2): cast only the envelope spec in toQueryEnvelopes

plugin.spec is the un-narrowed plugin-spec union, so the construction needs to
pick out the specific spec — but casting the whole array `as unknown as
QueryEnvelopeDTO[]` also discarded the type-check on the `type` discriminator.
Cast just `spec` to the variant spec (single `as`, mirroring the CompositeQuery
case), keeping `type` and the array type-checked against QueryEnvelopeDTO.

* refactor(dashboards-v2): drop the as-unknown-as casts from queryV5 consumers

Discriminator narrowing makes envelope.spec typed, so the double casts I added
when adapting these files reduce to single `as` or none:
- extractClickhouseQueryNames: cast-free (ClickHouseQueryDTO has `name`).
- withBarStepInterval: read spec.stepInterval directly; the rebuilt spec keeps a
  single `as BuilderQuerySpecDTO` (spreading the optional union drops required
  `signal`).
- withPagination: single `as` on the rebuilt spec, same reason.
- extractAggregationsPerQuery: single `as` only on the heterogeneous aggregations.
- hasRunnableQueries: single `as QuerySpecView` (its boolean .filter doesn't
  narrow, and the view exposes signal-as-string + metricName).

No `as unknown as` remain in the files this PR touched.

* refactor(qbv5): inline discriminator helpers, trim schema comments

Inline the x-signoz-discriminator construction directly into the
builderQuerySpec and QueryEnvelope PrepareJSONSchema methods, dropping the
signozDiscriminatorKey const and the schemaRef/markDiscriminator helpers.
Trim verbose PrepareJSONSchema doc comments to 1-2 lines each.

Pure refactor: generated openapi.yml is unchanged.

* fix(qbv5): make variable value schema non-nullable

The ALL selection of a dynamic variable sends the marker string __all__,
not null, so the generated value schema should not allow null. Regenerate
the OpenAPI spec and frontend clients to drop the nullable type.
2026-06-26 10:09:00 +00:00
Abhi kumar
853397a79e feat(dashboards-v2): create new panels from the editor (#11777)
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
Release Drafter / update_release_draft (push) Has been cancelled
* feat(dashboards-v2): list panel kind (logs/traces) with row detail

* feat(dashboards-v2): list columns editor with datasource column switch

* feat(dashboards-v2): move list columns editor below the query builder

Replace the config-pane Columns section with a dnd-kit reorderable editor
rendered beneath the query builder (V1 parity); sanitize selectFields to the
field-key DTO so saved columns drop non-contract keys (isIndexed).

* style(dashboards-v2): format list panel header with oxfmt

* refactor(dashboards-v2): drop the unsupported-panel fallback

Every panel kind now has a renderer, so the "not yet supported in V2"
body is dead. Remove UnsupportedPanelBody and render the panel body
behind a plain `panelDefinition &&` guard. Also clean up the panel-kind
typing in Panel.tsx: `spec.plugin.kind` is non-optional and already a
PanelKind, so the `as unknown as` cast and `?.` chains go away.

* refactor(dashboards-v2): tighten the getPanelDefinition cast

Drop the dead `if (!kind)` guard (a partial registry already returns
undefined for absent keys) and reduce the `as unknown as` double-cast to
a single `as` — comparability clears it, so the unknown laundering was
unnecessary.

* refactor(dashboards-v2): narrow renderer props to the panel kind

PanelRendererProps<K> now carries a `panel` narrowed to kind K (via a
PluginOfKind helper that picks the variant from the generated plugin
union), so each renderer reads `panel.spec.plugin.spec` as its exact spec
DTO. Removes the per-renderer `as <Kind>SpecDTO` cast (and the now-dead
`?? {}` fallbacks) across all seven renderers; the single unavoidable
widening stays in getPanelDefinition.

* feat(dashboards-v2): create new panels from the editor

Add a draft-first create flow: picking a panel type opens the editor at
`/panel/new?panelKind=…&layoutIndex=…` on an in-memory default panel, and
nothing is persisted until save — cancelling leaves the dashboard
untouched (V1 parity). On save a new panel is minted (uuid) and added to
the target section via `createPanelOps` (resolves the section, or creates
one when the dashboard has none).

All "Add panel" triggers (section header, empty section, empty dashboard,
toolbar) route through a single useCreatePanel hook + the V2 type picker;
the leftover V1 global modal and useAddPanelToSection are removed.

New panels seed sensible defaults from the kind's supported signals: the
query datasource (e.g. List → logs, not the unsupported metrics default)
and, for List, the signal's default columns — so the Columns control
isn't empty on first open. New panels always re-serialize their query for
the kind on save, so the persisted query is valid even if untouched.

echo "--- working tree after commits (should be only dev hack) ---"; git status --short

* fix(dashboards-v2): place a new panel in the last row's free space

createPanelOps placed every new panel at x:0 on a fresh row, so a half-full
last row left its right half empty. Find the first free slot in the section's
last row (right of its panels) and only wrap to a new row when it can't fit.

* fix(uplot): center a single-item chart legend

isSingleRow read the container width from a ref inside useMemo, so it computed
once at mount (ref still null → width 0 → false) and only recovered if the
component happened to re-render. The uPlot legend re-renders on plot sync so it
recovered by luck; the Pie legend doesn't, leaving a single item clipped in a
narrow grid track. Measure the container with useResizeObserver instead.

* feat(dashboards-v2): unified panel empty, no-data and no-query states

Add a shared PanelMessage component (icon + title + description + optional
action) backing every non-chart panel state. PanelBody now shows a 'Nothing to
visualize yet' state for panels with no runnable query and a polished error
state with Retry. NoData becomes a 'No data in this time range' affordance with
an optional Retry, wired through a new optional refetch on the renderer props.

* fix(dashboards-v2): derive the preview "Plotted with" tag from the panel query

The editor preview hardcoded EQueryType.QUERY_BUILDER, so PromQL/ClickHouse
panels were mislabelled and list panels still showed the tag. Add a V2 PlotTag
(mirroring V1: hidden for list panels and before a query exists) fed by a new
getPanelQueryType util that reads the panel's V5 envelopes.

* feat(dashboards-v2): panel header title with description tooltip

Replace PanelHeader's ReactNode title with explicit name + description props,
rendering the description as an info-icon tooltip in the header instead of a
wrapper around the title. Panel passes them directly (no headerTitle memo).

* refactor(dashboards-v2): let consumers own the refetch loader signal

usePanelQuery returns the raw react-query isLoading; the dashboard panel and the
editor preview now compute isLoading || isFetching themselves, so each surface
decides whether a background refetch shows the full loader.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dashboards-v2): default a threshold's label to an empty string on save

* feat(dashboards-v2): show the panel header in the editor preview

Reuse PanelHeader in PreviewPane so the editor preview shows the panel
title, description tooltip, refetch spinner, and error/warning indicators,
with the body rendered flush beneath it like the dashboard grid panel.

Add a hideActions flag to PanelHeader to suppress the actions menu in the
editor: View/Edit/Clone/Delete don't apply there, and omitting panelActions
alone isn't enough since View/Edit/Download survive their own gates. Wire the
table/list header search through the preview as the grid does, and thread the
raw isFetching for the header's refetch spinner.

* feat(dashboards-v2): seed new panels with per-kind config defaults

A new panel was seeded with an empty plugin spec, so the config pane's
dropdowns and segmented controls (time scope, legend position, line style /
interpolation, fill mode) opened with nothing selected.

Derive a default plugin spec from each kind's declared sections via a new pure
buildDefaultPluginSpec helper and seed it in createDefaultPanel. Each default
equals the matching renderer fallback, so only the config-pane display changes,
not the rendered output. Controls whose empty state already reads as the chart
default (unit/decimals "auto", switches, numeric "Auto" inputs) are left unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dashboards-v2): preserve list columns across datasource round-trips

The List panel stores a single selectFields list, so switching the query
signal replaced the columns with the new datasource's defaults — going
logs -> traces -> logs discarded a customized logs selection.

Remember each signal's columns and restore them when switching back;
defaults still seed a signal the first time it's seen.

* docs(dashboards-v2): trim verbose comments to minimal-why style

Condense multi-paragraph block comments and drop comments that restate
the code across the new-panel editor surface, keeping only the non-obvious
"why" and concise JSDoc on functions and type fields. Comment-only — no
code, types, or test assertions changed.

* fix(dashboards-v2): show resource attributes like service.name in trace list panels

The List renderer only flattened nested resource/attribute maps for logs, so a
selected resource field such as service.name (nested under resources_string)
rendered N/A for traces even though the same data shows in V1. Flatten traces too.

* feat(dashboards-v2): open new List panels on a runnable logs query (V1 parity)

A new List panel opened blank: its seed query had an empty orderBy and lived only
in the builder, so the preview didn't run on open. Seed spec.queries at creation
with the V1 list logs query (orderBy timestamp desc) so the panel opens on logs,
pre-sorted, and the preview runs immediately; wire up useSeedNewListColumns so its
default columns (timestamp, body) are populated too. Switching to traces is handled
by the builder's list-view path.

* fix(dashboards-v2): drop all config-pane sections from the List panel

List columns are edited below the query builder, and Context Links isn't wanted in
the config pane, so the List panel now declares no config-pane sections.

* refactor(dashboards-v2): address PR review feedback

- getPanelDefinition: make PanelRegistry total over PanelKind so it never
  returns undefined (a missing kind is now a compile error)
- toQueryEnvelopes/hasRunnableQueries: accept `... | null` and drop the
  `?? []` at call sites
- consolidate panel loading to a single `isFetching` signal (Panel,
  PreviewPane, PanelEditor)
- rename defaultDataSource -> signal; use TelemetrytypesSignalDTO enum for
  the List default columns
- useCreatePanel now owns the panel-type picker state, deduping the three
  add-panel triggers; rename pluginKind -> panelKind
- LabelThresholdRow: extract onSave into a callback; ThresholdsSection test
  uses userEvent
- PreviewPane: drop the optional chaining on the required display field
- trim over-verbose comments

* refactor(dashboards-v2): type panel signals as TelemetrytypesSignalDTO

Replace the query-builder `DataSource` enum with `TelemetrytypesSignalDTO`
across the panel signal plumbing — the panel definitions' `supportedSignals`,
the List columns editor/suggestions/defaults, and the editor query-sync and
column-seeding hooks — and make the signal non-optional where a panel always
resolves one. Drops the now-dead `DataSource` imports.

* chore: pr review changes

* chore: pr review changes

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:48:20 +00:00
Vinicius Lourenço
bd526df11d feat(roles): rework the create/update pages (#11815)
* fix(styles): little fix for callout

I will upstream this fix eventually

* fix(settings): ensure tab content can use height: 100%

We will need this fix for the create/edit role page later during json mode

* refactor(authz): define the verbs that supports selectorId

* refactor(roles): drop old components

We will rewrite them

* refactor(roles): use css modules on role settings

* refactor(roles): rewrite delete role modal to use dialog component

* feat(roles): add initial type files

* feat(error-in-place): add testId to allow assert on future tests

* refactor(permission-denied-full-page): change the wording a little bit to match the API

This change was requested by Vikrant

* feat(service-account-drawer): add copy button for ID

Suggested by https://github.com/SigNoz/platform-pod/issues/2529#issuecomment-4776975323

* fix(service-account-drawer): not validating the permissions correctly for read/update

Found at https://github.com/SigNoz/platform-pod/issues/2529#issuecomment-4776975323

* feat(timezone): add little helper to allow format optional dates

* refactor(authz): export parse permission to get objectId when needed

* test(authz): add helper to grant by prefix

* test(test-utils): include tooltip provider

* feat(hooks): add hook to block navigation via history

* feat(roles): add hook to map API interface to own interface for easier integration

* feat(roles): add hook to fetch role permissions

* feat(roles): add config object around how to define each section for each resource

* feat(roles): add config for monaco when showing json

* feat(view-roles): add selected ids component

* feat(view-roles): add component row to render selected roles

* feat(view-roles): add card component to render each resource selected

* feat(view-roles): add overview component to view roles

* feat(view-roles): add readonly json viewer

* feat(view-roles): add view role page

* test(view-roles): add tests for view roles

* feat(create-edit-roles): add hook for form validation

* feat(create-edit-roles): add hook for unsaved changes

* feat(create-edit-roles): add hook for generic logic for create-edit

* feat(create-edit-roles): add component to render json

* feat(create-edit-roles): add component to add new selectors for a permission

* feat(create-edit-roles): add component to select scopes of a permission

* feat(create-edit-roles): add component render resources to be selected

* feat(create-edit-roles): add component render permission editor

* feat(create-edit-roles): add page to create/edit roles

* test(create-edit-roles): add tests for page and subcomponents

* feat(list-roles): refactor to css modules & add row click to open view role page

* fix(list-roles): change description when feature gate is disabled

* refactor(roles): removed action labels and use function instead

* refactor(authz): add todo about permission.config

* feat(roles): add routes for create/edit/view

* test(service-account): update missing tests

* fix(pr): address comments

* fix(pr): rename suffix of hooks from callbacks to actions

* fix(pr): remove all usages of getByText

* test(use-navigation-blocker): fix test
2026-06-25 17:24:16 +00:00
Nikhil Mantri
8ac07d3d37 feat(infra-monitoring): v2 onboarding/checks api (#11104)
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
Release Drafter / update_release_draft (push) Has been cancelled
* chore: baseline setup

* chore: endpoint detail update

* chore: added logic for hosts v3 api

* fix: bug fix

* chore: disk usage

* chore: added validate function

* chore: added some unit tests

* chore: return status as a string

* chore: yarn generate api

* chore: removed isSendingK8sAgentsMetricsCode

* chore: moved funcs

* chore: added validation on order by

* chore: added pods list logic

* chore: updated openapi yml

* chore: updated spec

* chore: pods api meta start time

* chore: nil pointer check

* chore: nil pointer dereference fix in req.Filter

* chore: added temporalities of metrics

* chore: added pods metrics temporality

* chore: unified composite key function

* chore: code improvements

* chore: added pods list api updates

* chore: hostStatusNone added for clarity that this field can be left empty as well in payload

* chore: yarn generate api

* chore: return errors from getMetadata and lint fix

* chore: return errors from getMetadata and lint fix

* chore: added hostName logic

* chore: modified getMetadata query

* chore: add type for response and files rearrange

* chore: warnings added passing from queryResponse warning to host lists response struct

* chore: added better metrics existence check

* chore: added a TODO remark

* chore: added required metrics check

* chore: distributed samples table to local table change for get metadata

* chore: frontend fix

* chore: endpoint correction

* chore: endpoint modification openapi

* chore: escape backtick to prevent sql injection

* chore: rearrage

* chore: improvements

* chore: validate order by to validate function

* chore: improved description

* chore: added TODOs and made filterByStatus a part of filter struct

* chore: ignore empty string hosts in get active hosts

* feat(infra-monitoring): v2 hosts list - return counts of active & inactive hosts for custom group by attributes (#10956)

* chore: add functionality for showing active and inactive counts in custom group by

* chore: bug fix

* chore: added subquery for active and total count

* chore: ignore empty string hosts in get active hosts

* fix: sinceUnixMilli for determining active hosts compute once per request

* chore: refactor code

* chore: rename HostsList -> ListHosts

* chore: rearrangement

* chore: inframonitoring types renaming

* chore: added types package

* chore: file structure further breakdown for clarity

* chore: comments correction

* chore: removed temporalities

* chore: pods code restructuring

* chore: comments resolve

* chore: added json tag required: true

* chore: removed pod metric temporalities

* chore: removed internal server error

* chore: added status unauthorized

* chore: remove a defensive nil map check, the function ensure non-nil map when err nil

* chore: cleanup and rename

* chore: make sort stable in case of tiebreaker by comparing composite group by keys

* chore: added types and constants

* chore: added specs for all component types

* chore: added attrs presence check function

* chore: added onboarding splits

* chore: regen api client for inframonitoring

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: added required tags

* chore: added support for pod phase unknown

* chore: removed pods - order by phase

* chore: improved api description to document -1 as no data in numeric fields

* fix: rebase fixes

* chore: added onboarding api

* chore: renamed method

* chore: get onboarding spec

* chore: simplify

* chore: readability improvement

* chore: added a note from otel

* chore: added a note from otel

* feat(infra-monitoring): v2 pods list apis - phase counts when custom grouping (#11088)

* chore: added phase counts feature

* chore: added queries for pod phase counts in custom group by

* chore: added unknown phase count

* fix: isPodUIDInGroupBy in buildPodRecords

* chore: 3 cte --> 2 cte

* chore: pod phase with local table of time series as counts

* chore: comment correction

* chore: corrected comment

* chore: value column for samples table added

* chore: removed query G for phase counts

* chore: rename variable

* chore: added PodPhaseNum constants to types

* chore: updated comment

* chore: onboarding specs updated to match v2 infra-monitoring apis

* chore: integration tests added

* chore: documentation future links added

* chore: added new metrics existence function + modified return types

* chore: not required parameter removal

* chore: reformatted integration tests

* chore: renamed onboarding -> checks

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-06-25 13:57:50 +00:00
Nageshbansal
9bab8e0ae2 docs(deploy): restructure migration guide and add Docker Swarm (#11851)
* docs(deploy): restructure migration guide and add Docker Swarm

* docs(deploy): title migration guide for both install script and deploy

* docs: fix the signoz docs troubleshooting guide url

* docs(deploy): point troubleshooting at Slack and Foundry issues
2026-06-25 11:48:37 +00:00
1504 changed files with 27863 additions and 71500 deletions

View File

@@ -70,7 +70,11 @@ jobs:
cd tests/e2e && pnpm install --frozen-lockfile
- name: playwright-browsers
run: |
cd tests/e2e && pnpm playwright install --with-deps ${{ matrix.project }}
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
docker rm pw
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
- name: bring-up-stack
run: |
cd tests && \

View File

@@ -38,7 +38,6 @@ jobs:
fail-fast: false
matrix:
suite:
- spanmapper
- alerts
- basepath
- callbackauthn

View File

@@ -56,17 +56,6 @@ jobs:
PRIMUS_REF: main
JS_SRC: frontend
JS_PKG_MANAGER: pnpm
languages:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: run
run: bash frontend/scripts/validate-md-languages.sh
openapi:
if: |
github.event_name == 'merge_group' ||

View File

@@ -29,6 +29,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule/implmetricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/retention"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
@@ -119,6 +121,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(_ sqlstore.SQLStore, _ dashboard.Module, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
return implcloudintegration.NewModule(), nil
},
func(_ sqlstore.SQLStore, _ telemetrystore.TelemetryStore, _ dashboard.Module, _ queryparser.QueryParser, _ licensing.Licensing, _ flagger.Flagger, _ telemetrytypes.MetadataStore, _ factory.ProviderSettings, _ int) metricreductionrule.Module {
return implmetricreductionrule.NewModule()
},
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, nil, nil))
},

View File

@@ -24,6 +24,7 @@ import (
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration/implcloudprovider"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
eeimplmetricreductionrule "github.com/SigNoz/signoz/ee/modules/metricreductionrule/implmetricreductionrule"
eequerier "github.com/SigNoz/signoz/ee/querier"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
eerules "github.com/SigNoz/signoz/ee/query-service/rules"
@@ -46,6 +47,7 @@ import (
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/retention"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
@@ -182,6 +184,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), dashboardModule, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
},
func(sqlStore sqlstore.SQLStore, ts telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, lic licensing.Licensing, flgr pkgflagger.Flagger, ms telemetrytypes.MetadataStore, ps factory.ProviderSettings, threads int) metricreductionrule.Module {
return eeimplmetricreductionrule.NewModule(sqlStore, ts, dashboardModule, queryParser, lic, flgr, ms, ps, threads)
},
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, eerules.PrepareTaskFunc, eerules.TestNotification))
},

View File

@@ -141,6 +141,10 @@ querier:
flux_interval: 5m
# The maximum number of concurrent queries for missing ranges.
max_concurrent_queries: 4
# When filtering logs by trace_id, clamp the query window to the trace time
# range with padding to include slightly delayed log exports. Logs only; set
# to 0 to disable.
log_trace_id_window_padding: 5m
##################### TelemetryStore #####################
telemetrystore:

View File

@@ -1,48 +1,76 @@
# Migrating from the install script to Foundry
# Migrating from the install script and `deploy/` to Foundry
The install script (`install.sh`) and the bundled Compose and Swarm files
under `deploy/` are deprecated in favor of [Foundry][foundry], the supported
way to install and manage SigNoz. This guide moves an existing Docker Compose
or Docker Swarm deployment to Foundry and reattaches your existing volumes, so
your data is preserved.
> [!IMPORTANT]
> The install script is now deprecated and will no longer receive updates.
> This guide is only for **existing** `install.sh` / `deploy/` deployments.
> Setting up SigNoz for the first time? Skip migration and install Foundry
> directly: [SigNoz install docs][install-docs].
This guide walks you through migrating an existing SigNoz deployment running via
Docker Compose to [Foundry](https://signoz.io/docs/install/docker/).
## How it works
> [!NOTE]
> Setting up SigNoz for the first time? You don't need this guide — follow the [SigNoz installation docs](https://signoz.io/docs/install/) instead.
Foundry splits a deployment into two commands:
## Overview
To stay up to date on new installation platforms and patterns, please refer to [Foundry](https://github.com/SigNoz/foundry).
- `foundryctl forge` generates the deployment manifests from a `casting.yaml`.
It never touches running containers, so it is safe to re-run while you
iterate.
- `foundryctl cast` applies those manifests: it (re)creates the containers and
reuses the volumes you point it at.
Two `foundryctl` commands are used throughout this guide:
- **`forge`** — generates deployment manifests from your `casting.yaml`. It does not touch running containers, so it is safe to re-run while you iterate.
- **`cast`** — applies the generated manifests: it creates and starts the containers (and pulls new images).
You write one `casting.yaml`, point a few patches at your existing data
volumes, then cast. The steps below are the same for Compose and Swarm; they
differ only in the casting (step 3) and how you stop the old stack (step 5).
## Prerequisites
- [ ] Install Foundry - `curl -fsSL https://signoz.io/foundry.sh | bash`
## Migration Steps
> [!WARNING]
> **Before proceeding, back up both:**
> - **Your docker volumes** — these hold your data.
> - **Your existing `docker-compose.yaml` (and any config it references)** — keep a copy somewhere safe. The compose manifests are no longer distributed by SigNoz, so this backup is your only way to roll back to your previous setup.
- An existing SigNoz deployment from `install.sh` or `deploy/` (Compose or
Swarm).
- `foundryctl` (installed in step 1).
1. Make a note of the volume names used by your existing deployment for the following components:
- ClickHouse
- SigNoz
- ZooKeeper
## Migrate
> If you used the docker compose file we provided, the volumes will be `signoz-clickhouse`, `signoz-sqlite`, and `signoz-zookeeper-1`.
### 1. Install Foundry
2. Generate your `casting.yaml`. Based on internal testing, the following casting should generate the manifests that mimic the legacy docker compose setup (compare against your backed-up `docker-compose.yaml`). Once created, run `foundryctl forge -f casting.yaml`.
```bash
curl -fsSL https://signoz.io/foundry.sh | bash
```
Foundry has a [Docker Compose example](https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose). Please use it as a reference.
### 2. Keep your rollback path
> [!WARNING]
> If your deployment had more than 1 shard or replica, you will need to adjust your manifest volumes accordingly.
This migration reattaches your existing volumes in place; it does not move or
delete your data. The only destructive action is passing `--volumes` / `-v`
when you stop the old stack (step 5), so avoid that flag.
> [!IMPORTANT]
> The `replica` and `shard` macros below are placeholders. Replace them with the values from your existing ClickHouse configuration (check the `macros` section of your current ClickHouse config, e.g. `config.xml`/`metrika.xml`), otherwise the generated manifests will not match your existing data.
> Keep a copy of your existing `docker-compose.yaml` / stack file (and any
> config it references). SigNoz no longer distributes these files, so this copy
> is your only way to roll back.
### 3. Write your `casting.yaml`
Use the casting for your deployment. Both reproduce the legacy single-node
setup (ClickHouse + ZooKeeper + SQLite) and reattach your existing volumes;
they differ only in `spec.deployment.flavor` and the volume-reuse patch
(Compose volumes have a `name` to replace; Swarm volumes are bare, so the whole
entry is replaced). If your deployment ran more than one shard or replica,
adjust the volume patches accordingly. The
[Docker Compose example][compose-example] is a useful reference.
> [!IMPORTANT]
> The `replica` and `shard` macros are placeholders. Replace them with the
> values from your existing ClickHouse config (the `macros` section of
> `config.xml` / `metrika.xml`), or the generated manifests will not match your
> existing data.
<details>
<summary><b>Docker Compose</b> casting.yaml</summary>
```yaml
# casting.yaml
apiVersion: v1alpha1
kind: Installation
metadata:
@@ -61,8 +89,8 @@ spec:
data:
config-0-0.yaml: |
macros:
replica: "example01-01-1" # replace with your existing ClickHouse replica macro (see legacy configuration files for reference)
shard: "01" # replace with your existing ClickHouse shard macro (see legacy configuration files for reference)
replica: "example01-01-1" # replace with your replica macro
shard: "01" # replace with your shard macro
patches:
- target: "deployment/compose.yaml"
operations:
@@ -80,50 +108,163 @@ spec:
value: root
```
> [!NOTE]
> The `user: root` patch on the ZooKeeper service is required so the container can read/write the data in your reused ZooKeeper volume, which was created with `root`-owned files by the legacy compose setup. Without it, ZooKeeper may fail to start with permission errors.
</details>
If you had custom configurations for features like SMTP or additional ingestion processors/receivers, you will need to include those in your casting file via [patches](https://github.com/SigNoz/foundry/blob/main/docs/concepts/patches.md), [custom configuration](https://github.com/SigNoz/foundry/blob/main/docs/concepts/moldings.md#custom-config-files) or [environment variables](https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md#molding-spec) based on your previous configuration.
<details>
<summary><b>Docker Swarm</b> casting.yaml</summary>
3. Review your manifests, we suggest executing the following checks on your manifests before proceeding:
- [ ] Validate the container images match what your deployment had, Foundry uses `latest` on generation by default.
- [ ] If your signoz version was older than latest, please check the [upgrade path](https://signoz.io/docs/operate/upgrade/) first.
- [ ] Check the produced manifests in `pours/deployment` match your older configurations. Extra consideration and review needs to be done on `compose.yaml` as this will be the main entry point for your new deployment.
- [ ] The configuration files for clickhouse are now in YAML so validate your custom settings are present.
```yaml
# casting.yaml
apiVersion: v1alpha1
kind: Installation
metadata:
name: signoz
spec:
deployment:
flavor: swarm
mode: docker
metastore:
kind: sqlite
telemetrykeeper:
kind: zookeeper
telemetrystore:
spec:
config:
data:
config-0-0.yaml: |
macros:
replica: "example01-01-1" # replace with your replica macro
shard: "01" # replace with your shard macro
patches:
- target: "deployment/compose.yaml"
operations:
- op: replace
path: /volumes/signoz-telemetrykeeper-0-data
value:
name: signoz-zookeeper-1
- op: replace
path: /volumes/signoz-telemetrystore-0-0-data
value:
name: signoz-clickhouse
- op: replace
path: /volumes/signoz-metastore-sqlite-0-data
value:
name: signoz-sqlite
- op: add
path: /services/signoz-telemetrykeeper-zookeeper-0/user
value: root
```
4. Execute a `docker compose down`. **Do not** include parameters such as `--volumes` (or `-v`), as it will wipe the volumes we need to maintain and reuse to avoid data loss.
</details>
> [!NOTE]
> This will generate downtime so please plan accordingly.
> The `user: root` patch on the ZooKeeper service lets the container read and
> write the data in your reused ZooKeeper volume, whose files the legacy setup
> created as `root`. Without it, ZooKeeper may fail to start with permission
> errors.
5. Validate the SigNoz containers are down with `docker ps -a`. Multiple containers cannot bind the same volume.
If you had custom configuration (SMTP, extra ingestion receivers/processors,
or custom ClickHouse settings), carry it over via [patches][patches],
[custom config files][custom-config], or [environment variables][env-vars].
6. Run `foundryctl cast -f casting.yaml`. This will recreate the containers based on the spec. This process will download new container images.
### 4. Generate and review the manifests
```bash
foundryctl forge -f casting.yaml
```
Review `pours/deployment/` before deploying:
- [ ] Container images match your current deployment. Foundry generates with
`latest` by default; if your SigNoz version was older than latest, check the
[upgrade path][upgrade-path] first.
- [ ] The generated manifests match your previous configuration, especially
`compose.yaml` (the new entry point for your deployment).
- [ ] The ClickHouse config is now YAML rather than XML; confirm your custom
settings carried over (see [ClickHouse configuration files][ch-config] for
the XML-to-YAML mapping).
### 5. Stop the old deployment
Use the command for your deployment. Do **not** pass `--volumes` / `-v`; that
would delete the data you are migrating.
```bash
docker compose down # Compose
docker stack rm signoz # Swarm
```
> [!NOTE]
> When `cast` is run, the migration container will execute its migrations.
> This causes downtime, so plan accordingly.
## Verifying the Migration
- SigNoz containers will be up and running.
- Log in to the SigNoz UI and verify that data is present.
- Signoz will run on localhost:8080
- Validate that your data ingestion is receiving data.
- Ingesters will receive data on localhost:4317(grpc) and localhost:4318(http)
- Review the logs from both ClickHouse and ZooKeeper; no errors should be present.
Confirm nothing is still bound to the volumes before continuing:
## Rolling Back
Because step 4 brought the legacy stack down *without* `-v`, your original volumes
are untouched and still hold your data. To roll back:
```bash
docker ps -a
```
- Stop and remove the containers created by Foundry (`docker compose down`, again without `-v`).
- Confirm the containers are gone with `docker ps -a` so nothing else is bound to the volumes.
- Reapply your original docker compose file (`docker compose up -d`). It will reattach to the
existing volumes and restore your prior state.
### 6. Deploy with Foundry
```bash
foundryctl cast -f casting.yaml
```
This recreates the containers against your existing volumes and pulls the
images. The migration container runs the schema migrations as part of `cast`.
**Prefer not to use `cast`?** The manifests in `pours/deployment/` are standard
Docker artifacts you can apply yourself. Run the command from that directory so
the relative config paths resolve:
```bash
cd pours/deployment
docker compose up -d # Compose
docker stack deploy -c compose.yaml signoz # Swarm
```
## Verify
- All SigNoz containers are running.
- The UI is reachable on `http://localhost:8080`, and OTLP on `4317` (gRPC)
and `4318` (HTTP), so already-instrumented apps and saved bookmarks keep
working.
- Your existing data is present in the UI, and new data is being ingested.
- ClickHouse and ZooKeeper logs show no errors.
## Roll back
Step 5 left your volumes untouched, so your data is intact. To return to the
previous setup:
1. Bring down the Foundry deployment (`docker compose down` or
`docker stack rm signoz`, again without `-v`).
2. Confirm the containers are gone with `docker ps -a`.
3. Re-apply your backed-up stack: `docker compose up -d` (Compose) or
`docker stack deploy -c docker-compose.yaml signoz` (Swarm). It reattaches
the same volumes and restores your prior state.
## Troubleshooting
- Please reach out to our community on [Slack](https://signoz.io/slack).
If the migration runs into trouble, reach out on [Slack][slack] or open a
[Foundry issue][foundry-issues].
## References
- [SigNoz Docker installation docs](https://signoz.io/docs/install/docker/)
- [SigNoz documentation](https://signoz.io/docs)
- [Foundry](https://github.com/SigNoz/foundry)
- [Foundry][foundry]
- [Casting file reference][casting-ref]
- [Custom config files][custom-config]
- [Patches][patches]
- [SigNoz documentation][signoz-docs]
[foundry]: https://github.com/SigNoz/foundry
[install-docs]: https://signoz.io/docs/install/
[compose-example]: https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose
[patches]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/patches.md
[custom-config]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/moldings.md#custom-config-files
[env-vars]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md#molding-spec
[casting-ref]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md
[ch-config]: https://clickhouse.com/docs/operations/configuration-files
[upgrade-path]: https://signoz.io/docs/operate/upgrade/
[slack]: https://signoz.io/slack
[foundry-issues]: https://github.com/SigNoz/foundry/issues
[signoz-docs]: https://signoz.io/docs

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,55 @@ var (
> 💡 **Note**: Error codes must match the regex `^[a-z_]+$` otherwise the code will panic.
### Message
The primary, human-readable summary of what went wrong, set when the error is created via `errors.New` / `errors.Newf`. Note there are two distinct `message` fields in the response: this top-level one states the overall failure, while each entry under [Additional](#additional) carries its own [message](#message-1) explaining one specific facet of it.
### Url
An optional link to documentation that explains the error in more depth, set with `WithUrl`. It is left empty when the error has no associated doc.
```go
return errors.New(errors.TypeInvalidInput, CodeBadThing, "bad thing").
WithUrl("https://signoz.io/docs/...")
```
### Additional
`errors` is a list of supplementary details that explain the top-level `message`. Each entry has its own `message` and `suggestions`, so a single error can surface several distinct problems individually. Attach details with `WithAdditional` (message only) or `WithSuggestiveAdditional` (message plus the suggestions that belong to it):
#### Message
A single, self-contained sentence describing one specific facet of the error (e.g. ``field `filed` not found``), distinct from the top-level [Message](#message). Prefer one detail per distinct problem over concatenating several into one message.
#### Suggestions
The suggestions tied to that specific detail — typically a ``did you mean: `x` `` correction for the value the detail is about. These are distinct from the error-wide [Suggestions](#suggestions) below: detail-scoped suggestions never leak into the top-level list.
```go
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
WithAdditional("field `field` not found")
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
WithSuggestiveAdditional("field `filed` not found", "did you mean: `field`")
```
### Retry
Carries the `delay` the client should wait before retrying, set with `WithRetryAfter`. It is `null` when the error is not retryable.
```go
return errors.NewTimeoutf(CodeSlow, "upstream timed out").
WithRetryAfter(5 * time.Second)
```
### Suggestions
`WithSuggestions` sets the error-wide `suggestions` list — hints about the error as a whole (e.g. "narrow the time range window"), as opposed to suggestions tied to a single detail. Prefer the builders in [pkg/errors/suggestions.go](/pkg/errors/suggestions.go) over hand-writing the strings so the phrasing stays consistent:
- `NewSuggestionsOnLevenshteinDistance(invalidInput, noun, validInputs)` — returns a ``did you mean: `x` `` correction (when a close typo match exists) followed by the valid-references list.
- `NewValidReferences(noun, values...)` — formats a capped list as ``valid <noun> are `a`, `b` `` (e.g. `"valid fields are"`, `"valid keys are"`). Returns `""` for an empty set.
- `NewSuggestionsFromFunc(produce)` — wraps a caller-computed correction string as a one-element ``did you mean: `x` `` slice (or nil when it returns `""`), for callers with their own matching strategy.
`noun` names the kind of value being suggested. Use one of the exported `Noun*` constants (`errors.NounFields`, `errors.NounKeys`, `errors.NounServices`, …) so the wording stays uniform across the codebase.
```go
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, validFields)...)
```
## Show me some examples
### Using the error

View File

@@ -143,6 +143,10 @@ func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*autht
return provider.pkgAuthzService.List(ctx, orgID)
}
func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
return provider.pkgAuthzService.Collect(ctx, orgID)
}
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*authtypes.Role, error) {
return provider.pkgAuthzService.ListByOrgIDAndNames(ctx, orgID, names)
}
@@ -370,7 +374,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
}
for _, cb := range provider.onBeforeRoleDelete {
if err := cb(ctx, orgID, id); err != nil {
if err := cb(ctx, orgID, id, role.Name); err != nil {
return err
}
}

View File

@@ -290,6 +290,10 @@ func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, m
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
}
func (module *module) GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
return module.pkgDashboardModule.GetByMetricNamesV2(ctx, orgID, metricNames)
}
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.List(ctx, orgID)
}

View File

@@ -0,0 +1,565 @@
package implmetricreductionrule
import (
"context"
"slices"
"time"
sqlbuilder "github.com/huandu/go-sqlbuilder"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
)
var (
reductionRulesTable = telemetrymetrics.DBName + "." + telemetrymetrics.ReductionRulesTableName
metadataTable = telemetrymetrics.DBName + "." + telemetrymetrics.AttributesMetadataTableName
bufferSeriesTable = telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4BufferTableName
)
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
type volumeRow struct {
MetricName string
Ingested uint64
Reduced uint64
}
type volumePoint struct {
TimestampMs int64
Ingested uint64
Reduced uint64
}
type clickhouse struct {
telemetryStore telemetrystore.TelemetryStore
threads int
}
func newClickhouse(telemetryStore telemetrystore.TelemetryStore, threads int) *clickhouse {
return &clickhouse{telemetryStore: telemetryStore, threads: threads}
}
func (c *clickhouse) withThreads(ctx context.Context) context.Context {
return ctxtypes.SetClickhouseMaxThreads(ctx, c.threads)
}
func floorToTimeSeriesBucket(ms int64) int64 {
return ms - (ms % timeSeriesBucketMilli)
}
func strictEffectiveFrom(sb *sqlbuilder.SelectBuilder, metricNames []string, effectiveFrom map[string]int64) string {
names := make([]any, 0, len(metricNames))
froms := make([]any, 0, len(metricNames))
for _, name := range metricNames {
names = append(names, name)
froms = append(froms, effectiveFrom[name])
}
return "unix_milli >= transform(metric_name, " + sb.Var(names) + ", " + sb.Var(froms) + ", 0)"
}
func (c *clickhouse) Sync(ctx context.Context, metricName string, labels []string, matchType string, effectiveFromMs int64, deleted bool, updatedAt time.Time) error {
ctx = c.withThreads(ctx)
ib := sqlbuilder.NewInsertBuilder()
ib.InsertInto(reductionRulesTable)
ib.Cols("metric_name", "labels", "match_type", "effective_from_unix_milli", "deleted", "updated_at")
ib.Values(metricName, labels, matchType, effectiveFromMs, deleted, updatedAt)
query, args := ib.BuildWithFlavor(sqlbuilder.ClickHouse)
if err := c.telemetryStore.ClickhouseDB().Exec(ctx, query, args...); err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "failed to sync reduction rule to clickhouse")
}
return nil
}
func (c *clickhouse) AttributeKeys(ctx context.Context, metricName string, startMs, endMs int64) ([]string, error) {
ctx = c.withThreads(ctx)
sb := sqlbuilder.NewSelectBuilder()
sb.Select("attr_name")
sb.Distinct()
sb.From(metadataTable)
sb.Where(
sb.E("metric_name", metricName),
"NOT startsWith(attr_name, '__')",
sb.GE("last_reported_unix_milli", startMs),
sb.LE("first_reported_unix_milli", endMs),
)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch metric attribute keys")
}
defer rows.Close()
keys := make([]string, 0)
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan attribute key")
}
keys = append(keys, key)
}
return keys, rows.Err()
}
func (c *clickhouse) EstimateCardinality(ctx context.Context, metricName string, keptLabels []string, startMs, endMs int64) (uint64, uint64, error) {
ctx = c.withThreads(ctx)
startMs = floorToTimeSeriesBucket(startMs)
sb := sqlbuilder.NewSelectBuilder()
reducedExpr := "1"
if len(keptLabels) > 0 {
reducedExpr = "uniq(("
for i, label := range keptLabels {
if i > 0 {
reducedExpr += ", "
}
reducedExpr += "JSONExtractString(labels, " + sb.Var(label) + ")"
}
reducedExpr += "))"
}
sb.Select("uniq(fingerprint)", reducedExpr)
sb.From(bufferSeriesTable)
conds := []string{
sb.E("metric_name", metricName),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
sb.E("is_reduced", false),
}
sb.Where(conds...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var current, reduced uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&current, &reduced); err != nil {
return 0, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to estimate reduction impact")
}
if len(keptLabels) == 0 && current == 0 {
reduced = 0
}
if reduced > current {
reduced = current
}
return current, reduced, nil
}
// VolumeByMetric returns ingested vs reduced series counts per metric.
func (c *clickhouse) VolumeByMetric(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]volumeRow, error) {
if len(metricNames) == 0 {
return map[string]volumeRow{}, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.ingestedSeriesCount(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
reduced, err := c.reducedSeriesCount(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
out := make(map[string]volumeRow, len(metricNames))
for metricName, count := range ingested {
out[metricName] = volumeRow{MetricName: metricName, Ingested: count, Reduced: out[metricName].Reduced}
}
for metricName, count := range reduced {
row := out[metricName]
row.MetricName = metricName
row.Reduced = count
out[metricName] = row
}
return out, nil
}
// ingestedSeriesCount counts distinct raw fingerprints per metric from the samples buffer over the
// window.
func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", "uniq(fingerprint)")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
conds := []string{
sb.In("metric_name", names...),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("metric_name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count ingested series")
}
defer rows.Close()
out := make(map[string]uint64, len(metricNames))
for rows.Next() {
var (
metricName string
count uint64
)
if err := rows.Scan(&metricName, &count); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series count")
}
out[metricName] = count
}
return out, rows.Err()
}
// reducedSeriesCount counts distinct reduced_fingerprints per metric, summed across the two 60s
// reduced sample tables.
func (c *clickhouse) reducedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
out := make(map[string]uint64, len(metricNames))
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
counts, err := c.reducedSeriesCountForTable(ctx, telemetrymetrics.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for metricName, count := range counts {
out[metricName] += count
}
}
return out, nil
}
func (c *clickhouse) reducedSeriesCountForTable(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", "uniq(reduced_fingerprint)")
sb.From(table)
conds := []string{
sb.In("metric_name", names...),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("metric_name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count reduced series")
}
defer rows.Close()
out := make(map[string]uint64, len(metricNames))
for rows.Next() {
var (
metricName string
count uint64
)
if err := rows.Scan(&metricName, &count); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series count")
}
out[metricName] = count
}
return out, rows.Err()
}
// RankByVolume ranks metrics by ingested/reduced series volume. Like VolumeByMetric, the counts read
// the samples tables with a strict effective_from gate; the reduced count sums distinct
// reduced_fingerprints across the two 60s reduced sample tables.
func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, orderBy metricreductionruletypes.ReductionRuleOrderBy, order metricreductionruletypes.Order, startMs, endMs int64, offset, limit int) ([]volumeRow, error) {
if len(metricNames) == 0 {
return []volumeRow{}, nil
}
ctx = c.withThreads(ctx)
orderExpr := "ingested"
switch orderBy {
case metricreductionruletypes.OrderByReducedVolume:
orderExpr = "reduced"
case metricreductionruletypes.OrderByReduction:
orderExpr = "if(ingested = 0, 0, (toFloat64(ingested) - toFloat64(reduced)) / toFloat64(ingested))"
}
direction := "ASC"
if order == metricreductionruletypes.OrderDesc {
direction = "DESC"
}
ingestedTable := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName
reducedLast := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedLastTableName
reducedSum := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedSumTableName
sb := sqlbuilder.NewSelectBuilder()
sb.Select("base.metric_name AS metric_name", "ifNull(i.cnt, 0) AS ingested", "ifNull(d.cnt, 0) AS reduced")
sb.From("(SELECT arrayJoin(" + sb.Var(metricNames) + ") AS metric_name) AS base")
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, uniq(fingerprint) AS cnt FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS i",
"base.metric_name = i.metric_name",
)
// Reduced series are spread across two type-specific tables; union the per-table distinct
// reduced_fingerprints and sum per metric (a metric only lands in the table matching its type).
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, sum(cnt) AS cnt FROM ("+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedLast+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
" UNION ALL "+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedSum+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
") GROUP BY metric_name) AS d",
"base.metric_name = d.metric_name",
)
sb.OrderBy(orderExpr + " " + direction)
if limit > 0 {
sb.Limit(limit).Offset(offset)
}
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to rank reduction rules by volume")
}
defer rows.Close()
out := make([]volumeRow, 0, len(metricNames))
for rows.Next() {
var row volumeRow
if err := rows.Scan(&row.MetricName, &row.Ingested, &row.Reduced); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan volume row")
}
out = append(out, row)
}
return out, rows.Err()
}
func (c *clickhouse) SampleVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, uint64, error) {
if len(metricNames) == 0 {
return 0, 0, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.countRawSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return 0, 0, err
}
last, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return 0, 0, err
}
sum, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return 0, 0, err
}
return ingested, min(last+sum, ingested), nil
}
func (c *clickhouse) countRawSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("count()")
sb.From(table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var count uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count ingested samples")
}
return count, nil
}
func (c *clickhouse) countReducedSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
// Reduced tables key the series on reduced_fingerprint (not fingerprint); dedupe ReplacingMergeTree recomputes.
sb.Select("uniq(reduced_fingerprint, unix_milli)")
sb.From(table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var count uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count reduced samples")
}
return count, nil
}
// SeriesTimeseries returns ingested vs reduced series per 60s bucket from the samples tables, gated
// to each metric's strict effective_from (see strictEffectiveFrom).
func (c *clickhouse) SeriesTimeseries(ctx context.Context, allMetrics, reducedMetrics []string, effectiveFrom map[string]int64, startMs, endMs int64) ([]volumePoint, error) {
if len(allMetrics) == 0 {
return []volumePoint{}, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.ingestedSeriesByBucket(ctx, allMetrics, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
retained := make(map[int64]uint64)
if len(reducedMetrics) > 0 {
reduced, err := c.reducedSeriesByBucket(ctx, reducedMetrics, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for ts, count := range reduced {
retained[ts] += count
}
}
reducedSet := make(map[string]struct{}, len(reducedMetrics))
for _, name := range reducedMetrics {
reducedSet[name] = struct{}{}
}
nonReduced := make([]string, 0, len(allMetrics))
for _, name := range allMetrics {
if _, ok := reducedSet[name]; !ok {
nonReduced = append(nonReduced, name)
}
}
if len(nonReduced) > 0 {
nonReducedIngested, err := c.ingestedSeriesByBucket(ctx, nonReduced, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for ts, count := range nonReducedIngested {
retained[ts] += count
}
}
return mergeVolumePoints(ingested, retained), nil
}
func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
buckets := make(map[int64]struct{}, len(ingested))
for ts := range ingested {
buckets[ts] = struct{}{}
}
for ts := range reduced {
buckets[ts] = struct{}{}
}
timestamps := make([]int64, 0, len(buckets))
for ts := range buckets {
timestamps = append(timestamps, ts)
}
slices.Sort(timestamps)
points := make([]volumePoint, 0, len(timestamps))
for _, ts := range timestamps {
points = append(points, volumePoint{
TimestampMs: ts,
Ingested: ingested[ts],
Reduced: reduced[ts],
})
}
return points
}
// ingestedSeriesByBucket counts distinct raw fingerprints per hourly bucket from the samples buffer.
func (c *clickhouse) ingestedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
sb.Select(bucketExpr, "uniq(fingerprint)")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
return c.scanBuckets(ctx, sb)
}
// reducedSeriesByBucket counts distinct reduced_fingerprints per hourly bucket, summed across the two
// reduced sample tables (a metric only lands in the table matching its type, so per-bucket sums are
// exact).
func (c *clickhouse) reducedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
out := make(map[int64]uint64)
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
sb.Select(bucketExpr, "uniq(reduced_fingerprint)")
sb.From(telemetrymetrics.DBName + "." + table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
counts, err := c.scanBuckets(ctx, sb)
if err != nil {
return nil, err
}
for ts, count := range counts {
out[ts] += count
}
}
return out, nil
}
func (c *clickhouse) scanBuckets(ctx context.Context, sb *sqlbuilder.SelectBuilder) (map[int64]uint64, error) {
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to bucket series by time")
}
defer rows.Close()
out := make(map[int64]uint64)
for rows.Next() {
var (
ts int64
count uint64
)
if err := rows.Scan(&ts, &count); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series bucket")
}
out[ts] = count
}
return out, rows.Err()
}

View File

@@ -0,0 +1,571 @@
package implmetricreductionrule
import (
"context"
"log/slog"
"sort"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
// effectiveFromMargin delays effective_from so the collector picks up the synced rule before it
// goes live; it must be >= the collector's rule-refresh interval (see signoz-otel-collector#839).
effectiveFromMargin = 5 * time.Minute
defaultPreviewLookback = 24 * time.Hour
pricePerMillionSamplesUSD = 0.1
monthDuration = 30 * 24 * time.Hour
)
type module struct {
store metricreductionruletypes.Store
ch *clickhouse
dashboard dashboard.Module
ruleStore ruletypes.RuleStore
licensing licensing.Licensing
flagger flagger.Flagger
metadataStore telemetrytypes.MetadataStore
logger *slog.Logger
}
func NewModule(sqlStore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, licensing licensing.Licensing, flagger flagger.Flagger, metadataStore telemetrytypes.MetadataStore, providerSettings factory.ProviderSettings, threads int) metricreductionrule.Module {
scoped := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/modules/metricreductionrule/implmetricreductionrule")
return &module{
store: NewStore(sqlStore),
ch: newClickhouse(telemetryStore, threads),
dashboard: dashboardModule,
ruleStore: sqlrulestore.NewRuleStore(sqlStore, queryParser, providerSettings),
licensing: licensing,
flagger: flagger,
metadataStore: metadataStore,
logger: scoped.Logger(),
}
}
func (m *module) checkAccess(ctx context.Context, orgID valuer.UUID) error {
if !m.flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, featuretypes.NewFlaggerEvaluationContext(orgID)) {
return errors.Newf(errors.TypeUnsupported, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupported, "metric volume control is not enabled")
}
if _, err := m.licensing.GetActive(ctx, orgID); err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "metric volume control requires a valid license").WithAdditional(err.Error())
}
return nil
}
func (m *module) List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) (*metricreductionruletypes.GettableReductionRules, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
if err := params.Validate(); err != nil {
return nil, err
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
endMs := now.UnixMilli()
switch params.OrderBy {
case metricreductionruletypes.OrderByMetricName, metricreductionruletypes.OrderByLastUpdated:
return m.listSortedByColumn(ctx, orgID, params, startMs, endMs)
default:
return m.listSortedByVolume(ctx, orgID, params, startMs, endMs)
}
}
func (m *module) listSortedByColumn(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams, startMs, endMs int64) (*metricreductionruletypes.GettableReductionRules, error) {
domainRules, total, err := m.store.List(ctx, orgID, params)
if err != nil {
return nil, err
}
metricNames := make([]string, len(domainRules))
effectiveFrom := make(map[string]int64, len(domainRules))
for i, rule := range domainRules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(domainRules))
for _, rule := range domainRules {
rules = append(rules, withVolume(toGettableReductionRule(rule), volumes[rule.MetricName]))
}
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
}
func (m *module) listSortedByVolume(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams, startMs, endMs int64) (*metricreductionruletypes.GettableReductionRules, error) {
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{Search: params.Search, MetricName: params.MetricName})
if err != nil {
return nil, err
}
if total == 0 {
return &metricreductionruletypes.GettableReductionRules{Rules: []metricreductionruletypes.GettableReductionRule{}, Total: 0}, nil
}
metricNames := make([]string, len(allRules))
effectiveFrom := make(map[string]int64, len(allRules))
ruleByMetric := make(map[string]*metricreductionruletypes.ReductionRule, len(allRules))
for i, rule := range allRules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
ruleByMetric[rule.MetricName] = rule
}
ranked, err := m.ch.RankByVolume(ctx, metricNames, effectiveFrom, params.OrderBy, params.Order, startMs, endMs, params.Offset, params.Limit)
if err != nil {
return nil, err
}
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(ranked))
for _, row := range ranked {
rule, ok := ruleByMetric[row.MetricName]
if !ok {
continue
}
rules = append(rules, withVolume(toGettableReductionRule(rule), row))
}
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
}
func (m *module) Create(ctx context.Context, orgID valuer.UUID, userEmail string, req *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
if err := req.Validate(); err != nil {
return nil, err
}
if err := m.validateMetricForReduction(ctx, orgID, req.MetricName); err != nil {
return nil, err
}
now := time.Now()
rule := metricreductionruletypes.NewReductionRule(orgID, req.MetricName, req.MatchType, req.Labels, now.Add(effectiveFromMargin), userEmail)
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
if err := m.store.Create(ctx, rule); err != nil {
return err
}
return m.ch.Sync(ctx, rule.MetricName, rule.Labels, rule.MatchType.StringValue(), rule.EffectiveFrom.UnixMilli(), false, rule.UpdatedAt)
}); err != nil {
return nil, err
}
gettable := toGettableReductionRule(rule)
return &gettable, nil
}
func (m *module) GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*metricreductionruletypes.GettableReductionRule, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
rule, err := m.store.GetByID(ctx, orgID, id)
if err != nil {
return nil, err
}
gettable := toGettableReductionRule(rule)
return &gettable, nil
}
func (m *module) UpdateByID(ctx context.Context, orgID valuer.UUID, userEmail string, id valuer.UUID, req *metricreductionruletypes.UpdatableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
existing, err := m.store.GetByID(ctx, orgID, id)
if err != nil {
return nil, err
}
if err := req.Validate(); err != nil {
return nil, err
}
now := time.Now()
existing.MatchType = req.MatchType
existing.Labels = metricreductionruletypes.LabelList(req.Labels)
existing.EffectiveFrom = now.Add(effectiveFromMargin)
existing.UpdatedAt = now
existing.UpdatedBy = userEmail
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
if err := m.store.Upsert(ctx, existing); err != nil {
return err
}
return m.ch.Sync(ctx, existing.MetricName, existing.Labels, existing.MatchType.StringValue(), existing.EffectiveFrom.UnixMilli(), false, existing.UpdatedAt)
}); err != nil {
return nil, err
}
gettable := toGettableReductionRule(existing)
return &gettable, nil
}
func (m *module) DeleteByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
if err := m.checkAccess(ctx, orgID); err != nil {
return err
}
rule, err := m.store.GetByID(ctx, orgID, id)
if err != nil {
return err
}
now := time.Now()
effectiveFromMs := now.Add(effectiveFromMargin).UnixMilli()
return m.store.RunInTx(ctx, func(ctx context.Context) error {
if err := m.store.DeleteByID(ctx, orgID, id); err != nil {
return err
}
return m.ch.Sync(ctx, rule.MetricName, []string{}, metricreductionruletypes.MatchTypeDrop.StringValue(), effectiveFromMs, true, now)
})
}
func (m *module) Preview(ctx context.Context, orgID valuer.UUID, req *metricreductionruletypes.PostableReductionRulePreview) (*metricreductionruletypes.GettableReductionRulePreview, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
if err := req.Validate(); err != nil {
return nil, err
}
if err := m.validateMetricForReduction(ctx, orgID, req.MetricName); err != nil {
return nil, err
}
lookback := time.Duration(req.LookbackMs) * time.Millisecond
if lookback <= 0 {
lookback = defaultPreviewLookback
}
now := time.Now()
startMs := now.Add(-lookback).UnixMilli()
endMs := now.UnixMilli()
current, reduced, reductionPercent, dropped, err := m.estimateVolume(ctx, req.MetricName, req.MatchType, req.Labels, startMs, endMs)
if err != nil {
return nil, err
}
// Baseline is what the metric keeps today (its current rule, or raw if none) so the preview reads
// as current -> proposed.
currentReduced := current
if existing, gerr := m.store.Get(ctx, orgID, req.MetricName); gerr == nil {
if _, existingReduced, _, _, eerr := m.estimateVolume(ctx, req.MetricName, existing.MatchType, existing.Labels, startMs, endMs); eerr == nil {
currentReduced = existingReduced
}
}
return &metricreductionruletypes.GettableReductionRulePreview{
IngestedSeries: current,
CurrentRetainedSeries: currentReduced,
RetainedSeries: reduced,
ReductionPercent: reductionPercent,
DroppedLabels: dropped,
AffectedAssets: m.relatedAssetImpact(ctx, orgID, req.MetricName, dropped),
EffectiveFrom: now.Add(effectiveFromMargin),
}, nil
}
func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreductionruletypes.GettableReductionRuleStats, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
endMs := now.UnixMilli()
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
if err != nil {
return nil, err
}
if total == 0 {
return &metricreductionruletypes.GettableReductionRuleStats{}, nil
}
metricNames := make([]string, len(allRules))
effectiveFrom := make(map[string]int64, len(allRules))
for i, rule := range allRules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
var ingestedSeries, retainedSeries uint64
reducedMetricNames := make([]string, 0, len(volumes))
reducedEffectiveFrom := make(map[string]int64, len(volumes))
for name, volume := range volumes {
ingestedSeries += volume.Ingested
retained := effectiveRetained(volume.Ingested, volume.Reduced)
retainedSeries += retained
if retained < volume.Ingested {
reducedMetricNames = append(reducedMetricNames, name)
reducedEffectiveFrom[name] = effectiveFrom[name]
}
}
ingestedSamples, reducedSamples, err := m.ch.SampleVolume(ctx, reducedMetricNames, reducedEffectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
return &metricreductionruletypes.GettableReductionRuleStats{
IngestedSeries: ingestedSeries,
RetainedSeries: retainedSeries,
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ingestedSamples, reducedSamples, startMs, endMs),
}, nil
}
// monthlySavingsUSD extrapolates the windowed sample reduction to a monthly figure at the per-sample
// list price. Ingested is gated to effective_from upstream, so pre-activation hours don't inflate it.
func monthlySavingsUSD(ingestedSamples, reducedSamples uint64, startMs, endMs int64) float64 {
if reducedSamples >= ingestedSamples || endMs <= startMs {
return 0
}
savedSamples := float64(ingestedSamples - reducedSamples)
monthlySamples := savedSamples * float64(monthDuration.Milliseconds()) / float64(endMs-startMs)
return monthlySamples / 1_000_000 * pricePerMillionSamplesUSD
}
func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuildertypesv5.QueryRangeResponse, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
endMs := now.UnixMilli()
allRules, _, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
if err != nil {
return nil, err
}
metricNames := make([]string, len(allRules))
effectiveFrom := make(map[string]int64, len(allRules))
for i, rule := range allRules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
reducedNames := make([]string, 0, len(volumes))
for name, volume := range volumes {
if effectiveRetained(volume.Ingested, volume.Reduced) < volume.Ingested {
reducedNames = append(reducedNames, name)
}
}
points, err := m.ch.SeriesTimeseries(ctx, metricNames, reducedNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
return buildVolumeTimeseries(points), nil
}
func buildVolumeTimeseries(points []volumePoint) *querybuildertypesv5.QueryRangeResponse {
ingested := make([]*querybuildertypesv5.TimeSeriesValue, 0, len(points))
reduced := make([]*querybuildertypesv5.TimeSeriesValue, 0, len(points))
for _, point := range points {
ingested = append(ingested, &querybuildertypesv5.TimeSeriesValue{Timestamp: point.TimestampMs, Value: float64(point.Ingested)})
reduced = append(reduced, &querybuildertypesv5.TimeSeriesValue{Timestamp: point.TimestampMs, Value: float64(point.Reduced)})
}
return &querybuildertypesv5.QueryRangeResponse{
Type: querybuildertypesv5.RequestTypeTimeSeries,
Data: querybuildertypesv5.QueryData{
Results: []any{
&querybuildertypesv5.TimeSeriesData{
QueryName: "reduction_volume",
Aggregations: []*querybuildertypesv5.AggregationBucket{
{
Series: []*querybuildertypesv5.TimeSeries{
{Labels: []*querybuildertypesv5.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "series"}, Value: "ingested"}}, Values: ingested},
{Labels: []*querybuildertypesv5.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "series"}, Value: "retained"}}, Values: reduced},
},
},
},
},
},
},
}
}
func (m *module) validateMetricForReduction(ctx context.Context, orgID valuer.UUID, metricName string) error {
lastSeen, err := m.metadataStore.FetchLastSeenInfoMulti(ctx, metricName)
if err != nil {
return err
}
if lastSeen[metricName] == 0 {
return errors.NewNotFoundf(errors.CodeNotFound, "metric not found: %q", metricName)
}
now := time.Now()
startTs := uint64(now.Add(-defaultPreviewLookback).UnixMilli())
endTs := uint64(now.UnixMilli())
_, types, _, err := m.metadataStore.FetchTemporalityAndTypeMulti(ctx, orgID, startTs, endTs, metricName)
if err != nil {
return err
}
if types[metricName] == metrictypes.ExpHistogramType {
return errors.Newf(errors.TypeInvalidInput, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupportedMetricType,
"exponential histogram metrics cannot be reduced in v1")
}
return nil
}
func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metricName string, dropped []string) []metricreductionruletypes.AffectedAsset {
affected := make([]metricreductionruletypes.AffectedAsset, 0)
droppedSet := make(map[string]struct{}, len(dropped))
for _, label := range dropped {
droppedSet[label] = struct{}{}
}
if dashboards, err := m.dashboard.GetByMetricNames(ctx, orgID, []string{metricName}); err != nil {
m.logger.WarnContext(ctx, "failed to fetch related dashboards for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
} else {
for _, item := range dashboards[metricName] {
usedLabels := append(splitCSV(item["group_by"]), splitCSV(item["filter_by"])...)
affected = append(affected, metricreductionruletypes.AffectedAsset{
Type: metricreductionruletypes.AssetTypeDashboard,
ID: item["dashboard_id"],
Name: item["dashboard_name"],
Widget: &metricreductionruletypes.AffectedWidget{ID: item["widget_id"], Name: item["widget_name"]},
ImpactedLabels: intersectLabels(usedLabels, droppedSet),
})
}
}
if alerts, err := m.ruleStore.GetStoredRulesByMetricName(ctx, orgID.String(), metricName); err != nil {
m.logger.WarnContext(ctx, "failed to fetch related alerts for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
} else {
for _, a := range alerts {
affected = append(affected, metricreductionruletypes.AffectedAsset{
Type: metricreductionruletypes.AssetTypeAlert,
ID: a.AlertID,
Name: a.AlertName,
})
}
}
return affected
}
func toGettableReductionRule(rule *metricreductionruletypes.ReductionRule) metricreductionruletypes.GettableReductionRule {
return metricreductionruletypes.GettableReductionRule{
Identifiable: rule.Identifiable,
TimeAuditable: rule.TimeAuditable,
UserAuditable: rule.UserAuditable,
MetricName: rule.MetricName,
MatchType: rule.MatchType,
Labels: rule.Labels,
EffectiveFrom: rule.EffectiveFrom,
Active: !rule.EffectiveFrom.After(time.Now()),
}
}
func effectiveRetained(ingested, reduced uint64) uint64 {
if reduced == 0 || reduced > ingested {
return ingested
}
return reduced
}
func withVolume(rule metricreductionruletypes.GettableReductionRule, volume volumeRow) metricreductionruletypes.GettableReductionRule {
rule.IngestedSeries = volume.Ingested
rule.RetainedSeries = effectiveRetained(volume.Ingested, volume.Reduced)
if volume.Ingested > 0 {
rule.ReductionPercent = (1 - float64(rule.RetainedSeries)/float64(volume.Ingested)) * 100
}
return rule
}
func intersectLabels(keys []string, droppedSet map[string]struct{}) []string {
seen := make(map[string]struct{})
var out []string
for _, key := range keys {
if _, ok := droppedSet[key]; !ok {
continue
}
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, key)
}
return out
}
func splitCSV(s string) []string {
if s == "" {
return nil
}
return strings.Split(s, ",")
}
func resolveDroppedKept(matchType metricreductionruletypes.MatchType, ruleLabels, keys []string) (dropped, kept []string) {
ruleSet := make(map[string]struct{}, len(ruleLabels))
for _, l := range ruleLabels {
ruleSet[l] = struct{}{}
}
for _, k := range keys {
if metricreductionruletypes.IsProtectedLabel(k) {
kept = append(kept, k)
continue
}
_, listed := ruleSet[k]
drop := listed
if matchType == metricreductionruletypes.MatchTypeKeep {
drop = !listed
}
if drop {
dropped = append(dropped, k)
} else {
kept = append(kept, k)
}
}
sort.Strings(dropped)
sort.Strings(kept)
return dropped, kept
}
func (m *module) estimateVolume(ctx context.Context, metricName string, matchType metricreductionruletypes.MatchType, labels []string, startMs, endMs int64) (current uint64, reduced uint64, reductionPercent float64, dropped []string, err error) {
keys, err := m.ch.AttributeKeys(ctx, metricName, startMs, endMs)
if err != nil {
return 0, 0, 0, nil, err
}
dropped, kept := resolveDroppedKept(matchType, labels, keys)
current, reduced, err = m.ch.EstimateCardinality(ctx, metricName, kept, startMs, endMs)
if err != nil {
return 0, 0, 0, nil, err
}
if current > 0 && reduced <= current {
reductionPercent = (1 - float64(reduced)/float64(current)) * 100
}
return current, reduced, reductionPercent, dropped, nil
}

View File

@@ -0,0 +1,145 @@
package implmetricreductionrule
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type store struct {
sqlstore sqlstore.SQLStore
}
func NewStore(sqlstore sqlstore.SQLStore) metricreductionruletypes.Store {
return &store{sqlstore: sqlstore}
}
func (s *store) List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) ([]*metricreductionruletypes.ReductionRule, int, error) {
column := "metric_name"
if params.OrderBy == metricreductionruletypes.OrderByLastUpdated {
column = "updated_at"
}
direction := "ASC"
if params.Order == metricreductionruletypes.OrderDesc {
direction = "DESC"
}
rules := make([]*metricreductionruletypes.ReductionRule, 0)
query := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&rules).
Where("org_id = ?", orgID).
Order(column + " " + direction)
if params.Search != "" {
query = query.Where("metric_name LIKE ?", "%"+params.Search+"%")
}
if params.MetricName != "" {
query = query.Where("metric_name = ?", params.MetricName)
}
if params.Limit > 0 {
query = query.Limit(params.Limit).Offset(params.Offset)
}
total, err := query.ScanAndCount(ctx)
if err != nil {
return nil, 0, err
}
return rules, total, nil
}
func (s *store) Get(ctx context.Context, orgID valuer.UUID, metricName string) (*metricreductionruletypes.ReductionRule, error) {
rule := new(metricreductionruletypes.ReductionRule)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(rule).
Where("org_id = ?", orgID).
Where("metric_name = ?", metricName).
Scan(ctx)
if err != nil {
return nil, s.sqlstore.WrapNotFoundErrf(err, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found for metric %q", metricName)
}
return rule, nil
}
func (s *store) GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*metricreductionruletypes.ReductionRule, error) {
rule := new(metricreductionruletypes.ReductionRule)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(rule).
Where("org_id = ?", orgID).
Where("id = ?", id).
Scan(ctx)
if err != nil {
return nil, s.sqlstore.WrapNotFoundErrf(err, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found with id %q", id.String())
}
return rule, nil
}
func (s *store) Create(ctx context.Context, rule *metricreductionruletypes.ReductionRule) error {
res, err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(rule).
On("CONFLICT (org_id, metric_name) DO NOTHING").
Exec(ctx)
if err != nil {
return err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return errors.Newf(errors.TypeAlreadyExists, metricreductionruletypes.ErrCodeMetricReductionRuleAlreadyExists,
"a reduction rule for metric %q already exists", rule.MetricName)
}
return nil
}
func (s *store) Upsert(ctx context.Context, rule *metricreductionruletypes.ReductionRule) error {
_, err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(rule).
On("CONFLICT (org_id, metric_name) DO UPDATE").
Set("match_type = EXCLUDED.match_type").
Set("labels = EXCLUDED.labels").
Set("effective_from = EXCLUDED.effective_from").
Set("updated_at = EXCLUDED.updated_at").
Set("updated_by = EXCLUDED.updated_by").
Exec(ctx)
return err
}
func (s *store) DeleteByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
res, err := s.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model((*metricreductionruletypes.ReductionRule)(nil)).
Where("org_id = ?", orgID).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return errors.Newf(errors.TypeNotFound, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found with id %q", id.String())
}
return nil
}
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
}

View File

@@ -107,6 +107,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
metricsReduction := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableMetricsReduction.String()),
Active: metricsReduction,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -152,3 +152,7 @@ func (f *formatter) LowerExpression(expression string) []byte {
sql = append(sql, ')')
return sql
}
func (f *formatter) EscapeLikePattern(value string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(value)
}

View File

@@ -15,6 +15,8 @@
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles",
"role_details": "Role Details",
"role_edit": "Edit Role",
"role_create": "Create Role",
"members": "Members",
"service_accounts": "Service Accounts",
"mcp_server": "MCP Server"

View File

@@ -82,6 +82,8 @@
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
"ROLE_DETAILS": "SigNoz | Role Details",
"ROLE_CREATE": "SigNoz | Create Role",
"ROLE_EDIT": "SigNoz | Edit Role",
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"

View File

@@ -1,13 +0,0 @@
#!/usr/bin/env bash
# Extracts unique fenced code block language identifiers from all .md files under frontend/src/
# Usage: bash frontend/scripts/extract-md-languages.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC_DIR="$SCRIPT_DIR/../src"
grep -roh '```[a-zA-Z0-9_+-]*' "$SRC_DIR" --include='*.md' \
| sed 's/^```//' \
| grep -v '^$' \
| sort -u

View File

@@ -1,41 +0,0 @@
#!/usr/bin/env bash
# Validates that all fenced code block languages used in .md files are registered
# in the syntax highlighter.
# Usage: bash frontend/scripts/validate-md-languages.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SYNTAX_HIGHLIGHTER="$SCRIPT_DIR/../src/components/MarkdownRenderer/syntaxHighlighter.ts"
# Get all languages used in .md files
md_languages=$("$SCRIPT_DIR/extract-md-languages.sh")
# Get all registered languages from syntaxHighlighter.ts
registered_languages=$(grep -oP "registerLanguage\('\K[^']+" "$SYNTAX_HIGHLIGHTER" | sort -u)
missing_languages=()
for lang in $md_languages; do
# Skip ai-* block markers — these are custom AI block types rendered by
# RichCodeBlock as React components (e.g. ActionBlock, LineChartBlock),
# not real syntax languages, so they don't need highlighter registration.
if [[ "$lang" == ai-* ]]; then
continue
fi
if ! echo "$registered_languages" | grep -qx "$lang"; then
missing_languages+=("$lang")
fi
done
if [ ${#missing_languages[@]} -gt 0 ]; then
echo "Error: The following languages are used in .md files but not registered in syntaxHighlighter.ts:"
for lang in "${missing_languages[@]}"; do
echo " - $lang"
done
echo ""
echo "Please add them to: frontend/src/components/MarkdownRenderer/syntaxHighlighter.ts"
exit 1
fi
echo "All markdown code block languages are registered in syntaxHighlighter.ts"

View File

@@ -3,12 +3,12 @@ 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';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useIsAIObservabilityEnabled } from 'hooks/useIsAIObservabilityEnabled';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
@@ -37,11 +37,11 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
activeLicense,
isFetchingActiveLicense,
trialInfo,
featureFlags,
} = useAppContext();
const isAdmin = user.role === USER_ROLES.ADMIN;
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
const mapRoutes = useMemo(
() =>
@@ -133,6 +133,14 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
return <Redirect to={ROUTES.HOME} />;
}
if (
(pathname.startsWith(`${ROUTES.LLM_OBSERVABILITY_BASE}/`) ||
pathname === ROUTES.LLM_OBSERVABILITY_BASE) &&
!isAIObservabilityEnabled
) {
return <Redirect to={ROUTES.HOME} />;
}
// Check for workspace access restriction (cloud only)
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;
@@ -212,14 +220,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
}
}
// Check for GET_STARTED → GET_STARTED_WITH_CLOUD redirect (feature flag)
if (
currentRoute?.path === ROUTES.GET_STARTED &&
featureFlags?.find((e) => e.name === FeatureKeys.ONBOARDING_V3)?.active
) {
return <Redirect to={ROUTES.GET_STARTED_WITH_CLOUD} />;
}
// Main routing logic
if (currentRoute) {
const { isPrivate, key } = currentRoute;

View File

@@ -2,7 +2,6 @@ import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter, Route, Switch, useLocation } from 'react-router-dom';
import { act, render, screen, waitFor } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
@@ -1263,80 +1262,6 @@ describe('PrivateRoute', () => {
});
});
describe('Get Started Route Redirect', () => {
it('should redirect to GET_STARTED_WITH_CLOUD when on GET_STARTED and ONBOARDING_V3 feature flag is active', async () => {
renderPrivateRoute({
initialRoute: ROUTES.GET_STARTED,
appContext: {
isLoggedIn: true,
featureFlags: [
{
name: FeatureKeys.ONBOARDING_V3,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
},
});
await assertRedirectsTo(ROUTES.GET_STARTED_WITH_CLOUD);
});
it('should not redirect when on GET_STARTED and ONBOARDING_V3 feature flag is inactive', () => {
renderPrivateRoute({
initialRoute: ROUTES.GET_STARTED,
appContext: {
isLoggedIn: true,
featureFlags: [
{
name: FeatureKeys.ONBOARDING_V3,
active: false,
usage: 0,
usage_limit: -1,
route: '',
},
],
},
});
assertStaysOnRoute(ROUTES.GET_STARTED);
});
it('should not redirect when on GET_STARTED and ONBOARDING_V3 feature flag is not present', () => {
renderPrivateRoute({
initialRoute: ROUTES.GET_STARTED,
appContext: {
isLoggedIn: true,
featureFlags: [],
},
});
assertStaysOnRoute(ROUTES.GET_STARTED);
});
it('should not redirect when on different route even if ONBOARDING_V3 is active', () => {
renderPrivateRoute({
initialRoute: ROUTES.HOME,
appContext: {
isLoggedIn: true,
featureFlags: [
{
name: FeatureKeys.ONBOARDING_V3,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
},
});
assertStaysOnRoute(ROUTES.HOME);
});
});
describe('Loading States', () => {
it('should not redirect while license is still being fetched', () => {
renderPrivateRoute({
@@ -1496,16 +1421,16 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
it('should allow EDITOR to access /get-started route', () => {
it('should allow EDITOR to access /get-started-with-signoz-cloud route', () => {
renderPrivateRoute({
initialRoute: ROUTES.GET_STARTED,
initialRoute: ROUTES.GET_STARTED_WITH_CLOUD,
appContext: {
isLoggedIn: true,
user: createMockUser({ role: USER_ROLES.EDITOR as ROLES }),
},
});
assertStaysOnRoute(ROUTES.GET_STARTED);
assertStaysOnRoute(ROUTES.GET_STARTED_WITH_CLOUD);
});
});

View File

@@ -57,13 +57,6 @@ export const TraceFilter = Loadable(
() => import(/* webpackChunkName: "Trace Filter Page" */ 'pages/Trace'),
);
export const TraceDetail = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetail Page" */ 'pages/TraceDetailV2/index'
),
);
export const TraceDetailOldRedirect = Loadable(
() =>
import(
@@ -90,14 +83,6 @@ export const SettingsPage = Loadable(
() => import(/* webpackChunkName: "SettingsPage" */ 'pages/Settings'),
);
export const GettingStarted = Loadable(
() => import(/* webpackChunkName: "GettingStarted" */ 'pages/GettingStarted'),
);
export const Onboarding = Loadable(
() => import(/* webpackChunkName: "Onboarding" */ 'pages/OnboardingPage'),
);
export const OrgOnboarding = Loadable(
() => import(/* webpackChunkName: "OrgOnboarding" */ 'pages/OrgOnboarding'),
);
@@ -337,3 +322,17 @@ export const AIAssistantPage = Loadable(
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
),
);
export const LLMObservabilityPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Page" */ 'pages/LLMObservability'
),
);
export const LLMObservabilityModelPricingPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
),
);

View File

@@ -23,6 +23,8 @@ import {
IntegrationsDetailsPage,
LicensePage,
ListAllALertsPage,
LLMObservabilityPage,
LLMObservabilityModelPricingPage,
LiveLogs,
Login,
Logs,
@@ -33,7 +35,6 @@ import {
MeterExplorerPage,
MetricsExplorer,
OldLogsExplorer,
Onboarding,
OnboardingV2,
OrgOnboarding,
PasswordReset,
@@ -70,13 +71,6 @@ const routes: AppRoutes[] = [
isPrivate: false,
key: 'SIGN_UP',
},
{
path: ROUTES.GET_STARTED,
exact: false,
component: Onboarding,
isPrivate: true,
key: 'GET_STARTED',
},
{
path: ROUTES.GET_STARTED_WITH_CLOUD,
exact: false,
@@ -477,6 +471,13 @@ const routes: AppRoutes[] = [
key: 'METRICS_EXPLORER_VIEWS',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER_VOLUME_CONTROL',
isPrivate: true,
},
{
path: ROUTES.METER,
@@ -513,6 +514,20 @@ const routes: AppRoutes[] = [
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_BASE,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_BASE',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
exact: true,
component: LLMObservabilityModelPricingPage,
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
isPrivate: true,
},
];
export const SUPPORT_ROUTE: AppRoutes = {

View File

@@ -4,14 +4,22 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
GetChecks200,
GetChecksParams,
InframonitoringtypesPostableClustersDTO,
InframonitoringtypesPostableDaemonSetsDTO,
InframonitoringtypesPostableDeploymentsDTO,
@@ -38,6 +46,93 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Checks whether the metrics and attributes required to power the infra-monitoring section selected by the 'type' query parameter (hosts, processes, pods, nodes, deployments, daemonsets, statefulsets, jobs, namespaces, clusters, volumes) are being received. For each collector receiver or processor that contributes required metrics or attributes, lists what is present and what is missing, with a prebuilt user-facing message and a docs link per missing component. Default-enabled metrics are those expected as soon as the receiver is configured; optional metrics require 'enabled: true' in receiver config. 'ready' is true only when every missing list is empty.
* @summary Run Infra Monitoring Setup Checks
*/
export const getChecks = (params: GetChecksParams, signal?: AbortSignal) => {
return GeneratedAPIInstance<GetChecks200>({
url: `/api/v2/infra_monitoring/checks`,
method: 'GET',
params,
signal,
});
};
export const getGetChecksQueryKey = (params?: GetChecksParams) => {
return [
`/api/v2/infra_monitoring/checks`,
...(params ? [params] : []),
] as const;
};
export const getGetChecksQueryOptions = <
TData = Awaited<ReturnType<typeof getChecks>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetChecksParams,
options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetChecksQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getChecks>>> = ({
signal,
}) => getChecks(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getChecks>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetChecksQueryResult = NonNullable<
Awaited<ReturnType<typeof getChecks>>
>;
export type GetChecksQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Run Infra Monitoring Setup Checks
*/
export function useGetChecks<
TData = Awaited<ReturnType<typeof getChecks>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetChecksParams,
options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetChecksQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Run Infra Monitoring Setup Checks
*/
export const invalidateGetChecks = async (
queryClient: QueryClient,
params: GetChecksParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetChecksQueryKey(params) },
options,
);
return queryClient;
};
/**
* Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* @summary List Clusters for Infra Monitoring

View File

@@ -18,32 +18,776 @@ import type {
} from 'react-query';
import type {
CreateMetricReductionRule201,
DeleteMetricReductionRuleByIDPathParameters,
GetMetricAlerts200,
GetMetricAlertsParams,
GetMetricAttributes200,
GetMetricAttributesParams,
GetMetricDashboards200,
GetMetricDashboardsParams,
GetMetricDashboardsV2200,
GetMetricDashboardsV2Params,
GetMetricHighlights200,
GetMetricHighlightsParams,
GetMetricMetadata200,
GetMetricMetadataParams,
GetMetricReductionRuleByID200,
GetMetricReductionRuleByIDPathParameters,
GetMetricReductionRuleStats200,
GetMetricReductionRuleTimeseries200,
GetMetricsOnboardingStatus200,
GetMetricsStats200,
GetMetricsTreemap200,
InspectMetrics200,
ListMetricReductionRules200,
ListMetricReductionRulesParams,
ListMetrics200,
ListMetricsParams,
MetricreductionruletypesPostableReductionRuleDTO,
MetricreductionruletypesPostableReductionRulePreviewDTO,
MetricreductionruletypesUpdatableReductionRuleDTO,
MetricsexplorertypesInspectMetricsRequestDTO,
MetricsexplorertypesStatsRequestDTO,
MetricsexplorertypesTreemapRequestDTO,
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
PreviewMetricReductionRule200,
RenderErrorResponseDTO,
UpdateMetricReductionRuleByID200,
UpdateMetricReductionRuleByIDPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Returns active metric volume-control (label reduction) rules.
* @summary List metric reduction rules
*/
export const listMetricReductionRules = (
params?: ListMetricReductionRulesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListMetricReductionRules200>({
url: `/api/v2/metric_reduction_rules`,
method: 'GET',
params,
signal,
});
};
export const getListMetricReductionRulesQueryKey = (
params?: ListMetricReductionRulesParams,
) => {
return [
`/api/v2/metric_reduction_rules`,
...(params ? [params] : []),
] as const;
};
export const getListMetricReductionRulesQueryOptions = <
TData = Awaited<ReturnType<typeof listMetricReductionRules>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListMetricReductionRulesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMetricReductionRules>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListMetricReductionRulesQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listMetricReductionRules>>
> = ({ signal }) => listMetricReductionRules(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listMetricReductionRules>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListMetricReductionRulesQueryResult = NonNullable<
Awaited<ReturnType<typeof listMetricReductionRules>>
>;
export type ListMetricReductionRulesQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary List metric reduction rules
*/
export function useListMetricReductionRules<
TData = Awaited<ReturnType<typeof listMetricReductionRules>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListMetricReductionRulesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMetricReductionRules>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListMetricReductionRulesQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List metric reduction rules
*/
export const invalidateListMetricReductionRules = async (
queryClient: QueryClient,
params?: ListMetricReductionRulesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListMetricReductionRulesQueryKey(params) },
options,
);
return queryClient;
};
/**
* Creates a volume-control rule for a metric and returns it with its id; fails if the metric already has a rule.
* @summary Create a metric reduction rule
*/
export const createMetricReductionRule = (
metricreductionruletypesPostableReductionRuleDTO?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateMetricReductionRule201>({
url: `/api/v2/metric_reduction_rules`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: metricreductionruletypesPostableReductionRuleDTO,
signal,
});
};
export const getCreateMetricReductionRuleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
TContext
> => {
const mutationKey = ['createMetricReductionRule'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createMetricReductionRule>>,
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> }
> = (props) => {
const { data } = props ?? {};
return createMetricReductionRule(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateMetricReductionRuleMutationResult = NonNullable<
Awaited<ReturnType<typeof createMetricReductionRule>>
>;
export type CreateMetricReductionRuleMutationBody =
| BodyType<MetricreductionruletypesPostableReductionRuleDTO>
| undefined;
export type CreateMetricReductionRuleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create a metric reduction rule
*/
export const useCreateMetricReductionRule = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
TContext
> => {
return useMutation(getCreateMetricReductionRuleMutationOptions(options));
};
/**
* Deletes a volume-control rule by its id.
* @summary Delete a metric reduction rule by id
*/
export const deleteMetricReductionRuleByID = (
{ id }: DeleteMetricReductionRuleByIDPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/metric_reduction_rules/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteMetricReductionRuleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
TError,
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
TError,
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
TContext
> => {
const mutationKey = ['deleteMetricReductionRuleByID'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
{ pathParams: DeleteMetricReductionRuleByIDPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteMetricReductionRuleByID(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteMetricReductionRuleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>
>;
export type DeleteMetricReductionRuleByIDMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete a metric reduction rule by id
*/
export const useDeleteMetricReductionRuleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
TError,
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
TError,
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
TContext
> => {
return useMutation(getDeleteMetricReductionRuleByIDMutationOptions(options));
};
/**
* Returns a single volume-control rule by its id.
* @summary Get a metric reduction rule by id
*/
export const getMetricReductionRuleByID = (
{ id }: GetMetricReductionRuleByIDPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricReductionRuleByID200>({
url: `/api/v2/metric_reduction_rules/${id}`,
method: 'GET',
signal,
});
};
export const getGetMetricReductionRuleByIDQueryKey = ({
id,
}: GetMetricReductionRuleByIDPathParameters) => {
return [`/api/v2/metric_reduction_rules/${id}`] as const;
};
export const getGetMetricReductionRuleByIDQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetMetricReductionRuleByIDPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricReductionRuleByIDQueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricReductionRuleByID>>
> = ({ signal }) => getMetricReductionRuleByID({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricReductionRuleByIDQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricReductionRuleByID>>
>;
export type GetMetricReductionRuleByIDQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get a metric reduction rule by id
*/
export function useGetMetricReductionRuleByID<
TData = Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetMetricReductionRuleByIDPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricReductionRuleByIDQueryOptions(
{ id },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get a metric reduction rule by id
*/
export const invalidateGetMetricReductionRuleByID = async (
queryClient: QueryClient,
{ id }: GetMetricReductionRuleByIDPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricReductionRuleByIDQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* Updates the match type and labels of a volume-control rule by its id; the metric name is immutable.
* @summary Update a metric reduction rule by id
*/
export const updateMetricReductionRuleByID = (
{ id }: UpdateMetricReductionRuleByIDPathParameters,
metricreductionruletypesUpdatableReductionRuleDTO?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateMetricReductionRuleByID200>({
url: `/api/v2/metric_reduction_rules/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: metricreductionruletypesUpdatableReductionRuleDTO,
signal,
});
};
export const getUpdateMetricReductionRuleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
TError,
{
pathParams: UpdateMetricReductionRuleByIDPathParameters;
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
TError,
{
pathParams: UpdateMetricReductionRuleByIDPathParameters;
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
},
TContext
> => {
const mutationKey = ['updateMetricReductionRuleByID'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
{
pathParams: UpdateMetricReductionRuleByIDPathParameters;
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateMetricReductionRuleByID(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateMetricReductionRuleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>
>;
export type UpdateMetricReductionRuleByIDMutationBody =
| BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>
| undefined;
export type UpdateMetricReductionRuleByIDMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update a metric reduction rule by id
*/
export const useUpdateMetricReductionRuleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
TError,
{
pathParams: UpdateMetricReductionRuleByIDPathParameters;
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
TError,
{
pathParams: UpdateMetricReductionRuleByIDPathParameters;
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
},
TContext
> => {
return useMutation(getUpdateMetricReductionRuleByIDMutationOptions(options));
};
/**
* Estimates the series reduction and related-asset impact of a candidate volume-control rule without persisting it.
* @summary Preview a metric reduction rule
*/
export const previewMetricReductionRule = (
metricreductionruletypesPostableReductionRulePreviewDTO?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PreviewMetricReductionRule200>({
url: `/api/v2/metric_reduction_rules/preview`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: metricreductionruletypesPostableReductionRulePreviewDTO,
signal,
});
};
export const getPreviewMetricReductionRuleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof previewMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof previewMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
TContext
> => {
const mutationKey = ['previewMetricReductionRule'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof previewMetricReductionRule>>,
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> }
> = (props) => {
const { data } = props ?? {};
return previewMetricReductionRule(data);
};
return { mutationFn, ...mutationOptions };
};
export type PreviewMetricReductionRuleMutationResult = NonNullable<
Awaited<ReturnType<typeof previewMetricReductionRule>>
>;
export type PreviewMetricReductionRuleMutationBody =
| BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO>
| undefined;
export type PreviewMetricReductionRuleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Preview a metric reduction rule
*/
export const usePreviewMetricReductionRule = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof previewMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof previewMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
TContext
> => {
return useMutation(getPreviewMetricReductionRuleMutationOptions(options));
};
/**
* Returns total ingested vs retained series and the estimated monthly savings across all volume-control rules.
* @summary Metric reduction stats
*/
export const getMetricReductionRuleStats = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetMetricReductionRuleStats200>({
url: `/api/v2/metric_reduction_rules/stats`,
method: 'GET',
signal,
});
};
export const getGetMetricReductionRuleStatsQueryKey = () => {
return [`/api/v2/metric_reduction_rules/stats`] as const;
};
export const getGetMetricReductionRuleStatsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricReductionRuleStatsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricReductionRuleStats>>
> = ({ signal }) => getMetricReductionRuleStats(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricReductionRuleStatsQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricReductionRuleStats>>
>;
export type GetMetricReductionRuleStatsQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Metric reduction stats
*/
export function useGetMetricReductionRuleStats<
TData = Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricReductionRuleStatsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Metric reduction stats
*/
export const invalidateGetMetricReductionRuleStats = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricReductionRuleStatsQueryKey() },
options,
);
return queryClient;
};
/**
* Returns ingested vs retained series over time across all volume-control rules (hourly buckets), in the query-range time-series response shape.
* @summary Metric reduction volume over time
*/
export const getMetricReductionRuleTimeseries = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetMetricReductionRuleTimeseries200>({
url: `/api/v2/metric_reduction_rules/timeseries`,
method: 'GET',
signal,
});
};
export const getGetMetricReductionRuleTimeseriesQueryKey = () => {
return [`/api/v2/metric_reduction_rules/timeseries`] as const;
};
export const getGetMetricReductionRuleTimeseriesQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricReductionRuleTimeseriesQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>
> = ({ signal }) => getMetricReductionRuleTimeseries(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricReductionRuleTimeseriesQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>
>;
export type GetMetricReductionRuleTimeseriesQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Metric reduction volume over time
*/
export function useGetMetricReductionRuleTimeseries<
TData = Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricReductionRuleTimeseriesQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Metric reduction volume over time
*/
export const invalidateGetMetricReductionRuleTimeseries = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricReductionRuleTimeseriesQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoint returns a list of distinct metric names within the specified time range
* @summary List metric names
@@ -1045,3 +1789,100 @@ export const useGetMetricsTreemap = <
> => {
return useMutation(getGetMetricsTreemapMutationOptions(options));
};
/**
* This endpoint returns associated v2 dashboards for a specified metric
* @summary Get metric dashboards (v2)
*/
export const getMetricDashboardsV2 = (
params: GetMetricDashboardsV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricDashboardsV2200>({
url: `/api/v3/metrics/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricDashboardsV2QueryKey = (
params?: GetMetricDashboardsV2Params,
) => {
return [`/api/v3/metrics/dashboards`, ...(params ? [params] : [])] as const;
};
export const getGetMetricDashboardsV2QueryOptions = <
TData = Awaited<ReturnType<typeof getMetricDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetMetricDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricDashboardsV2QueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricDashboardsV2>>
> = ({ signal }) => getMetricDashboardsV2(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricDashboardsV2QueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricDashboardsV2>>
>;
export type GetMetricDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get metric dashboards (v2)
*/
export function useGetMetricDashboardsV2<
TData = Awaited<ReturnType<typeof getMetricDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetMetricDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricDashboardsV2QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get metric dashboards (v2)
*/
export const invalidateGetMetricDashboardsV2 = async (
queryClient: QueryClient,
params: GetMetricDashboardsV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricDashboardsV2QueryKey(params) },
options,
);
return queryClient;
};

File diff suppressed because it is too large Load Diff

View File

@@ -30,10 +30,8 @@ import type {
RenderErrorResponseDTO,
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesPostableSpanMapperTestDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
TestSpanMappers200,
UpdateSpanMapperGroupPathParameters,
UpdateSpanMapperPathParameters,
} from '../sigNoz.schemas';
@@ -782,86 +780,3 @@ export const useUpdateSpanMapper = <
> => {
return useMutation(getUpdateSpanMapperMutationOptions(options));
};
/**
* Tests how span mappers would transform sample spans
* @summary Test span mappers against sample spans
*/
export const testSpanMappers = (
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<TestSpanMappers200>({
url: `/api/v1/span_mapper_groups/test`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableSpanMapperTestDTO,
signal,
});
};
export const getTestSpanMappersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
const mutationKey = ['testSpanMappers'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof testSpanMappers>>,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
> = (props) => {
const { data } = props ?? {};
return testSpanMappers(data);
};
return { mutationFn, ...mutationOptions };
};
export type TestSpanMappersMutationResult = NonNullable<
Awaited<ReturnType<typeof testSpanMappers>>
>;
export type TestSpanMappersMutationBody =
| BodyType<SpantypesPostableSpanMapperTestDTO>
| undefined;
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Test span mappers against sample spans
*/
export const useTestSpanMappers = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
return useMutation(getTestSpanMappersMutationOptions(options));
};

View File

@@ -1,35 +0,0 @@
import { ApiV2Instance as axios } from 'api';
import { omit } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV2PayloadProps,
GetTraceV2SuccessResponse,
} from 'types/api/trace/getTraceV2';
const getTraceV2 = async (
props: GetTraceV2PayloadProps,
): Promise<SuccessResponse<GetTraceV2SuccessResponse> | ErrorResponse> => {
let uncollapsedSpans = [...props.uncollapsedSpans];
if (!props.isSelectedSpanIDUnCollapsed) {
uncollapsedSpans = uncollapsedSpans.filter(
(node) => node !== props.selectedSpanId,
);
}
const postData: GetTraceV2PayloadProps = {
...props,
uncollapsedSpans,
};
const response = await axios.post<GetTraceV2SuccessResponse>(
`/traces/waterfall/${props.traceId}`,
omit(postData, 'traceId'),
);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
};
export default getTraceV2;

View File

@@ -41,6 +41,7 @@ const getTraceV4 = async (
> & { spans: WireSpan[] | null };
// Derive 'service.name' from resource for convenience — only derived field
// todo(tech-debt): to remove use of this and to directly use service.name from resources.
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
...span,
'service.name': span.resource?.['service.name'] || '',

View File

@@ -1,25 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { useIsDarkMode } from 'hooks/useDarkMode';
function FlamegraphImg(): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 3c1 3 2.5 3.5 3.5 4.5A5 5 0 0113 11a5 5 0 11-10 0c0-.3 0-.6.1-.9a2 2 0 103.3-2C4 5.5 7 3 8 3zM21 4h-8M20 14.5h-3M20 9.5h-3M21 20H4"
stroke={isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default FlamegraphImg;

View File

@@ -21,6 +21,8 @@ interface ErrorInPlaceProps {
width?: string | number;
/** Custom content instead of ErrorContent */
children?: ReactNode;
/** Test ID for testing */
'data-testid'?: string;
}
/**
@@ -44,6 +46,7 @@ function ErrorInPlace({
height = '100%',
width = '100%',
children,
'data-testid': dataTestId,
}: ErrorInPlaceProps): JSX.Element {
const containerStyle: React.CSSProperties = {
display: 'flex',
@@ -59,7 +62,11 @@ function ErrorInPlace({
};
return (
<div className={`error-in-place ${className}`.trim()} style={containerStyle}>
<div
className={`error-in-place ${className}`.trim()}
style={containerStyle}
data-testid={dataTestId}
>
{children || <ErrorContent error={error} />}
</div>
);

View File

@@ -0,0 +1,163 @@
.inviteMembers {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: 100%;
}
.table {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
}
.header {
display: flex;
gap: var(--spacing-8);
align-items: center;
width: 100%;
}
.headerCellEmail {
flex: 1 1 0;
min-width: 0;
}
.headerCellRole {
flex: 0 0 160px;
width: 160px;
}
.headerCellAction {
flex: 0 0 32px;
width: 32px;
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
width: 100%;
}
.row {
display: flex;
gap: var(--spacing-8);
align-items: flex-start;
width: 100%;
}
.cellEmail {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
flex: 1 1 0;
min-width: 0;
--input-background: var(
--invite-members-field-background,
var(--l2-background)
);
--input-hover-background: var(
--invite-members-field-background,
var(--l2-background)
);
--input-focus-background: var(
--invite-members-field-background,
var(--l2-background)
);
--input-disabled-background: var(
--invite-members-field-background,
var(--l2-background)
);
input::placeholder {
color: var(--l3-foreground);
}
}
.cellRole {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
flex: 0 0 160px;
width: 160px;
:global(.roles-single-select) {
width: 100%;
:global(.ant-select-selector) {
background-color: var(
--invite-members-field-background,
var(--l2-background)
) !important;
}
}
}
.cellAction {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
flex: 0 0 32px;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
}
.errorText {
color: var(--danger-background);
}
.addRow {
display: flex;
justify-content: flex-start;
margin-top: var(--spacing-2);
}
.callout {
animation: shake 300ms ease-out;
&[data-type='success'] {
animation: none;
}
}
.results {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.resultsList {
margin: var(--spacing-2) 0 0 var(--spacing-8);
padding: 0;
li {
margin-bottom: var(--spacing-1);
}
}
@keyframes shake {
0% {
transform: translateX(0);
}
25% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}

View File

@@ -0,0 +1,221 @@
import { CircleAlert, Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import RolesSelect from 'components/RolesSelect/RolesSelect';
import styles from './InviteMembers.module.scss';
import { InviteMembersProps } from './types';
import { useInviteMembers } from './useInviteMembers';
function InviteMembers({
className,
initialRowCount = 3,
minRows = 1,
emailPlaceholder = 'e.g. john@signoz.io',
showHeader = true,
showAddButton = true,
onSuccess,
onPartialSuccess,
onAllFailed,
renderFooter,
}: InviteMembersProps): JSX.Element {
const {
rows,
emailValidity,
hasInvalidEmails,
hasInvalidRoles,
isSubmitting,
inviteResults,
addRow,
removeRow,
updateEmail,
updateRole,
reset,
submit,
touchedRows,
failedResults,
successResults,
} = useInviteMembers({
initialRowCount,
onSuccess,
onPartialSuccess,
onAllFailed,
});
const canSubmit = !isSubmitting && touchedRows.length > 0;
const canRemoveRow = rows.length > minRows;
const getValidationErrorMessage = (): string => {
if (hasInvalidEmails && hasInvalidRoles) {
return 'Please enter valid emails and select roles for team members';
}
if (hasInvalidEmails) {
return 'Please enter valid emails for team members';
}
return 'Please select roles for team members';
};
const hasValidationErrors = hasInvalidEmails || hasInvalidRoles;
const hasResults = inviteResults !== null;
const hasFailures = failedResults.length > 0;
const hasSuccesses = successResults.length > 0;
return (
<div className={cx(styles.inviteMembers, className)}>
<div className={styles.table}>
{showHeader && (
<div className={styles.header}>
<Typography.Text
size="base"
weight="semibold"
className={styles.headerCellEmail}
>
Email address
</Typography.Text>
<Typography.Text
size="base"
weight="semibold"
className={styles.headerCellRole}
>
Role
</Typography.Text>
<div className={styles.headerCellAction} />
</div>
)}
<div className={styles.rows}>
{rows.map((row) => (
<div key={row.id} className={styles.row}>
<div className={styles.cellEmail}>
<Input
type="email"
placeholder={emailPlaceholder}
value={row.email}
onChange={(e): void => updateEmail(row.id, e.target.value)}
name={`invite-email-${row.id}`}
autoComplete="email"
data-testid={`invite-email-${row.id}`}
/>
{emailValidity[row.id] === false && row.email.trim() !== '' && (
<Typography.Text size="small" className={styles.errorText}>
Invalid email address
</Typography.Text>
)}
</div>
<div className={styles.cellRole}>
<RolesSelect
mode="single"
value={row.roleId || undefined}
onChange={(roleId): void => updateRole(row.id, roleId)}
placeholder="Select role"
allowClear={false}
id={`invite-role-${row.id}`}
/>
</div>
<div className={styles.cellAction}>
{canRemoveRow && (
<Button
variant="ghost"
color="destructive"
onClick={(): void => removeRow(row.id)}
aria-label="Remove row"
data-testid={`invite-remove-${row.id}`}
>
<Trash2 size={12} />
</Button>
)}
</div>
</div>
))}
</div>
{showAddButton && (
<div className={styles.addRow}>
<Button
variant="dashed"
color="secondary"
prefix={<Plus size={12} />}
onClick={addRow}
data-testid="invite-add-row"
>
Add another
</Button>
</div>
)}
</div>
{hasValidationErrors && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className={styles.callout}
data-testid="invite-validation-error"
>
{getValidationErrorMessage()}
</Callout>
)}
{hasResults && hasFailures && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className={styles.callout}
data-testid="invite-api-error"
>
<div className={styles.results}>
{hasSuccesses && (
<Typography.Text size="small">
{successResults.length} invite(s) sent successfully.
</Typography.Text>
)}
<Typography.Text size="small">
{failedResults.length} invite(s) failed:
</Typography.Text>
<ul className={styles.resultsList}>
{failedResults.map((result) => (
<li key={result.email}>
<Typography.Text size="small">
{result.email}: {result.error}
</Typography.Text>
</li>
))}
</ul>
</div>
</Callout>
)}
{hasResults && !hasFailures && hasSuccesses && (
<Callout
type="success"
size="small"
showIcon
className={styles.callout}
data-testid="invite-success"
>
<Typography.Text size="small">
{successResults.length} invite(s) sent successfully!
</Typography.Text>
</Callout>
)}
{renderFooter?.({
submit,
reset,
canSubmit,
isSubmitting,
touchedCount: touchedRows.length,
})}
</div>
);
}
export default InviteMembers;

View File

@@ -0,0 +1,240 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import {
CREATE_USER_ENDPOINT,
createErrorHandler,
createRolesHandler,
createSuccessHandler,
VALID_EMAIL,
} from './testUtils';
describe('InviteMembers - Edge Cases', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
describe('reset behavior', () => {
it('clears all rows when reset is called', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
initialRowCount={2}
renderFooter={({ reset }): JSX.Element => (
<button data-testid="reset-btn" onClick={reset}>
Reset
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getByTestId('reset-btn'));
const resetInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(resetInputs).toHaveLength(2);
resetInputs.forEach((input) => {
expect(input).toHaveValue('');
});
});
it('clears results on reset', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit, reset }): JSX.Element => (
<>
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
<button data-testid="reset-btn" onClick={reset}>
Reset
</button>
</>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-success'),
).resolves.toBeInTheDocument();
await user.click(screen.getByTestId('reset-btn'));
expect(screen.queryByTestId('invite-success')).not.toBeInTheDocument();
});
});
describe('results cleared on edit', () => {
it('clears API error when email is edited', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
await user.type(emailInputs[0], 'x');
await waitFor(() => {
expect(screen.queryByTestId('invite-api-error')).not.toBeInTheDocument();
});
});
it('clears API error when role is changed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
const viewerElements = screen.getAllByText('Viewer');
await user.click(viewerElements[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await waitFor(() => {
expect(screen.queryByTestId('invite-api-error')).not.toBeInTheDocument();
});
});
});
describe('empty submission', () => {
it('does not submit when no rows are touched', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSuccess = jest.fn();
render(
<InviteMembers
onSuccess={onSuccess}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
await user.click(screen.getByTestId('submit-btn'));
expect(onSuccess).not.toHaveBeenCalled();
});
});
describe('submitting state', () => {
it('disables submit while submitting', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit, isSubmitting }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit} disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
const submitBtn = screen.getByTestId('submit-btn');
await user.click(submitBtn);
await waitFor(() => {
expect(screen.queryByText('Submitting...')).not.toBeInTheDocument();
});
});
});
describe('whitespace handling', () => {
it('trims email whitespace before submission', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const calls: { email: string }[] = [];
server.use(
rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
const body = await req.json();
calls.push(body);
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
}),
);
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], ' alice@signoz.io ');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(calls).toHaveLength(1);
expect(calls[0].email).toBe('alice@signoz.io');
});
});
});
});

View File

@@ -0,0 +1,95 @@
import { server } from 'mocks-server/server';
import { render, screen } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import { createRolesHandler, createSuccessHandler } from './testUtils';
describe('InviteMembers - Rendering', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
it('renders default initial row count of 3', () => {
render(<InviteMembers />);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(emailInputs).toHaveLength(3);
});
it('renders custom initial row count', () => {
render(<InviteMembers initialRowCount={5} />);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(emailInputs).toHaveLength(5);
});
it('renders header by default', () => {
render(<InviteMembers />);
expect(screen.getByText('Email address')).toBeInTheDocument();
expect(screen.getByText('Role')).toBeInTheDocument();
});
it('hides header when showHeader is false', () => {
render(<InviteMembers showHeader={false} />);
expect(screen.queryByText('Email address')).not.toBeInTheDocument();
expect(screen.queryByText('Role')).not.toBeInTheDocument();
});
it('renders add button by default', () => {
render(<InviteMembers />);
expect(
screen.getByRole('button', { name: /add another/i }),
).toBeInTheDocument();
});
it('hides add button when showAddButton is false', () => {
render(<InviteMembers showAddButton={false} />);
expect(
screen.queryByRole('button', { name: /add another/i }),
).not.toBeInTheDocument();
});
it('renders custom email placeholder', () => {
render(<InviteMembers emailPlaceholder="custom@placeholder.com" />);
const emailInputs = screen.getAllByPlaceholderText('custom@placeholder.com');
expect(emailInputs).toHaveLength(3);
});
it('applies custom className', () => {
const { container } = render(<InviteMembers className="custom-class" />);
expect(container.querySelector('.custom-class')).toBeInTheDocument();
});
it('renders footer via renderFooter prop', () => {
render(
<InviteMembers
renderFooter={({ canSubmit }): JSX.Element => (
<button data-testid="custom-footer" disabled={!canSubmit}>
Custom Submit
</button>
)}
/>,
);
expect(screen.getByTestId('custom-footer')).toBeInTheDocument();
expect(screen.getByTestId('custom-footer')).toBeDisabled();
});
it('renders role select for each row', () => {
render(<InviteMembers initialRowCount={2} />);
const roleSelects = screen.getAllByText('Select role');
expect(roleSelects).toHaveLength(2);
});
});

View File

@@ -0,0 +1,95 @@
import { server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import { createRolesHandler, createSuccessHandler } from './testUtils';
describe('InviteMembers - Row Management', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
it('adds a row when "Add another" is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={2} />);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
await user.click(screen.getByRole('button', { name: /add another/i }));
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(3);
});
it('removes a row when trash button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={3} />);
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
expect(removeButtons).toHaveLength(3);
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
});
it('respects minRows constraint when removing rows', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={2} minRows={2} />);
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
0,
);
await user.click(screen.getByRole('button', { name: /add another/i }));
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
expect(removeButtons).toHaveLength(3);
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
0,
);
});
it('cannot remove rows below minRows=1 default', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={2} />);
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(1);
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
0,
);
});
it('preserves data in other rows when removing one', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={3} />);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'first@signoz.io');
await user.type(emailInputs[2], 'third@signoz.io');
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
await user.click(removeButtons[1]);
const remainingInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(remainingInputs).toHaveLength(2);
expect(remainingInputs[0]).toHaveValue('first@signoz.io');
expect(remainingInputs[1]).toHaveValue('third@signoz.io');
});
});

View File

@@ -0,0 +1,362 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import {
CREATE_USER_ENDPOINT,
createErrorHandler,
createRolesHandler,
createSuccessHandler,
createTrackingHandler,
VALID_EMAIL,
} from './testUtils';
describe('InviteMembers - Submission', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
describe('API calls', () => {
it('calls createUser API for each touched row', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { handler, calls } = createTrackingHandler();
server.use(handler);
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
email: 'alice@signoz.io',
userRoles: [{ id: 'role-viewer' }],
});
});
});
it('calls createUser API for multiple touched rows', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { handler, calls } = createTrackingHandler();
server.use(handler);
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.type(emailInputs[2], 'charlie@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
const adminOptions = await screen.findAllByText('Admin');
await user.click(adminOptions[adminOptions.length - 1]);
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(calls).toHaveLength(3);
});
expect(calls[0]).toMatchObject({
email: 'alice@signoz.io',
userRoles: [{ id: 'role-viewer' }],
});
expect(calls[1]).toMatchObject({
email: 'bob@signoz.io',
userRoles: [{ id: 'role-editor' }],
});
expect(calls[2]).toMatchObject({
email: 'charlie@signoz.io',
userRoles: [{ id: 'role-admin' }],
});
});
});
describe('callbacks', () => {
it('calls onSuccess when all invites succeed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSuccess = jest.fn();
render(
<InviteMembers
onSuccess={onSuccess}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(onSuccess).toHaveBeenCalled();
});
});
it('calls onAllFailed when all invites fail', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onAllFailed = jest.fn();
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
onAllFailed={onAllFailed}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(onAllFailed).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
email: VALID_EMAIL,
success: false,
}),
]),
);
});
});
it('calls onPartialSuccess when some invites succeed and some fail', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onPartialSuccess = jest.fn();
const onSuccess = jest.fn();
const onAllFailed = jest.fn();
const apiCalls: string[] = [];
let callCount = 0;
server.use(
createRolesHandler(),
rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
const body = await req.json();
apiCalls.push(body.email);
callCount++;
if (callCount === 1) {
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
}
return res(
ctx.status(409),
ctx.json({
error: {
code: 'already_exists',
message: 'User already exists',
},
}),
);
}),
);
render(
<InviteMembers
initialRowCount={2}
onSuccess={onSuccess}
onPartialSuccess={onPartialSuccess}
onAllFailed={onAllFailed}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(apiCalls).toHaveLength(2);
});
expect(apiCalls).toStrictEqual(['alice@signoz.io', 'bob@signoz.io']);
expect(onSuccess).not.toHaveBeenCalled();
expect(onAllFailed).not.toHaveBeenCalled();
expect(onPartialSuccess).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ email: 'alice@signoz.io', success: true }),
expect.objectContaining({
email: 'bob@signoz.io',
success: false,
error: 'User already exists',
}),
]),
);
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
expect(
screen.getByText('1 invite(s) sent successfully.'),
).toBeInTheDocument();
expect(screen.getByText('1 invite(s) failed:')).toBeInTheDocument();
expect(
screen.getByText('bob@signoz.io: User already exists'),
).toBeInTheDocument();
});
});
describe('result display', () => {
it('shows success callout when all invites succeed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-success'),
).resolves.toBeInTheDocument();
});
it('shows error callout with failed emails when API fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
expect(screen.getByText(/1 invite\(s\) failed/)).toBeInTheDocument();
});
});
describe('footer props', () => {
it('provides correct canSubmit state', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ canSubmit }): JSX.Element => (
<button data-testid="submit-btn" disabled={!canSubmit}>
Submit
</button>
)}
/>,
);
expect(screen.getByTestId('submit-btn')).toBeDisabled();
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
expect(screen.getByTestId('submit-btn')).not.toBeDisabled();
});
it('provides touchedCount', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ touchedCount }): JSX.Element => (
<span data-testid="touched-count">{touchedCount}</span>
)}
/>,
);
expect(screen.getByTestId('touched-count')).toHaveTextContent('0');
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'a@b.com');
expect(screen.getByTestId('touched-count')).toHaveTextContent('1');
await user.type(emailInputs[1], 'c@d.com');
expect(screen.getByTestId('touched-count')).toHaveTextContent('2');
});
});
});

View File

@@ -0,0 +1,217 @@
import { server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import {
createRolesHandler,
createSuccessHandler,
INVALID_EMAIL,
VALID_EMAIL,
} from './testUtils';
describe('InviteMembers - Validation', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
describe('email validation', () => {
it('shows email validation error when email is invalid and role is selected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please enter valid emails for team members'),
).resolves.toBeInTheDocument();
});
it('shows inline error for invalid email field', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Invalid email address'),
).resolves.toBeInTheDocument();
});
it('clears validation error when email is corrected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please enter valid emails for team members'),
).resolves.toBeInTheDocument();
await user.clear(emailInputs[0]);
await user.type(emailInputs[0], VALID_EMAIL);
await waitFor(() => {
expect(
screen.queryByText('Please enter valid emails for team members'),
).not.toBeInTheDocument();
});
});
});
describe('role validation', () => {
it('shows role validation error when role is missing', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please select roles for team members'),
).resolves.toBeInTheDocument();
});
it('clears role validation error when role is selected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please select roles for team members'),
).resolves.toBeInTheDocument();
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await waitFor(() => {
expect(
screen.queryByText('Please select roles for team members'),
).not.toBeInTheDocument();
});
});
});
describe('combined validation', () => {
it('shows combined error when both email and role are invalid', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText(
'Please enter valid emails and select roles for team members',
),
).resolves.toBeInTheDocument();
});
});
describe('touched rows', () => {
it('only validates touched rows (rows with email or role)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
expect(
screen.queryByTestId('invite-validation-error'),
).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,54 @@
import { RestHandler } from 'msw';
import { rest } from 'mocks-server/server';
export const CREATE_USER_ENDPOINT = '*/api/v2/users';
export const LIST_ROLES_ENDPOINT = '*/api/v1/roles';
export const MOCK_ROLES = [
{ id: 'role-admin', name: 'Admin', description: 'Admin role' },
{ id: 'role-editor', name: 'Editor', description: 'Editor role' },
{ id: 'role-viewer', name: 'Viewer', description: 'Viewer role' },
];
export const VALID_EMAIL = 'alice@signoz.io';
export const INVALID_EMAIL = 'not-an-email';
export function createSuccessHandler(): RestHandler {
return rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json({ data: { id: 'user-123' } })),
);
}
export function createErrorHandler(
code: string,
message: string,
status = 409,
): RestHandler {
return rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(status),
ctx.json({
errors: [{ code, msg: message }],
}),
),
);
}
export function createRolesHandler(): RestHandler {
return rest.get(LIST_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: MOCK_ROLES })),
);
}
export function createTrackingHandler(): {
handler: RestHandler;
calls: unknown[];
} {
const calls: unknown[] = [];
const handler = rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
const body = await req.json();
calls.push(body);
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
});
return { handler, calls };
}

View File

@@ -0,0 +1,64 @@
import { ReactNode } from 'react';
export interface InviteMemberRow {
id: string;
email: string;
roleId: string;
}
export interface InviteResult {
email: string;
success: boolean;
error?: string;
}
export interface FooterRenderProps {
submit: () => Promise<InviteResult[]>;
reset: () => void;
canSubmit: boolean;
isSubmitting: boolean;
touchedCount: number;
}
export interface UseInviteMembersOptions {
initialRowCount?: number;
onSuccess?: () => void;
onPartialSuccess?: (results: InviteResult[]) => void;
onAllFailed?: (results: InviteResult[]) => void;
}
export interface UseInviteMembersReturn {
rows: InviteMemberRow[];
emailValidity: Record<string, boolean>;
hasInvalidEmails: boolean;
hasInvalidRoles: boolean;
isSubmitting: boolean;
inviteResults: InviteResult[] | null;
addRow: () => void;
removeRow: (id: string) => void;
updateEmail: (id: string, email: string) => void;
updateRole: (id: string, roleId: string | undefined) => void;
reset: () => void;
submit: () => Promise<InviteResult[]>;
touchedRows: InviteMemberRow[];
failedResults: InviteResult[];
successResults: InviteResult[];
canSubmit: boolean;
}
export interface InviteMembersProps {
className?: string;
initialRowCount?: number;
minRows?: number;
emailPlaceholder?: string;
showHeader?: boolean;
showAddButton?: boolean;
onSuccess?: () => void;
onPartialSuccess?: (results: InviteResult[]) => void;
onAllFailed?: (results: InviteResult[]) => void;
renderFooter?: (props: FooterRenderProps) => ReactNode;
}

View File

@@ -0,0 +1,245 @@
import { useCallback, useMemo, useState } from 'react';
import { AxiosError } from 'axios';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { createUser } from 'api/generated/services/users';
import { cloneDeep, debounce } from 'lodash-es';
import { EMAIL_REGEX } from 'utils/app';
import { getBaseUrl } from 'utils/basePath';
import { v4 as uuid } from 'uuid';
import {
InviteMemberRow,
InviteResult,
UseInviteMembersOptions,
UseInviteMembersReturn,
} from './types';
const createEmptyRow = (): InviteMemberRow => ({
id: uuid(),
email: '',
roleId: '',
});
const isRowTouched = (row: InviteMemberRow): boolean =>
row.email.trim() !== '' || row.roleId !== '';
export function useInviteMembers(
options: UseInviteMembersOptions = {},
): UseInviteMembersReturn {
const {
initialRowCount = 3,
onSuccess,
onPartialSuccess,
onAllFailed,
} = options;
const [rows, setRows] = useState<InviteMemberRow[]>(() =>
Array.from({ length: initialRowCount }, () => createEmptyRow()),
);
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState(false);
const [hasInvalidRoles, setHasInvalidRoles] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [inviteResults, setInviteResults] = useState<InviteResult[] | null>(
null,
);
const touchedRows = useMemo(() => rows.filter(isRowTouched), [rows]);
const failedResults = useMemo(
() => inviteResults?.filter((r) => !r.success) ?? [],
[inviteResults],
);
const successResults = useMemo(
() => inviteResults?.filter((r) => r.success) ?? [],
[inviteResults],
);
const debouncedValidateEmail = useMemo(
() =>
debounce((email: string, rowId: string) => {
const isValid = EMAIL_REGEX.test(email);
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
}, 500),
[],
);
const validateAllRows = useCallback((): boolean => {
let isValid = true;
let hasEmailErrors = false;
let hasRoleErrors = false;
const updatedEmailValidity: Record<string, boolean> = {};
const touched = rows.filter(isRowTouched);
touched.forEach((row) => {
const emailValid = EMAIL_REGEX.test(row.email);
const roleValid = row.roleId !== '';
if (!emailValid || !row.email) {
isValid = false;
hasEmailErrors = true;
}
if (!roleValid) {
isValid = false;
hasRoleErrors = true;
}
updatedEmailValidity[row.id] = emailValid;
});
setEmailValidity(updatedEmailValidity);
setHasInvalidEmails(hasEmailErrors);
setHasInvalidRoles(hasRoleErrors);
return isValid;
}, [rows]);
const addRow = useCallback((): void => {
setRows((prev) => [...prev, createEmptyRow()]);
}, []);
const removeRow = useCallback((id: string): void => {
setRows((prev) => prev.filter((r) => r.id !== id));
setEmailValidity((prev) => {
const updated = { ...prev };
delete updated[id];
return updated;
});
}, []);
const updateEmail = useCallback(
(id: string, email: string): void => {
setRows((prev) => {
const updated = cloneDeep(prev);
const row = updated.find((r) => r.id === id);
if (row) {
row.email = email;
}
return updated;
});
if (hasInvalidEmails) {
setHasInvalidEmails(false);
}
if (emailValidity[id] === false) {
setEmailValidity((prev) => ({ ...prev, [id]: true }));
}
if (inviteResults) {
setInviteResults(null);
}
debouncedValidateEmail(email, id);
},
[hasInvalidEmails, emailValidity, inviteResults, debouncedValidateEmail],
);
const updateRole = useCallback(
(id: string, roleId: string | undefined): void => {
setRows((prev) => {
const updated = cloneDeep(prev);
const row = updated.find((r) => r.id === id);
if (row) {
row.roleId = roleId ?? '';
}
return updated;
});
if (hasInvalidRoles) {
setHasInvalidRoles(false);
}
if (inviteResults) {
setInviteResults(null);
}
},
[hasInvalidRoles, inviteResults],
);
const reset = useCallback((): void => {
setRows(Array.from({ length: initialRowCount }, () => createEmptyRow()));
setEmailValidity({});
setHasInvalidEmails(false);
setHasInvalidRoles(false);
setInviteResults(null);
}, [initialRowCount]);
const submit = useCallback(async (): Promise<InviteResult[]> => {
if (!validateAllRows()) {
return [];
}
const touched = rows.filter(isRowTouched);
if (touched.length === 0) {
return [];
}
setIsSubmitting(true);
setInviteResults(null);
const results: InviteResult[] = [];
for (const row of touched) {
try {
await createUser({
email: row.email.trim(),
frontendBaseUrl: getBaseUrl(),
userRoles: [{ id: row.roleId }],
});
results.push({ email: row.email, success: true });
} catch (err) {
const apiErr = convertToApiError(err as AxiosError<RenderErrorResponseDTO>);
results.push({
email: row.email,
success: false,
error: apiErr?.getErrorMessage() ?? 'Unknown error',
});
}
}
setInviteResults(results);
setIsSubmitting(false);
const failures = results.filter((r) => !r.success);
const successes = results.filter((r) => r.success);
if (failures.length === 0) {
onSuccess?.();
} else if (successes.length > 0) {
onPartialSuccess?.(results);
} else {
onAllFailed?.(results);
}
return results;
}, [validateAllRows, rows, onSuccess, onPartialSuccess, onAllFailed]);
const canSubmit = useMemo(
() => !isSubmitting && touchedRows.length > 0,
[isSubmitting, touchedRows.length],
);
return {
rows,
emailValidity,
hasInvalidEmails,
hasInvalidRoles,
isSubmitting,
inviteResults,
addRow,
removeRow,
updateEmail,
updateRole,
reset,
submit,
touchedRows,
failedResults,
successResults,
canSubmit,
};
}

View File

@@ -62,13 +62,13 @@ function ErrorTitleAndKey({
switch (parentTitle) {
case 'Consumers':
link = `${ROUTES.GET_STARTED_APPLICATION_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Consumers}`;
link = `${ROUTES.GET_STARTED_WITH_CLOUD}?${QueryParams.getStartedSource}=self-hosted-kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Consumers}`;
break;
case 'Producers':
link = `${ROUTES.GET_STARTED_APPLICATION_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Producers}`;
link = `${ROUTES.GET_STARTED_WITH_CLOUD}?${QueryParams.getStartedSource}=self-hosted-kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Producers}`;
break;
case 'Kafka':
link = `${ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Kafka}`;
link = `${ROUTES.GET_STARTED_WITH_CLOUD}?${QueryParams.getStartedSource}=self-hosted-kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Kafka}`;
break;
default:
link = '';

View File

@@ -5,13 +5,9 @@ describe('PermissionDeniedFullPage', () => {
it('renders the title and subtitle with the permissionName interpolated', () => {
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
expect(
screen.getByText("Uh-oh! You don't have permission to view this page."),
).toBeInTheDocument();
expect(screen.getByText('Uh-oh! You are not authorized')).toBeInTheDocument();
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
expect(
screen.getByText(/Please ask your SigNoz administrator to grant access/),
).toBeInTheDocument();
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
});
it('renders with a different permissionName', () => {

View File

@@ -2,6 +2,7 @@ import { CircleSlash2 } from '@signozhq/icons';
import styles from './PermissionDeniedFullPage.module.scss';
import { Style } from '@signozhq/design-tokens';
import { useAppContext } from 'providers/App/App';
interface PermissionDeniedFullPageProps {
permissionName: string;
@@ -10,18 +11,18 @@ interface PermissionDeniedFullPageProps {
function PermissionDeniedFullPage({
permissionName,
}: PermissionDeniedFullPageProps): JSX.Element {
const { user } = useAppContext();
return (
<div className={styles.container}>
<div className={styles.content}>
<span className={styles.icon}>
<CircleSlash2 color={Style.CALLOUT_WARNING_TITLE} size={14} />
</span>
<p className={styles.title}>
Uh-oh! You don&apos;t have permission to view this page.
</p>
<p className={styles.title}>Uh-oh! You are not authorized</p>
<p className={styles.subtitle}>
You need <code className={styles.permission}>{permissionName}</code> to
view this page. Please ask your SigNoz administrator to grant access.
<code className={styles.permission}>user/{user.id}</code> is not authorized
to perform <code className={styles.permission}>{permissionName}</code>
</p>
</div>
</div>

View File

@@ -1,7 +1,9 @@
import { useCallback } from 'react';
import { LockKeyhole } from '@signozhq/icons';
import { useCallback, useEffect, useState } from 'react';
import { Check, Copy, LockKeyhole } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { useCopyToClipboard } from 'react-use';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import RolesSelect from 'components/RolesSelect';
@@ -46,6 +48,23 @@ function OverviewTab({
saveErrors = [],
}: OverviewTabProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const [, copyToClipboard] = useCopyToClipboard();
const [hasCopiedId, setHasCopiedId] = useState(false);
const handleCopyId = useCallback((): void => {
if (account.id) {
copyToClipboard(account.id);
setHasCopiedId(true);
}
}, [account.id, copyToClipboard]);
useEffect(() => {
if (hasCopiedId) {
const timer = setTimeout(() => setHasCopiedId(false), 2000);
return (): void => clearTimeout(timer);
}
return undefined;
}, [hasCopiedId]);
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
@@ -93,6 +112,17 @@ function OverviewTab({
</label>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{account.id || '—'}</span>
{account.id && (
<Button
variant="link"
color="secondary"
onClick={handleCopyId}
className="sa-drawer__copy-btn"
data-testid="copy-id-btn"
>
{hasCopiedId ? <Check size={14} /> : <Copy size={14} />}
</Button>
)}
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</div>

View File

@@ -203,6 +203,19 @@
opacity: 0.6;
}
&__copy-btn {
flex-shrink: 0;
padding: 0;
height: auto;
min-height: auto;
color: var(--foreground);
opacity: 0.6;
&:hover {
opacity: 1;
}
}
&__disabled-roles {
display: flex;
flex-wrap: wrap;

View File

@@ -16,7 +16,6 @@ import {
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { GuardAuthZ } from 'components/GuardAuthZ/GuardAuthZ';
import PermissionDeniedCallout from 'components/PermissionDeniedCallout/PermissionDeniedCallout';
import { useRoles } from 'components/RolesSelect';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
@@ -477,15 +476,9 @@ function ServiceAccountDrawer({
!isAccountLoading &&
!isAccountError &&
selectedAccountId && (
<GuardAuthZ
relation="read"
object={`serviceaccount:${selectedAccountId}`}
fallbackOnNoPermissions={(): JSX.Element => (
<PermissionDeniedCallout permissionName="serviceaccount:read" />
)}
>
<>
{activeTab === ServiceAccountDrawerTab.Overview && account && (
<>
{activeTab === ServiceAccountDrawerTab.Overview &&
(canRead && account ? (
<OverviewTab
account={account}
localName={localName}
@@ -504,23 +497,24 @@ function ServiceAccountDrawer({
onRefetchRoles={refetchRoles}
saveErrors={saveErrors}
/>
)}
{activeTab === ServiceAccountDrawerTab.Keys &&
(canListKeys ? (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
canUpdate={canUpdate}
accountId={selectedAccountId}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
) : (
<PermissionDeniedCallout permissionName="factor-api-key:list" />
))}
</>
</GuardAuthZ>
) : (
<PermissionDeniedCallout permissionName="serviceaccount:read" />
))}
{activeTab === ServiceAccountDrawerTab.Keys &&
(canListKeys ? (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
canUpdate={canUpdate}
accountId={selectedAccountId}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
) : (
<PermissionDeniedCallout permissionName="factor-api-key:list" />
))}
</>
)}
</div>
</div>

View File

@@ -1,106 +0,0 @@
.span-hover-card {
.ant-popover-inner {
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 32%, transparent) 0%,
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
);
padding: 12px 16px;
border: 1px solid var(--l1-border);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 32%, transparent) 0%,
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
);
backdrop-filter: blur(20px);
border-radius: 4px;
z-index: -1;
will-change: background-color, backdrop-filter;
}
}
&__title {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
&__operation {
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.48px;
}
&__service {
font-size: 0.875rem;
color: var(--muted-foreground);
font-weight: 400;
}
&__error {
font-size: 0.75rem;
color: var(--danger-background);
font-weight: 500;
}
&__row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
gap: 16px;
}
&__label {
color: var(--muted-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
&__value {
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
text-align: right;
}
&__relative-time {
display: flex;
align-items: center;
margin-top: 4px;
gap: 8px;
border-radius: 1px 0 0 1px;
background: linear-gradient(
90deg,
hsla(358, 75%, 59%, 0.2) 0%,
transparent 100%
);
&-icon {
width: 2px;
height: 20px;
flex-shrink: 0;
border-radius: 2px;
background: var(--danger-background);
}
}
&__relative-text {
color: var(--bg-cherry-300);
font-size: 12px;
line-height: 20px;
}
}

View File

@@ -1,103 +0,0 @@
import { ReactNode } from 'react';
import { Popover } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
import { useTimezone } from 'providers/Timezone';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
import './SpanHoverCard.styles.scss';
interface ITraceMetadata {
startTime: number;
endTime: number;
}
interface SpanHoverCardProps {
span: Span;
traceMetadata: ITraceMetadata;
children: ReactNode;
}
function SpanHoverCard({
span,
traceMetadata,
children,
}: SpanHoverCardProps): JSX.Element {
const duration = span.durationNano / 1e6; // Convert nanoseconds to milliseconds
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(duration);
const { timezone } = useTimezone();
// Calculate relative start time from trace start
const relativeStartTime = span.timestamp - traceMetadata.startTime;
const { time: relativeTime, timeUnitName: relativeTimeUnit } =
convertTimeToRelevantUnit(relativeStartTime);
// Format absolute start time
const startTimeFormatted = dayjs(span.timestamp)
.tz(timezone.value)
.format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS);
const getContent = (): JSX.Element => (
<div className="span-hover-card">
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Duration:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{toFixed(formattedDuration, 2)}
{timeUnitName}
</Typography.Text>
</div>
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Events:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{span.event?.length || 0}
</Typography.Text>
</div>
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Start time:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{startTimeFormatted}
</Typography.Text>
</div>
<div className="span-hover-card__relative-time">
<div className="span-hover-card__relative-time-icon" />
<Typography.Text className="span-hover-card__relative-text">
{toFixed(relativeTime, 2)}
{relativeTimeUnit} after trace start
</Typography.Text>
</div>
</div>
);
return (
<Popover
title={
<div className="span-hover-card__title">
<Typography.Text className="span-hover-card__operation">
{span.name}
</Typography.Text>
</div>
}
mouseEnterDelay={0.2}
content={getContent()}
trigger="hover"
rootClassName="span-hover-card"
autoAdjustOverflow
arrow={false}
>
{children}
</Popover>
);
}
export default SpanHoverCard;

View File

@@ -1,291 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { TimezoneContextType } from 'providers/Timezone';
import { Span } from 'types/api/trace/getTraceV2';
import SpanHoverCard from '../SpanHoverCard';
// Mock timezone provider so SpanHoverCard can use useTimezone without a real context
jest.mock('providers/Timezone', () => ({
__esModule: true,
useTimezone: (): TimezoneContextType => ({
timezone: {
name: 'Coordinated Universal Time — UTC, GMT',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
},
browserTimezone: {
name: 'Coordinated Universal Time — UTC, GMT',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
},
updateTimezone: jest.fn(),
formatTimezoneAdjustedTimestamp: jest.fn(() => 'mock-date'),
isAdaptationEnabled: true,
setIsAdaptationEnabled: jest.fn(),
}),
}));
// Mock dayjs for testing, including timezone helpers used in timezoneUtils
jest.mock('dayjs', () => {
const mockDayjsInstance: any = {};
mockDayjsInstance.format = jest.fn((formatString: string) =>
// Match the DD_MMM_YYYY_HH_MM_SS format: 'DD MMM YYYY, HH:mm:ss'
formatString === 'DD MMM YYYY, HH:mm:ss'
? '15 Mar 2024, 14:23:45'
: 'mock-date',
);
// Support chaining: dayjs().tz(timezone).format(...) and dayjs().tz(timezone).utcOffset()
mockDayjsInstance.tz = jest.fn(() => mockDayjsInstance);
mockDayjsInstance.utcOffset = jest.fn(() => 0);
const mockDayjs = jest.fn(() => mockDayjsInstance);
Object.assign(mockDayjs, {
extend: jest.fn(),
// Support dayjs.tz.guess()
tz: { guess: jest.fn(() => 'UTC') },
});
return mockDayjs;
});
const HOVER_ELEMENT_ID = 'hover-element';
const mockSpan: Span = {
spanId: 'test-span-id',
traceId: 'test-trace-id',
rootSpanId: 'root-span-id',
parentSpanId: 'parent-span-id',
name: 'GET /api/users',
timestamp: 1679748225000000,
durationNano: 150000000,
serviceName: 'user-service',
kind: 1,
hasError: false,
level: 1,
references: [],
tagMap: {},
event: [
{
name: 'event1',
timeUnixNano: 1679748225100000,
attributeMap: {},
isError: false,
},
{
name: 'event2',
timeUnixNano: 1679748225200000,
attributeMap: {},
isError: false,
},
],
rootName: 'root-span',
statusMessage: '',
statusCodeString: 'OK',
spanKind: 'server',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 1,
};
const mockTraceMetadata = {
startTime: 1679748225000000,
endTime: 1679748226000000,
};
describe('SpanHoverCard', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('renders child element correctly', () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid="child-element">Hover me</div>
</SpanHoverCard>,
);
expect(screen.getByTestId('child-element')).toBeInTheDocument();
expect(screen.getByText('Hover me')).toBeInTheDocument();
});
it('shows popover after 0.2 second delay on hover', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Hover for details</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover over the element
fireEvent.mouseEnter(hoverElement);
// Popover should NOT appear immediately
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
// Advance time by 0.5 seconds
act(() => {
jest.advanceTimersByTime(200);
});
// Now popover should appear
expect(screen.getByText('Duration:')).toBeInTheDocument();
});
it('does not show popover if hover is too brief', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Quick hover test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Quick hover and unhover (less than the 0.2s delay)
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(100); // Only 0.1 seconds
});
fireEvent.mouseLeave(hoverElement);
// Advance past the full delay
act(() => {
jest.advanceTimersByTime(400);
});
// Popover should not appear
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
});
it('displays span information in popover content after delay', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Test span</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Check that popover shows span operation name in title
expect(screen.getByText('GET /api/users')).toBeInTheDocument();
// Check duration information
expect(screen.getByText('Duration:')).toBeInTheDocument();
expect(screen.getByText('150ms')).toBeInTheDocument();
// Check events count
expect(screen.getByText('Events:')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
// Check start time label
expect(screen.getByText('Start time:')).toBeInTheDocument();
});
it('displays date in DD MMM YYYY, HH:mm:ss format with seconds', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Date format test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Verify the DD MMM YYYY, HH:mm:ss format is displayed
expect(screen.getByText('15 Mar 2024, 14:23:45')).toBeInTheDocument();
});
it('displays relative time information', async () => {
const spanWithRelativeTime: Span = {
...mockSpan,
timestamp: mockTraceMetadata.startTime + 1000000, // 1 second later
};
render(
<SpanHoverCard span={spanWithRelativeTime} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Relative time test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Check relative time display
expect(screen.getByText(/after trace start/)).toBeInTheDocument();
});
it('handles spans with no events correctly', async () => {
const spanWithoutEvents: Span = {
...mockSpan,
event: [],
};
render(
<SpanHoverCard span={spanWithoutEvents} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>No events test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByText('Events:')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
});
it('verifies mouseEnterDelay prop is set to 0.5', () => {
const { container } = render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Delay test</div>
</SpanHoverCard>,
);
// The mouseEnterDelay prop should be set on the Popover component
// This test verifies the implementation includes the delay
const popover = container.querySelector('.ant-popover');
expect(popover).not.toBeInTheDocument(); // Initially not visible
// Hover to trigger delay mechanism
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
fireEvent.mouseEnter(hoverElement);
// Should not appear before delay
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
// Should appear after delay
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByText('Duration:')).toBeInTheDocument();
});
});

View File

@@ -7,10 +7,10 @@ export enum FeatureKeys {
GATEWAY = 'gateway',
PREMIUM_SUPPORT = 'premium_support',
ANOMALY_DETECTION = 'anomaly_detection',
ONBOARDING_V3 = 'onboarding_v3',
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
USE_DASHBOARD_V2 = 'use_dashboard_v2',
EMABLE_AI_OBSERVABILITY = 'enable_ai_observability',
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',
}

View File

@@ -11,14 +11,7 @@ const ROUTES = {
TRACE_DETAIL_OLD: '/trace-old/:id',
TRACES_EXPLORER: '/traces-explorer',
ONBOARDING: '/onboarding',
GET_STARTED: '/get-started',
GET_STARTED_WITH_CLOUD: '/get-started-with-signoz-cloud',
GET_STARTED_APPLICATION_MONITORING: '/get-started/application-monitoring',
GET_STARTED_LOGS_MANAGEMENT: '/get-started/logs-management',
GET_STARTED_INFRASTRUCTURE_MONITORING:
'/get-started/infrastructure-monitoring',
GET_STARTED_AWS_MONITORING: '/get-started/aws-monitoring',
GET_STARTED_AZURE_MONITORING: '/get-started/azure-monitoring',
USAGE_EXPLORER: '/usage-explorer',
APPLICATION: '/services',
ALL_DASHBOARD: '/dashboard',
@@ -56,7 +49,9 @@ const ROUTES = {
TRACE_EXPLORER: '/trace-explorer',
BILLING: '/settings/billing',
ROLES_SETTINGS: '/settings/roles',
ROLE_CREATE: '/settings/roles/new',
ROLE_DETAILS: '/settings/roles/:roleId',
ROLE_EDIT: '/settings/roles/:roleId/edit',
MEMBERS_SETTINGS: '/settings/members',
SUPPORT: '/support',
LOGS_SAVE_VIEWS: '/logs/saved-views',
@@ -79,6 +74,7 @@ const ROUTES = {
METRICS_EXPLORER: '/metrics-explorer/summary',
METRICS_EXPLORER_EXPLORER: '/metrics-explorer/explorer',
METRICS_EXPLORER_VIEWS: '/metrics-explorer/views',
METRICS_EXPLORER_VOLUME_CONTROL: '/metrics-explorer/volume-control',
API_MONITORING_BASE: '/api-monitoring',
API_MONITORING: '/api-monitoring/explorer',
METRICS_EXPLORER_BASE: '/metrics-explorer',
@@ -93,6 +89,8 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_BASE: '/llm-observability',
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
} as const;
export default ROUTES;

View File

@@ -48,6 +48,48 @@ describe('getAutoContexts', () => {
]);
});
it('includes the query in alert edit context', () => {
const ruleId = 'rule-edit';
const query = { queryType: 'builder', builder: { queryData: [] } };
const compositeQuery = encodeURIComponent(JSON.stringify(query));
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.compositeQuery}=${compositeQuery}`;
const contexts = getAutoContexts(ROUTES.EDIT_ALERTS, search);
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: {
page: 'alert_edit',
ruleId,
query,
},
},
]);
});
it('includes the query in alert new context (no ruleId)', () => {
const query = { queryType: 'builder', builder: { queryData: [] } };
const compositeQuery = encodeURIComponent(JSON.stringify(query));
const search = `?${QueryParams.compositeQuery}=${compositeQuery}`;
const contexts = getAutoContexts(ROUTES.ALERTS_NEW, search);
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'alert',
resourceId: null,
metadata: {
page: 'alert_new',
query,
},
},
]);
});
it('returns triggered alerts context on alert history without ruleId', () => {
const contexts = getAutoContexts(ROUTES.ALERT_HISTORY, '');

View File

@@ -377,9 +377,63 @@
}
.contextPopoverEmpty {
// Fill the entity panel so the state sits centred in the dead space rather
// than clinging to the top-left corner.
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px 20px;
text-align: center;
font-size: 12px;
color: var(--l3-foreground);
padding: 10px 8px;
}
.contextPopoverEmptyIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
// `--empty-accent` is set per category on the root (robin/cherry/forest).
color: var(--empty-accent);
background: color-mix(in srgb, var(--empty-accent), transparent 88%);
border-radius: var(--radius-2);
}
.contextPopoverEmptyTitle {
margin: 0;
max-width: 280px;
color: var(--l2-foreground);
font-size: 13px;
font-weight: 600;
line-height: 1.4;
// Clamp to 2 lines with an ellipsis so a long query can't blow out the
// popover height. The CTA below is a stock DS link button, so the query is
// kept readable here rather than forcing the button to wrap.
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
}
.contextPopoverEmptyCta {
max-width: 100%;
}
.contextPopoverEmptyCtaLabel {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
text-align: center;
}
.micBtn {

View File

@@ -42,19 +42,22 @@ import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { MessageAttachment } from '../../types';
import { MessageContext } from '../../../../api/ai-assistant/chat';
import {
Bell,
LayoutDashboard,
Mic,
Plus,
Search,
Send,
ShieldCheck,
Square,
TriangleAlert,
X,
} from '@signozhq/icons';
import styles from './ChatInput.module.scss';
import ContextPickerEmptyState from './ContextPickerEmptyState';
import {
CONTEXT_CATEGORIES,
CONTEXT_CATEGORY_ICONS,
ContextCategory,
} from './contextPicker';
interface ChatInputProps {
onSend: (
@@ -162,10 +165,6 @@ const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
/** sessionStorage key for the "voice input failed this tab" flag. */
const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
type ContextCategory = (typeof CONTEXT_CATEGORIES)[number];
interface SelectedContextItem {
category: ContextCategory;
entityId: string;
@@ -205,12 +204,6 @@ interface ContextEntityItem {
value: string;
}
const CONTEXT_CATEGORY_ICONS = {
Dashboards: LayoutDashboard,
Alerts: Bell,
Services: ShieldCheck,
} satisfies Record<ContextCategory, unknown>;
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -331,6 +324,30 @@ export default function ChatInput({
[mentionRange, selectedContexts, text],
);
// Empty-state CTA: drop a starter prompt into the composer (never auto-sent)
// and hand the user the caret at the end so they can finish the sentence.
const handleContextPrefill = useCallback(
(prompt: string) => {
const next = capText(prompt);
setText(next);
committedTextRef.current = next;
setMentionRange(null);
setPickerSearchQuery('');
setIsContextPickerOpen(false);
// Defer so React commits the new value before we place the caret.
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) {
return;
}
el.focus();
const end = el.value.length;
el.setSelectionRange(end, end);
});
},
[capText],
);
const focusCategory = useCallback((category: ContextCategory) => {
setActiveContextCategory(category);
setPickerSearchQuery('');
@@ -824,10 +841,14 @@ export default function ChatInput({
// Type-ahead filter against the `@<query>` typed in the textarea. When
// the picker was opened from the "Add Context" button there's no
// mention query, so fall back to the in-popover search input.
const mentionQuery = mentionRange
? text.slice(mentionRange.start + 1, mentionRange.end).toLowerCase()
const rawMentionQuery = mentionRange
? text.slice(mentionRange.start + 1, mentionRange.end)
: '';
const mentionQuery = rawMentionQuery.toLowerCase();
const activeQuery = mentionQuery || pickerSearchQuery.trim().toLowerCase();
// Original-case query for empty-state copy + prefill ("checkout", not the
// lowercased filter key). Mirrors `activeQuery`'s mention-then-search order.
const displayQuery = rawMentionQuery || pickerSearchQuery.trim();
const filteredContextOptions = activeQuery
? contextEntitiesByCategory[activeContextCategory].filter((entity) =>
entity.value.toLowerCase().includes(activeQuery),
@@ -1071,9 +1092,11 @@ export default function ChatInput({
Failed to load {activeContextCategory.toLowerCase()}.
</div>
) : filteredContextOptions.length === 0 ? (
<div className={styles.contextPopoverEmpty}>
No matching entities
</div>
<ContextPickerEmptyState
category={activeContextCategory}
query={displayQuery}
onPrefill={handleContextPrefill}
/>
) : (
filteredContextOptions.map((option, index) => {
const isSelected = selectedContexts.some(

View File

@@ -0,0 +1,68 @@
import { Sparkles } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import type { CSSProperties } from 'react';
import styles from './ChatInput.module.scss';
import {
CONTEXT_CATEGORY_ICONS,
ContextCategory,
getContextPickerEmptyContent,
} from './contextPicker';
// Per-category accent, mapped to semantic accent tokens (robin is the brand
// primary). Exposed to the SCSS as the `--empty-accent` custom property so the
// icon and CTA share one colour per category.
const CATEGORY_ACCENT: Record<ContextCategory, string> = {
Dashboards: 'var(--accent-primary)',
Alerts: 'var(--accent-cherry)',
Services: 'var(--accent-forest)',
};
interface ContextPickerEmptyStateProps {
category: ContextCategory;
/** The active search query (mention or in-popover search), original case. */
query: string;
/** Drops the starter prompt into the composer (never auto-sends). */
onPrefill: (prompt: string) => void;
}
/**
* Empty state for the @-mention context picker. Distinguishes a brand-new user
* with nothing to pick (onboarding) from a search that matched nothing, and in
* both cases offers a clickable CTA that seeds the composer.
*/
export default function ContextPickerEmptyState({
category,
query,
onPrefill,
}: ContextPickerEmptyStateProps): JSX.Element {
const { title, ctaLabel, prefill } = getContextPickerEmptyContent(
category,
query,
);
const CategoryIcon = CONTEXT_CATEGORY_ICONS[category];
return (
<div
className={styles.contextPopoverEmpty}
style={{ '--empty-accent': CATEGORY_ACCENT[category] } as CSSProperties}
>
<span className={styles.contextPopoverEmptyIcon} aria-hidden="true">
<CategoryIcon size={16} />
</span>
<p className={styles.contextPopoverEmptyTitle}>{title}</p>
<Button
type="button"
variant="link"
size="sm"
color="primary"
className={styles.contextPopoverEmptyCta}
onClick={(): void => onPrefill(prefill)}
data-testid={`ai-context-empty-cta-${category}`}
prefix={<Sparkles size={14} />}
>
<span className={styles.contextPopoverEmptyCtaLabel}>{ctaLabel}</span>
</Button>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
// The prefill flow only depends on the context-picker data hooks resolving to
// empty lists (so the empty state renders) — mock them to skip real fetches.
jest.mock('hooks/dashboard/useGetAllDashboard', () => ({
useGetAllDashboard: (): unknown => ({
data: [],
isLoading: false,
isError: false,
}),
}));
jest.mock('api/generated/services/rules', () => ({
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
getListRulesQueryKey: (): string[] => ['rules'],
}));
jest.mock('hooks/useQueryService', () => ({
useQueryService: (): unknown => ({
data: [],
isLoading: false,
isFetching: false,
isError: false,
}),
}));
// Irrelevant to the prefill flow and otherwise require browser APIs / extra
// context providers, so stub them out.
jest.mock('../../../hooks/useSpeechRecognition', () => ({
useSpeechRecognition: (): unknown => ({
isListening: false,
isSupported: false,
permission: 'prompt',
start: jest.fn(),
discard: jest.fn(),
}),
}));
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
useAIAssistantAnalyticsContext: (): unknown => ({
threadId: undefined,
page: '/',
mode: 'sidepane',
}),
}));
// eslint-disable-next-line import/first
import { TooltipProvider } from '@signozhq/ui/tooltip';
// eslint-disable-next-line import/first
import ChatInput from '../ChatInput';
function renderChatInput(): void {
render(
<TooltipProvider>
<ChatInput onSend={jest.fn()} />
</TooltipProvider>,
);
}
function getComposer(): HTMLTextAreaElement {
return screen.getByPlaceholderText(/Ask anything/i) as HTMLTextAreaElement;
}
describe('ChatInput — empty-state CTA prefill flow', () => {
it('full-replaces existing prose with the query-seeded prompt and closes the picker', async () => {
renderChatInput();
// Pre-existing prose in the composer.
await userEvent.type(getComposer(), 'show me something');
// Open the picker and search for an entity that does not exist.
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
await userEvent.type(
await screen.findByPlaceholderText(/search dashboards/i),
'chk',
);
const cta = await screen.findByTestId('ai-context-empty-cta-Dashboards');
expect(cta).toHaveTextContent('Create a dashboard for "chk"');
await userEvent.click(cta);
// Full-replace is intentional: the prefill is a complete sentence, so the
// prior "show me something" prose is discarded rather than producing
// broken grammar (see handleContextPrefill). The query is seeded in.
expect(getComposer().value).toBe('Create a dashboard for chk');
// Picker closed → the empty-state CTA is gone.
await waitFor(() =>
expect(
screen.queryByTestId('ai-context-empty-cta-Dashboards'),
).not.toBeInTheDocument(),
);
});
it('seeds only the prefix (with trailing space) in the onboarding case', async () => {
renderChatInput();
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
const cta = await screen.findByTestId('ai-context-empty-cta-Dashboards');
expect(cta).toHaveTextContent('Ask me to create one');
await userEvent.click(cta);
expect(getComposer().value).toBe('Create a dashboard for ');
await waitFor(() =>
expect(
screen.queryByTestId('ai-context-empty-cta-Dashboards'),
).not.toBeInTheDocument(),
);
});
});

View File

@@ -0,0 +1,135 @@
import { render, screen, userEvent } from 'tests/test-utils';
import { ContextCategory } from '../contextPicker';
import ContextPickerEmptyState from '../ContextPickerEmptyState';
function renderEmptyState(
category: ContextCategory,
query: string,
onPrefill = jest.fn(),
): { onPrefill: jest.Mock; container: HTMLElement } {
const { container } = render(
<ContextPickerEmptyState
category={category}
query={query}
onPrefill={onPrefill}
/>,
);
return { onPrefill, container };
}
function ctaFor(category: ContextCategory): HTMLElement {
return screen.getByTestId(`ai-context-empty-cta-${category}`);
}
describe('ContextPickerEmptyState', () => {
describe('onboarding (no query)', () => {
it('renders dashboards copy and prefills the prefix only', async () => {
const { onPrefill } = renderEmptyState('Dashboards', '');
expect(screen.getByText('No dashboards yet.')).toBeInTheDocument();
const cta = ctaFor('Dashboards');
expect(cta).toHaveTextContent('Ask me to create one');
await userEvent.click(cta);
expect(onPrefill).toHaveBeenCalledTimes(1);
expect(onPrefill).toHaveBeenCalledWith('Create a dashboard for ');
});
it('renders alerts copy and prefills the prefix only', async () => {
const { onPrefill } = renderEmptyState('Alerts', '');
expect(screen.getByText('No alerts yet.')).toBeInTheDocument();
expect(ctaFor('Alerts')).toHaveTextContent('Ask me to create one');
await userEvent.click(ctaFor('Alerts'));
expect(onPrefill).toHaveBeenCalledWith('Create an alert for ');
});
it('renders instrumentation-flavoured services copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Services', '');
expect(
screen.getByText('No services reporting data yet.'),
).toBeInTheDocument();
expect(ctaFor('Services')).toHaveTextContent(
'Ask me to help set up instrumentation',
);
await userEvent.click(ctaFor('Services'));
expect(onPrefill).toHaveBeenCalledWith(
'Help me set up instrumentation for ',
);
});
it('treats a whitespace-only query as onboarding', () => {
renderEmptyState('Dashboards', ' ');
expect(screen.getByText('No dashboards yet.')).toBeInTheDocument();
});
});
describe('search miss (query, no match)', () => {
it('seeds the query into dashboards copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Dashboards', 'checkout');
expect(
screen.getByText('No dashboards match "checkout".'),
).toBeInTheDocument();
expect(ctaFor('Dashboards')).toHaveTextContent(
'Create a dashboard for "checkout"',
);
await userEvent.click(ctaFor('Dashboards'));
expect(onPrefill).toHaveBeenCalledWith('Create a dashboard for checkout');
});
it('seeds the query into alerts copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Alerts', 'checkout');
expect(screen.getByText('No alerts match "checkout".')).toBeInTheDocument();
await userEvent.click(ctaFor('Alerts'));
expect(onPrefill).toHaveBeenCalledWith('Create an alert for checkout');
});
it('uses instrumentation wording for services search misses', async () => {
const { onPrefill } = renderEmptyState('Services', 'checkout');
expect(
screen.getByText('No services match "checkout".'),
).toBeInTheDocument();
await userEvent.click(ctaFor('Services'));
expect(onPrefill).toHaveBeenCalledWith(
'Help me set up instrumentation for checkout',
);
});
it('preserves the original casing of the query in copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Dashboards', 'Checkout API');
expect(
screen.getByText('No dashboards match "Checkout API".'),
).toBeInTheDocument();
await userEvent.click(ctaFor('Dashboards'));
expect(onPrefill).toHaveBeenCalledWith(
'Create a dashboard for Checkout API',
);
});
});
describe('per-category accent token', () => {
it.each<[ContextCategory, string]>([
['Dashboards', 'var(--accent-primary)'],
['Alerts', 'var(--accent-cherry)'],
['Services', 'var(--accent-forest)'],
])('maps %s to the semantic accent %s', (category, accent) => {
const { container } = renderEmptyState(category, '');
const root = container.firstChild as HTMLElement;
expect(root.style.getPropertyValue('--empty-accent')).toBe(accent);
});
});
it('does not auto-send: nothing fires until the CTA is clicked', () => {
const { onPrefill } = renderEmptyState('Dashboards', 'checkout');
expect(onPrefill).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,100 @@
import {
CONTEXT_CATEGORIES,
getContextPickerEmptyContent,
} from '../contextPicker';
describe('getContextPickerEmptyContent', () => {
describe('onboarding (no query)', () => {
it('returns per-category copy and prefix-only prefill for dashboards', () => {
expect(getContextPickerEmptyContent('Dashboards', '')).toStrictEqual({
title: 'No dashboards yet.',
ctaLabel: 'Ask me to create one',
prefill: 'Create a dashboard for ',
});
});
it('returns per-category copy and prefix-only prefill for alerts', () => {
expect(getContextPickerEmptyContent('Alerts', '')).toStrictEqual({
title: 'No alerts yet.',
ctaLabel: 'Ask me to create one',
prefill: 'Create an alert for ',
});
});
it('returns instrumentation-flavoured copy for services', () => {
expect(getContextPickerEmptyContent('Services', '')).toStrictEqual({
title: 'No services reporting data yet.',
ctaLabel: 'Ask me to help set up instrumentation',
prefill: 'Help me set up instrumentation for ',
});
});
it('treats a whitespace-only query as no query', () => {
expect(getContextPickerEmptyContent('Dashboards', ' ')).toStrictEqual({
title: 'No dashboards yet.',
ctaLabel: 'Ask me to create one',
prefill: 'Create a dashboard for ',
});
});
it('leaves the prefill ending in a space so the caret sits after it', () => {
CONTEXT_CATEGORIES.forEach((category) => {
expect(getContextPickerEmptyContent(category, '').prefill).toMatch(/ $/);
});
});
});
describe('search miss (query, no match)', () => {
it('seeds the query into dashboards copy and prefill', () => {
expect(getContextPickerEmptyContent('Dashboards', 'checkout')).toStrictEqual(
{
title: 'No dashboards match "checkout".',
ctaLabel: 'Create a dashboard for "checkout"',
prefill: 'Create a dashboard for checkout',
},
);
});
it('seeds the query into alerts copy and prefill', () => {
expect(getContextPickerEmptyContent('Alerts', 'checkout')).toStrictEqual({
title: 'No alerts match "checkout".',
ctaLabel: 'Create an alert for "checkout"',
prefill: 'Create an alert for checkout',
});
});
it('uses instrumentation wording for services search misses', () => {
expect(getContextPickerEmptyContent('Services', 'checkout')).toStrictEqual({
title: 'No services match "checkout".',
ctaLabel: 'Set up instrumentation for "checkout"',
prefill: 'Help me set up instrumentation for checkout',
});
});
it('preserves the original casing of the query', () => {
const { title, ctaLabel, prefill } = getContextPickerEmptyContent(
'Dashboards',
'Checkout API',
);
expect(title).toBe('No dashboards match "Checkout API".');
expect(ctaLabel).toBe('Create a dashboard for "Checkout API"');
expect(prefill).toBe('Create a dashboard for Checkout API');
});
it('trims surrounding whitespace from the query', () => {
expect(
getContextPickerEmptyContent('Dashboards', ' checkout ').prefill,
).toBe('Create a dashboard for checkout');
});
});
it('never emits an em-dash (house style)', () => {
CONTEXT_CATEGORIES.forEach((category) => {
const empty = getContextPickerEmptyContent(category, '');
const miss = getContextPickerEmptyContent(category, 'q');
[empty, miss].forEach(({ title, ctaLabel, prefill }) => {
expect(`${title}${ctaLabel}${prefill}`).not.toContain('—');
});
});
});
});

View File

@@ -0,0 +1,103 @@
import { Bell, LayoutDashboard, ShieldCheck } from '@signozhq/icons';
/** Ordered category tabs shown in the @-mention context picker. */
export const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
export type ContextCategory = (typeof CONTEXT_CATEGORIES)[number];
/**
* Icon per category, shared by the picker tablist and the empty state. `satisfies`
* keeps the concrete component types so callers can render `<Icon size={n} />`.
*/
export const CONTEXT_CATEGORY_ICONS = {
Dashboards: LayoutDashboard,
Alerts: Bell,
Services: ShieldCheck,
} satisfies Record<ContextCategory, unknown>;
/**
* Resolved copy + composer prefill for one render of the context picker's empty
* state. The picker is tabbed, so a user only ever views one category at a
* time — each category gets its own onboarding and search-miss copy rather than
* a single combined "nothing to show" line.
*/
export interface ContextPickerEmptyContent {
/** Primary line explaining why the list is empty. */
title: string;
/** Clickable call to action that prefills (never auto-sends) the composer. */
ctaLabel: string;
/**
* Text dropped into the composer when the CTA is clicked. When there's no
* search query this is just the prefix with a trailing space, leaving the
* caret at the end for the user to type the entity name.
*/
prefill: string;
}
interface CategoryCopy {
/** Onboarding line, e.g. "No dashboards yet." */
emptyTitle: string;
/** Onboarding CTA label, e.g. "Ask me to create one". */
emptyCtaLabel: string;
/** Search-miss line, e.g. `No dashboards match "checkout".` */
matchTitle: (query: string) => string;
/** Search-miss CTA label, e.g. `Create a dashboard for "checkout"`. */
matchCtaLabel: (query: string) => string;
/**
* Composer prefill prefix (with trailing space). The prefill is always
* `${prefillPrefix}${query}`, so the search-miss case seeds the query and
* the onboarding case leaves only the prefix.
*/
prefillPrefix: string;
}
// Services get instrumentation-flavoured copy: the assistant can't "create" a
// service, they come from telemetry, so the CTA points at setup instead.
const CONTEXT_PICKER_COPY: Record<ContextCategory, CategoryCopy> = {
Dashboards: {
emptyTitle: 'No dashboards yet.',
emptyCtaLabel: 'Ask me to create one',
matchTitle: (query) => `No dashboards match "${query}".`,
matchCtaLabel: (query) => `Create a dashboard for "${query}"`,
prefillPrefix: 'Create a dashboard for ',
},
Alerts: {
emptyTitle: 'No alerts yet.',
emptyCtaLabel: 'Ask me to create one',
matchTitle: (query) => `No alerts match "${query}".`,
matchCtaLabel: (query) => `Create an alert for "${query}"`,
prefillPrefix: 'Create an alert for ',
},
Services: {
emptyTitle: 'No services reporting data yet.',
emptyCtaLabel: 'Ask me to help set up instrumentation',
matchTitle: (query) => `No services match "${query}".`,
matchCtaLabel: (query) => `Set up instrumentation for "${query}"`,
prefillPrefix: 'Help me set up instrumentation for ',
},
};
/**
* Build the empty-state copy for a category. The two states are driven solely
* by whether a search query is active: a non-empty query yields the search-miss
* variant, an empty query the onboarding variant.
*/
export function getContextPickerEmptyContent(
category: ContextCategory,
query: string,
): ContextPickerEmptyContent {
const copy = CONTEXT_PICKER_COPY[category];
const trimmed = query.trim();
if (trimmed) {
return {
title: copy.matchTitle(trimmed),
ctaLabel: copy.matchCtaLabel(trimmed),
prefill: `${copy.prefillPrefix}${trimmed}`,
};
}
return {
title: copy.emptyTitle,
ctaLabel: copy.emptyCtaLabel,
prefill: copy.prefillPrefix,
};
}

View File

@@ -75,6 +75,38 @@
word-break: break-word;
}
// Error bubble: a subtle error-tinted callout replacing the default
// assistant background, rendered when a turn fails.
.bubble.error {
.assistant & {
background: var(--callout-error-background);
border: 1px solid var(--callout-error-border);
}
}
.errorContent {
display: flex;
align-items: flex-start;
gap: 8px;
}
.errorIcon {
flex-shrink: 0;
margin-top: 2px;
color: var(--destructive);
}
.errorText {
color: var(--callout-error-title);
white-space: pre-wrap;
word-break: break-word;
}
.retryButton {
margin-top: 6px;
align-self: flex-start;
}
// User-bubble row: pencil button sits to the LEFT of the bubble within
// the right-aligned message line, so it visually "ends" at the bubble's
// right edge while keeping the bubble in its original position.

View File

@@ -2,6 +2,10 @@ import React, { useMemo } from 'react';
import cx from 'classnames';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Button } from '@signozhq/ui/button';
import { RotateCw, TriangleAlert } from '@signozhq/icons';
import { RetryActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
// Side-effect: registers all built-in block types into the BlockRegistry
import '../blocks';
@@ -104,18 +108,23 @@ function renderGroup(group: RenderGroup): JSX.Element {
interface MessageBubbleProps {
message: Message;
onRegenerate?: () => void;
onRetry?: () => void;
isLastAssistant?: boolean;
}
export default function MessageBubble({
message,
onRegenerate,
onRetry,
isLastAssistant = false,
}: MessageBubbleProps): JSX.Element {
const variant = useVariant();
const isCompact = variant === 'panel';
const isUser = message.role === 'user';
const isError = !isUser && Boolean(message.isError);
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
const showRetry =
isError && message.retryAction === RetryActionDTO.manual && Boolean(onRetry);
// Recompute groups only when the blocks array identity changes — store
// updates that don't touch this message's blocks should not re-render the
@@ -138,7 +147,7 @@ export default function MessageBubble({
<div className={messageClass} data-testid={`ai-message-${message.id}`}>
<div className={bodyClass}>
<div className={styles.bubbleRow}>
<div className={styles.bubble}>
<div className={cx(styles.bubble, { [styles.error]: isError })}>
{message.attachments && message.attachments.length > 0 && (
<div className={styles.attachments}>
{message.attachments.map((att) => {
@@ -161,6 +170,11 @@ export default function MessageBubble({
{isUser ? (
<p className={styles.text}>{message.content}</p>
) : isError ? (
<div className={styles.errorContent}>
<TriangleAlert size={14} className={styles.errorIcon} />
<span className={styles.errorText}>{message.content}</span>
</div>
) : hasBlocks ? (
<MessageContext.Provider value={{ messageId: message.id }}>
{groups.map((g) => renderGroup(g))}
@@ -183,7 +197,21 @@ export default function MessageBubble({
</div>
</div>
{!isUser && !message.isRateLimitError && (
{showRetry && (
<Button
className={styles.retryButton}
size="sm"
variant="ghost"
color="secondary"
onClick={onRetry}
testId={`ai-message-retry-${message.id}`}
>
<RotateCw size={12} />
Retry
</Button>
)}
{!isUser && !isError && !message.isRateLimitError && (
<MessageFeedback
message={message}
onRegenerate={onRegenerate}

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { render, screen, userEvent } from 'tests/test-utils';
import {
ErrorCodeDTO,
RetryActionDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Message } from '../../../types';
// react-markdown + remark-gfm are ESM-only and pull a large untransformed
// dependency chain into jest. The error-rendering path under test renders
// plain text (no markdown), so stub them to keep the import graph loadable.
jest.mock('react-markdown', () => ({
__esModule: true,
default: ({ children }: { children?: React.ReactNode }): React.ReactNode =>
children,
}));
jest.mock('remark-gfm', () => ({
__esModule: true,
default: (): void => undefined,
}));
// eslint-disable-next-line import/first
import MessageBubble from '../MessageBubble';
function errorMessage(overrides: Partial<Message> = {}): Message {
return {
id: 'err-1',
role: 'assistant',
content: 'This conversation is still finishing a previous response.',
isError: true,
errorCode: ErrorCodeDTO.thread_busy,
retryAction: RetryActionDTO.manual,
createdAt: 0,
...overrides,
};
}
const retryButton = (): HTMLElement | null =>
screen.queryByRole('button', { name: /retry/i });
describe('MessageBubble — error rendering', () => {
it('shows a Retry button for a manual error and invokes onRetry on click', async () => {
const onRetry = jest.fn();
render(<MessageBubble message={errorMessage()} onRetry={onRetry} />);
// Error copy is rendered, and the feedback bar is suppressed on errors.
expect(
screen.getByText(/still finishing a previous response/i),
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /copy message/i }),
).not.toBeInTheDocument();
const button = retryButton();
expect(button).toBeInTheDocument();
await userEvent.click(button as HTMLElement);
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('hides the Retry button when retryAction is none', () => {
render(
<MessageBubble
message={errorMessage({ retryAction: RetryActionDTO.none })}
onRetry={jest.fn()}
/>,
);
expect(retryButton()).not.toBeInTheDocument();
});
it('hides the Retry button when retryAction is auto', () => {
render(
<MessageBubble
message={errorMessage({ retryAction: RetryActionDTO.auto })}
onRetry={jest.fn()}
/>,
);
expect(retryButton()).not.toBeInTheDocument();
});
it('hides the Retry button when no onRetry handler is provided', () => {
render(<MessageBubble message={errorMessage()} />);
expect(retryButton()).not.toBeInTheDocument();
});
});

View File

@@ -37,6 +37,9 @@ export default function VirtualizedMessages({
const regenerateAssistantMessage = useAIAssistantStore(
(s) => s.regenerateAssistantMessage,
);
const retryAssistantMessage = useAIAssistantStore(
(s) => s.retryAssistantMessage,
);
const { threadId } = useAIAssistantAnalyticsContext(conversationId);
const streamingStatus = useAIAssistantStore(
(s) => s.streams[conversationId]?.streamingStatus ?? '',
@@ -85,6 +88,14 @@ export default function VirtualizedMessages({
[conversationId, isStreaming, regenerateAssistantMessage, threadId],
);
const handleRetry = useCallback((): void => {
if (isStreaming) {
return;
}
void logEvent(AIAssistantEvents.RetryClicked, { threadId });
void retryAssistantMessage(conversationId);
}, [conversationId, isStreaming, retryAssistantMessage, threadId]);
// Scroll all the way to the actual bottom — including the 64px of bottom
// padding on the scroller — so the last bubble has visible breathing room
// above the disclaimer / input bar. Virtuoso's `scrollToIndex(LAST,
@@ -206,6 +217,11 @@ export default function VirtualizedMessages({
? (): void => handleRegenerate(msg.id)
: undefined
}
onRetry={
msg.isError && isLastAssistant && !showStreamingSlot
? handleRetry
: undefined
}
isLastAssistant={isLastAssistant}
/>
);

View File

@@ -90,6 +90,7 @@ export enum AIAssistantEvents {
SuggestedPromptClicked = 'AI Assistant: Suggested prompt clicked',
CancelClicked = 'AI Assistant: Cancel clicked',
RegenerateClicked = 'AI Assistant: Regenerate clicked',
RetryClicked = 'AI Assistant: Retry clicked',
MessageCopied = 'AI Assistant: Message copied',
FeedbackSubmitted = 'AI Assistant: Feedback submitted',
ResourceOpened = 'AI Assistant: Resource opened',

View File

@@ -124,7 +124,9 @@ export function getAutoContexts(
}
}
// Alert edit — `/alerts/edit?ruleId=…`.
// Alert edit — `/alerts/edit?ruleId=…`. The form syncs its query-builder
// state to the URL (`useShareBuilderUrl`), so shared metadata carries the
// alert's query + time range, mirroring the dashboard panel editor.
if (matchPath(pathname, { path: ROUTES.EDIT_ALERTS, exact: true })) {
const ruleId = params.get(QueryParams.ruleId);
if (ruleId) {
@@ -133,19 +135,21 @@ export function getAutoContexts(
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: { page: 'alert_edit', ruleId },
metadata: { page: 'alert_edit', ruleId, ...sharedMetadata },
},
];
}
}
// Alert new — `/alerts/new`. No rule id yet (draft), but the query-builder
// state is on the URL, so shared metadata carries the in-progress query.
if (matchPath(pathname, { path: ROUTES.ALERTS_NEW, exact: true })) {
return [
{
source: 'auto',
type: 'alert',
resourceId: null,
metadata: { page: 'alert_new' },
metadata: { page: 'alert_new', ...sharedMetadata },
},
];
}

View File

@@ -0,0 +1,263 @@
import {
ErrorCodeDTO,
RetryActionDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import type { SSEEvent } from 'api/ai-assistant/chat';
import { useAIAssistantStore } from '../useAIAssistantStore';
import type { Message } from '../../types';
// The store talks to the chat API only through these named exports. Mock the
// whole module so we can drive the SSE stream + REST calls deterministically.
jest.mock('api/ai-assistant/chat', () => ({
__esModule: true,
createThread: jest.fn(),
sendMessage: jest.fn(),
streamEvents: jest.fn(),
approveExecution: jest.fn(),
clarifyExecution: jest.fn(),
regenerateMessage: jest.fn(),
rejectExecution: jest.fn(),
cancelExecution: jest.fn(),
listThreads: jest.fn(),
getThreadDetail: jest.fn(),
updateThread: jest.fn(),
submitFeedback: jest.fn(),
}));
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
const chat = jest.requireMock('api/ai-assistant/chat') as Record<
string,
jest.Mock
>;
// Builds a single-use async stream from a fixed list of SSE events.
async function* eventStream(events: SSEEvent[]): AsyncGenerator<SSEEvent> {
for (const event of events) {
yield event;
}
}
function errorEvent(
executionId: string,
code: ErrorCodeDTO,
retryAction: RetryActionDTO,
): SSEEvent {
return {
type: 'error',
executionId,
error: { code, message: 'backend message' },
retryAction,
};
}
function lastMessage(conversationId: string): Message {
const conv = useAIAssistantStore.getState().conversations[conversationId];
return conv.messages[conv.messages.length - 1];
}
describe('useAIAssistantStore — streaming error handling', () => {
beforeEach(() => {
jest.clearAllMocks();
useAIAssistantStore.setState((s) => {
s.conversations = {};
s.streams = {};
s.activeConversationId = null;
});
});
it('commits a manually-retryable error bubble with friendly copy and metadata', async () => {
chat.createThread.mockResolvedValue('thread-1');
chat.sendMessage.mockResolvedValue('exec-1');
chat.streamEvents.mockReturnValueOnce(
eventStream([
errorEvent('exec-1', ErrorCodeDTO.thread_busy, RetryActionDTO.manual),
]),
);
useAIAssistantStore.getState().startNewConversation();
await useAIAssistantStore.getState().sendMessage('hello');
const conv = useAIAssistantStore.getState().conversations['thread-1'];
expect(conv.messages).toHaveLength(2);
expect(conv.messages[0]).toMatchObject({ role: 'user', content: 'hello' });
expect(conv.messages[1]).toMatchObject({
role: 'assistant',
isError: true,
errorCode: ErrorCodeDTO.thread_busy,
retryAction: RetryActionDTO.manual,
});
// Code-specific FE copy, not the raw backend message.
expect(conv.messages[1].content).toContain(
'still finishing a previous response',
);
});
it('replays the send on retry without re-pushing the user message', async () => {
chat.createThread.mockResolvedValue('thread-1');
chat.sendMessage.mockResolvedValue('exec-1');
chat.streamEvents.mockReturnValueOnce(
eventStream([
errorEvent('exec-1', ErrorCodeDTO.thread_busy, RetryActionDTO.manual),
]),
);
useAIAssistantStore.getState().startNewConversation();
await useAIAssistantStore.getState().sendMessage('hello');
// The retry succeeds this time.
chat.streamEvents.mockReturnValueOnce(
eventStream([
{
type: 'message',
executionId: 'exec-1',
messageId: 'm1',
delta: 'Hi there',
done: true,
},
]),
);
await useAIAssistantStore.getState().retryAssistantMessage('thread-1');
const conv = useAIAssistantStore.getState().conversations['thread-1'];
// Error bubble replaced by the assistant reply; the user message stays.
expect(conv.messages).toHaveLength(2);
expect(conv.messages[0]).toMatchObject({ role: 'user', content: 'hello' });
expect(conv.messages[1]).toMatchObject({
role: 'assistant',
content: 'Hi there',
});
expect(conv.messages[1].isError).toBeUndefined();
// Thread already existed on retry; the user message was never re-sent as new.
expect(chat.createThread).toHaveBeenCalledTimes(1);
expect(chat.sendMessage).toHaveBeenCalledTimes(2);
});
it('silently retries auto-flagged errors, then downgrades to manual once spent', async () => {
chat.createThread.mockResolvedValue('thread-2');
chat.sendMessage.mockResolvedValue('exec');
// Always auto-retryable: 1 initial attempt + MAX_AUTO_RETRIES (2) = 3 sends.
chat.streamEvents.mockImplementation(() =>
eventStream([
errorEvent('exec', ErrorCodeDTO.internal_error, RetryActionDTO.auto),
]),
);
useAIAssistantStore.getState().startNewConversation();
await useAIAssistantStore.getState().sendMessage('hi');
expect(chat.sendMessage).toHaveBeenCalledTimes(3);
expect(lastMessage('thread-2')).toMatchObject({
isError: true,
errorCode: ErrorCodeDTO.internal_error,
// Auto budget exhausted → presented as manual so a Retry button shows.
retryAction: RetryActionDTO.manual,
});
}, 10000);
it('marks rate-limit errors and offers no retry', async () => {
chat.createThread.mockResolvedValue('thread-3');
chat.sendMessage.mockResolvedValue('exec');
chat.streamEvents.mockReturnValueOnce(
eventStream([
errorEvent('exec', ErrorCodeDTO.hourly_message_limit, RetryActionDTO.none),
]),
);
useAIAssistantStore.getState().startNewConversation();
await useAIAssistantStore.getState().sendMessage('hi');
expect(lastMessage('thread-3')).toMatchObject({
isError: true,
isRateLimitError: true,
retryAction: RetryActionDTO.none,
});
// No retry thunk registered for a non-retryable error — retry is a no-op.
const before =
useAIAssistantStore.getState().conversations['thread-3'].messages.length;
await useAIAssistantStore.getState().retryAssistantMessage('thread-3');
expect(
useAIAssistantStore.getState().conversations['thread-3'].messages,
).toHaveLength(before);
});
it('recovers silently when an auto-flagged error succeeds on retry', async () => {
chat.createThread.mockResolvedValue('thread-4');
chat.sendMessage.mockResolvedValue('exec');
chat.streamEvents
.mockReturnValueOnce(
eventStream([
errorEvent('exec', ErrorCodeDTO.internal_error, RetryActionDTO.auto),
]),
)
.mockReturnValueOnce(
eventStream([
{
type: 'message',
executionId: 'exec',
messageId: 'm1',
delta: 'Recovered',
done: true,
},
]),
);
useAIAssistantStore.getState().startNewConversation();
await useAIAssistantStore.getState().sendMessage('hi');
// 1 initial attempt + 1 silent auto retry, then success — no error bubble.
expect(chat.sendMessage).toHaveBeenCalledTimes(2);
const conv = useAIAssistantStore.getState().conversations['thread-4'];
expect(conv.messages).toHaveLength(2);
expect(conv.messages[0]).toMatchObject({ role: 'user', content: 'hi' });
expect(conv.messages[1]).toMatchObject({
role: 'assistant',
content: 'Recovered',
});
expect(conv.messages.some((m) => m.isError)).toBe(false);
}, 10000);
it('replays the originating action on retry for a non-send error (approve)', async () => {
chat.approveExecution.mockResolvedValue('exec-a');
chat.streamEvents.mockReturnValueOnce(
eventStream([
errorEvent('exec-a', ErrorCodeDTO.thread_busy, RetryActionDTO.manual),
]),
);
const convId = useAIAssistantStore.getState().startNewConversation();
await useAIAssistantStore.getState().approveAction(convId, 'approval-1');
expect(lastMessage(convId)).toMatchObject({
isError: true,
retryAction: RetryActionDTO.manual,
});
expect(chat.approveExecution).toHaveBeenCalledTimes(1);
// Retry replays the approval (not a send) and succeeds this time.
chat.streamEvents.mockReturnValueOnce(
eventStream([
{
type: 'message',
executionId: 'exec-a',
messageId: 'm1',
delta: 'Approved',
done: true,
},
]),
);
await useAIAssistantStore.getState().retryAssistantMessage(convId);
const conv = useAIAssistantStore.getState().conversations[convId];
expect(conv.messages).toHaveLength(1);
expect(conv.messages[0]).toMatchObject({
role: 'assistant',
content: 'Approved',
});
expect(conv.messages[0].isError).toBeUndefined();
expect(chat.approveExecution).toHaveBeenCalledTimes(2);
expect(chat.sendMessage).not.toHaveBeenCalled();
});
});

View File

@@ -8,6 +8,7 @@ import type {
MessageActionDTO,
MessageSummaryDTOBlocksAnyOfItem,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { RetryActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
approveExecution,
@@ -35,7 +36,10 @@ import {
MessageBlock,
MessageRole,
} from '../types';
import { resolveAssistantErrorMessage } from '../utils/resolveAssistantErrorMessage';
import {
resolveAssistantError,
type AssistantErrorResolution,
} from '../utils/resolveAssistantError';
// ---------------------------------------------------------------------------
// Types used by module-level helpers
@@ -56,6 +60,15 @@ interface SSEStreamCtx {
const streamControllers = new Map<string, AbortController>();
/**
* Per-conversation retry thunks for the most recent failed turn. Populated by
* `finalizeStreamingError` when the error is manually retryable; consumed by
* the `retryAssistantMessage` action when the user clicks Retry. Transient
* (not persisted) — it shares the in-memory lifetime of the error bubble it
* backs, so a page reload drops both together.
*/
const retryRegistry = new Map<string, () => Promise<void>>();
function abortStream(conversationId: string): void {
const ctrl = streamControllers.get(conversationId);
if (ctrl) {
@@ -197,7 +210,7 @@ function resetStreamingState(
* Marker thrown by `runStreamingLoop` when an SSE event reports
* `invalid_token`. Callers that own an originating action (sendMessage /
* approve / clarify / regenerate) catch this and re-issue that action via
* `streamWithAuthRetry`; the retry's first REST call will 401, at which point
* `streamWithRetry`; the retry's first REST call will 401, at which point
* the shared axios `interceptorRejected` rotates the access token and replays.
*/
class AuthExpiredError extends Error {
@@ -207,27 +220,50 @@ class AuthExpiredError extends Error {
}
}
/** Capped silent re-attempts for backend-flagged transient (`auto`) errors. */
const MAX_AUTO_RETRIES = 2;
/** Backoff before each auto re-attempt, indexed by prior auto-retry count. */
const AUTO_RETRY_BACKOFF_MS = [500, 1500];
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
/** True when the SSE error carries the backend's `retryAction: 'auto'` flag. */
function isAutoRetryableError(err: unknown): boolean {
return (
(err as { retryAction?: unknown } | undefined)?.retryAction ===
RetryActionDTO.auto
);
}
/**
* Runs the originating action (e.g. sendMessage POST) and streams the
* resulting execution. On `AuthExpiredError`, re-issues `start` once — the
* retry's REST call hits 401, the shared axios interceptor rotates the
* access token and replays, and the new SSE picks up the rotated token from
* localStorage. Backend signals `retryAction: 'manual'` for `invalid_token`,
* so the dead execution can't be resumed — only a fresh one helps.
* resulting execution, with two independent retry budgets:
*
* • Auth — on `AuthExpiredError` (SSE `invalid_token`), re-issues `start`
* once. The retry's REST call 401s, the shared axios interceptor rotates
* the access token + replays, and the new SSE picks up the rotated token.
* Backend flags `invalid_token` as `manual`, so only a fresh execution helps.
* • Auto — on an SSE error the backend flagged `retryAction: 'auto'`
* (transient), silently re-issues `start` up to `MAX_AUTO_RETRIES` times
* with backoff. Once exhausted the error propagates so the caller can
* surface a manual Retry affordance.
*
* Both reset the stream state before re-attempting so a dead execution's
* partial output isn't concatenated onto the retry.
*/
async function streamWithAuthRetry(
async function streamWithRetry(
conversationId: string,
start: () => Promise<string>,
set: StoreSetter,
): Promise<void> {
for (let attempt = 0; attempt <= 1; attempt += 1) {
if (attempt > 0) {
// Drop any partial content/events from the previous attempt so the
// retried execution's stream isn't concatenated with the dead one.
set((s) => {
resetStreamingState(s, conversationId);
});
}
let authRetried = false;
let autoRetries = 0;
for (;;) {
// eslint-disable-next-line no-await-in-loop
const executionId = await start();
const ctrl = newStreamController(conversationId);
@@ -242,10 +278,28 @@ async function streamWithAuthRetry(
return;
} catch (err) {
streamControllers.delete(conversationId);
if (err instanceof AuthExpiredError && attempt < 1) {
continue;
if (err instanceof AuthExpiredError && !authRetried) {
authRetried = true;
} else if (isAutoRetryableError(err) && autoRetries < MAX_AUTO_RETRIES) {
// eslint-disable-next-line no-await-in-loop
await delay(AUTO_RETRY_BACKOFF_MS[autoRetries] ?? 1500);
autoRetries += 1;
} else {
if (isAutoRetryableError(err)) {
// Auto-retry budget spent — present the failure as manually
// retryable so the caller surfaces a Retry button rather than
// silently giving up.
(err as { retryAction?: RetryActionDTO }).retryAction =
RetryActionDTO.manual;
}
throw err;
}
throw err;
// Drop partial content/events from the failed attempt before retrying.
set((s) => {
resetStreamingState(s, conversationId);
});
}
}
}
@@ -258,7 +312,7 @@ async function streamWithAuthRetry(
*
* On an `invalid_token` error event (e.g. MCP auth expired mid-execution),
* throws `AuthExpiredError` so the caller can re-issue the originating
* action via `streamWithAuthRetry`. We don't refresh here ourselves — the
* action via `streamWithRetry`. We don't refresh here ourselves — the
* retry's REST call will 401 and the shared axios `interceptorRejected`
* handles rotation + replay. Throws on any other `error` event — the
* caller's catch block handles UI feedback.
@@ -484,20 +538,37 @@ function hasPendingInput(conversationId: string, get: StoreGetter): boolean {
return Boolean(stream?.pendingApproval || stream?.pendingClarification);
}
/**
* Commits a failed turn as an error message and removes the stream entry.
* When the failure is manually retryable and a `retry` thunk is supplied, the
* thunk is stashed in `retryRegistry` so the bubble's Retry button can replay
* the originating action.
*/
function finalizeStreamingError(
conversationId: string,
errorContent: string,
resolution: AssistantErrorResolution,
set: StoreSetter,
isRateLimit = false,
retry?: () => Promise<void>,
): void {
const { message, code, retryAction, isRateLimit } = resolution;
if (retryAction === RetryActionDTO.manual && retry) {
retryRegistry.set(conversationId, retry);
} else {
retryRegistry.delete(conversationId);
}
set((s) => {
const conv = s.conversations[conversationId];
if (conv) {
conv.messages.push({
id: uuidv4(),
role: 'assistant',
content: errorContent,
content: message,
createdAt: Date.now(),
isError: true,
retryAction,
...(code ? { errorCode: code } : {}),
...(isRateLimit ? { isRateLimitError: true } : {}),
});
conv.updatedAt = Date.now();
@@ -506,6 +577,40 @@ function finalizeStreamingError(
});
}
/**
* Shared streaming wrapper for actions that have no pre-stream setup beyond
* resetting state (approve / clarify / regenerate). Streams the execution,
* finalizes the message on success, and on failure resolves the error +
* registers `retry` (the caller's own re-invocation) so the bubble can replay
* it. `sendMessage` does not use this — it owns thread-creation/re-keying and
* runs its own equivalent loop.
*/
async function streamAndFinalize(
conversationId: string,
start: () => Promise<string>,
fallback: string,
logLabel: string,
set: StoreSetter,
get: StoreGetter,
retry: () => Promise<void>,
): Promise<void> {
try {
await streamWithRetry(conversationId, start, set);
if (!hasPendingInput(conversationId, get)) {
finalizeStreamingMessage(conversationId, set, get);
}
} catch (err) {
// Abort errors are expected when the user cancels — not a failure.
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
// eslint-disable-next-line no-console
console.error(logLabel, err);
const resolution = resolveAssistantError(err, fallback);
finalizeStreamingError(conversationId, resolution, set, retry);
}
}
// ---------------------------------------------------------------------------
// Store interface
// ---------------------------------------------------------------------------
@@ -564,6 +669,8 @@ export interface AIAssistantStore {
conversationId: string,
messageId: string,
) => Promise<void>;
/** Replays the originating action for a manually-retryable error bubble. */
retryAssistantMessage: (conversationId: string) => Promise<void>;
submitMessageFeedback: (
messageId: string,
rating: FeedbackRating,
@@ -877,7 +984,7 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
// there's no "originating action" to redo — reopening the
// same dead executionId would just re-emit the failure.
// Let the error bubble; the user can send a new message,
// which will go through `streamWithAuthRetry`.
// which will go through `streamWithRetry`.
if (
detail.activeExecutionId &&
!streamControllers.has(threadId) &&
@@ -1060,7 +1167,7 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
attachments?: MessageAttachment[],
contexts?: MessageContext[],
): Promise<void> => {
let convId = get().activeConversationId;
const convId = get().activeConversationId;
if (!convId || !get().conversations[convId]) {
return;
}
@@ -1093,63 +1200,75 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
};
set((state) => {
const conv = state.conversations[convId!];
const conv = state.conversations[convId];
conv.messages.push(userMessage);
conv.updatedAt = Date.now();
if (!conv.title && text.trim()) {
conv.title = deriveTitle(text);
}
resetStreamingState(state, convId!);
resetStreamingState(state, convId);
});
try {
let { threadId } = get().conversations[convId];
if (!threadId) {
threadId = await createThread();
// Re-key the conversation from client UUID to backend threadId
// so fetchThreads won't create a duplicate entry later.
const oldId = convId;
convId = threadId;
set((s) => {
const conv = s.conversations[oldId];
if (conv) {
conv.id = convId!;
conv.threadId = convId!;
s.conversations[convId!] = conv;
delete s.conversations[oldId];
if (s.activeConversationId === oldId) {
s.activeConversationId = convId!;
// The full send — ensure a backend thread exists (re-keying the
// optimistic client UUID on first send), POST the message, and
// stream the reply. Defined as a closure so the error bubble's
// Retry button can replay it without re-pushing the user message.
const runSend = async (cid: string): Promise<void> => {
let targetConvId = cid;
try {
let { threadId } = get().conversations[targetConvId];
if (!threadId) {
threadId = await createThread();
// Re-key the conversation from client UUID to backend threadId
// so fetchThreads won't create a duplicate entry later.
const oldId = targetConvId;
const newId = threadId;
set((s) => {
const conv = s.conversations[oldId];
if (conv) {
conv.id = newId;
conv.threadId = newId;
s.conversations[newId] = conv;
delete s.conversations[oldId];
if (s.activeConversationId === oldId) {
s.activeConversationId = newId;
}
const stream = s.streams[oldId];
if (stream) {
s.streams[newId] = stream;
delete s.streams[oldId];
}
}
const stream = s.streams[oldId];
if (stream) {
s.streams[convId!] = stream;
delete s.streams[oldId];
}
}
});
}
const tid = threadId;
await streamWithAuthRetry(
convId,
() => sendMessageToThread(tid, text, contexts),
set,
);
});
targetConvId = newId;
}
const tid = threadId;
await streamWithRetry(
targetConvId,
() => sendMessageToThread(tid, text, contexts),
set,
);
if (!hasPendingInput(convId, get)) {
finalizeStreamingMessage(convId, set, get);
if (!hasPendingInput(targetConvId, get)) {
finalizeStreamingMessage(targetConvId, set, get);
}
} catch (err) {
// Abort errors are expected when the user cancels — not a failure.
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
console.error('[AIAssistant] sendMessage failed:', err);
const resolution = resolveAssistantError(
err,
'Something went wrong while fetching the response. Please try again.',
);
finalizeStreamingError(targetConvId, resolution, set, () =>
runSend(targetConvId),
);
}
} catch (err) {
// Abort errors are expected when the user cancels — not a failure
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
console.error('[AIAssistant] sendMessage failed:', err);
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while fetching the response. Please try again.',
);
finalizeStreamingError(convId, message, set, isRateLimit);
}
};
await runSend(convId);
},
approveAction: async (
@@ -1167,26 +1286,17 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
}
});
try {
await streamWithAuthRetry(
const run = (): Promise<void> =>
streamAndFinalize(
conversationId,
() => approveExecution(approvalId),
set,
);
if (!hasPendingInput(conversationId, get)) {
finalizeStreamingMessage(conversationId, set, get);
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
console.error('[AIAssistant] approveAction failed:', err);
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while processing the approval. Please try again.',
'[AIAssistant] approveAction failed:',
set,
get,
run,
);
finalizeStreamingError(conversationId, message, set, isRateLimit);
}
await run();
},
rejectAction: async (
@@ -1246,26 +1356,17 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
resetStreamingState(s, conversationId);
});
try {
await streamWithAuthRetry(
const run = (): Promise<void> =>
streamAndFinalize(
conversationId,
() => regenerateMessage(messageId),
set,
);
if (!hasPendingInput(conversationId, get)) {
finalizeStreamingMessage(conversationId, set, get);
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
console.error('[AIAssistant] regenerateAssistantMessage failed:', err);
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while regenerating the response. Please try again.',
'[AIAssistant] regenerateAssistantMessage failed:',
set,
get,
run,
);
finalizeStreamingError(conversationId, message, set, isRateLimit);
}
await run();
},
submitMessageFeedback: async (
@@ -1312,26 +1413,42 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
}
});
try {
await streamWithAuthRetry(
const run = (): Promise<void> =>
streamAndFinalize(
conversationId,
() => clarifyExecution(clarificationId, answers),
set,
);
if (!hasPendingInput(conversationId, get)) {
finalizeStreamingMessage(conversationId, set, get);
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
console.error('[AIAssistant] submitClarification failed:', err);
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while processing your answers. Please try again.',
'[AIAssistant] submitClarification failed:',
set,
get,
run,
);
finalizeStreamingError(conversationId, message, set, isRateLimit);
await run();
},
retryAssistantMessage: async (conversationId: string): Promise<void> => {
const retry = retryRegistry.get(conversationId);
if (!retry) {
return;
}
retryRegistry.delete(conversationId);
// Drop the trailing error bubble we're retrying from and reset the
// stream so the in-progress retry renders immediately. The retry
// thunk replays the originating action without re-pushing the
// user's message.
set((s) => {
const conv = s.conversations[conversationId];
if (conv) {
const last = conv.messages[conv.messages.length - 1];
if (last?.isError) {
conv.messages.pop();
}
}
resetStreamingState(s, conversationId);
});
await retry();
},
})),
{

View File

@@ -15,9 +15,11 @@
import type {
ApprovalEventDTO,
ClarificationEventDTO,
ErrorCodeDTO,
FeedbackRatingDTO,
MessageActionDTO,
MessageActionKindDTO,
RetryActionDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
/** Client-only file attachment — no API equivalent (uploads happen via data URLs). */
@@ -91,6 +93,18 @@ export interface Message {
* bar (copy/vote/regenerate) is hidden — retrying would just 429 again.
*/
isRateLimitError?: boolean;
/**
* Marks an assistant message that represents a failed turn. Drives the
* error styling and replaces the feedback bar with a retry affordance.
*/
isError?: boolean;
/** Known backend error code for the failure, when recognised. */
errorCode?: ErrorCodeDTO;
/**
* Retry semantics for a failed turn — `manual` renders an inline Retry
* button on the error bubble; `none`/`auto` render no button.
*/
retryAction?: RetryActionDTO;
createdAt: number;
}

View File

@@ -0,0 +1,154 @@
import { AxiosError } from 'axios';
import {
ErrorCodeDTO,
RetryActionDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { resolveAssistantError } from '../resolveAssistantError';
const FALLBACK = 'Something went wrong. Please try again.';
function restError(status: number, code: string, message: string): AxiosError {
const err = new AxiosError('Request failed');
err.response = {
status,
data: { error: { code, message } },
} as AxiosError['response'];
return err;
}
describe('resolveAssistantError', () => {
describe('message resolution', () => {
it('prefers code-specific FE copy over the backend message', () => {
const err = restError(409, ErrorCodeDTO.thread_busy, 'raw backend phrasing');
const { message } = resolveAssistantError(err, FALLBACK);
expect(message).toBe(
'This conversation is still finishing a previous response. Give it a moment and try again.',
);
});
it('falls through to the backend message for a known code without FE copy', () => {
const err = restError(
400,
ErrorCodeDTO.message_not_found,
'No such message exists.',
);
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
message: 'No such message exists.',
code: ErrorCodeDTO.message_not_found,
retryAction: RetryActionDTO.none,
isRateLimit: false,
});
});
it('falls back when the error code is not in ErrorCodeDTO', () => {
const err = restError(400, 'future_unknown_code', 'Backend-only message');
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
message: FALLBACK,
code: undefined,
retryAction: RetryActionDTO.none,
isRateLimit: false,
});
});
});
describe('rate limiting', () => {
it('marks HTTP 429 responses as rate limited and non-retryable', () => {
const err = restError(
429,
ErrorCodeDTO.hourly_message_limit,
'Hourly limit reached.',
);
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
message: "You've reached the hourly message limit. Please try again later.",
code: ErrorCodeDTO.hourly_message_limit,
retryAction: RetryActionDTO.none,
isRateLimit: true,
});
});
it('treats known SSE rate-limit codes as rate limited', () => {
const err = Object.assign(new Error('Daily token limit exceeded.'), {
code: ErrorCodeDTO.daily_token_limit,
});
const res = resolveAssistantError(err, FALLBACK);
expect(res.isRateLimit).toBe(true);
expect(res.retryAction).toBe(RetryActionDTO.none);
});
it('marks 429 as rate limited even when the code is unknown', () => {
const err = restError(429, 'future_unknown_code', 'Too many requests');
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
message: FALLBACK,
code: undefined,
retryAction: RetryActionDTO.none,
isRateLimit: true,
});
});
});
describe('retryAction resolution', () => {
it('honours an explicit retryAction from an SSE error event', () => {
const err = Object.assign(new Error('Transient hiccup'), {
code: ErrorCodeDTO.internal_error,
retryAction: RetryActionDTO.auto,
});
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
RetryActionDTO.auto,
);
});
it('forces none for non-retryable permission errors', () => {
const err = restError(403, ErrorCodeDTO.permission_denied, 'forbidden');
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
RetryActionDTO.none,
);
});
it('derives manual for 409 conflicts', () => {
const err = restError(409, ErrorCodeDTO.thread_has_active_execution, 'busy');
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
RetryActionDTO.manual,
);
});
it('derives manual for 5xx responses', () => {
const err = restError(503, 'future_unknown_code', 'unavailable');
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
RetryActionDTO.manual,
);
});
it('derives manual for network failures with no response', () => {
const err = new AxiosError('Network Error');
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
RetryActionDTO.manual,
);
});
it('derives none for other 4xx responses', () => {
const err = restError(400, 'future_unknown_code', 'bad request');
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
RetryActionDTO.none,
);
});
it('defaults to manual for non-Axios errors with no code', () => {
expect(resolveAssistantError(new Error('boom'), FALLBACK).retryAction).toBe(
RetryActionDTO.manual,
);
});
});
});

View File

@@ -1,91 +0,0 @@
import { AxiosError } from 'axios';
import { ErrorCodeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { resolveAssistantErrorMessage } from '../resolveAssistantErrorMessage';
const FALLBACK = 'Something went wrong. Please try again.';
describe('resolveAssistantErrorMessage', () => {
it('returns backend message for a known error code', () => {
const err = new AxiosError('Request failed');
err.response = {
status: 400,
data: {
error: {
code: ErrorCodeDTO.thread_busy,
message: 'This thread is busy. Try again shortly.',
},
},
} as AxiosError['response'];
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
message: 'This thread is busy. Try again shortly.',
isRateLimit: false,
});
});
it('falls back when error code is not in ErrorCodeDTO', () => {
const err = new AxiosError('Request failed');
err.response = {
status: 400,
data: {
error: {
code: 'future_unknown_code',
message: 'Backend-only message',
},
},
} as AxiosError['response'];
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
message: FALLBACK,
isRateLimit: false,
});
});
it('marks HTTP 429 responses as rate limited', () => {
const err = new AxiosError('Too many requests');
err.response = {
status: 429,
data: {
error: {
code: ErrorCodeDTO.hourly_message_limit,
message: 'Hourly limit reached.',
},
},
} as AxiosError['response'];
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
message: 'Hourly limit reached.',
isRateLimit: true,
});
});
it('uses backend message for known SSE rate-limit error codes', () => {
const err = Object.assign(new Error('Daily token limit exceeded.'), {
code: ErrorCodeDTO.daily_token_limit,
});
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
message: 'Daily token limit exceeded.',
isRateLimit: true,
});
});
it('marks 429 as rate limited even when error code is unknown', () => {
const err = new AxiosError('Too many requests');
err.response = {
status: 429,
data: {
error: {
code: 'future_unknown_code',
message: 'Too many requests',
},
},
} as AxiosError['response'];
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
message: FALLBACK,
isRateLimit: true,
});
});
});

View File

@@ -0,0 +1,209 @@
import { isAxiosError } from 'axios';
import {
ErrorCodeDTO,
RetryActionDTO,
type ErrorBodyDTO,
type ErrorResponseDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
export interface AssistantErrorResolution {
/** User-facing copy: code-specific FE copy → backend message → caller fallback. */
message: string;
/** Known backend error code, when one we recognise was supplied. */
code?: ErrorCodeDTO;
/**
* Whether/how the failed action may be retried:
* • `auto` — transient; the caller may silently re-attempt (capped).
* • `manual` — surface a Retry affordance to the user.
* • `none` — retrying would re-fail deterministically; offer nothing.
*/
retryAction: RetryActionDTO;
/** Quota/limit error — callers hide the retry + feedback bar (retrying just re-limits). */
isRateLimit: boolean;
}
/** Quota/limit codes — surfaced as rate-limit errors (no retry, feedback bar hidden). */
const RATE_LIMIT_ERROR_CODES = new Set<ErrorCodeDTO>([
ErrorCodeDTO.rate_limit_override_exceeds_ceiling,
ErrorCodeDTO.thread_message_limit,
ErrorCodeDTO.connection_limit_exceeded,
ErrorCodeDTO.hourly_message_limit,
ErrorCodeDTO.daily_message_limit,
ErrorCodeDTO.daily_token_limit,
ErrorCodeDTO.daily_cost_limit,
ErrorCodeDTO.budget_exceeded,
]);
/**
* Codes whose retry would re-fail deterministically — permission/config/validation
* failures. These force `retryAction: none` regardless of HTTP status.
*/
const NON_RETRYABLE_CODES = new Set<ErrorCodeDTO>([
ErrorCodeDTO.permission_denied,
ErrorCodeDTO.user_disabled,
ErrorCodeDTO.org_disabled,
ErrorCodeDTO.validation_error,
ErrorCodeDTO.invalid_content_length,
ErrorCodeDTO.invalid_fork_target,
ErrorCodeDTO.missing_signoz_url,
ErrorCodeDTO.invalid_signoz_url,
ErrorCodeDTO.region_not_configured,
]);
/**
* Code-specific, user-friendly copy. Takes precedence over the backend's raw
* `error.message` so the user sees an actionable, consistent sentence rather
* than internal phrasing. Codes absent here fall through to the backend message.
*/
const ERROR_CODE_COPY: Partial<Record<ErrorCodeDTO, string>> = {
[ErrorCodeDTO.permission_denied]:
"You don't have permission to do that. Contact your workspace admin if you think this is a mistake.",
[ErrorCodeDTO.user_disabled]:
'Your access to the AI assistant has been disabled. Contact your workspace admin to re-enable it.',
[ErrorCodeDTO.org_disabled]:
'The AI assistant is disabled for your organisation. An admin can enable it in settings.',
[ErrorCodeDTO.thread_busy]:
'This conversation is still finishing a previous response. Give it a moment and try again.',
[ErrorCodeDTO.thread_has_active_execution]:
'This conversation is still finishing a previous response. Give it a moment and try again.',
[ErrorCodeDTO.hourly_message_limit]:
"You've reached the hourly message limit. Please try again later.",
[ErrorCodeDTO.daily_message_limit]:
"You've reached the daily message limit. Please try again tomorrow.",
[ErrorCodeDTO.daily_token_limit]:
"You've reached today's usage limit. Please try again tomorrow.",
[ErrorCodeDTO.daily_cost_limit]:
"You've reached today's usage limit. Please try again tomorrow.",
[ErrorCodeDTO.budget_exceeded]:
"You've reached your usage budget. Contact your workspace admin to raise it.",
[ErrorCodeDTO.thread_message_limit]:
'This conversation has reached its length limit. Start a new conversation to continue.',
[ErrorCodeDTO.connection_limit_exceeded]:
'Too many active conversations right now. Close one and try again.',
[ErrorCodeDTO.max_turns_exceeded]:
'The assistant reached the maximum number of steps for this request. Try rephrasing or breaking it into smaller asks.',
[ErrorCodeDTO.region_unreachable]:
"Couldn't reach your region's services. Please try again in a moment.",
[ErrorCodeDTO.region_not_configured]:
'No region is configured for the AI assistant yet. An admin can set this up in settings.',
[ErrorCodeDTO.mcp_unavailable]:
'A required service is temporarily unavailable. Please try again shortly.',
[ErrorCodeDTO.sandbox_unavailable]:
'The execution environment is temporarily unavailable. Please try again shortly.',
[ErrorCodeDTO.internal_error]:
'Something went wrong on our end. Please try again.',
};
function isErrorCodeDTO(code: string | undefined): code is ErrorCodeDTO {
return (
code !== undefined && (Object.values(ErrorCodeDTO) as string[]).includes(code)
);
}
function isRetryActionDTO(value: unknown): value is RetryActionDTO {
return (
typeof value === 'string' &&
(Object.values(RetryActionDTO) as string[]).includes(value)
);
}
/**
* Pulls the structured error body out of either an Axios REST error or the
* SSE error the streaming loop throws (a plain `Error` augmented with `code`).
*/
function getErrorBody(err: unknown): ErrorBodyDTO | null {
if (isAxiosError(err)) {
return (err.response?.data as ErrorResponseDTO | undefined)?.error ?? null;
}
const code = (err as { code?: string } | undefined)?.code;
const message = err instanceof Error ? err.message : undefined;
if (!code || !message) {
return null;
}
return { code: code as ErrorCodeDTO, message };
}
function isRateLimit(code: ErrorCodeDTO | undefined, err: unknown): boolean {
if (isAxiosError(err) && err.response?.status === 429) {
return true;
}
return code !== undefined && RATE_LIMIT_ERROR_CODES.has(code);
}
/**
* Resolves how the failed action may be retried. The backend's explicit signal
* (SSE `ErrorEventDTO.retryAction`) is authoritative; otherwise we derive it
* from the rate-limit/non-retryable code sets and the HTTP status.
*/
function resolveRetryAction(
err: unknown,
code: ErrorCodeDTO | undefined,
rateLimited: boolean,
): RetryActionDTO {
const explicit = (err as { retryAction?: unknown } | undefined)?.retryAction;
if (isRetryActionDTO(explicit)) {
return explicit;
}
if (rateLimited || (code !== undefined && NON_RETRYABLE_CODES.has(code))) {
return RetryActionDTO.none;
}
if (isAxiosError(err)) {
const status = err.response?.status;
// No response → network/timeout failure; retrying may well succeed.
if (status === undefined || status === 408) {
return RetryActionDTO.manual;
}
if (status === 401 || status === 403) {
return RetryActionDTO.none;
}
if (status === 409 || status >= 500) {
return RetryActionDTO.manual;
}
// Other 4xx (validation, bad request) re-fail deterministically.
return RetryActionDTO.none;
}
// Non-Axios transport/parse error with no code — let the user retry.
return RetryActionDTO.manual;
}
function resolveMessage(
code: ErrorCodeDTO | undefined,
body: ErrorBodyDTO | null,
fallback: string,
): string {
if (code !== undefined && ERROR_CODE_COPY[code]) {
return ERROR_CODE_COPY[code] as string;
}
// Trust the backend's message only for codes we recognise — never surface
// raw text for unknown codes (could be an internal stack trace).
if (code !== undefined && body?.message.trim()) {
return body.message.trim();
}
return fallback;
}
/**
* Single resolution point for both SSE and REST assistant errors. Maps the
* error onto user-facing copy plus retry semantics, degrading gracefully for
* unknown codes (falls back to `fallback` + a `manual` retry where sensible).
*/
export function resolveAssistantError(
err: unknown,
fallback: string,
): AssistantErrorResolution {
const body = getErrorBody(err);
const code = isErrorCodeDTO(body?.code) ? body?.code : undefined;
const rateLimited = isRateLimit(code, err);
return {
message: resolveMessage(code, body, fallback),
code,
retryAction: resolveRetryAction(err, code, rateLimited),
isRateLimit: rateLimited,
};
}

View File

@@ -1,71 +0,0 @@
import { isAxiosError } from 'axios';
import {
ErrorCodeDTO,
type ErrorBodyDTO,
type ErrorResponseDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
export interface AssistantErrorResolution {
message: string;
isRateLimit: boolean;
}
function isErrorCodeDTO(code: string | undefined): code is ErrorCodeDTO {
return (
code !== undefined && (Object.values(ErrorCodeDTO) as string[]).includes(code)
);
}
const RATE_LIMIT_ERROR_CODES = new Set<ErrorCodeDTO>([
ErrorCodeDTO.rate_limit_override_exceeds_ceiling,
ErrorCodeDTO.thread_message_limit,
ErrorCodeDTO.connection_limit_exceeded,
ErrorCodeDTO.hourly_message_limit,
ErrorCodeDTO.daily_message_limit,
ErrorCodeDTO.daily_token_limit,
ErrorCodeDTO.daily_cost_limit,
ErrorCodeDTO.budget_exceeded,
]);
function isRateLimitError(code: string | undefined, err: unknown): boolean {
if (isAxiosError(err) && err.response?.status === 429) {
return true;
}
return isErrorCodeDTO(code) && RATE_LIMIT_ERROR_CODES.has(code);
}
function getErrorBody(err: unknown): ErrorBodyDTO | null {
if (isAxiosError(err)) {
return (err.response?.data as ErrorResponseDTO | undefined)?.error ?? null;
}
const code = (err as { code?: string } | undefined)?.code;
const message = err instanceof Error ? err.message : undefined;
if (!code || !message) {
return null;
}
return { code: code as ErrorCodeDTO, message };
}
/**
* Uses `error.message` when `error.code` is a known `ErrorCodeDTO`;
* otherwise returns `fallback`.
*/
export function resolveAssistantErrorMessage(
err: unknown,
fallback: string,
): AssistantErrorResolution {
const body = getErrorBody(err);
const isRateLimit = isRateLimitError(body?.code, err);
if (body && isErrorCodeDTO(body.code) && body.message.trim()) {
return {
message: body.message.trim(),
isRateLimit,
};
}
return { message: fallback, isRateLimit: Boolean(isRateLimit) };
}

View File

@@ -26,7 +26,12 @@ jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
describe('Should check if the edit alert channel is properly displayed', () => {
beforeEach(() => {
render(<EditAlertChannels initialValue={editAlertChannelInitialValue} />);
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
});
afterEach(() => {
jest.clearAllMocks();

View File

@@ -0,0 +1,81 @@
import EditAlertChannels from 'container/EditAlertChannels';
import { editAlertChannelInitialValue } from 'mocks-server/__mockdata__/alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: { success: jest.fn(), error: jest.fn() },
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
interface EditRequest {
id: string;
body: { name: string; slack_configs: { send_resolved: boolean }[] };
}
// Captures the PUT /channels/:id request the edit form fires, so assertions can
// run against the real HTTP payload instead of a hand-mocked api client.
function mockEditChannel(): { calls: EditRequest[] } {
const result: { calls: EditRequest[] } = { calls: [] };
server.use(
rest.put('http://localhost/api/v1/channels/:id', async (req, res, ctx) => {
result.calls.push({
id: req.params.id as string,
body: await req.json(),
});
return res(
ctx.status(200),
ctx.json({ status: 'success', data: 'channel updated' }),
);
}),
);
return result;
}
describe('EditAlertChannels save', () => {
afterEach(() => jest.clearAllMocks());
it('sends the channelId in the edit request (regression: empty id)', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('3');
});
it('persists send_resolved toggle in the edit request', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
const user = userEvent.setup();
const sendResolved = screen.getByTestId('field-send-resolved-checkbox');
expect(sendResolved).toBeChecked();
await user.click(sendResolved);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('3');
expect(edit.calls[0].body.slack_configs[0].send_resolved).toBe(false);
});
});

View File

@@ -413,14 +413,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const isPanelEditorV2 = routeKey === 'DASHBOARD_PANEL_EDITOR';
const renderFullScreen =
pathname === ROUTES.GET_STARTED ||
pathname === ROUTES.ONBOARDING ||
pathname === ROUTES.GET_STARTED_WITH_CLOUD ||
pathname === ROUTES.GET_STARTED_APPLICATION_MONITORING ||
pathname === ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING ||
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
pathname === ROUTES.GET_STARTED_AWS_MONITORING ||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING ||
isPublicDashboard ||
isPanelEditorV2;

View File

@@ -32,6 +32,7 @@ import APIError from 'types/api/error';
function EditAlertChannels({
initialValue,
channelId: id,
}: EditAlertChannelsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('channels');
@@ -53,11 +54,6 @@ function EditAlertChannels({
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
// Extract channelId from URL pathname since useParams doesn't work in nested routing
const { pathname } = window.location;
const channelIdMatch = pathname.match(/\/settings\/channels\/edit\/([^/]+)/);
const id = channelIdMatch ? channelIdMatch[1] : '';
const [type, setType] = useState<ChannelType>(
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
);
@@ -520,6 +516,7 @@ interface EditAlertChannelsProps {
initialValue: {
[x: string]: unknown;
};
channelId: string;
}
export default EditAlertChannels;

View File

@@ -136,6 +136,7 @@ function FormAlertChannels({
<Form.Item>
<Button
data-testid="save-channel-button"
disabled={savingState}
loading={savingState}
type="primary"
@@ -144,6 +145,7 @@ function FormAlertChannels({
{t('button_save_channel')}
</Button>
<Button
data-testid="test-channel-button"
disabled={testingState}
loading={testingState}
onClick={(): void => onTestHandler(type)}
@@ -151,6 +153,7 @@ function FormAlertChannels({
{t('button_test_channel')}
</Button>
<Button
data-testid="return-button"
onClick={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}

View File

@@ -1,29 +0,0 @@
.full-screen-header-container {
display: flex;
justify-content: center;
align-items: center;
padding: 24px 0;
.brand-logo {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
cursor: pointer;
img {
height: 32px;
width: 32px;
}
.brand-logo-name {
font-family: 'Work Sans', sans-serif;
font-size: 24px;
font-style: normal;
font-weight: 500;
line-height: 18px;
color: var(--l1-foreground);
}
}
}

View File

@@ -1,28 +0,0 @@
import history from 'lib/history';
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import './FullScreenHeader.styles.scss';
export default function FullScreenHeader({
overrideRoute,
}: {
overrideRoute?: string;
}): React.ReactElement {
const handleLogoClick = (): void => {
history.push(overrideRoute || '/');
};
return (
<div className="full-screen-header-container">
<div className="brand-logo" onClick={handleLogoClick}>
<img src={signozBrandLogoUrl} alt="SigNoz" />
<div className="brand-logo-name">SigNoz</div>
</div>
</div>
);
}
FullScreenHeader.defaultProps = {
overrideRoute: '/',
};

View File

@@ -0,0 +1,27 @@
.llmObservability {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
}
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
.title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.subtitle {
margin: var(--spacing-2) 0 0;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}
}

View File

@@ -0,0 +1,18 @@
import styles from './LLMObservability.module.scss';
function LLMObservability(): JSX.Element {
return (
<div className={styles.llmObservability} data-testid="llm-observability-page">
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>LLM Observability</h1>
<p className={styles.subtitle}>
Monitor and analyze your LLM usage, costs, and performance
</p>
</div>
</header>
</div>
);
}
export default LLMObservability;

View File

@@ -0,0 +1,30 @@
.llmObservabilityModelPricing {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
}
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
.title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.subtitle {
margin: var(--spacing-2) 0 0;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}
}

View File

@@ -0,0 +1,47 @@
import { Tabs } from '@signozhq/ui/tabs';
import { Typography } from '@signozhq/ui/typography';
import ModelCostTabPanel from './ModelCostTabPanel';
import styles from './LLMObservabilityModelPricing.module.scss';
function LLMObservabilityModelPricing(): JSX.Element {
return (
<div
className={styles.llmObservabilityModelPricing}
data-testid="llm-observability-model-pricing-page"
>
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<Typography.Text as="h1" size="large" weight="semibold">
Configuration
</Typography.Text>
<Typography.Text color="muted">
Model pricing and cost estimation settings
</Typography.Text>
</div>
</header>
<Tabs
// Model costs is the only enabled tab for now, so default to it. When
// the unpriced-models tab lands, this can become a URL-backed param.
defaultValue="model-costs"
items={[
{
key: 'model-costs',
label: 'Model costs',
children: <ModelCostTabPanel />,
},
{
// Unpriced-models tab lands in a later PR.
key: 'unpriced-models',
label: 'Unpriced models',
disabled: true,
children: null,
},
]}
/>
</div>
);
}
export default LLMObservabilityModelPricing;

View File

@@ -0,0 +1,7 @@
.pageError {
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
color: var(--text-cherry-400);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
import { useTableParams } from 'components/TanStackTableView';
import { Typography } from '@signozhq/ui/typography';
import { LIMIT_KEY, PAGE_KEY, PAGE_SIZE } from '../constants';
import styles from './ModelCostTabPanel.module.scss';
import ModelCostsTable from './components/ModelCostsTable';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
function ModelCostTabPanel(): JSX.Element {
const { page, limit } = useTableParams(
{ page: PAGE_KEY, limit: LIMIT_KEY },
{ page: 1, limit: PAGE_SIZE },
);
// Search + source filters are intentionally omitted for now — the list API
// doesn't honour them yet. They'll be reintroduced here once it does.
const listParams: ListLLMPricingRulesParams = {
offset: (page - 1) * limit,
limit,
};
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
const rules: LlmpricingruletypesLLMPricingRuleDTO[] = useMemo(
() => data?.data?.items || [],
[data],
);
const total = data?.data?.total ?? 0;
return (
<>
{isError && (
<div className={styles.pageError} role="alert">
Failed to load pricing rules. Please try again.
</div>
)}
{/* Read-only listing. Edit/Add wiring + the drawer land in the next PR. */}
<ModelCostsTable
rules={rules}
isLoading={isLoading}
total={total}
selectedRuleId={null}
canManage={false}
onEdit={(): void => undefined}
onDelete={(): void => undefined}
/>
<footer>
<Typography.Text color="muted" size="small">
All prices per 1M tokens (USD)
</Typography.Text>
</footer>
</>
);
}
export default ModelCostTabPanel;

View File

@@ -0,0 +1,8 @@
.actionButton {
opacity: 0.7;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
}

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { Ellipsis } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './ModelCostActionsMenu.module.scss';
interface ModelCostActionsMenuProps {
rule: LlmpricingruletypesLLMPricingRuleDTO;
canManage: boolean;
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
}
// Per-row kebab menu for the model-costs table. Only manage users get actions
// (Edit + Delete); view-only users have nothing to act on, so the cell stays
// empty rather than showing a single-item menu.
function ModelCostActionsMenu({
rule,
canManage,
onEdit,
onDelete,
}: ModelCostActionsMenuProps): JSX.Element | null {
const menuItems = useMemo<MenuItem[]>(
() => [
{
key: 'edit',
label: 'Edit',
onClick: (): void => onEdit(rule),
},
{
key: 'delete',
label: 'Delete',
danger: true,
onClick: (): void => onDelete(rule),
},
],
[onEdit, onDelete, rule],
);
if (!canManage) {
return null;
}
return (
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.actionButton}
testId={`model-cost-actions-${rule.id}`}
>
<Ellipsis size={16} />
</Button>
</DropdownMenuSimple>
);
}
export default ModelCostActionsMenu;

View File

@@ -0,0 +1,20 @@
.modelCostsTable {
margin-top: var(--spacing-8);
--tanstack-table-row-height: 48px;
height: calc(100vh - 250px);
overflow-y: auto;
:global(table) tbody tr {
cursor: default;
}
}
.modelCostsEmpty {
display: flex;
align-items: center;
justify-content: center;
margin-top: var(--spacing-8);
min-height: 400px;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,73 @@
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import {
LIMIT_KEY,
PAGE_KEY,
PAGE_SIZE,
SKELETON_ROW_COUNT,
} from '../../../constants';
import styles from './ModelCostsTable.module.scss';
import { getModelCostsColumns } from './TableConfig';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
interface ModelCostsTableProps {
rules: LlmpricingruletypesLLMPricingRuleDTO[];
isLoading: boolean;
total: number;
selectedRuleId: string | null;
canManage: boolean;
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
}
// The table owns its own pagination URL state (page/limit) via enableQueryParams;
// ModelCostsTab reads the same keys to build the list request. Virtual scroll is
// disabled: a plain table renders fine at our page sizes (up to 100 rows) and the
// fixed-height scroll viewport (.modelCostsTable) keeps large pages scrolling
// inside the table.
function ModelCostsTable({
rules,
isLoading,
total,
selectedRuleId,
canManage,
onEdit,
onDelete,
}: ModelCostsTableProps): JSX.Element {
const columns = useMemo(
() => getModelCostsColumns({ canManage, onEdit, onDelete }),
[canManage, onEdit, onDelete],
);
if (!isLoading && rules.length === 0) {
return (
<div className={styles.modelCostsEmpty} data-testid="model-costs-empty">
No model costs yet.
</div>
);
}
return (
<TanStackTable<LlmpricingruletypesLLMPricingRuleDTO>
className={styles.modelCostsTable}
data={rules}
columns={columns}
isLoading={isLoading}
skeletonRowCount={SKELETON_ROW_COUNT}
getRowKey={(row): string => row.id}
isRowActive={(row): boolean => row.id === selectedRuleId}
disableVirtualScroll
testId="model-costs-table"
enableQueryParams={{ page: PAGE_KEY, limit: LIMIT_KEY }}
pagination={{
total,
defaultLimit: PAGE_SIZE,
showTotalCount: true,
totalCountLabel: 'models',
}}
/>
);
}
export default ModelCostsTable;

View File

@@ -0,0 +1 @@
export { getModelCostsColumns } from './table.config';

View File

@@ -0,0 +1,161 @@
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import type { TableColumnDef } from 'components/TanStackTableView';
import { startCase } from 'lodash-es';
import styles from './tableConfig.module.scss';
import ModelCostActionsMenu from '../ModelCostActionsMenu';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
} from '../../../../utils';
interface ColumnsConfig {
canManage: boolean;
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
}
// Column definitions for the model-costs TanStackTable. Sorting is intentionally
// off across the board — the list API only accepts offset/limit, so there's no
// server-side ordering to back a sortable header yet.
export function getModelCostsColumns({
canManage,
onEdit,
onDelete,
}: ColumnsConfig): TableColumnDef<LlmpricingruletypesLLMPricingRuleDTO>[] {
return [
{
id: 'model',
header: 'Model',
accessorFn: (row): string => row.modelName ?? '',
// Flexes to absorb spare width alongside Extra buckets so the row fills
// the container instead of leaving a gap on the right.
width: { min: 240, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<div className={styles.modelCell}>
<Typography.Text
weight="semibold"
truncate={1}
testId={`model-cell-name-${row.id}`}
>
{row.modelName}
</Typography.Text>
<Typography.Text truncate={1}>{getCanonicalId(row)}</Typography.Text>
</div>
),
},
{
id: 'provider',
header: 'Provider',
accessorKey: 'provider',
width: { min: 140 },
enableMove: false,
cell: ({ row }): string => row.provider ?? '',
},
{
id: 'input',
header: 'Input / 1M',
width: { min: 120 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text>
{formatPricePerMillion(row.pricing?.input)}
</Typography.Text>
),
},
{
id: 'output',
header: 'Output / 1M',
width: { min: 120 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text>
{formatPricePerMillion(row.pricing?.output)}
</Typography.Text>
),
},
{
id: 'extraBuckets',
header: 'Extra buckets',
width: { min: 200, default: '100%' },
enableMove: false,
cell: ({ row }): JSX.Element => {
const buckets = getExtraBuckets(row);
if (buckets.length === 0) {
return (
<Typography.Text color="muted" as="span">
</Typography.Text>
);
}
return (
<div className={styles.extraBuckets}>
{buckets.map((bucket) => (
<Badge
key={bucket.key}
color="vanilla"
variant="outline"
className={styles.extraBucketsChip}
>
<Typography.Text as="span" size="small">
{startCase(bucket.key)}
</Typography.Text>
<Typography.Text as="span" size="small" weight="semibold">
{formatPricePerMillion(bucket.pricePerMillion)}
</Typography.Text>
</Badge>
))}
</div>
);
},
},
{
id: 'source',
header: 'Source',
width: { min: 130 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Badge
color={row.isOverride ? 'amber' : 'robin'}
variant="outline"
className={styles.sourceBadge}
data-testid={`source-badge-${row.id}`}
>
{getSourceLabel(row)}
</Badge>
),
},
{
id: 'lastSeen',
header: 'Last seen',
width: { min: 120 },
enableMove: false,
cell: ({ row }): string => getRelativeLastSeen(row),
},
{
id: 'actions',
header: '',
width: { fixed: '56px', ignoreLastColumnFill: true },
pin: 'right',
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element | null => (
<ModelCostActionsMenu
rule={row}
canManage={canManage}
onEdit={onEdit}
onDelete={onDelete}
/>
),
},
];
}

View File

@@ -0,0 +1,26 @@
.modelCell {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
min-width: 0;
}
.extraBuckets {
display: flex;
// Keep chips on a single line so the row stays at the table's fixed row
// height; the column flexes to 100% so there's room for both.
flex-wrap: nowrap;
gap: var(--spacing-3);
overflow: hidden;
}
.extraBucketsChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
margin: 0;
}
.sourceBadge {
margin: 0;
}

View File

@@ -0,0 +1 @@
export { default } from './ModelCostsTable';

View File

@@ -0,0 +1 @@
export { default } from './ModelCostTabPanel';

View File

@@ -0,0 +1,6 @@
export const PAGE_SIZE = 20;
export const PAGE_KEY = 'page';
export const LIMIT_KEY = 'limit';
export const SKELETON_ROW_COUNT = PAGE_SIZE;

View File

@@ -0,0 +1,4 @@
export interface ExtraBucket {
key: string;
pricePerMillion: number;
}

View File

@@ -0,0 +1,60 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type { ExtraBucket } from './types';
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
dayjs.extend(relativeTime);
const getRelativeTime = (
timestamp: string | number | Date | null | undefined,
): string => {
const parsed = timestamp != null ? dayjs(timestamp) : null;
return parsed?.isValid() ? parsed.fromNow() : '—';
};
// ─── Display helpers ─────────────────────────────────────────────────────────
export const formatPricePerMillion = (value: number | undefined): string => {
if (value === undefined || value === null) {
return '—';
}
// 2dp is enough for per-1M pricing. we can update this later we models have sub-cent pricing.
return `$${value.toFixed(2)}`;
};
export const getExtraBuckets = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): ExtraBucket[] => {
const cache = rule.pricing?.cache;
if (!cache) {
return [];
}
const buckets: ExtraBucket[] = [];
if (typeof cache.read === 'number' && cache.read > 0) {
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
}
if (typeof cache.write === 'number' && cache.write > 0) {
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
}
return buckets;
};
export const getSourceLabel = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): 'Auto' | 'User override' => (rule.isOverride ? 'User override' : 'Auto');
export const getRelativeLastSeen = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
// Canonical id shown under the model name, e.g. "openai:gpt-4o". Both segments
// are lower-cased so the id is consistently normalised (providers/models can
// arrive with mixed casing).
export const getCanonicalId = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => {
const provider = rule.provider?.trim().toLowerCase() || 'unknown';
const model = rule.modelName?.trim().toLowerCase() || 'unknown';
return `${provider}:${model}`;
};

View File

@@ -1,10 +1,12 @@
.members-settings {
.members-settings-page {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
height: 100%;
}
.members-settings {
&__header {
display: flex;
flex-direction: column;

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