Compare commits

..

38 Commits

Author SHA1 Message Date
nityanandagohain
9daa31892a Merge remote-tracking branch 'origin/main' into issue_5267 2026-07-21 22:26:04 +05:30
nityanandagohain
ad2f7b8601 fix: cleanup integration test 2026-07-21 22:26:00 +05:30
Ashwin Bhatkal
c7f4aa01c4 feat(dashboard-v2): product analytics instrumentation for list + detail pages (#12199)
* feat(dashboard-v2): add analytics event catalog for list + detail

Introduce DashboardListEvents and DashboardDetailEvents enums as the single
source of truth for V2 dashboard product-analytics event names. Grouped
action-discriminator events (RowAction, SectionAction, PanelAction,
DrilldownAction, JsonEditorAction, PublicDashboardAction) mirror the existing
'Dashboard Detail: Panel action' pattern. PanelAction reuses the existing V1
string so panel actions stay comparable across versions.

* feat(dashboard-v2): instrument dashboards list page analytics

Fire logEvent for create/import funnel (tab switch, created success, import
failed), find/navigate (search, filter, sort, columns, pin, pagination),
Views rail (select/save/rename/delete/reset, rail toggle), row actions
(view/open-new-tab/copy-link/rename/edit-tags/duplicate/lock/delete),
legacy-dashboard dialog, and error-state actions.

* feat(dashboard-v2): instrument dashboard detail page analytics

Fire logEvent across the detail page: lifecycle (open, rename, clone, delete,
lock, full-screen, stale-reload, public-url), sections (add/rename/clone/delete,
collapse, reorder, first-section migration), panels (add, edit/clone/delete/move,
export csv/png/svg, view, editor save/discard, type change, search, no-data),
layout drag/resize (rate-limited), variables setup (save/delete/reorder/test/
apply-to-all) and runtime selection (value selected rate-limited, multiselect
clear, options-fetch failed), drill-down (open + view-logs/traces/breakout/
filter/set-variable/create-variable), panel zoom, JSON editor actions, settings
overview save/discard, cursor sync, and public-dashboard actions.

* fix: lint

* refactor(dashboard-v2): extract inline analytics handlers into named functions

Address PR review: pull the inline JSX arrow handlers that wrap logEvent out
into named handlers across both V2 dashboard pages (public-url open, cursor/
tooltip sync, settings/JSON/full-screen actions, panel search, drilldown
view-logs/traces, create-modal tab, error retry/contact-support, row open-in-
new-tab/copy-link, legacy contact-support, save-view actions). Behavior
unchanged. Handlers defined inside .map()/per-row renderers and pre-existing
complex value-mapping handlers (ValueSelector) are intentionally left inline.
2026-07-21 14:13:26 +00:00
Vinicius Lourenço
e93792d427 fix(overlayscrollbars): leaking memory due to img.load event listener (#12179)
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
2026-07-21 12:07:55 +00:00
Vinicius Lourenço
6d0f618a9e feat(infra-monitoring): persist custom time on drawer across categories (#12038)
* feat(globalTime): add method to reset time of child to parent

* refactor(infra-monitoring): extract content from K8sBaseDetails

* feat(entity-details): add support to persist custom time across categories of k8s

* test(entity-details): update existing tests

* test(entity-details): add regression test for time conversion

* fix(k8s-list): ensure default value for list is 30m

* fix(entity-counts-section): address todo around inherit selected time

* fix(pr): missing fix for tab validation, lost of moving/merge
2026-07-21 11:58:26 +00:00
Ashwin Bhatkal
564d479aae test(e2e): fix teardown crashes (network endpoint race + eager keeper lookup) (#12204)
* test(e2e): retry network teardown to survive async endpoint detach

`--teardown` fails on CI with `docker.errors.APIError: 403 ... error while
removing network ... has active endpoints`: containers are torn down before
the network, but Docker detaches endpoints asynchronously, so the network
can still report active endpoints for a moment. Retry the removal,
force-disconnecting any stragglers each round.

* test(e2e): resolve keeper coordinator lazily so --teardown doesn't crash

`create_clickhouse` / `create_clickhouse_cluster` computed
`next(iter(keeper.container_configs.values()))` eagerly. Under `--teardown`
the keeper fixture is empty, so it raised StopIteration during teardown
setup, before any finalizer ran. Move the lookup inside create().
2026-07-21 11:44:30 +00:00
Vikrant Gupta
253ca7dd7e fix(session): validate ref and callback state against global allowed_origins (#12172)
* fix(session): use global external_url instead of ref param for SSO state

The sessions/context endpoint no longer reads the client-controlled ref
query param to build the SSO state and callback URLs. Each callback
authn provider now derives the site URL from the server-configured
global external_url, and siteURL is removed from the CallbackAuthN and
session Module interfaces.

* fix(session): validate ref and callback state against global allowed_origins

Instead of deriving SSO urls from the global external_url, keep the ref
roundtrip and validate its origin against the new optional
global.allowed_origins config. The callback state url is re-validated
before tokens are attached to it, closing the forged RelayState/state
exfiltration path. When allowed_origins is not configured, redirect
targets are not validated, preserving existing installs.

* fix(session): scope ref origin validation to sso auth domains

Move the allowed_origins check from the sessions/context handler to
getOrgSessionContext, right before the SSO login URL is built. A
disallowed ref no longer fails the whole request; only orgs with an
SSO-enabled auth domain get a per-org warning with password fallback.
2026-07-21 10:28:49 +00:00
Ashwin Bhatkal
cc45c1bef1 feat(dashboard-v2): share a dashboard link with the current variable selection (#12198)
* feat(share): support a page-specific extra option in the share dialog

* feat(dashboard-v2): share a dashboard link with the current variable selection
2026-07-21 07:08:20 +00:00
Abhi kumar
e70b3a0b52 chore: pr review fixes for 12137 (#12142) 2026-07-21 06:43:10 +00:00
Abhi kumar
f514af469e fix(dashboards-v2): panel editor Save/dirty, refresh retention, dropdown clipping & time-window fixes (#12146)
* fix(dashboards-v2): carry active time window across panel editor navigation

Opening, creating, or leaving the V2 panel editor now preserves the selected time
range (relative or custom) via URL params derived from Redux global time, so a
custom range picked in the editor isn't reset to the dashboard default on return.

Adds timeParamsFromGlobalTime + useTimeSearchParams; useOpenPanelEditor now takes an
options object ({ handoffState, search }) and appends the time params, and
useCreatePanel routes through it.

* fix(dashboards-v2): sync panel editor preview to the staged query on browser back/forward

The query builder reverts both currentQuery and stagedQuery via initQueryBuilderData on a
URL re-stage (chiefly browser Back/Forward), but the preview kept the last Run's result.
Commit the staged query into the draft whenever it re-stages outside an explicit Run, so the
preview follows. Live edits touch only currentQuery, so they still wait for Run; commitQuery
no-ops when unchanged.

* fix(dashboards-v2): stop query-builder dropdowns being clipped in the panel editor

The panel editor renders the query builder inside an overflow:hidden resizable pane, so
antd Select popups (group-by, order-by, having, metric name, aggregator, units) were cut
off. Make the shared QB filters defer to an ancestor ConfigProvider's popup container
(falling back to trigger.parentNode) via useSelectPopupContainer, and wrap the editor's
builder in a ConfigProvider that portals popups to document.body. Scoped to the editor
host, so the View modal keeps its own container and other surfaces are unchanged.

* fix(dashboards-v2): anchor panel editor dirty check to saved panel; retain query edits on refresh

The editor re-derived its dirty baseline from the incoming panel prop, which
is seeded from transient state — a stale URL compositeQuery after a refresh or
the View-mode handoff spec — instead of the persisted panel. So the draft was
compared against an already-edited baseline: after a refresh the panel showed
the saved query yet read dirty (inverted), and a View->Edit query change read
clean with Save disabled.

- Thread a savedPanel baseline (existingPanel) through PanelEditorPage ->
  PanelEditorContainer -> usePanelEditSession, distinct from the seed panel.
- usePanelEditorDraft compares isSpecDirty against savedPanel; the draft still
  seeds from the (possibly handed-off) panel.
- usePanelEditorQuerySync computes isQueryDirty as a V5-envelope comparison of
  the live query against the saved queries, routing both sides through the same
  fromPerses->toPerses round-trip so builder-added defaults absent from an older
  stored query never read an untouched panel as modified. Replaces the racy
  captured-first-stagedQuery baseline.
- Drop the mount forceReset on useShareBuilderUrl (both its reasons are now
  moot) so a refresh / browser Back-Forward hydrates the edited query from the
  URL and the staged-query effect syncs it into the draft — query edits survive
  a refresh instead of reverting to the saved query.

Add real-QueryBuilderProvider integration tests covering untouched-not-dirty
(incl. an older/minimal stored query), refresh retention, and handoff-dirty;
update the draft + query-sync unit tests.
2026-07-21 06:40:43 +00:00
Vikrant Gupta
806853798d feat(authz): add support for telemetry resource provisioning (#12168)
* feat(authz): provision telemetry roles with plaintext selectors and hashed tuples

Accept a user-facing telemetry selector string (<query_type>/<key>/<value>
with trailing wildcards), validate and canonicalize it, store it as-is in the
role JSON record, and hash it only at the OpenFGA boundary in Object(). Grant
and check both flow through Object() so the hashes match; the plaintext record
stays the readable source of truth for display and recreation.

Because the hash is one-way, role Update diffs at the tuple level via
DiffTuples instead of reconstructing transaction groups from stored tuples.

* refactor(authz): rename telemetry selector helpers and unexport hash

Rename grant_selector.go to selector.go, CanonicalizeTelemetryGrantSelector to
NewTelemetryGrantSelector, and CanonicalTelemetryGrantKey to NewTelemetryGrantKey.
Unexport telemetrySelectorHash since Object() is its only caller.

* fix(authz): expand telemetry ladder in the permission check API

The check API only probed the exact selector plus the full wildcard, so a
scoped grant like builder_query/service.name/* reported a concrete query as
unauthorized even though enforcement allowed it. Relocate the grant selector
ladder to telemetrytypes as NewTelemetryGrantSelectors so both enforcement and
the check API share it, and canonicalize plus fan out the ladder for telemetry
check transactions. Non-telemetry resources keep the exact-plus-wildcard probe.

* refactor(authz): move newCheckSelectors to the bottom of tuple.go

* fix(authz): reject key-scoped selectors for promql and clickhouse_sql

Only builder_query and builder_sub_query extract key-scoped selectors on the
check side, so a grant like clickhouse_sql/service.name/signoz could never
match anything. Track key-scope support per query type and reject the
<query_type>/<key>/<value> form for query types that only emit <query_type>/*.

* test(authz): use a valid promql selector in the check API test

The promql/service.name/service-a case became invalid input after key-scoped
promql/clickhouse_sql selectors were rejected, so the check endpoint returned
400 instead of 200. Use promql/* to keep exercising the different-query-type
denial with a valid selector.

* feat(authz): allow signoz.workspace.key.id as a telemetry grant key

Add signoz.workspace.key.id to the telemetry grant key allowlist so roles can
scope telemetry access by ingestion key, alongside service.name. Grant
validation and check-side extraction both pick it up through the shared
NewTelemetryGrantKey path; bare and resource.-prefixed spellings fold to the
same canonical key.

* Revert "feat(authz): allow signoz.workspace.key.id as a telemetry grant key"

This reverts commit c50d122b54.

* feat(authz): scope telemetry grants by signoz.workspace.key.id

Make signoz.workspace.key.id the sole telemetry grant key instead of
service.name, so roles scope telemetry access by ingestion key. The allowlist
is the only production change; grant validation and check-side extraction are
name-agnostic. Update the querierauthz integration suite and unit tests to the
new key.

* test(authz): update query_range_resources extractor tests to signoz.workspace.key.id

The grant-key switch left this extractor test filtering on and expecting
builder_query/service.name/... IDs, which now fall back to builder_query/*.
Rename the filters and expectations to signoz.workspace.key.id.
2026-07-21 06:13:13 +00:00
Jugal Kishore
f002b29685 fix(onboarding): list Temporal Cloud Metrics under metrics, not APM/Traces (#12203)
Temporal Cloud Metrics scrapes an OpenMetrics endpoint but only showed
under APM/Traces, bundled with the Go and TypeScript SDKs. Split it into
a standalone metrics card and trimmed the Temporal APM card to Go and
TypeScript.

Closes SigNoz/growth-pod#1122
2026-07-21 06:08:36 +00:00
Vinicius Lourenço
a5d4ef4498 fix(uplot): missing destroy uplot on unmount (#12182)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
2026-07-21 03:02:32 +00:00
Tushar Vats
967289ebc6 fix(metrics): resolve any filter key context instead of erroring (#12188)
* fix(metrics): resolve any filter key context instead of erroring

The metrics condition builder hard-errored ("key <name> not found") whenever a
filter key was absent from the metadata keys. This hit the full-text search
column (metric_name in the Metrics Explorer, labels in query_range) and every
explicit label context (resource./scope./attribute.), so a bare/partial filter
token 400'd the Metrics Explorer treemap and metric queries.

Metrics keeps every label in the `labels` JSON with no per-context storage, so
FieldFor already maps any key on its own (intrinsics incl. metric_name to their
column, every label context to a labels JSON extract). Use the key directly in
the len(keys)==0 branch so filters resolve and run instead of 400ing - matching
group-by, which already collapses all label contexts to labels.

Update the label-context test to expect the collapse (resource./scope. now
resolve like bare/attribute.), add unit coverage for the branch, and add a
queriermetrics test that a free-text filter no longer errors.

* fix(metrics): resolve free-text and unregistered filter keys instead of erroring

The metrics condition builder hard-errored ("key <name> not found") whenever a
filter key was absent from the metadata keys: the full-text search column
(metric_name on the Metrics Explorer, labels on query_range) and every explicit
label context (resource./scope.). So a bare/partial filter token 400'd the
Metrics Explorer treemap and metric query-builder filters.

Metrics has no per-context storage - every label lives in the `labels` JSON, and
FieldFor maps any key on its own (intrinsics incl. metric_name to their column,
every label context to a labels extract). Add an opt-in WithIgnoreNotFoundKeys
to the metrics condition builder that resolves such keys directly (matching
nothing) instead of erroring:

- query-builder (querier provider) and Metrics Explorer opt in, so free-text and
  unregistered contexts resolve - matching the statement builder's own fallback
  and group-by, which already collapses all label contexts to labels;
- infra-monitoring list APIs keep the default (strict), so a typo'd attribute
  still surfaces as a 400.

Intrinsics always resolve regardless of the flag. Adds unit coverage for both
modes and a queriermetrics test that a free-text filter no longer errors; the
label-context test now expects the collapse.

* chore: updated integration tests

* fix: dont assert warning == []

* chore: removed warning scaffolding for now

* fix: add back removed integration test

---------

Co-authored-by: nikhilmantri0902 <nikhil.mantri1999@gmail.com>
2026-07-21 01:48:54 +00:00
Srikanth Chekuri
bf0130a983 fix(clickhouseprometheus): resolve __name__ matchers against metric_name and anchor matcher regexes (#12154)
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
The read client reduced every __name__ matcher to metric_name = <value>
(empty string when absent), so nameless selectors ({job="api"}), regex
names ({__name__=~".+"}), and negations silently returned empty. Map the
__name__ matcher onto the metric_name column per matcher type, drop the
condition when no __name__ matcher exists, and narrow the samples query
by the metric names the series lookup discovered.

Matcher regexes are now anchored (^(?:...)$) the way Prometheus compiles
them; ClickHouse match() is a substring search, so {job=~"api"} also
matched job="api-server" before.

The lookup and samples SQL is built with huandu/go-sqlbuilder (ClickHouse
flavor) as in telemetrymetrics and the v2 transpiler: the samples query
embeds the series lookup as a nested builder, with argument order merged
by the builder in render position instead of hand-numbered placeholders.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:59:34 +00:00
Tushar Vats
9d4349c9d1 fix(querybuilder): resolve data-type collisions for numeric intrinsic columns (#12176)
A span/log query that aggregates or filters a numeric intrinsic column
(e.g. duration_nano) failed with ClickHouse NO_COMMON_TYPE (386) when a
same-named, type-consistent attribute existed in the metadata: field-key
resolution unioned the intrinsic column with the attribute into a multiIf/OR
whose branches had incompatible types (UInt64 intrinsic vs Float64 attribute).
This broke /api/v1/span_percentile on affected tenants.

- fallback_expr: DataTypeCollisionHandledFieldName now handles the unspecified
  data type in the projection/aggregation path (operator Unknown), coercing the
  column so collision multiIf branches share a supertype. Comparison contexts
  are left bare so the column index stays usable.
- telemetrytraces/condition_builder: run collision handling for duration_nano
  (so a same-named string attribute is cast in comparisons) and coerce numeric
  duration values to int64, keeping the intrinsic comparison bare/index-friendly
  while preserving the duration-string QoL parsing.
- tests: add a type-consistent "collision" trace_noise variant; cover it in the
  percentile aggregation test and add a duration_nano QoL filter regression test.
2026-07-20 16:57:39 +00:00
Pandey
d095645d61 fix(querybuildertypesv5): round-trip unset stepInterval and empty field-key values (#12171)
* fix(querybuildertypesv5): omit unset stepInterval on the wire

Step is a struct (struct{ time.Duration }), so omitempty had no effect — an
unset stepInterval serialized as 0 instead of being omitted, so a typed
client reading a query back saw a 0 it never sent (create -> GET drift).

Tag stepInterval with ,omitzero so an unset value is dropped while a set
value still serializes (as seconds), on all three sites: builder query,
trace-operator, and secondary aggregation. Schema-invisible (no OpenAPI /
client change). source and the metric enums were already handled in #12164.

* fix(telemetrytypes): round-trip empty fieldContext/fieldDataType on field keys

A TelemetryFieldKey can deliberately leave fieldContext/fieldDataType empty
to match across any context / data type, but ,omitzero dropped that empty
value on serialize, so a typed client that sent "" read it back as absent
(create -> GET drift).

Make both fields always serialize and add the empty member to their Enum()s
so "" is a valid schema value that round-trips verbatim — the same approach
#12164 used for source. Signal keeps ,omitzero: its empty value is invalid
for the query/variable signal contexts that share the enum (adding "" there
breaks those consumers), and a field key's signal is not deliberately empty.

Regenerate the OpenAPI spec + client (fieldContext/fieldDataType enums gain
"") and update the ScalarData marshal test (column keys now echo the fields).

* fix(telemetrytypes): round-trip empty signal on field keys

Extend the field-key round-trip fix to Signal: add the empty member to
Signal.Enum() and make TelemetryFieldKey.Signal always serialize, so an
empty ("any") field-key signal round-trips as a valid value instead of
being dropped — matching the fieldContext/fieldDataType treatment.

The Signal enum is shared with query/variable signals, where "" is invalid.
Narrow the frontend's TelemetrySignal type to logs/traces/metrics (so the
variable/panel signal selectors stay exhaustive), label the empty member in
the panel type switcher's map, and fold an empty drilldown signal into "all".

Regenerate the OpenAPI spec + client (Signal enum gains ""), and update the
ScalarData marshal test and the querierlogs aggregation label assertions to
include the now-serialized empty signal.
2026-07-20 16:31:47 +00:00
Vinicius Lourenço
429fca62be fix(query-search): do not override token classes (#12139)
* fix(query-search): do not override token classes

* test(token-highlight): add regression test

* fix(pr): address comments
2026-07-20 13:07:07 +00:00
Aditya Singh
20518a98e9 fix(logs-explorer): prevent long json body from overflowing (#12173)
* feat: allow body content to wrap preventing content overflow

* feat: align key name and pin to the top
2026-07-20 13:03:28 +00:00
Gaurav Tewari
453a524ff4 Merge branch 'main' into issue_5267 2026-07-20 18:18:46 +05:30
Gaurav Tewari
0b7c266b09 fix: css fixes for llm o11y model pricing and overview (#12145)
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
* fix: css fixes for llm o11y model pricing and overview

* fix: spacing issue

* chore: update modal drawer cost

* chore: remove comments

* chore: spacing between chips right

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-20 11:19:48 +00:00
Gaurav Tewari
8014c065e8 feat: rename llm o11y to ai o11y [2/2] (#12141)
* feat: add llm o11y to side nav

* chore: rename route

* chore: review comments

* chore: update constants

* chore: change name to ai

* chore: rename draft

* chore: move model pricing out of settings

* chore: update comment

* revert: undo LLM->AI file/dir moves, keep constant rename

Reverts the two file-relocation commits (c25060950 rename LLMObservability
dir -> AIObservability; 1a18bc848 move ModelPricing out of Settings) while
preserving the LLM->AI string/constant renames (route constants + paths,
side-nav entry/labels, page titles, permission keys). Component files stay
under container/LLMObservability with their original names.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-20 11:18:08 +00:00
Gaurav Tewari
08f92df0a6 Merge branch 'main' into issue_5267 2026-07-20 16:20:40 +05:30
Nityananda Gohain
6e5ff46761 Merge branch 'main' into issue_5267 2026-07-20 10:09:13 +05:30
nityanandagohain
72448fa163 fix: update group_id to groupId 2026-07-14 22:20:33 +05:30
nityanandagohain
a45f6341da Merge remote-tracking branch 'origin/main' into issue_5267 2026-07-14 21:50:19 +05:30
nityanandagohain
f534ea2467 fix: minor cleanup 2026-07-14 21:47:02 +05:30
nityanandagohain
d4843ecd8b Merge remote-tracking branch 'origin/main' into issue_5267 2026-07-14 21:10:50 +05:30
nityanandagohain
477b81892d fix: update go.mod 2026-07-14 21:09:28 +05:30
nityanandagohain
9604438723 Merge remote-tracking branch 'origin/main' into issue_5267 2026-06-25 17:23:58 +05:30
nityanandagohain
e0a66b7a10 fix: more cleanup 2026-06-25 17:20:48 +05:30
nityanandagohain
b01be1f31c fix: update openapi 2026-06-25 11:19:08 +05:30
nityanandagohain
c8a007cd8d fix: cleanup go.mod 2026-06-25 10:55:54 +05:30
nityanandagohain
205b23b830 fix: lint 2026-06-25 10:52:27 +05:30
nityanandagohain
68b166f75e fix: more cleanup and integration test 2026-06-25 10:50:52 +05:30
nityanandagohain
20d8ca6d7d Merge remote-tracking branch 'origin/main' into issue_5267 2026-06-25 10:06:52 +05:30
nityanandagohain
1e1be670f1 feat: integrate with collector 2026-06-23 12:07:16 +05:30
nityanandagohain
d1682f2ab6 feat: span mapper test endpoint 2026-06-19 18:31:31 +05:30
257 changed files with 6021 additions and 3540 deletions

View File

@@ -58,6 +58,7 @@ jobs:
- role
- rootuser
- serviceaccount
- spanmapper
- querier_json_body
- querier_skip_resource_fingerprint
- ttl

View File

@@ -9,6 +9,11 @@ global:
# the path component (e.g. /signoz in https://example.com/signoz) is used
# as the base path for all HTTP routes (both API and web frontend).
external_url: <unset>
# origins (scheme://host[:port], no path) allowed as login redirect targets. include
# the origin the signoz ui is served on. when not configured, redirect targets are
# not validated.
# allowed_origins:
# - https://signoz.example.com
# the url where the SigNoz backend receives telemetry data (traces, metrics, logs) from instrumented applications.
ingestion_url: <unset>
# the url of the SigNoz MCP server. when unset, the MCP settings page is hidden in the frontend.

View File

@@ -8084,6 +8084,19 @@ components:
required:
- items
type: object
SpantypesGettableSpanMapperTest:
properties:
collectorLogs:
items:
type: string
nullable: true
type: array
spans:
items:
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
nullable: true
type: array
type: object
SpantypesGettableSpanMappers:
properties:
items:
@@ -8180,6 +8193,39 @@ components:
- name
- condition
type: object
SpantypesPostableSpanMapperTest:
properties:
groups:
items:
$ref: '#/components/schemas/SpantypesPostableSpanMapperTestGroup'
nullable: true
type: array
spans:
items:
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
nullable: true
type: array
required:
- spans
- groups
type: object
SpantypesPostableSpanMapperTestGroup:
properties:
condition:
$ref: '#/components/schemas/SpantypesSpanMapperGroupCondition'
enabled:
type: boolean
mappers:
items:
$ref: '#/components/schemas/SpantypesPostableSpanMapper'
nullable: true
type: array
name:
type: string
required:
- name
- condition
type: object
SpantypesPostableTraceAggregations:
properties:
aggregations:
@@ -8341,6 +8387,17 @@ components:
- operation
- priority
type: object
SpantypesSpanMapperTestSpan:
properties:
attributes:
additionalProperties: {}
nullable: true
type: object
resource:
additionalProperties: {}
nullable: true
type: object
type: object
SpantypesUpdatableSpanMapper:
properties:
config:
@@ -8558,6 +8615,7 @@ components:
- resource
- attribute
- body
- ""
type: string
TelemetrytypesFieldDataType:
enum:
@@ -8566,6 +8624,7 @@ components:
- float64
- int64
- number
- ""
type: string
TelemetrytypesGettableFieldKeys:
properties:
@@ -8597,6 +8656,7 @@ components:
- traces
- logs
- metrics
- ""
type: string
TelemetrytypesSource:
enum:
@@ -14071,6 +14131,69 @@ paths:
summary: Update a span mapper
tags:
- spanmapper
/api/v1/span_mapper_groups/test:
post:
deprecated: false
description: Tests how span mappers would transform sample spans
operationId: TestSpanMappers
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesPostableSpanMapperTest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableSpanMapperTest'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Test span mappers against sample spans
tags:
- spanmapper
/api/v1/stats:
get:
deprecated: false

View File

@@ -223,17 +223,12 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
return err
}
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
desiredTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, updatedRole.TransactionGroups)
if err != nil {
return err
}
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
if err != nil {
return err
}
additionTuples, deletionTuples := authtypes.DiffTuples(existingTuples, desiredTuples)
err = provider.Write(ctx, additionTuples, deletionTuples)
if err != nil {

View File

@@ -240,17 +240,17 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
mock := mockStore.Mock()
// Mock the fingerprint query (for Prometheus label matching)
// args: $1=metric_name (the __name__ matcher maps onto the column)
mock.ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric", "__name__", "test_metric").
WithArgs("test_metric").
WillReturnRows(fingerprintRows)
// Mock the samples query (for Prometheus metric data)
// args: metric_name IN (discovered names), subquery metric_name, start, end
mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs(
"test_metric",
"test_metric",
"__name__",
"test_metric",
queryStart,
queryEnd,
).

View File

@@ -89,7 +89,7 @@
"lodash-es": "^4.17.21",
"motion": "12.4.13",
"nuqs": "2.8.8",
"overlayscrollbars": "^2.8.1",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-react": "^0.5.6",
"papaparse": "5.4.1",
"posthog-js": "1.298.0",
@@ -221,5 +221,18 @@
"*.(scss|css)": [
"stylelint"
]
},
"overrides": {
"@babel/core@<=7.29.0": ">=7.29.6 <8",
"@istanbuljs/load-nyc-config>js-yaml": ">=4.2.0 <5",
"cookie@<0.7.0": ">=0.7.1 <1",
"dompurify@<=3.4.10": ">=3.4.11 <4",
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1 <0.29.0",
"js-cookie@<=3.0.5": ">=3.0.7 <4",
"js-yaml@>=4.0.0 <=4.1.1": ">=4.2.0 <5",
"prismjs@<1.30.0": ">=1.30.0 <2",
"react-router@>=6.7.0 <6.30.4": ">=6.30.4 <7",
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
}
}
}

View File

@@ -187,11 +187,11 @@ importers:
specifier: 2.8.8
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
overlayscrollbars:
specifier: ^2.8.1
version: 2.9.2
specifier: ^2.16.0
version: 2.16.0
overlayscrollbars-react:
specifier: ^0.5.6
version: 0.5.6(overlayscrollbars@2.9.2)(react@18.2.0)
version: 0.5.6(overlayscrollbars@2.16.0)(react@18.2.0)
papaparse:
specifier: 5.4.1
version: 5.4.1
@@ -6941,8 +6941,8 @@ packages:
overlayscrollbars: ^2.0.0
react: '>=16.8.0'
overlayscrollbars@2.9.2:
resolution: {integrity: sha512-iDT84r39i7oWP72diZN2mbJUsn/taCq568aQaIrc84S87PunBT7qtsVltAF2esk7ORTRjQDnfjVYoqqTzgs8QA==}
overlayscrollbars@2.16.0:
resolution: {integrity: sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==}
oxfmt@0.54.0:
resolution: {integrity: sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ==}
@@ -16456,12 +16456,12 @@ snapshots:
outvariant@1.4.0: {}
overlayscrollbars-react@0.5.6(overlayscrollbars@2.9.2)(react@18.2.0):
overlayscrollbars-react@0.5.6(overlayscrollbars@2.16.0)(react@18.2.0):
dependencies:
overlayscrollbars: 2.9.2
overlayscrollbars: 2.16.0
react: 18.2.0
overlayscrollbars@2.9.2: {}
overlayscrollbars@2.16.0: {}
oxfmt@0.54.0:
dependencies:

View File

@@ -62,7 +62,7 @@
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
}

View File

@@ -87,7 +87,7 @@
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
}

View File

@@ -134,8 +134,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
}
if (
(pathname.startsWith(`${ROUTES.LLM_OBSERVABILITY_BASE}/`) ||
pathname === ROUTES.LLM_OBSERVABILITY_BASE) &&
(pathname.startsWith(`${ROUTES.AI_OBSERVABILITY_BASE}/`) ||
pathname === ROUTES.AI_OBSERVABILITY_BASE) &&
!isAIObservabilityEnabled
) {
return <Redirect to={ROUTES.HOME} />;

View File

@@ -514,24 +514,24 @@ const routes: AppRoutes[] = [
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
path: ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
key: 'AI_OBSERVABILITY_ATTRIBUTE_MAPPING',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
path: ROUTES.AI_OBSERVABILITY_OVERVIEW,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_OVERVIEW',
key: 'AI_OBSERVABILITY_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
path: ROUTES.AI_OBSERVABILITY_CONFIGURATION,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_CONFIGURATION',
key: 'AI_OBSERVABILITY_CONFIGURATION',
isPrivate: true,
},
];

View File

@@ -0,0 +1,20 @@
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
export function mockFieldsAPIsWithEmptyResponse(): void {
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
),
),
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/values`, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
),
),
);
}

View File

@@ -3479,6 +3479,7 @@ export enum TelemetrytypesFieldContextDTO {
resource = 'resource',
attribute = 'attribute',
body = 'body',
'' = '',
}
export enum TelemetrytypesFieldDataTypeDTO {
string = 'string',
@@ -3486,11 +3487,13 @@ export enum TelemetrytypesFieldDataTypeDTO {
float64 = 'float64',
int64 = 'int64',
number = 'number',
'' = '',
}
export enum TelemetrytypesSignalDTO {
traces = 'traces',
logs = 'logs',
metrics = 'metrics',
'' = '',
}
export interface Querybuildertypesv5GroupByKeyDTO {
/**
@@ -9267,6 +9270,48 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
items: SpantypesSpanMapperGroupDTO[];
}
export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOAttributes =
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOResource =
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
export interface SpantypesSpanMapperTestSpanDTO {
/**
* @type object,null
*/
attributes?: SpantypesSpanMapperTestSpanDTOAttributes;
/**
* @type object,null
*/
resource?: SpantypesSpanMapperTestSpanDTOResource;
}
export interface SpantypesGettableSpanMapperTestDTO {
/**
* @type array,null
*/
collectorLogs?: string[] | null;
/**
* @type array,null
*/
spans?: SpantypesSpanMapperTestSpanDTO[] | null;
}
export enum SpantypesSpanMapperOperationDTO {
move = 'move',
copy = 'copy',
@@ -9608,6 +9653,33 @@ export interface SpantypesPostableSpanMapperGroupDTO {
name: string;
}
export interface SpantypesPostableSpanMapperTestGroupDTO {
condition: SpantypesSpanMapperGroupConditionDTO | null;
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type array,null
*/
mappers?: SpantypesPostableSpanMapperDTO[] | null;
/**
* @type string
*/
name: string;
}
export interface SpantypesPostableSpanMapperTestDTO {
/**
* @type array,null
*/
groups: SpantypesPostableSpanMapperTestGroupDTO[] | null;
/**
* @type array,null
*/
spans: SpantypesSpanMapperTestSpanDTO[] | null;
}
export interface SpantypesSpanAggregationDTO {
aggregation: SpantypesSpanAggregationTypeDTO;
field: TelemetrytypesTelemetryFieldKeyDTO;
@@ -10958,6 +11030,14 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type TestSpanMappers200 = {
data: SpantypesGettableSpanMapperTestDTO;
/**
* @type string
*/
status: string;
};
export type GetStats200Data = { [key: string]: unknown };
export type GetStats200 = {

View File

@@ -30,8 +30,10 @@ import type {
RenderErrorResponseDTO,
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesPostableSpanMapperTestDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
TestSpanMappers200,
UpdateSpanMapperGroupPathParameters,
UpdateSpanMapperPathParameters,
} from '../sigNoz.schemas';
@@ -780,3 +782,86 @@ 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 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 128 128"><path fill="#ea4535" d="M80.6 40.3h.4l-.2-.2 14-14v-.3c-11.8-10.4-28.1-14-43.2-9.5C36.5 20.8 24.9 32.8 20.7 48c.2-.1.5-.2.8-.2 5.2-3.4 11.4-5.4 17.9-5.4 2.2 0 4.3.2 6.4.6.1-.1.2-.1.3-.1 9-9.9 24.2-11.1 34.6-2.6h-.1z"/><path fill="#557ebf" d="M108.1 47.8c-2.3-8.5-7.1-16.2-13.8-22.1L80 39.9c6 4.9 9.5 12.3 9.3 20v2.5c16.9 0 16.9 25.2 0 25.2H63.9v20h-.1l.1.2h25.4c14.6.1 27.5-9.3 31.8-23.1 4.3-13.8-1-28.8-13-36.9z"/><path fill="#36a852" d="M39 107.9h26.3V87.7H39c-1.9 0-3.7-.4-5.4-1.1l-15.2 14.6v.2c6 4.3 13.2 6.6 20.7 6.6z"/><path fill="#f9bc15" d="M40.2 41.9c-14.9.1-28.1 9.3-32.9 22.8-4.8 13.6 0 28.5 11.8 37.3l15.6-14.9c-8.6-3.7-10.6-14.5-4-20.8 6.6-6.4 17.8-4.4 21.7 3.8L68 55.2C61.4 46.9 51.1 42 40.2 42.1z"/></svg>

Before

Width:  |  Height:  |  Size: 805 B

View File

@@ -20,7 +20,7 @@ import { Globe, Inbox, SquarePen } from '@signozhq/icons';
import AnnouncementsModal from './AnnouncementsModal';
import FeedbackModal from './FeedbackModal';
import ShareURLModal from './ShareURLModal';
import ShareURLModal, { type ShareURLExtraOption } from './ShareURLModal';
import './HeaderRightSection.styles.scss';
import { Typography } from '@signozhq/ui/typography';
@@ -29,12 +29,15 @@ interface HeaderRightSectionProps {
enableAnnouncements: boolean;
enableShare: boolean;
enableFeedback: boolean;
/** Optional page-specific toggle for the share dialog (e.g. "Include variables"). */
shareModalExtraOption?: ShareURLExtraOption;
}
function HeaderRightSection({
enableAnnouncements,
enableShare,
enableFeedback,
shareModalExtraOption,
}: HeaderRightSectionProps): JSX.Element | null {
const location = useLocation();
@@ -185,7 +188,7 @@ function HeaderRightSection({
rootClassName="header-section-popover-root"
className="shareable-link-popover"
placement="bottomRight"
content={<ShareURLModal />}
content={<ShareURLModal extraOption={shareModalExtraOption} />}
open={openShareURLModal}
destroyTooltipOnHide
arrow={false}

View File

@@ -24,7 +24,22 @@ const routesToBeSharedWithTime = [
ROUTES.METER_EXPLORER,
];
function ShareURLModal(): JSX.Element {
/**
* An optional, page-specific toggle in the share dialog (e.g. a dashboard's
* "Include variables"). When enabled, `apply` mutates the URL params that go into
* the shared link. Keeps this shared modal generic — the page owns what it adds.
*/
export interface ShareURLExtraOption {
label: string;
defaultEnabled?: boolean;
apply: (params: URLSearchParams) => void;
}
interface ShareURLModalProps {
extraOption?: ShareURLExtraOption;
}
function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
const urlQuery = useUrlQuery();
const location = useLocation();
const { selectedTime } = useSelector<AppState, GlobalReducer>(
@@ -34,6 +49,9 @@ function ShareURLModal(): JSX.Element {
const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(
selectedTime !== 'custom',
);
const [enableExtraOption, setEnableExtraOption] = useState(
extraOption?.defaultEnabled ?? false,
);
const startTime = urlQuery.get(QueryParams.startTime);
const endTime = urlQuery.get(QueryParams.endTime);
@@ -93,6 +111,11 @@ function ShareURLModal(): JSX.Element {
}
}
if (extraOption && enableExtraOption) {
extraOption.apply(urlQuery);
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
}
return currentUrl;
};
@@ -143,6 +166,20 @@ function ShareURLModal(): JSX.Element {
</>
)}
{extraOption && (
<div className="absolute-relative-time-toggler-container">
<Typography.Text className="absolute-relative-time-toggler-label">
{extraOption.label}
</Typography.Text>
<div className="absolute-relative-time-toggler">
<Switch
value={enableExtraOption}
onChange={(): void => setEnableExtraOption((prev) => !prev)}
/>
</div>
</div>
)}
<div className="share-link">
<div className="url-share-container">
<div className="url-share-container-header">

View File

@@ -247,11 +247,6 @@
var(--l2-background)
) !important;
&,
.ͼ1a {
color: var(--query-builder-v2-color, var(--l2-foreground));
}
::-moz-selection {
background: var(
--query-builder-v2-selection-background-color,

View File

@@ -293,11 +293,6 @@ $max-recents-shown: 5;
var(--l2-background)
) !important;
&,
.ͼ1a {
color: var(--query-builder-v2-color, var(--l2-foreground));
}
::-moz-selection {
background: var(
--query-builder-v2-selection-background-color,

View File

@@ -89,10 +89,10 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: '/llm-observability/attribute-mapping',
LLM_OBSERVABILITY_BASE: '/llm-observability',
LLM_OBSERVABILITY_OVERVIEW: '/llm-observability/overview',
LLM_OBSERVABILITY_CONFIGURATION: '/llm-observability/configuration',
AI_OBSERVABILITY_ATTRIBUTE_MAPPING: '/ai-observability/attribute-mapping',
AI_OBSERVABILITY_BASE: '/ai-observability',
AI_OBSERVABILITY_OVERVIEW: '/ai-observability/overview',
AI_OBSERVABILITY_CONFIGURATION: '/ai-observability/configuration',
} as const;
export default ROUTES;

View File

@@ -1,148 +1,39 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button, Drawer, Tooltip } from 'antd';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { X } from '@signozhq/icons';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { Drawer } from 'antd';
import logEvent from 'api/common/logEvent';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import APIError from 'types/api/error';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import {
BarChart,
ChevronsLeftRight,
Compass,
DraftingCompass,
ScrollText,
X,
} from '@signozhq/icons';
import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import {
LogsAggregatorOperator,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
GlobalTimeProvider,
NANO_SECOND_MULTIPLIER,
useGlobalTimeStore,
} from 'store/globalTime';
import { InfraMonitoringEntity, VIEW_TYPES } from '../constants';
import EntityEvents from '../EntityDetailsUtils/EntityEvents';
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
import {
SelectedItemParams,
useInfraMonitoringEventsFilters,
useInfraMonitoringLogFilters,
useInfraMonitoringSelectedItemParams,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from '../hooks';
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants';
import { useInfraMonitoringSelectedItemParams } from '../hooks';
import LoadingContainer from '../LoadingContainer';
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
import { K8sBaseDetailsProps } from './types';
import '../EntityDetailsUtils/entityDetails.styles.scss';
import { parseAsString, useQueryState } from 'nuqs';
import {
EntityCountConfig,
EntityCountsSection,
} from './components/EntityCountsSection/EntityCountsSection';
const TimeRangeOffset = 1000000000;
export type {
CustomTab,
CustomTabRenderProps,
K8sBaseDetailsProps,
K8sDetailsCountConfig,
K8sDetailsFilters,
K8sDetailsMetadataConfig,
} from './types';
export interface K8sDetailsMetadataConfig<T> {
label: string;
getValue: (entity: T) => string | number;
render?: (value: string | number, entity: T) => React.ReactNode;
}
export type K8sDetailsCountConfig<T> = EntityCountConfig<T>;
export interface K8sDetailsFilters {
filter: { expression: string };
start: number;
end: number;
}
export interface CustomTabRenderProps<T> {
entity: T;
timeRange: { startTime: number; endTime: number };
selectedInterval: Time;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
}
export interface CustomTab<T> {
key: string;
label: string;
icon: React.ReactNode;
render: (props: CustomTabRenderProps<T>) => React.ReactNode;
}
export interface K8sBaseDetailsProps<T> {
category: InfraMonitoringEntity;
eventCategory: string;
// Data fetching configuration
getSelectedItemExpression: (params: SelectedItemParams) => string;
fetchEntityData: (
filters: K8sDetailsFilters,
signal?: AbortSignal,
) => Promise<{ data: T | null; error?: APIError | null }>;
// Entity configuration
getEntityName: (entity: T) => string;
getInitialLogTracesExpression: (entity: T) => string;
getInitialEventsExpression: (entity: T) => string;
metadataConfig: K8sDetailsMetadataConfig<T>[];
countsConfig?: K8sDetailsCountConfig<T>[];
getCountsFilterExpression?: (entity: T) => string;
entityWidgetInfo: {
title: string;
yAxisUnit: string;
docPath?: string;
}[];
getEntityQueryPayload: (
entity: T,
start: number,
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
queryKeyPrefix: string;
/** When true, only metrics are shown and the Metrics/Logs/Traces/Events tab bar is hidden. */
hideDetailViewTabs?: boolean;
tabsConfig?: {
showMetrics?: boolean;
showLogs?: boolean;
showTraces?: boolean;
showEvents?: boolean;
};
customTabs?: Array<CustomTab<T>>;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function K8sBaseDetails<T>({
category,
eventCategory,
@@ -163,7 +54,6 @@ export default function K8sBaseDetails<T>({
}: K8sBaseDetailsProps<T>): JSX.Element {
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const lastComputedMinMax = useGlobalTimeStore((s) => s.lastComputedMinMax);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
@@ -237,86 +127,9 @@ export default function K8sBaseDetails<T>({
const entityName = entity ? getEntityName(entity) : '';
// Content state (previously in K8sBaseDetailsContent)
const tabVisibility = useMemo(
() => ({
showMetrics: true,
showLogs: true,
showTraces: true,
showEvents: true,
...tabsConfig,
}),
[tabsConfig],
);
const { startMs, endMs } = useMemo(
() => ({
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER),
}),
[lastComputedMinMax],
);
const [modalTimeRange, setModalTimeRange] = useState(() => ({
startTime: startMs,
endTime: endMs,
}));
// TODO(h4ad): Remove this and use context/zustand
const lastSelectedInterval = useRef<Time | null>(null);
const [selectedInterval, setSelectedInterval] = useState<Time>(
lastSelectedInterval.current
? lastSelectedInterval.current
: isCustomTimeRange(selectedTime)
? DEFAULT_TIME_RANGE
: selectedTime,
);
const [selectedView, setSelectedView] = useInfraMonitoringView();
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
const validTabs = useMemo(() => {
const tabs: string[] = [];
if (tabVisibility.showMetrics) {
tabs.push(VIEW_TYPES.METRICS);
}
if (tabVisibility.showLogs) {
tabs.push(VIEW_TYPES.LOGS);
}
if (tabVisibility.showTraces) {
tabs.push(VIEW_TYPES.TRACES);
}
if (tabVisibility.showEvents) {
tabs.push(VIEW_TYPES.EVENTS);
}
if (customTabs) {
tabs.push(...customTabs.map((t) => t.key));
}
return tabs;
}, [tabVisibility, customTabs]);
useEffect(() => {
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
void setSelectedView(firstValid);
}
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
const [, setEventsFiltersParam] = useInfraMonitoringEventsFilters();
const [userLogsExpression] = useQueryState(
K8S_ENTITY_LOGS_EXPRESSION_KEY,
parseAsString,
);
const [userTracesExpression] = useQueryState(
K8S_ENTITY_TRACES_EXPRESSION_KEY,
parseAsString,
);
useEffect(() => {
if (entity) {
logEvent(InfraMonitoringEvents.PageVisited, {
void logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
@@ -324,136 +137,6 @@ export default function K8sBaseDetails<T>({
}
}, [entity, eventCategory]);
useEffect(() => {
const currentSelectedInterval = lastSelectedInterval.current || selectedTime;
if (!isCustomTimeRange(currentSelectedInterval)) {
setSelectedInterval(currentSelectedInterval);
const { minTime, maxTime } = getMinMaxTime();
setModalTimeRange({
startTime: Math.floor(minTime / TimeRangeOffset),
endTime: Math.floor(maxTime / TimeRangeOffset),
});
}
}, [getMinMaxTime, selectedTime]);
const handleTabChange = (value: string | null): void => {
if (!value) {
return;
}
setSelectedView(value);
setLogFiltersParam(null);
setTracesFiltersParam(null);
setEventsFiltersParam(null);
logEvent(InfraMonitoringEvents.TabChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: value,
});
};
const handleTimeChange = useCallback(
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
lastSelectedInterval.current = interval as Time;
setSelectedInterval(interval as Time);
if (interval === 'custom' && dateTimeRange) {
setModalTimeRange({
startTime: Math.floor(dateTimeRange[0] / 1000),
endTime: Math.floor(dateTimeRange[1] / 1000),
});
} else {
const { maxTime, minTime } = GetMinMax(interval);
setModalTimeRange({
startTime: Math.floor(minTime / TimeRangeOffset),
endTime: Math.floor(maxTime / TimeRangeOffset),
});
}
logEvent(InfraMonitoringEvents.TimeUpdated, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
interval,
view: effectiveView,
});
},
[eventCategory, effectiveView],
);
const handleExplorePagesRedirect = (): void => {
const urlQuery = new URLSearchParams();
if (selectedInterval !== 'custom') {
urlQuery.set(QueryParams.relativeTime, selectedInterval);
} else {
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.startTime, modalTimeRange.startTime.toString());
urlQuery.set(QueryParams.endTime, modalTimeRange.endTime.toString());
}
logEvent(InfraMonitoringEvents.ExploreClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: selectedView,
});
if (selectedView === VIEW_TYPES.LOGS) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userLogsExpression || '',
);
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
expression: fullExpression,
filter: { expression: fullExpression },
},
],
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userTracesExpression || '',
);
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.traces,
aggregateOperator: TracesAggregatorOperator.NOOP,
expression: fullExpression,
filter: { expression: fullExpression },
},
],
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}
};
return (
<Drawer
width="70%"
@@ -498,207 +181,33 @@ export default function K8sBaseDetails<T>({
</div>
)}
{entity && !isEntityLoading && !hasResponseError && (
<>
<div className="entity-detail-drawer__entity">
<div className="entity-details-grid">
<div className="labels-row">
{metadataConfig.map((config) => (
<Typography.Text
key={config.label}
color="muted"
className="entity-details-metadata-label"
>
{config.label}
</Typography.Text>
))}
</div>
<div className="values-row">
{metadataConfig.map((config) => {
const value = config.getValue(entity);
const displayValue = String(value);
return (
<Typography.Text
key={config.label}
className="entity-details-metadata-value"
>
{config.render ? (
config.render(value, entity)
) : (
<Tooltip title={displayValue}>{displayValue}</Tooltip>
)}
</Typography.Text>
);
})}
</div>
</div>
{countsConfig &&
countsConfig.length > 0 &&
selectedItem &&
getCountsFilterExpression && (
<EntityCountsSection
entity={entity}
countsConfig={countsConfig}
selectedItem={selectedItem}
filterExpression={getCountsFilterExpression(entity)}
closeDrawer={handleClose}
/>
)}
</div>
{!hideDetailViewTabs && (
<div className="views-tabs-container">
<ToggleGroupSimple
type="single"
className="views-tabs"
onChange={handleTabChange}
value={selectedView}
items={[
...(tabVisibility.showMetrics
? [
{
value: VIEW_TYPES.METRICS,
label: (
<div className="view-title">
<BarChart size={14} />
Metrics
</div>
),
},
]
: []),
...(tabVisibility.showLogs
? [
{
value: VIEW_TYPES.LOGS,
label: (
<div className="view-title">
<ScrollText size={14} />
Logs
</div>
),
},
]
: []),
...(tabVisibility.showTraces
? [
{
value: VIEW_TYPES.TRACES,
label: (
<div className="view-title">
<DraftingCompass size={14} />
Traces
</div>
),
},
]
: []),
...(tabVisibility.showEvents
? [
{
value: VIEW_TYPES.EVENTS,
label: (
<div className="view-title">
<ChevronsLeftRight size={14} />
Events
</div>
),
},
]
: []),
...(customTabs?.map((tab) => ({
value: tab.key,
label: (
<div className="view-title">
{tab.icon}
{tab.label}
</div>
),
})) ?? []),
]}
/>
{selectedView === VIEW_TYPES.LOGS && (
<Tooltip title="Go to Logs Explorer" placement="left">
<Button
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
/>
</Tooltip>
)}
{selectedView === VIEW_TYPES.TRACES && (
<Tooltip title="Go to Traces Explorer" placement="left">
<Button
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
/>
</Tooltip>
)}
</div>
)}
{effectiveView === VIEW_TYPES.METRICS && (
<EntityMetrics<T>
entity={entity}
selectedInterval={selectedInterval}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
isModalTimeSelection
entityWidgetInfo={entityWidgetInfo}
getEntityQueryPayload={getEntityQueryPayload}
category={category}
queryKey={`${queryKeyPrefix}Metrics`}
/>
)}
{effectiveView === VIEW_TYPES.LOGS && (
<EntityLogs
timeRange={modalTimeRange}
isModalTimeSelection
handleTimeChange={handleTimeChange}
selectedInterval={selectedInterval}
queryKey={`${queryKeyPrefix}Logs`}
category={category}
initialExpression={logsAndTracesInitialExpression}
/>
)}
{effectiveView === VIEW_TYPES.TRACES && (
<EntityTraces
timeRange={modalTimeRange}
isModalTimeSelection
handleTimeChange={handleTimeChange}
selectedInterval={selectedInterval}
queryKey={`${queryKeyPrefix}Traces`}
category={category}
initialExpression={logsAndTracesInitialExpression}
/>
)}
{effectiveView === VIEW_TYPES.EVENTS && tabVisibility.showEvents && (
<EntityEvents
timeRange={modalTimeRange}
isModalTimeSelection
handleTimeChange={handleTimeChange}
selectedInterval={selectedInterval}
category={category}
queryKey={`${queryKeyPrefix}Events`}
initialExpression={eventsInitialExpression}
/>
)}
{customTabs?.map((tab) =>
selectedView === tab.key ? (
<React.Fragment key={tab.key}>
{tab.render({
entity,
timeRange: modalTimeRange,
selectedInterval,
handleTimeChange,
})}
</React.Fragment>
) : null,
)}
</>
<GlobalTimeProvider
inheritGlobalTime
enableUrlParams={{
relativeTimeKey: INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
startTimeKey: INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
endTimeKey: INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
}}
>
<K8sBaseDetailsContent<T>
entity={entity}
category={category}
eventCategory={eventCategory}
metadataConfig={metadataConfig}
countsConfig={countsConfig}
getCountsFilterExpression={getCountsFilterExpression}
selectedItem={selectedItem}
handleClose={handleClose}
entityWidgetInfo={entityWidgetInfo}
getEntityQueryPayload={getEntityQueryPayload}
queryKeyPrefix={queryKeyPrefix}
hideDetailViewTabs={hideDetailViewTabs}
tabsConfig={tabsConfig}
customTabs={customTabs}
logsAndTracesInitialExpression={logsAndTracesInitialExpression}
eventsInitialExpression={eventsInitialExpression}
/>
</GlobalTimeProvider>
)}
</Drawer>
);

View File

@@ -0,0 +1,401 @@
import { Fragment, useEffect, useMemo } from 'react';
import {
BarChart,
ChevronsLeftRight,
Compass,
DraftingCompass,
ScrollText,
} from '@signozhq/icons';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Typography } from '@signozhq/ui/typography';
import { Button, Tooltip } from 'antd';
import logEvent from 'api/common/logEvent';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { parseAsString, useQueryState } from 'nuqs';
import {
LogsAggregatorOperator,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { VIEW_TYPES } from '../constants';
import { useEntityDetailsTime } from '../EntityDetailsUtils/EntityDateTimeSelector/useEntityDetailsTime';
import EntityEvents from '../EntityDetailsUtils/EntityEvents';
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
import {
useInfraMonitoringEventsFilters,
useInfraMonitoringLogFilters,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from '../hooks';
import { EntityCountsSection } from './components/EntityCountsSection/EntityCountsSection';
import { K8sBaseDetailsContentProps } from './types';
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function K8sBaseDetailsContent<T>({
entity,
category,
eventCategory,
metadataConfig,
countsConfig,
getCountsFilterExpression,
selectedItem,
handleClose,
entityWidgetInfo,
getEntityQueryPayload,
queryKeyPrefix,
hideDetailViewTabs,
tabsConfig,
customTabs,
logsAndTracesInitialExpression,
eventsInitialExpression,
}: K8sBaseDetailsContentProps<T>): JSX.Element {
const { timeRange, selectedInterval, handleTimeChange } =
useEntityDetailsTime();
const tabVisibility = useMemo(
() => ({
showMetrics: true,
showLogs: true,
showTraces: true,
showEvents: true,
...tabsConfig,
}),
[tabsConfig],
);
const [selectedView, setSelectedView] = useInfraMonitoringView();
const validTabs = useMemo(() => {
const tabs: string[] = [];
if (tabVisibility.showMetrics) {
tabs.push(VIEW_TYPES.METRICS);
}
if (tabVisibility.showLogs) {
tabs.push(VIEW_TYPES.LOGS);
}
if (tabVisibility.showTraces) {
tabs.push(VIEW_TYPES.TRACES);
}
if (tabVisibility.showEvents) {
tabs.push(VIEW_TYPES.EVENTS);
}
if (customTabs) {
tabs.push(...customTabs.map((t) => t.key));
}
return tabs;
}, [tabVisibility, customTabs]);
useEffect(() => {
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
void setSelectedView(firstValid);
}
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
const [, setEventsFiltersParam] = useInfraMonitoringEventsFilters();
const [userLogsExpression] = useQueryState(
K8S_ENTITY_LOGS_EXPRESSION_KEY,
parseAsString,
);
const [userTracesExpression] = useQueryState(
K8S_ENTITY_TRACES_EXPRESSION_KEY,
parseAsString,
);
const handleTabChange = (value: string | null): void => {
if (!value) {
return;
}
void setSelectedView(value);
void setLogFiltersParam(null);
void setTracesFiltersParam(null);
void setEventsFiltersParam(null);
void logEvent(InfraMonitoringEvents.TabChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: value,
});
};
const handleExplorePagesRedirect = (): void => {
const urlQuery = new URLSearchParams();
if (selectedInterval !== 'custom') {
urlQuery.set(QueryParams.relativeTime, selectedInterval);
} else {
urlQuery.delete(QueryParams.relativeTime);
// Explorer URL params are in milliseconds, timeRange is in seconds
urlQuery.set(QueryParams.startTime, (timeRange.startTime * 1000).toString());
urlQuery.set(QueryParams.endTime, (timeRange.endTime * 1000).toString());
}
void logEvent(InfraMonitoringEvents.ExploreClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: selectedView,
});
if (selectedView === VIEW_TYPES.LOGS) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userLogsExpression || '',
);
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
expression: fullExpression,
filter: { expression: fullExpression },
},
],
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userTracesExpression || '',
);
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.traces,
aggregateOperator: TracesAggregatorOperator.NOOP,
expression: fullExpression,
filter: { expression: fullExpression },
},
],
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}
};
return (
<>
<div className="entity-detail-drawer__entity">
<div className="entity-details-grid">
<div className="labels-row">
{metadataConfig.map((config) => (
<Typography.Text
key={config.label}
color="muted"
className="entity-details-metadata-label"
>
{config.label}
</Typography.Text>
))}
</div>
<div className="values-row">
{metadataConfig.map((config) => {
const value = config.getValue(entity);
const displayValue = String(value);
return (
<Typography.Text
key={config.label}
className="entity-details-metadata-value"
>
{config.render ? (
config.render(value, entity)
) : (
<Tooltip title={displayValue}>{displayValue}</Tooltip>
)}
</Typography.Text>
);
})}
</div>
</div>
{countsConfig &&
countsConfig.length > 0 &&
selectedItem &&
getCountsFilterExpression && (
<EntityCountsSection
entity={entity}
countsConfig={countsConfig}
selectedItem={selectedItem}
filterExpression={getCountsFilterExpression(entity)}
closeDrawer={handleClose}
/>
)}
</div>
{!hideDetailViewTabs && (
<div className="views-tabs-container">
<ToggleGroupSimple
type="single"
className="views-tabs"
onChange={handleTabChange}
value={selectedView}
items={[
...(tabVisibility.showMetrics
? [
{
value: VIEW_TYPES.METRICS,
label: (
<div className="view-title">
<BarChart size={14} />
Metrics
</div>
),
},
]
: []),
...(tabVisibility.showLogs
? [
{
value: VIEW_TYPES.LOGS,
label: (
<div className="view-title">
<ScrollText size={14} />
Logs
</div>
),
},
]
: []),
...(tabVisibility.showTraces
? [
{
value: VIEW_TYPES.TRACES,
label: (
<div className="view-title">
<DraftingCompass size={14} />
Traces
</div>
),
},
]
: []),
...(tabVisibility.showEvents
? [
{
value: VIEW_TYPES.EVENTS,
label: (
<div className="view-title">
<ChevronsLeftRight size={14} />
Events
</div>
),
},
]
: []),
...(customTabs?.map((tab) => ({
value: tab.key,
label: (
<div className="view-title">
{tab.icon}
{tab.label}
</div>
),
})) ?? []),
]}
/>
{selectedView === VIEW_TYPES.LOGS && (
<Tooltip title="Go to Logs Explorer" placement="left">
<Button
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
/>
</Tooltip>
)}
{selectedView === VIEW_TYPES.TRACES && (
<Tooltip title="Go to Traces Explorer" placement="left">
<Button
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
/>
</Tooltip>
)}
</div>
)}
{effectiveView === VIEW_TYPES.METRICS && (
<EntityMetrics<T>
entity={entity}
eventEntity={eventCategory}
entityWidgetInfo={entityWidgetInfo}
getEntityQueryPayload={getEntityQueryPayload}
category={category}
queryKey={`${queryKeyPrefix}Metrics`}
/>
)}
{effectiveView === VIEW_TYPES.LOGS && (
<EntityLogs
eventEntity={eventCategory}
queryKey={`${queryKeyPrefix}Logs`}
category={category}
initialExpression={logsAndTracesInitialExpression}
/>
)}
{effectiveView === VIEW_TYPES.TRACES && (
<EntityTraces
eventEntity={eventCategory}
queryKey={`${queryKeyPrefix}Traces`}
category={category}
initialExpression={logsAndTracesInitialExpression}
/>
)}
{effectiveView === VIEW_TYPES.EVENTS && tabVisibility.showEvents && (
<EntityEvents
eventEntity={eventCategory}
queryKey={`${queryKeyPrefix}Events`}
initialExpression={eventsInitialExpression}
category={category}
/>
)}
{customTabs?.map((tab) =>
selectedView === tab.key ? (
<Fragment key={tab.key}>
{tab.render({
entity,
timeRange,
selectedInterval,
handleTimeChange,
})}
</Fragment>
) : null,
)}
</>
);
}

View File

@@ -245,6 +245,7 @@ function K8sHeader<TData>({
showAutoRefresh={showAutoRefresh}
showRefreshText={false}
hideShareModal
defaultRelativeTime="30m"
/>
</div>

View File

@@ -52,7 +52,6 @@ export function EntityCountsSection<T>({
},
};
// TODO(H4ad): After https://github.com/SigNoz/signoz/pull/12038, inherit custom time of drawer to list
const urlParams = new URLSearchParams();
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
urlParams.set(
@@ -60,6 +59,44 @@ export function EntityCountsSection<T>({
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const currentSearchParams = new URLSearchParams(window.location.search);
const detailRelativeTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
);
const detailStartTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
);
const detailEndTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
);
const listRelativeTime = currentSearchParams.get(QueryParams.relativeTime);
const listStartTime = currentSearchParams.get(QueryParams.startTime);
const listEndTime = currentSearchParams.get(QueryParams.endTime);
if (listRelativeTime) {
urlParams.set(QueryParams.relativeTime, listRelativeTime);
} else if (listStartTime && listEndTime) {
urlParams.set(QueryParams.startTime, listStartTime);
urlParams.set(QueryParams.endTime, listEndTime);
}
if (detailRelativeTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
detailRelativeTime,
);
} else if (detailStartTime && detailEndTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
detailStartTime,
);
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
detailEndTime,
);
}
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
};

View File

@@ -1,3 +1,17 @@
import { ReactNode } from 'react';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import APIError from 'types/api/error';
import { EntityCountConfig } from './components/EntityCountsSection/EntityCountsSection';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
export type K8sDetailsCountConfig<T> = EntityCountConfig<T>;
export type K8sBaseFilters = {
filter: {
expression: string;
@@ -33,3 +47,100 @@ export type K8sTableRowData<T> = T & {
/** Metadata about which attributes were used for grouping */
groupedByMeta?: Record<string, string>;
};
export interface K8sDetailsMetadataConfig<T> {
label: string;
getValue: (entity: T) => string | number;
render?: (value: string | number, entity: T) => ReactNode;
}
export interface K8sDetailsFilters {
filter: { expression: string };
start: number;
end: number;
}
export interface K8sDetailsWidgetInfo {
title: string;
yAxisUnit: string;
}
export type GetEntityQueryPayload<T> = (
entity: T,
start: number,
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
export interface K8sDetailsTabsConfig {
showMetrics?: boolean;
showLogs?: boolean;
showTraces?: boolean;
showEvents?: boolean;
}
export interface K8sDetailsCustomTabRenderProps<T> {
entity: T;
/** Time range in seconds — see useEntityDetailsTime */
timeRange: { startTime: number; endTime: number };
selectedInterval: Time;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
}
export interface K8sDetailsCustomTab<T> {
key: string;
label: string;
icon: ReactNode;
render: (props: K8sDetailsCustomTabRenderProps<T>) => ReactNode;
}
export interface K8sBaseDetailsProps<T> {
category: InfraMonitoringEntity;
eventCategory: string;
// Data fetching configuration
getSelectedItemExpression: (params: SelectedItemParams) => string;
fetchEntityData: (
filters: K8sDetailsFilters,
signal?: AbortSignal,
) => Promise<{ data: T | null; error?: APIError | null }>;
// Entity configuration
getEntityName: (entity: T) => string;
getInitialLogTracesExpression: (entity: T) => string;
getInitialEventsExpression: (entity: T) => string;
metadataConfig: K8sDetailsMetadataConfig<T>[];
countsConfig?: K8sDetailsCountConfig<T>[];
getCountsFilterExpression?: (entity: T) => string;
entityWidgetInfo: K8sDetailsWidgetInfo[];
getEntityQueryPayload: GetEntityQueryPayload<T>;
queryKeyPrefix: string;
/** When true, only metrics are shown and the Metrics/Logs/Traces/Events tab bar is hidden. */
hideDetailViewTabs?: boolean;
tabsConfig?: K8sDetailsTabsConfig;
customTabs?: K8sDetailsCustomTab<T>[];
}
export interface K8sBaseDetailsContentProps<T> {
entity: T;
category: InfraMonitoringEntity;
eventCategory: string;
metadataConfig: K8sDetailsMetadataConfig<T>[];
countsConfig?: K8sDetailsCountConfig<T>[];
getCountsFilterExpression?: (entity: T) => string;
selectedItem: string | null;
handleClose: () => void;
entityWidgetInfo: K8sDetailsWidgetInfo[];
getEntityQueryPayload: GetEntityQueryPayload<T>;
queryKeyPrefix: string;
hideDetailViewTabs: boolean;
tabsConfig?: K8sDetailsTabsConfig;
customTabs?: K8sDetailsCustomTab<T>[];
logsAndTracesInitialExpression: string;
eventsInitialExpression: string;
}
// Aliases for backward compatibility
export type CustomTab<T> = K8sDetailsCustomTab<T>;
export type CustomTabRenderProps<T> = K8sDetailsCustomTabRenderProps<T>;

View File

@@ -0,0 +1,78 @@
import { useCallback } from 'react';
import { Undo } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import { InfraMonitoringEvents } from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useEntityDetailsTime } from './useEntityDetailsTime';
interface EntityDateTimeSelectorProps {
eventEntity: string;
category: InfraMonitoringEntity;
view: string;
}
function EntityDateTimeSelector({
eventEntity,
category,
view,
}: EntityDateTimeSelectorProps): JSX.Element {
const {
timeRange,
selectedInterval,
handleTimeChange,
handleResetToParentTime,
hasTimeChanged,
} = useEntityDetailsTime();
const onTimeChange = useCallback(
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
void logEvent(InfraMonitoringEvents.TimeUpdated, {
entity: eventEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view,
interval,
});
handleTimeChange(interval, dateTimeRange);
},
[category, view, eventEntity, handleTimeChange],
);
return (
<div className="entity-date-time-selector">
{hasTimeChanged && (
<TooltipSimple title="Reset to list time" side="bottom">
<Button
variant="outlined"
color="secondary"
onClick={handleResetToParentTime}
data-testid="reset-to-list-time-button"
prefix={<Undo size={14} />}
>
Reset
</Button>
</TooltipSimple>
)}
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection
onTimeChange={onTimeChange}
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
</div>
);
}
export default EntityDateTimeSelector;

View File

@@ -0,0 +1,95 @@
import { useCallback, useMemo } from 'react';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import {
createCustomTimeRange,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
useGlobalTime,
} from 'store/globalTime';
export interface EntityDetailsTimeRange {
/** Unix timestamp in seconds — the unit API payload builders expect. Multiply by 1000 for ms-based UI (modals, URL params). */
startTime: number;
/** Unix timestamp in seconds — the unit API payload builders expect. Multiply by 1000 for ms-based UI (modals, URL params). */
endTime: number;
}
export interface UseEntityDetailsTimeResult {
/** Time range in seconds */
timeRange: EntityDetailsTimeRange;
selectedInterval: Time;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
handleResetToParentTime: () => boolean;
canResetToParent: boolean;
hasTimeChanged: boolean;
}
export function useEntityDetailsTime(): UseEntityDetailsTimeResult {
const selectedTime = useGlobalTime((s) => s.selectedTime);
const lastComputedMinMax = useGlobalTime((s) => s.lastComputedMinMax);
const setSelectedTime = useGlobalTime((s) => s.setSelectedTime);
const handleResetToParentTime = useGlobalTime((s) => s.resetToParentTime);
const parentStore = useGlobalTime((s) => s.parentStore);
const canResetToParent = !!parentStore;
const hasTimeChanged = useMemo(() => {
if (!parentStore) {
return false;
}
return selectedTime !== parentStore.getState().selectedTime;
}, [parentStore, selectedTime]);
const timeRange = useMemo<EntityDetailsTimeRange>(
() => ({
startTime: Math.floor(
lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER / 1000,
),
endTime: Math.floor(
lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER / 1000,
),
}),
[lastComputedMinMax],
);
const selectedInterval = useMemo<Time>(
() =>
isCustomTimeRange(selectedTime)
? ('custom' as Time)
: (selectedTime as Time),
[selectedTime],
);
const handleTimeChange = useCallback(
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
if (interval === 'custom' && dateTimeRange) {
// DateTimeSelector delivers milliseconds; the store keys custom
// ranges by nanoseconds.
setSelectedTime(
createCustomTimeRange(
dateTimeRange[0] * NANO_SECOND_MULTIPLIER,
dateTimeRange[1] * NANO_SECOND_MULTIPLIER,
),
);
} else {
setSelectedTime(interval as Time);
}
},
[setSelectedTime],
);
return {
timeRange,
selectedInterval,
handleTimeChange,
handleResetToParentTime,
canResetToParent,
hasTimeChanged,
};
}

View File

@@ -21,17 +21,14 @@ import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { useQueryState } from 'nuqs';
import { DataSource } from 'types/common/queryBuilder';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { validateQuery } from 'utils/queryValidationUtils';
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
import EntityError from '../EntityError/EntityError';
import { EventContents } from './EventsContent';
@@ -53,16 +50,7 @@ interface EventDataType {
}
interface Props {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
eventEntity: string;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
@@ -77,13 +65,11 @@ const handleExpandRow = (record: EventDataType): JSX.Element => (
);
function EntityEventsContent({
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
eventEntity,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const { timeRange } = useEntityDetailsTime();
const expression = useExpression();
const inputExpression = useInputExpression();
const userExpression = useUserExpression();
@@ -131,8 +117,8 @@ function EntityEventsContent({
if (validation.isValid) {
querySearchOnRun(newUserExpression || '');
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
void logEvent(InfraMonitoringEvents.FilterApplied, {
entity: eventEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.EventsView,
@@ -141,7 +127,14 @@ function EntityEventsContent({
refetch();
}
},
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
[
inputExpression,
initialExpression,
refetch,
querySearchOnRun,
category,
eventEntity,
],
);
const queryData = useMemo(
@@ -229,16 +222,10 @@ function EntityEventsContent({
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={InfraMonitoringEvents.EventsView}
/>
<RunQueryBtn

View File

@@ -29,25 +29,25 @@ function verifyEntityEventsV5Request({
expect(orderKeys).toContain('timestamp');
}
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__esModule: true,
default: ({
onTimeChange,
}: {
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
}): JSX.Element => (
<button
type="button"
data-testid="mock-datetime-selection"
onClick={(): void => {
onTimeChange?.('5m');
}}
>
Select Time
</button>
default: (): JSX.Element => (
<div data-testid="mock-datetime-selection">Date Time</div>
),
}));
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
useEntityDetailsTime: (): {
timeRange: { startTime: number; endTime: number };
selectedInterval: string;
handleTimeChange: jest.Mock;
} => ({
timeRange: { startTime: 1, endTime: 2 },
selectedInterval: '5m',
handleTimeChange: jest.fn(),
}),
}));
describe('EntityEvents', () => {
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
@@ -69,10 +69,7 @@ describe('EntityEvents', () => {
searchParams={`${K8S_ENTITY_EVENTS_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
>
<EntityEvents
timeRange={{ startTime: 1, endTime: 2 }}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval="5m"
eventEntity="test"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression='k8s.pod.name = "x"'

View File

@@ -0,0 +1,81 @@
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
import { mockQueryRangeV5WithEventsResponse } from '__tests__/query_range_v5.util';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
createCustomTimeRange,
createGlobalTimeStore,
GlobalTimeContext,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime';
import { act, render, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import EntityEvents from '../EntityEvents';
import { K8S_ENTITY_EVENTS_EXPRESSION_KEY } from '../hooks';
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="mock-datetime-selection">Date Time</div>
),
}));
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
describe('EntityEvents time range wiring', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithEventsResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
mockFieldsAPIsWithEmptyResponse();
});
it('should query the V5 API with the global time range converted to milliseconds', async () => {
const store = createGlobalTimeStore();
store
.getState()
.setSelectedTime(
createCustomTimeRange(
START_MS * NANO_SECOND_MULTIPLIER,
END_MS * NANO_SECOND_MULTIPLIER,
),
);
const expression = 'k8s.pod.name = "test-pod"';
act(() => {
render(
<GlobalTimeContext.Provider value={store}>
<NuqsTestingAdapter
searchParams={`${K8S_ENTITY_EVENTS_EXPRESSION_KEY}=${encodeURIComponent(
expression,
)}`}
>
<EntityEvents
eventEntity="test"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression={expression}
/>
</NuqsTestingAdapter>
</GlobalTimeContext.Provider>,
);
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
expect(capturedPayloads[0].start).toBe(START_MS);
expect(capturedPayloads[0].end).toBe(END_MS);
});
});

View File

@@ -25,11 +25,6 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
@@ -38,6 +33,8 @@ import { ILog } from 'types/api/logs/log';
import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
import EntityError from '../EntityError/EntityError';
import { isKeyNotFoundError } from '../utils';
@@ -47,29 +44,18 @@ import { getEntityLogsQueryPayload } from './utils';
import styles from './EntityLogs.module.scss';
interface Props {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
eventEntity: string;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
}
function EntityLogsContent({
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
eventEntity,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const { timeRange } = useEntityDetailsTime();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const expression = useExpression();
@@ -134,7 +120,7 @@ function EntityLogsContent({
if (validation.isValid) {
querySearchOnRun(newUserExpression);
logEvent(InfraMonitoringEvents.FilterApplied, {
void logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
@@ -239,16 +225,10 @@ function EntityLogsContent({
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={InfraMonitoringEvents.LogsView}
/>
<RunQueryBtn

View File

@@ -50,25 +50,25 @@ jest.mock(
},
);
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__esModule: true,
default: ({
onTimeChange,
}: {
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
}): JSX.Element => (
<button
type="button"
data-testid="mock-datetime-selection"
onClick={(): void => {
onTimeChange?.('5m');
}}
>
Select Time
</button>
default: (): JSX.Element => (
<div data-testid="mock-datetime-selection">Date Time</div>
),
}));
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
useEntityDetailsTime: (): {
timeRange: { startTime: number; endTime: number };
selectedInterval: string;
handleTimeChange: jest.Mock;
} => ({
timeRange: { startTime: 1, endTime: 2 },
selectedInterval: '5m',
handleTimeChange: jest.fn(),
}),
}));
describe('EntityLogs', () => {
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
const itemHeight = 100;
@@ -94,10 +94,7 @@ describe('EntityLogs', () => {
>
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight }}>
<EntityLogs
timeRange={{ startTime: 1, endTime: 2 }}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval="5m"
eventEntity="test"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression='k8s.pod.name = "x"'

View File

@@ -0,0 +1,93 @@
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
import { mockQueryRangeV5WithLogsResponse } from '__tests__/query_range_v5.util';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
createCustomTimeRange,
createGlobalTimeStore,
GlobalTimeContext,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime';
import { act, render, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import EntityLogs from '../EntityLogs';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../hooks';
jest.mock(
'components/OverlayScrollbar/OverlayScrollbar',
() =>
function MockOverlayScrollbar({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return <div>{children}</div>;
},
);
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="mock-datetime-selection">Date Time</div>
),
}));
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
describe('EntityLogs time range wiring', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithLogsResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
mockFieldsAPIsWithEmptyResponse();
});
it('should query the V5 API with the global time range converted to milliseconds', async () => {
const store = createGlobalTimeStore();
store
.getState()
.setSelectedTime(
createCustomTimeRange(
START_MS * NANO_SECOND_MULTIPLIER,
END_MS * NANO_SECOND_MULTIPLIER,
),
);
const expression = 'k8s.pod.name = "test-pod"';
act(() => {
render(
<GlobalTimeContext.Provider value={store}>
<NuqsTestingAdapter
searchParams={`${K8S_ENTITY_LOGS_EXPRESSION_KEY}=${encodeURIComponent(
expression,
)}`}
>
<EntityLogs
eventEntity="test"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression={expression}
/>
</NuqsTestingAdapter>
</GlobalTimeContext.Provider>,
);
});
await waitFor(() => {
expect(capturedPayloads.length).toBeGreaterThanOrEqual(1);
});
expect(capturedPayloads[0].start).toBe(START_MS);
expect(capturedPayloads[0].end).toBe(END_MS);
});
});

View File

@@ -2,15 +2,11 @@ import { useCallback, useMemo, useRef } from 'react';
import { UseQueryResult } from 'react-query';
import { Skeleton } from 'antd';
import cx from 'classnames';
import { InfraMonitoringEvents } from 'constants/events';
import { PANEL_TYPES } from 'constants/queryBuilder';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -24,25 +20,17 @@ import { getMetricsExplorerUrl } from 'utils/explorerUtils';
import { buildEntityMetricsChartConfig } from './configBuilder';
import ChartHeader from './ChartHeader';
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
import { useEntityMetrics } from './hooks';
import { isKeyNotFoundError } from '../utils';
import styles from './EntityMetrics.module.scss';
import { MetricsTable } from './MetricsTable';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
interface EntityMetricsProps<T> {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
entity: T;
eventEntity: string;
entityWidgetInfo: {
title: string;
yAxisUnit: string;
@@ -59,16 +47,16 @@ interface EntityMetricsProps<T> {
}
function EntityMetrics<T>({
selectedInterval,
entity,
timeRange,
handleTimeChange,
isModalTimeSelection,
eventEntity,
entityWidgetInfo,
getEntityQueryPayload,
queryKey,
category,
}: EntityMetricsProps<T>): JSX.Element {
const { timeRange, selectedInterval, handleTimeChange } =
useEntityDetailsTime();
const { visibilities, setElement } = useMultiIntersectionObserver(
entityWidgetInfo.length,
{ threshold: 0.1 },
@@ -91,10 +79,8 @@ function EntityMetrics<T>({
const onDragSelect = useCallback(
(start: number, end: number): void => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
handleTimeChange('custom', [startTimestamp, endTimestamp]);
// UPlotConfigBuilder's setSelect hook already delivers milliseconds
handleTimeChange('custom', [Math.trunc(start), Math.trunc(end)]);
},
[handleTimeChange],
);
@@ -188,16 +174,10 @@ function EntityMetrics<T>({
return (
<>
<div className={styles.metricsHeader}>
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
onTimeChange={handleTimeChange}
defaultRelativeTime={DEFAULT_TIME_RANGE}
isModalTimeSelection={isModalTimeSelection}
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={InfraMonitoringEvents.MetricsView}
/>
</div>
<div className={styles.entityMetricsContainer}>

View File

@@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import * as appContextHooks from 'providers/App/App';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
import uPlot from 'uplot';
@@ -28,13 +27,28 @@ jest.mock('lib/uPlotV2/utils/dataUtils', () => ({
hasSingleVisiblePoint: jest.fn().mockReturnValue(false),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="date-time-selection">Date Time</div>
),
}));
const mockTimeRange = { startTime: 1705315200, endTime: 1705318800 };
let mockSelectedInterval = '5m';
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
useEntityDetailsTime: (): {
timeRange: { startTime: number; endTime: number };
selectedInterval: string;
handleTimeChange: jest.Mock;
} => ({
timeRange: mockTimeRange,
selectedInterval: mockSelectedInterval,
handleTimeChange: jest.fn(),
}),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries',
() => ({
@@ -144,13 +158,6 @@ const mockGetEntityQueryPayload = jest.fn().mockReturnValue([
},
]);
const mockTimeRange = {
startTime: 1705315200,
endTime: 1705318800,
};
const mockHandleTimeChange = jest.fn();
const mockQueries = [
{
data: {
@@ -283,10 +290,6 @@ const mockEmptyQueries = [
const renderEntityMetrics = (overrides = {}): any => {
const defaultProps = {
timeRange: mockTimeRange,
isModalTimeSelection: false,
handleTimeChange: mockHandleTimeChange,
selectedInterval: '5m' as Time,
entity: mockEntity,
entityWidgetInfo: mockEntityWidgetInfo,
getEntityQueryPayload: mockGetEntityQueryPayload,
@@ -298,11 +301,8 @@ const renderEntityMetrics = (overrides = {}): any => {
return render(
<MemoryRouter>
<EntityMetrics
timeRange={defaultProps.timeRange}
isModalTimeSelection={defaultProps.isModalTimeSelection}
handleTimeChange={defaultProps.handleTimeChange}
selectedInterval={defaultProps.selectedInterval}
entity={defaultProps.entity}
eventEntity="test"
entityWidgetInfo={defaultProps.entityWidgetInfo}
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
queryKey={defaultProps.queryKey}
@@ -344,6 +344,7 @@ const mockQueryPayloads = [
describe('EntityMetrics', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSelectedInterval = '5m';
mockUseEntityMetrics.mockReturnValue({
queries: mockQueries as any,
chartData: mockChartData,
@@ -454,7 +455,8 @@ describe('EntityMetrics', () => {
});
it('builds metrics explorer link with relativeTime when a relative interval is selected', () => {
renderEntityMetrics({ selectedInterval: '5m' as Time });
mockSelectedInterval = '5m';
renderEntityMetrics();
const href = screen
.getByTestId('open-metrics-explorer-0')
.getAttribute('href');
@@ -464,7 +466,8 @@ describe('EntityMetrics', () => {
});
it('builds metrics explorer link with absolute time range in milliseconds for custom interval', () => {
renderEntityMetrics({ selectedInterval: 'custom' as Time });
mockSelectedInterval = 'custom';
renderEntityMetrics();
const href = screen
.getByTestId('open-metrics-explorer-0')
.getAttribute('href');
@@ -478,7 +481,6 @@ describe('EntityMetrics', () => {
expect(mockUseEntityMetrics).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: 'test-query-key',
timeRange: mockTimeRange,
entity: mockEntity,
category: InfraMonitoringEntity.PODS,
}),

View File

@@ -0,0 +1,119 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import {
createCustomTimeRange,
createGlobalTimeStore,
GlobalTimeContext,
GlobalTimeStoreApi,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime';
import { act, render } from 'tests/test-utils';
import { buildEntityMetricsChartConfig } from '../configBuilder';
import EntityMetrics from '../EntityMetrics';
jest.mock('../configBuilder', () => ({
buildEntityMetricsChartConfig: jest.fn().mockReturnValue({
getId: jest.fn().mockReturnValue('mock-id'),
}),
}));
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="date-time-selection">Date Time</div>
),
}));
const mockBuildChartConfig = buildEntityMetricsChartConfig as jest.Mock;
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
const START_SECONDS = 1705315200;
const END_SECONDS = 1705318800;
const entity = { id: 'test-entity-1' };
// The jsdom IntersectionObserver polyfill never reports visibility, so the
// queries stay disabled and no network request is issued.
const queryPayload = {
graphType: PANEL_TYPES.TIME_SERIES,
} as unknown as GetQueryResultsProps;
function createStoreWithCustomRange(): GlobalTimeStoreApi {
const store = createGlobalTimeStore();
store
.getState()
.setSelectedTime(
createCustomTimeRange(
START_MS * NANO_SECOND_MULTIPLIER,
END_MS * NANO_SECOND_MULTIPLIER,
),
);
return store;
}
function renderEntityMetrics(store: GlobalTimeStoreApi): jest.Mock {
const getEntityQueryPayload = jest.fn().mockReturnValue([queryPayload]);
render(
<GlobalTimeContext.Provider value={store}>
<EntityMetrics
entity={entity}
eventEntity="test"
entityWidgetInfo={[{ title: 'CPU Usage', yAxisUnit: 'percentage' }]}
getEntityQueryPayload={getEntityQueryPayload}
queryKey="test-query-key"
category={InfraMonitoringEntity.PODS}
/>
</GlobalTimeContext.Provider>,
);
return getEntityQueryPayload;
}
describe('EntityMetrics time range wiring', () => {
beforeEach(() => {
mockBuildChartConfig.mockClear();
});
it('should build the metrics query payloads and chart config from the global time range in seconds', () => {
const store = createStoreWithCustomRange();
const getEntityQueryPayload = renderEntityMetrics(store);
expect(getEntityQueryPayload).toHaveBeenCalledWith(
entity,
START_SECONDS,
END_SECONDS,
false,
);
expect(mockBuildChartConfig).toHaveBeenCalledWith(
expect.objectContaining({
minTimeScale: START_SECONDS,
maxTimeScale: END_SECONDS,
}),
);
});
it('should store a drag selection (received in milliseconds) as the equivalent custom range', () => {
const store = createStoreWithCustomRange();
renderEntityMetrics(store);
const { onDragSelect } = mockBuildChartConfig.mock.calls[0][0];
// UPlotConfigBuilder's setSelect hook calls onDragSelect with milliseconds
const dragStartMs = 1705316000000;
const dragEndMs = 1705317000000;
act(() => {
onDragSelect(dragStartMs, dragEndMs);
});
expect(store.getState().selectedTime).toBe(
createCustomTimeRange(
dragStartMs * NANO_SECOND_MULTIPLIER,
dragEndMs * NANO_SECOND_MULTIPLIER,
),
);
});
});

View File

@@ -20,11 +20,6 @@ import { InfraMonitoringEvents } from 'constants/events';
import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
import { useQueryState } from 'nuqs';
@@ -32,10 +27,11 @@ import { DataSource } from 'types/common/queryBuilder';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { validateQuery } from 'utils/queryValidationUtils';
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
import EntityError from '../EntityError/EntityError';
import { selectedEntityTracesColumns } from '../utils';
import { isKeyNotFoundError } from '../utils';
import { isKeyNotFoundError, selectedEntityTracesColumns } from '../utils';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY, useEntityTraces } from './hooks';
import { getTraceListColumns } from './traceListColumns';
import { getEntityTracesQueryPayload } from './utils';
@@ -44,29 +40,18 @@ import styles from './EntityTraces.module.scss';
import { useTimezone } from 'providers/Timezone';
interface Props {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
eventEntity: string;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
}
function EntityTracesContent({
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
eventEntity,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const { timeRange } = useEntityDetailsTime();
const expression = useExpression();
const inputExpression = useInputExpression();
const userExpression = useUserExpression();
@@ -114,8 +99,8 @@ function EntityTracesContent({
if (validation.isValid) {
querySearchOnRun(newUserExpression || '');
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
void logEvent(InfraMonitoringEvents.FilterApplied, {
entity: eventEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.TracesView,
@@ -124,7 +109,14 @@ function EntityTracesContent({
refetch();
}
},
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
[
inputExpression,
initialExpression,
refetch,
querySearchOnRun,
category,
eventEntity,
],
);
const queryData = useMemo(
@@ -152,7 +144,7 @@ function EntityTracesContent({
const hasAdditionalFilters = !!userExpression?.trim();
const handleRowClick = useCallback(() => {
logEvent(InfraMonitoringEvents.ItemClicked, {
void logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
category,
view: InfraMonitoringEvents.TracesView,
@@ -169,16 +161,10 @@ function EntityTracesContent({
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={InfraMonitoringEvents.TracesView}
/>
<RunQueryBtn

View File

@@ -34,10 +34,8 @@ describe('EntityTraces - Default Behavior', () => {
});
it('should pass time range to API (converted to milliseconds)', async () => {
const timeRange = { startTime: 1000, endTime: 2000 };
act(() => {
renderEntityTraces({ timeRange });
renderEntityTraces();
});
await waitFor(() => {
@@ -47,8 +45,8 @@ describe('EntityTraces - Default Behavior', () => {
verifyQueryPayload({
payload: capturedPayloads[0],
expectedTimeRange: {
start: timeRange.startTime * 1000,
end: timeRange.endTime * 1000,
start: 1 * 1000,
end: 2 * 1000,
},
});
});

View File

@@ -0,0 +1,81 @@
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
createCustomTimeRange,
createGlobalTimeStore,
GlobalTimeContext,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime';
import { act, render, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import EntityTraces from '../EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../hooks';
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="mock-datetime-selection">Date Time</div>
),
}));
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
describe('EntityTraces time range wiring', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithTracesResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
mockFieldsAPIsWithEmptyResponse();
});
it('should query the V5 API with the global time range converted to milliseconds', async () => {
const store = createGlobalTimeStore();
store
.getState()
.setSelectedTime(
createCustomTimeRange(
START_MS * NANO_SECOND_MULTIPLIER,
END_MS * NANO_SECOND_MULTIPLIER,
),
);
const expression = 'k8s.pod.name = "test-pod"';
act(() => {
render(
<GlobalTimeContext.Provider value={store}>
<NuqsTestingAdapter
searchParams={`${K8S_ENTITY_TRACES_EXPRESSION_KEY}=${encodeURIComponent(
expression,
)}`}
>
<EntityTraces
eventEntity="test"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression={expression}
/>
</NuqsTestingAdapter>
</GlobalTimeContext.Provider>,
);
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
expect(capturedPayloads[0].start).toBe(START_MS);
expect(capturedPayloads[0].end).toBe(END_MS);
});
});

View File

@@ -1,7 +1,5 @@
import { ENVIRONMENT } from 'constants/env';
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, RenderResult } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
@@ -9,39 +7,19 @@ import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import EntityTraces from '../EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../hooks';
// QuerySearch fires autocomplete requests on mount; without handlers MSW
// passes them through to the real network and the resulting AxiosError fails
// whichever test happens to be running.
beforeEach(() => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
),
),
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/values`, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
),
),
);
mockFieldsAPIsWithEmptyResponse();
});
export interface RenderEntityTracesOptions {
expression?: string;
timeRange?: { startTime: number; endTime: number };
category?: InfraMonitoringEntity;
selectedInterval?: '5m' | '15m' | '30m' | '1h';
pagination?: { offset: number; limit: number };
}
export function renderEntityTraces({
expression = 'k8s.pod.name = "test-pod"',
timeRange = { startTime: 1, endTime: 2 },
category = InfraMonitoringEntity.PODS,
selectedInterval = '5m',
pagination,
}: RenderEntityTracesOptions = {}): RenderResult {
const encodedExpression = encodeURIComponent(expression);
@@ -55,10 +33,7 @@ export function renderEntityTraces({
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<EntityTraces
timeRange={timeRange}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval={selectedInterval}
eventEntity="test"
queryKey="test"
category={category}
initialExpression={expression}
@@ -101,21 +76,21 @@ export function verifyQueryPayload({
expect(orderKeys).toContain('timestamp');
}
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__esModule: true,
default: ({
onTimeChange,
}: {
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
}): JSX.Element => (
<button
type="button"
data-testid="mock-datetime-selection"
onClick={(): void => {
onTimeChange?.('5m');
}}
>
Select Time
</button>
default: (): JSX.Element => (
<div data-testid="mock-datetime-selection">Date Time</div>
),
}));
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
useEntityDetailsTime: (): {
timeRange: { startTime: number; endTime: number };
selectedInterval: string;
handleTimeChange: jest.Mock;
} => ({
timeRange: { startTime: 1, endTime: 2 },
selectedInterval: '5m',
handleTimeChange: jest.fn(),
}),
}));

View File

@@ -1,4 +1,5 @@
import { Container } from '@signozhq/icons';
import { InfraMonitoringEvents } from 'constants/events';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { CustomTab } from '../Base/K8sBaseDetails';
@@ -10,6 +11,20 @@ import {
import EntityMetrics from './EntityMetrics';
const categoryToEventEntity: Record<InfraMonitoringEntity, string> = {
[InfraMonitoringEntity.DAEMONSETS]: InfraMonitoringEvents.DaemonSet,
[InfraMonitoringEntity.DEPLOYMENTS]: InfraMonitoringEvents.Deployment,
[InfraMonitoringEntity.JOBS]: InfraMonitoringEvents.Job,
[InfraMonitoringEntity.NAMESPACES]: InfraMonitoringEvents.Namespace,
[InfraMonitoringEntity.STATEFULSETS]: InfraMonitoringEvents.StatefulSet,
[InfraMonitoringEntity.PODS]: InfraMonitoringEvents.Pod,
[InfraMonitoringEntity.NODES]: InfraMonitoringEvents.Node,
[InfraMonitoringEntity.CLUSTERS]: InfraMonitoringEvents.Cluster,
[InfraMonitoringEntity.VOLUMES]: InfraMonitoringEvents.Volume,
[InfraMonitoringEntity.HOSTS]: InfraMonitoringEvents.HostEntity,
[InfraMonitoringEntity.CONTAINERS]: 'container',
};
interface CreatePodMetricsTabParams<T> {
getQueryPayload: (
entity: T,
@@ -17,30 +32,29 @@ interface CreatePodMetricsTabParams<T> {
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
category: InfraMonitoringEntity;
queryKey: string;
category: InfraMonitoringEntity;
}
export function createPodMetricsTab<T>({
getQueryPayload,
category,
queryKey,
category,
}: CreatePodMetricsTabParams<T>): CustomTab<T> {
const eventEntity = categoryToEventEntity[category];
return {
key: VIEW_TYPES.POD_METRICS,
label: 'Pod Metrics',
icon: <Container size={14} />,
render: ({ entity, timeRange, selectedInterval, handleTimeChange }) => (
render: ({ entity }) => (
<EntityMetrics
entity={entity}
selectedInterval={selectedInterval}
timeRange={timeRange}
handleTimeChange={handleTimeChange}
isModalTimeSelection
eventEntity={eventEntity}
entityWidgetInfo={podUtilizationByPodWidgetInfo}
getEntityQueryPayload={getQueryPayload}
category={category}
queryKey={queryKey}
category={category}
/>
),
};

View File

@@ -454,3 +454,9 @@
gap: 16px;
}
}
.entity-date-time-selector {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -905,6 +905,9 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
SELECTED_ITEM: 'selectedItem',
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
DETAIL_RELATIVE_TIME: 'detailRelativeTime',
DETAIL_START_TIME: 'detailStartTime',
DETAIL_END_TIME: 'detailEndTime',
};
/** Metric namespace prefixes for /fields/keys and /fields/values APIs */

View File

@@ -17,12 +17,9 @@ import { ChevronDown, Dot, PencilLine, Plug, Plus } from '@signozhq/icons';
import AzureCloudAccountSetupModal from '../../AzureCloudServices/AddNewAccount/CloudAccountSetupModal';
import AzureAccountSettingsModal from '../../AzureCloudServices/EditAccount/AccountSettingsModal';
import GcpCloudAccountSetupDrawer from '../../GoogleCloudPlatform/AddNewAccount/CloudAccountSetupDrawer';
import GcpAccountSettingsDrawer from '../../GoogleCloudPlatform/EditAccount/AccountSettingsDrawer';
import {
mapAccountDtoToAwsCloudAccount,
mapAccountDtoToAzureCloudAccount,
mapAccountDtoToGcpCloudAccount,
} from '../../mapCloudAccountFromDto';
import AwsCloudAccountSetupModal from '../AddNewAccount/CloudAccountSetupModal';
import AwsAccountSettingsModal from '../EditAccount/AccountSettingsModal';
@@ -159,18 +156,6 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
});
}
if (type === IntegrationType.GCP_SERVICES) {
raw.forEach((account) => {
if (!account) {
return;
}
const mapped = mapAccountDtoToGcpCloudAccount(account);
if (mapped) {
mappedAccounts.push(mapped);
}
});
}
return mappedAccounts;
}, [listAccountsResponse, type]);
@@ -222,23 +207,13 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
// log telemetry event when an account is viewed.
useEffect(() => {
if (activeAccount) {
const { config } = activeAccount;
let enabledRegions: string[];
if ('regions' in config) {
// AWS
enabledRegions = config.regions;
} else if ('resource_groups' in config) {
// Azure
enabledRegions = config.resource_groups;
} else {
// GCP
enabledRegions = config.project_ids;
}
logEvent(`${type} Integration: Account viewed`, {
cloudAccountId: activeAccount?.cloud_account_id,
status: activeAccount?.status,
enabledRegions,
enabledRegions:
'regions' in activeAccount.config
? activeAccount.config.regions
: activeAccount.config.resource_groups,
});
}
}, [activeAccount, type]);
@@ -285,11 +260,6 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
onClose={(): void => setIsIntegrationModalOpen(false)}
/>
)}
{type === IntegrationType.GCP_SERVICES && (
<GcpCloudAccountSetupDrawer
onClose={(): void => setIsIntegrationModalOpen(false)}
/>
)}
</>
)}
@@ -311,13 +281,6 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
setActiveAccount={setActiveAccount}
/>
)}
{type === IntegrationType.GCP_SERVICES && (
<GcpAccountSettingsDrawer
onClose={(): void => setIsAccountSettingsModalOpen(false)}
account={activeAccount}
setActiveAccount={setActiveAccount}
/>
)}
</>
)}
</div>

View File

@@ -46,31 +46,14 @@ const EMPTY_FORM_VALUES: ServiceConfigFormValues = {
s3BucketsByRegion: {},
};
function getIntegrationServiceConfig(
type: IntegrationType,
serviceDetailsData?: ServiceDetailsData,
):
| { logs?: { enabled?: boolean }; metrics?: { enabled?: boolean } }
| undefined {
const config = serviceDetailsData?.cloudIntegrationService?.config;
if (type === IntegrationType.AWS_SERVICES) {
return config?.aws;
}
if (type === IntegrationType.GCP_SERVICES) {
return config?.gcp;
}
return config?.azure;
}
function getInitialFormValues(
type: IntegrationType,
serviceDetailsData?: ServiceDetailsData,
): ServiceConfigFormValues {
const integrationConfig = getIntegrationServiceConfig(
type,
serviceDetailsData,
);
const integrationConfig =
type === IntegrationType.AWS_SERVICES
? serviceDetailsData?.cloudIntegrationService?.config?.aws
: serviceDetailsData?.cloudIntegrationService?.config?.azure;
return {
logsEnabled: integrationConfig?.logs?.enabled || false,
@@ -115,21 +98,16 @@ function getServiceConfigPayload({
};
}
// Azure and GCP share the same simple logs/metrics enable-flag shape.
const signalConfig = {
logs: {
enabled: isLogsSupported ? logsEnabled : false,
},
metrics: {
enabled: isMetricsSupported ? metricsEnabled : false,
return {
azure: {
logs: {
enabled: isLogsSupported ? logsEnabled : false,
},
metrics: {
enabled: isMetricsSupported ? metricsEnabled : false,
},
},
};
if (type === IntegrationType.GCP_SERVICES) {
return { gcp: signalConfig };
}
return { azure: signalConfig };
}
function ServiceDetails({
@@ -185,10 +163,10 @@ function ServiceDetails({
? isAccountServiceLoading
: isReadOnlyServiceLoading;
const integrationConfig = getIntegrationServiceConfig(
type,
serviceDetailsData,
);
const integrationConfig =
type === IntegrationType.AWS_SERVICES
? serviceDetailsData?.cloudIntegrationService?.config?.aws
: serviceDetailsData?.cloudIntegrationService?.config?.azure;
const isServiceEnabledInPersistedConfig =
Boolean(integrationConfig?.logs?.enabled) ||
Boolean(integrationConfig?.metrics?.enabled);

View File

@@ -36,12 +36,8 @@ function AccountSettingsModal({
const queryClient = useQueryClient();
// `account.config` is the shared per-provider union (Azure | AWS | GCP).
// Narrow to Azure by `resource_groups` (Azure-only) rather than
// `deployment_region`, which GCP also has — so it no longer identifies
// Azure uniquely.
const azureConfig = useMemo(
() => ('resource_groups' in account.config ? account.config : null),
() => ('deployment_region' in account.config ? account.config : null),
[account.config],
);

View File

@@ -1,150 +0,0 @@
.setupDrawer {
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
// Fill every field with --l2-background (Input/ComboboxSimple default to transparent).
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--combobox-trigger-background-color: var(--l2-background);
// Text uses the brighter --l1-foreground, placeholders the duller --l3-foreground.
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
--combobox-trigger-placeholder-color: var(--l3-foreground);
// Shared resting border (--l2-border) and text size; focus borders stay per-component.
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--l2-border);
--combobox-trigger-border-color: var(--l2-border);
--input-font-size: var(--periscope-font-size-base);
--combobox-trigger-font-size: var(--periscope-font-size-base);
--combobox-trigger-value-font-size: var(--periscope-font-size-base);
// Bounded flex column so the header/footer stay put and only the body
// scrolls when content overflows.
display: flex;
flex-direction: column;
overflow: hidden;
[data-slot='drawer-header'],
[data-slot='drawer-footer'] {
flex-shrink: 0;
}
// The drawer body renders inside [data-slot='drawer-description'] — this is
// the only region allowed to scroll.
[data-slot='drawer-description'] {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--spacing-10);
min-height: 0;
padding: var(--spacing-10) var(--spacing-12);
overflow-y: auto;
}
[data-slot='select-content'] {
width: var(--radix-select-trigger-width);
}
[data-slot='combobox-content'] {
z-index: 5;
background: var(--l1-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
// Selected region value: bright, like every other field's text.
[data-slot='combobox-value'] {
color: var(--l1-foreground);
}
// Empty trigger (.regionEmpty, set from the RHF value): dull the placeholder text.
.regionEmpty [data-slot='combobox-value'] {
color: var(--l3-foreground);
}
}
.title {
h3 {
margin: 0;
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-semibold);
}
}
.footerContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0;
}
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.mono {
composes: mono from './shared.module.scss';
}
.fullWidth {
width: 100%;
}
.fieldError {
composes: fieldError from './shared.module.scss';
}
.projectIdsSelect {
:global(.ant-select-selector) {
min-height: 36px;
background: var(--l2-background);
border: 1px solid var(--l2-border) !important;
}
&:hover :global(.ant-select-selector),
&:global(.ant-select-focused) :global(.ant-select-selector) {
border-color: var(--l2-border);
box-shadow: none;
}
// antd defaults to 14px; pin to 13px to line up with the Input/Combobox fields.
:global(.ant-select-selection-placeholder),
:global(.ant-select-selection-search-input),
:global(.ant-select-selection-item) {
font-size: var(--periscope-font-size-base);
}
:global(.ant-select-selection-placeholder) {
color: var(--l3-foreground);
}
:global(.ant-select-selection-search-input) {
color: var(--l1-foreground);
}
:global(.ant-select-selection-item) {
color: var(--l1-foreground);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
:global(.ant-select-selection-item-remove) {
color: var(--l3-foreground);
&:hover {
color: var(--l1-foreground);
}
}
}

View File

@@ -1,286 +0,0 @@
import { useEffect, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { ComboboxSimple } from '@signozhq/ui/combobox';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { Select } from 'antd';
import cx from 'classnames';
import { GCP_REGIONS } from 'container/Integrations/constants';
import { IntegrationModalProps } from 'container/Integrations/HeroSection/types';
import { useCloudAccountSetupDrawer } from 'hooks/integration/gcp/useCloudAccountSetupDrawer';
import { Controller, useForm } from 'react-hook-form';
import { popupContainer } from 'utils/selectPopupContainer';
import ConnectionSecretsFields from './ConnectionSecretsFields';
import FieldLabel from './FieldLabel';
import FlowSelector from './FlowSelector';
import { GcpSetupFormValues, SetupFlow } from './types';
import styles from './CloudAccountSetupDrawer.module.scss';
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
value: region.value,
label: `${region.label} (${region.value})`,
}));
const DEFAULT_VALUES: GcpSetupFormValues = {
accountName: '',
deploymentProjectId: '',
deploymentRegion: '',
projectIds: [],
sigNozApiUrl: '',
sigNozApiKey: '',
ingestionUrl: '',
ingestionKey: '',
};
function CloudAccountSetupDrawer({
onClose,
}: IntegrationModalProps): JSX.Element {
const {
isLoading,
connectAccount,
handleClose,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
} = useCloudAccountSetupDrawer({ onClose });
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
defaultValues: DEFAULT_VALUES,
});
const [flow, setFlow] = useState<SetupFlow>('manual');
// Pre-fill the deployment/ingestion fields with the fetched credentials.
useEffect(() => {
if (!connectionParams) {
return;
}
setValue('sigNozApiUrl', connectionParams.sigNozApiUrl);
setValue('sigNozApiKey', connectionParams.sigNozApiKey);
setValue('ingestionUrl', connectionParams.ingestionUrl);
setValue('ingestionKey', connectionParams.ingestionKey);
}, [connectionParams, setValue]);
const footer = (
<div className={styles.footerContainer}>
{submitError && (
<Callout
type="error"
size="small"
showIcon
action="dismissible"
onClick={clearSubmitError}
title="Failed to connect GCP account"
testId="gcp-connect-error"
>
{submitError}
</Callout>
)}
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={handleClose}
testId="gcp-cancel-btn"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={handleSubmit(connectAccount)}
loading={isLoading}
disabled={isConnectionParamsLoading}
testId="gcp-connect-account-btn"
>
Connect Account
</Button>
</div>
</div>
);
return (
<DrawerWrapper
open={true}
className={styles.setupDrawer}
onOpenChange={(open): void => {
if (!open) {
handleClose();
}
}}
direction="right"
showCloseButton
title="Connect Google Cloud Platform"
width="base"
footer={footer}
drawerHeaderProps={{ className: styles.title }}
>
<FlowSelector value={flow} onChange={setFlow} />
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-account-name-input"
label="Account Name"
tooltip="A label to identify this group of GCP projects (org ID, billing email, or any descriptive name)"
required
/>
<Controller
name="accountName"
control={control}
rules={{ required: 'Please enter an account name' }}
render={({ field, fieldState }): JSX.Element => (
<>
<Input
id="gcp-account-name-input"
className={styles.fullWidth}
placeholder="e.g. my-org or billing@company.com"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-account-name-input"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-deployment-project-id-input"
label="Deployment Project ID"
tooltip="The GCP project that hosts your OTel Collector deployment — often separate from the projects you actually monitor"
required
/>
<Controller
name="deploymentProjectId"
control={control}
rules={{ required: 'Please enter the deployment project ID' }}
render={({ field, fieldState }): JSX.Element => (
<>
<Input
id="gcp-deployment-project-id-input"
className={cx(styles.fullWidth, styles.mono)}
placeholder="e.g. my-deployment-project-123"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-deployment-project-id-input"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-deployment-region-select"
label="Deployment Region"
tooltip="The GCP region where your OTel Collector will be deployed"
required
/>
<Controller
name="deploymentRegion"
control={control}
rules={{ required: 'Please select a region' }}
render={({ field, fieldState }): JSX.Element => (
<>
<ComboboxSimple
id="gcp-deployment-region-select"
className={cx(styles.fullWidth, {
[styles.regionEmpty]: !field.value,
})}
items={REGION_ITEMS}
value={field.value}
onChange={(value): void => field.onChange(value as string)}
placeholder="Select a region..."
inputPlaceholder="Search regions…"
withPortal={false}
testId="gcp-deployment-region-select"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-project-ids-select"
label="Projects to Monitor"
tooltip="Enter each GCP project ID then press Enter"
required
/>
<Controller
name="projectIds"
control={control}
rules={{
validate: (value): true | string =>
value.length > 0 || 'Please add at least one project ID',
}}
render={({ field, fieldState }): JSX.Element => (
<>
<Select
id="gcp-project-ids-select"
className={cx(styles.fullWidth, styles.projectIdsSelect)}
mode="tags"
value={field.value}
onChange={(value): void => field.onChange(value)}
placeholder="Add project IDs…"
tokenSeparators={[',', ' ']}
notFoundContent={null}
suffixIcon={null}
getPopupContainer={popupContainer}
data-testid="gcp-project-ids-select"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<ConnectionSecretsFields
control={control}
isLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
/>
</DrawerWrapper>
);
}
export default CloudAccountSetupDrawer;

View File

@@ -1,71 +0,0 @@
.drawerSurface {
composes: drawerSurface from './shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from './shared.module.scss';
}
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.headLabel {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-2);
}
.mono {
composes: mono from './shared.module.scss';
}
.fieldError {
composes: fieldError from './shared.module.scss';
}
.fullWidth {
width: 100%;
}
.secretsBody {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.skeletonLabel :global(.ant-skeleton-input) {
width: 120px;
min-width: 120px;
height: 14px;
}
.skeletonInput :global(.ant-skeleton-input) {
width: 100%;
min-width: 100%;
height: 36px;
}
.readonlyField {
display: flex;
gap: var(--spacing-2);
align-items: center;
height: 36px;
padding: 0 var(--spacing-2) 0 var(--spacing-4);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
.readonlyValue {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
// Match the other fields' value text: 13px and the brighter --l1-foreground.
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
text-overflow: ellipsis;
white-space: nowrap;
cursor: not-allowed;
}

View File

@@ -1,193 +0,0 @@
import { Lock } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { Control, Controller } from 'react-hook-form';
import FieldLabel from './FieldLabel';
import { GcpSetupFormValues } from './types';
import { SecretFieldType, validateSecretValue } from './validators';
import styles from './ConnectionSecretsFields.module.scss';
type CredentialField = keyof CloudintegrationtypesCredentialsDTO;
interface FieldConfig {
name: CredentialField;
label: string;
tooltip: string;
placeholder: string;
testId: string;
type: SecretFieldType;
}
const FIELDS: FieldConfig[] = [
{
name: 'sigNozApiUrl',
label: 'SigNoz API URL',
tooltip: 'Base URL of your SigNoz instance the collector reports to',
placeholder: 'https://<tenant>.signoz.cloud',
testId: 'gcp-signoz-api-url-input',
type: 'url',
},
{
name: 'sigNozApiKey',
label: 'SigNoz API Key',
tooltip: 'API key used to authenticate with your SigNoz instance',
placeholder: 'Enter SigNoz API key',
testId: 'gcp-signoz-api-key-input',
type: 'text',
},
{
name: 'ingestionUrl',
label: 'Ingestion URL',
tooltip: 'OTLP ingestion endpoint your OTel Collector sends telemetry to',
placeholder: 'https://ingest.<region>.signoz.cloud',
testId: 'gcp-ingestion-url-input',
type: 'url',
},
{
name: 'ingestionKey',
label: 'Ingestion Key',
tooltip: 'Ingestion key that authorizes telemetry sent to SigNoz',
placeholder: 'Enter ingestion key',
testId: 'gcp-ingestion-key-input',
type: 'text',
},
];
interface ConnectionSecretsFieldsProps {
control: Control<GcpSetupFormValues>;
isLoading: boolean;
connectionParams?: CloudintegrationtypesCredentialsDTO;
}
function ConnectionSecretsFields({
control,
isLoading,
connectionParams,
}: ConnectionSecretsFieldsProps): JSX.Element {
const hasMissingValue = FIELDS.some(
(field) => !connectionParams?.[field.name],
);
return (
<div className={styles.drawerSurface}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text weight="bold" size="base">
Deployment details &amp; ingestion secrets
</Typography.Text>
{!hasMissingValue && (
<div className={styles.headLabel}>
<Lock size={12} />
<Typography.Text as="span" size="small" className={styles.headLabel}>
Auto-filled by SigNoz
</Typography.Text>
</div>
)}
</div>
{isLoading ? (
<div className={styles.secretsBody} data-testid="gcp-secrets-skeleton">
{FIELDS.map((field) => (
<div key={field.name} className={styles.drawerSection}>
<Skeleton.Input active size="small" className={styles.skeletonLabel} />
<Skeleton.Input active block className={styles.skeletonInput} />
</div>
))}
</div>
) : (
<div className={styles.secretsBody}>
{FIELDS.map((field) => {
// Backend-provided values are read-only — the user can't edit them, so
// show a truncated value with a copy button. Missing values (enterprise)
// stay editable inputs with no copy button.
const providedValue = connectionParams?.[field.name];
if (providedValue) {
return (
<div key={field.name} className={styles.drawerSection}>
<FieldLabel
htmlFor={field.testId}
label={field.label}
tooltip={field.tooltip}
/>
<div className={styles.readonlyField}>
<Typography.Text
as="span"
id={field.testId}
className={cx(styles.readonlyValue, styles.mono)}
title={providedValue}
testId={field.testId}
>
{providedValue}
</Typography.Text>
<CopyButton
value={providedValue}
size={12}
ariaLabel={`Copy ${field.label}`}
testId={`${field.testId}-copy`}
onCopy={(): void => {
toast.success(`${field.label} copied to clipboard`, {
position: 'bottom-right',
});
}}
/>
</div>
</div>
);
}
return (
<div key={field.name} className={styles.drawerSection}>
<FieldLabel
htmlFor={field.testId}
label={field.label}
tooltip={field.tooltip}
/>
<Controller
name={field.name}
control={control}
rules={{
validate: (value): true | string =>
validateSecretValue(field.label, field.type, value),
}}
render={({ field: rhfField, fieldState }): JSX.Element => (
<>
<Input
id={field.testId}
className={cx(styles.fullWidth, styles.mono)}
placeholder={field.placeholder}
value={rhfField.value}
onChange={(e): void => rhfField.onChange(e.target.value)}
testId={field.testId}
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
);
})}
</div>
)}
</div>
);
}
ConnectionSecretsFields.defaultProps = {
connectionParams: undefined,
};
export default ConnectionSecretsFields;

View File

@@ -1,22 +0,0 @@
.fieldLabel {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.required {
composes: required from './shared.module.scss';
}
.infoTrigger {
display: inline-flex;
align-items: center;
color: var(--l3-foreground);
cursor: help;
}
.tooltipContent {
max-width: 240px;
white-space: normal;
word-break: break-word;
}

View File

@@ -1,49 +0,0 @@
import { Info } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './FieldLabel.module.scss';
interface FieldLabelProps {
htmlFor: string;
label: string;
tooltip: string;
required?: boolean;
}
function FieldLabel({
htmlFor,
label,
tooltip,
required,
}: FieldLabelProps): JSX.Element {
return (
<label className={styles.fieldLabel} htmlFor={htmlFor}>
{label}
<TooltipSimple
title={tooltip}
side="top"
tooltipContentProps={{ className: styles.tooltipContent }}
>
<span
className={styles.infoTrigger}
aria-label={`${label} help`}
data-testid={`${htmlFor}-tooltip`}
>
<Info size={12} />
</span>
</TooltipSimple>
{required && (
<span className={styles.required} aria-hidden="true">
*
</span>
)}
</label>
);
}
FieldLabel.defaultProps = {
required: false,
};
export default FieldLabel;

View File

@@ -1,84 +0,0 @@
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.drawerSurface {
composes: drawerSurface from './shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from './shared.module.scss';
}
.flowRadioGroup {
--radio-group-item-border-color: var(--l2-border);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
.flowRadio {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: var(--spacing-5);
width: 100%;
padding: var(--spacing-5) var(--spacing-6);
margin: 0;
border: 1px solid transparent;
border-radius: var(--radius-2);
box-sizing: border-box;
cursor: pointer;
transition:
background-color 0.12s ease,
border-color 0.12s ease;
> button[role='radio'] {
flex: 0 0 16px;
width: 16px;
height: 16px;
margin-top: 3px;
}
> label {
flex: 1 1 auto;
min-width: 0;
display: block;
text-align: left;
cursor: pointer;
font-size: inherit;
font-weight: inherit;
color: inherit;
}
&.flowRadioManual:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
}
&:hover {
background: var(--l3-background-hover);
}
&:has(button[disabled]) {
cursor: not-allowed;
opacity: 0.55;
&:hover {
background: var(--l3-background);
}
}
}
}
.flowRadioTitle {
display: flex;
gap: var(--spacing-2);
align-items: center;
}
.flowRadioDesc {
margin-top: var(--spacing-2);
}

View File

@@ -1,76 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { SetupFlow } from './types';
import styles from './FlowSelector.module.scss';
interface FlowSelectorProps {
value: SetupFlow;
onChange: (flow: SetupFlow) => void;
}
function FlowSelector({ value, onChange }: FlowSelectorProps): JSX.Element {
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text weight="bold" size="base">
Connection method
</Typography.Text>
</div>
<RadioGroup
value={value}
onChange={(next): void => onChange(next as SetupFlow)}
className={styles.flowRadioGroup}
>
<RadioGroupItem
value="manual"
containerClassName={cx(styles.flowRadio, styles.flowRadioManual)}
testId="gcp-flow-manual"
>
<div className={styles.flowRadioTitle}>
<Typography.Text weight="semibold" size="base">
Connect Manually
</Typography.Text>
</div>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.flowRadioDesc}
>
Deploy your own OTel Collector and configure log sinks.
</Typography.Text>
</RadioGroupItem>
<RadioGroupItem
value="agent"
containerClassName={styles.flowRadio}
testId="gcp-flow-agent"
disabled
>
<div className={styles.flowRadioTitle}>
<Typography.Text weight="semibold" size="base">
Connect via Agent
</Typography.Text>
<Badge color="robin" variant="default">
Soon
</Badge>
</div>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.flowRadioDesc}
>
SigNoz deploys and manages the collector for you.
</Typography.Text>
</RadioGroupItem>
</RadioGroup>
</div>
);
}
export default FlowSelector;

View File

@@ -1,36 +0,0 @@
.drawerSection {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.drawerSection > label {
font-size: var(--periscope-font-size-normal);
color: var(--l2-foreground);
}
.required {
color: var(--accent-cherry);
}
.fieldError {
font-size: var(--periscope-font-size-small);
color: var(--accent-cherry);
}
.drawerSurface {
padding: var(--spacing-7);
border-radius: 6px;
border: 1px solid var(--l2-border);
}
.drawerSurfaceHead {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-5);
}
.mono {
font-family: var(--font-family-mono);
}

View File

@@ -1,12 +0,0 @@
export type SetupFlow = 'manual' | 'agent';
export interface GcpSetupFormValues {
accountName: string;
deploymentProjectId: string;
deploymentRegion: string;
projectIds: string[];
sigNozApiUrl: string;
sigNozApiKey: string;
ingestionUrl: string;
ingestionKey: string;
}

View File

@@ -1,24 +0,0 @@
export type SecretFieldType = 'url' | 'text';
export function isValidUrl(value: string): boolean {
try {
return Boolean(new URL(value));
} catch {
return false;
}
}
export function validateSecretValue(
label: string,
type: SecretFieldType,
value: string | undefined,
): true | string {
const trimmed = value?.trim();
if (!trimmed) {
return `Please enter the ${label}`;
}
if (type === 'url' && !isValidUrl(trimmed)) {
return `Please enter a valid URL for ${label}`;
}
return true;
}

View File

@@ -1,165 +0,0 @@
import { Dispatch, SetStateAction, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { Save } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Form, Select } from 'antd';
import { invalidateListAccounts } from 'api/generated/services/cloudintegration';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { CloudAccount } from 'container/Integrations/types';
import { useAccountSettingsDrawer } from 'hooks/integration/gcp/useAccountSettingsDrawer';
import RemoveIntegrationAccount from '../../RemoveAccount/RemoveIntegrationAccount';
import '../../AmazonWebServices/EditAccount/AccountSettingsModal.style.scss';
interface AccountSettingsDrawerProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
function AccountSettingsDrawer({
onClose,
account,
setActiveAccount,
}: AccountSettingsDrawerProps): JSX.Element {
const {
form,
isLoading,
projectIds,
isSaveDisabled,
setProjectIds,
handleSubmit,
handleClose,
} = useAccountSettingsDrawer({ onClose, account, setActiveAccount });
const queryClient = useQueryClient();
const gcpConfig = useMemo(
() => ('project_ids' in account.config ? account.config : null),
[account.config],
);
return (
<DrawerWrapper
open={true}
className="account-settings-modal"
title="Account Settings"
direction="right"
showCloseButton
onOpenChange={(open): void => {
if (!open) {
handleClose();
}
}}
width="wide"
footer={
<div className="account-settings-modal__footer">
<RemoveIntegrationAccount
accountId={account?.id}
onRemoveIntegrationAccountSuccess={(): void => {
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.GCP,
});
setActiveAccount(null);
handleClose();
}}
cloudProvider={INTEGRATION_TYPES.GCP}
/>
<Button
variant="solid"
color="secondary"
disabled={isSaveDisabled}
onClick={handleSubmit}
loading={isLoading}
prefix={<Save size={14} />}
data-testid="gcp-update-account-btn"
>
Update Changes
</Button>
</div>
}
>
<Form
form={form}
layout="vertical"
initialValues={{
projectIds: gcpConfig?.project_ids || [],
}}
>
<div className="account-settings-modal__body">
<div className="account-settings-modal__body-account-info">
<div className="account-settings-modal__body-account-info-connected-account-details">
<div className="account-settings-modal__body-account-info-connected-account-details-title">
Connected Account details
</div>
<div className="account-settings-modal__body-account-info-connected-account-details-account-id">
Account Name:{' '}
<span className="account-settings-modal__body-account-info-connected-account-details-account-id-account-id">
{account?.providerAccountId}
</span>
</div>
</div>
</div>
{gcpConfig?.deployment_project_id && (
<div className="account-settings-modal__body-region-selector">
<div className="account-settings-modal__body-region-selector-title">
Deployment project ID
</div>
<div className="account-settings-modal__body-region-selector-description">
{gcpConfig.deployment_project_id}
</div>
</div>
)}
{gcpConfig?.deployment_region && (
<div className="account-settings-modal__body-region-selector">
<div className="account-settings-modal__body-region-selector-title">
Deployment region
</div>
<div className="account-settings-modal__body-region-selector-description">
{gcpConfig.deployment_region}
</div>
</div>
)}
<div className="account-settings-modal__body-region-selector">
<div className="account-settings-modal__body-region-selector-title">
Projects to monitor
</div>
<div className="account-settings-modal__body-region-selector-description">
Update the GCP project IDs that should be monitored.
</div>
<Form.Item
name="projectIds"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one project ID',
},
]}
>
<Select
mode="tags"
value={projectIds}
tokenSeparators={[',']}
onChange={(values): void => {
setProjectIds(values);
form.setFieldValue('projectIds', values);
}}
data-testid="gcp-edit-project-ids-select"
/>
</Form.Item>
</div>
</div>
</Form>
</DrawerWrapper>
);
}
export default AccountSettingsDrawer;

View File

@@ -60,44 +60,6 @@ function RemoveIntegrationAccount({
setIsModalOpen(false);
};
let modalDescription: JSX.Element;
if (cloudProvider === INTEGRATION_TYPES.AWS) {
modalDescription = (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your AWS account within the next ~15 minutes
(cloudformation stacks named signoz-integration-telemetry-collection in
enabled regions). <br />
<br />
After that, you can delete the cloudformation stack that was created
manually when connecting this account.
</>
);
} else if (cloudProvider === INTEGRATION_TYPES.GCP) {
modalDescription = (
<>
Removing this account will stop SigNoz from monitoring it. <br />
<br />
Since you manage the GCP resources yourself, remember to manually tear down
the OTel collector and Pub/Sub resources you created for this integration if
you no longer need them.
</>
);
} else {
modalDescription = (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your Azure subscription within the next ~15 minutes
(deployment stack named signoz-integration-telemetry will be deleted
automatically). <br />
<br />
After that, you have to manually delete &apos;signoz-integration&apos;
deployment stack that was created while connecting this account (Takes ~20
minutes to delete).
</>
);
}
return (
<div className="remove-integration-account-container">
<Button
@@ -122,7 +84,28 @@ function RemoveIntegrationAccount({
loading: isRemoveIntegrationLoading,
}}
>
{modalDescription}
{cloudProvider === INTEGRATION_TYPES.AWS ? (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your AWS account within the next ~15 minutes
(cloudformation stacks named signoz-integration-telemetry-collection in
enabled regions). <br />
<br />
After that, you can delete the cloudformation stack that was created
manually when connecting this account.
</>
) : (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your Azure subscription within the next ~15 minutes
(deployment stack named signoz-integration-telemetry will be deleted
automatically). <br />
<br />
After that, you have to manually delete &apos;signoz-integration&apos;
deployment stack that was created while connecting this account (Takes ~20
minutes to delete).
</>
)}
</Modal>
</div>
);

View File

@@ -47,27 +47,3 @@ export function mapAccountDtoToAzureCloudAccount(
providerAccountId: account.providerAccountId,
};
}
export function mapAccountDtoToGcpCloudAccount(
account: CloudintegrationtypesAccountDTO,
): IntegrationCloudAccount | null {
if (!account.providerAccountId) {
return null;
}
return {
id: account.id,
cloud_account_id: account.id,
config: {
deployment_region: account.config?.gcp?.deploymentRegion ?? '',
deployment_project_id: account.config?.gcp?.deploymentProjectId ?? '',
project_ids: account.config?.gcp?.projectIds ?? [],
},
status: {
integration: {
last_heartbeat_ts_ms: account.agentReport?.timestampMillis ?? 0,
},
},
providerAccountId: account.providerAccountId,
};
}

View File

@@ -13,8 +13,8 @@ import { ArrowLeft, MoveUpRight, RotateCw } from '@signozhq/icons';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import CloudIntegration from '../CloudIntegration/CloudIntegration';
import { IntegrationType } from '../types';
import { INTEGRATION_TYPES } from '../constants';
import { IntegrationType } from '../types';
import { handleContactSupport } from '../utils';
import IntegrationDetailContent from './IntegrationDetailContent';
import IntegrationDetailHeader from './IntegrationDetailHeader';
@@ -24,12 +24,6 @@ import { getConnectionStatesFromConnectionStatus } from './utils';
import './IntegrationDetailPage.styles.scss';
const cloudIntegrationTypeById: Record<string, IntegrationType> = {
[INTEGRATION_TYPES.AWS]: IntegrationType.AWS_SERVICES,
[INTEGRATION_TYPES.AZURE]: IntegrationType.AZURE_SERVICES,
[INTEGRATION_TYPES.GCP]: IntegrationType.GCP_SERVICES,
};
// eslint-disable-next-line sonarjs/cognitive-complexity
function IntegrationDetailPage(): JSX.Element {
const history = useHistory();
@@ -61,8 +55,19 @@ function IntegrationDetailPage(): JSX.Element {
),
);
if (integrationId && cloudIntegrationTypeById[integrationId]) {
return <CloudIntegration type={cloudIntegrationTypeById[integrationId]} />;
if (
integrationId === INTEGRATION_TYPES.AWS ||
integrationId === INTEGRATION_TYPES.AZURE
) {
return (
<CloudIntegration
type={
integrationId === INTEGRATION_TYPES.AWS
? IntegrationType.AWS_SERVICES
: IntegrationType.AZURE_SERVICES
}
/>
);
}
return (

View File

@@ -1,8 +1,7 @@
import awsDarkLogo from '@/assets/Logos/aws-dark.svg';
import azureOpenaiLogo from '@/assets/Logos/azure-openai.svg';
import gcpLogo from '@/assets/Logos/gcp.svg';
import { AzureRegion, GCPRegion } from './types';
import { AzureRegion } from './types';
export const INTEGRATION_TELEMETRY_EVENTS = {
INTEGRATIONS_LIST_VISITED: 'Integrations Page: Visited the list page',
@@ -22,7 +21,6 @@ export const INTEGRATION_TELEMETRY_EVENTS = {
export const INTEGRATION_TYPES = {
AWS: 'aws',
AZURE: 'azure',
GCP: 'gcp',
};
export const AWS_INTEGRATION = {
@@ -55,26 +53,7 @@ export const AZURE_INTEGRATION = {
is_new: true,
};
export const GCP_INTEGRATION = {
id: INTEGRATION_TYPES.GCP,
title: 'Google Cloud Platform',
description: 'Setup for GCP monitoring with SigNoz',
author: {
name: 'SigNoz',
email: 'integrations@signoz.io',
homepage: 'https://signoz.io',
},
icon: gcpLogo,
icon_alt: 'gcp-logo',
is_installed: false,
is_new: true,
};
export const ONE_CLICK_INTEGRATIONS = [
AWS_INTEGRATION,
AZURE_INTEGRATION,
GCP_INTEGRATION,
];
export const ONE_CLICK_INTEGRATIONS = [AWS_INTEGRATION, AZURE_INTEGRATION];
export const AZURE_REGIONS: AzureRegion[] = [
{
@@ -186,66 +165,3 @@ export const AZURE_REGIONS: AzureRegion[] = [
{ label: 'West US 2', value: 'westus2', geography: 'United States' },
{ label: 'West US 3', value: 'westus3', geography: 'United States' },
];
// Source of truth: pkg/types/cloudintegrationtypes/regions.go (GCP regions).
export const GCP_REGIONS: GCPRegion[] = [
{ label: 'Johannesburg', value: 'africa-south1', geography: 'Africa' },
{ label: 'Changhua County', value: 'asia-east1', geography: 'APAC' },
{ label: 'Hong Kong', value: 'asia-east2', geography: 'APAC' },
{ label: 'Tokyo', value: 'asia-northeast1', geography: 'APAC' },
{ label: 'Osaka', value: 'asia-northeast2', geography: 'APAC' },
{ label: 'Seoul', value: 'asia-northeast3', geography: 'APAC' },
{ label: 'Mumbai', value: 'asia-south1', geography: 'APAC' },
{ label: 'Delhi', value: 'asia-south2', geography: 'APAC' },
{ label: 'Singapore', value: 'asia-southeast1', geography: 'APAC' },
{ label: 'Jakarta', value: 'asia-southeast2', geography: 'APAC' },
{ label: 'Bangkok', value: 'asia-southeast3', geography: 'APAC' },
{ label: 'Sydney', value: 'australia-southeast1', geography: 'APAC' },
{ label: 'Melbourne', value: 'australia-southeast2', geography: 'APAC' },
{ label: 'Warsaw', value: 'europe-central2', geography: 'Europe' },
{ label: 'Hamina', value: 'europe-north1', geography: 'Europe' },
{ label: 'Stockholm', value: 'europe-north2', geography: 'Europe' },
{ label: 'Madrid', value: 'europe-southwest1', geography: 'Europe' },
{ label: 'St. Ghislain', value: 'europe-west1', geography: 'Europe' },
{ label: 'London', value: 'europe-west2', geography: 'Europe' },
{ label: 'Frankfurt', value: 'europe-west3', geography: 'Europe' },
{ label: 'Eemshaven', value: 'europe-west4', geography: 'Europe' },
{ label: 'Zurich', value: 'europe-west6', geography: 'Europe' },
{ label: 'Milan', value: 'europe-west8', geography: 'Europe' },
{ label: 'Paris', value: 'europe-west9', geography: 'Europe' },
{ label: 'Berlin', value: 'europe-west10', geography: 'Europe' },
{ label: 'Turin', value: 'europe-west12', geography: 'Europe' },
{ label: 'Doha', value: 'me-central1', geography: 'Middle East' },
{ label: 'Dammam', value: 'me-central2', geography: 'Middle East' },
{ label: 'Tel Aviv', value: 'me-west1', geography: 'Middle East' },
{
label: 'Montréal',
value: 'northamerica-northeast1',
geography: 'North America',
},
{
label: 'Toronto',
value: 'northamerica-northeast2',
geography: 'North America',
},
{
label: 'Querétaro',
value: 'northamerica-south1',
geography: 'North America',
},
{
label: 'São Paulo',
value: 'southamerica-east1',
geography: 'South America',
},
{ label: 'Santiago', value: 'southamerica-west1', geography: 'South America' },
{ label: 'Council Bluffs', value: 'us-central1', geography: 'North America' },
{ label: 'Moncks Corner', value: 'us-east1', geography: 'North America' },
{ label: 'Ashburn', value: 'us-east4', geography: 'North America' },
{ label: 'Columbus', value: 'us-east5', geography: 'North America' },
{ label: 'Dallas', value: 'us-south1', geography: 'North America' },
{ label: 'The Dalles', value: 'us-west1', geography: 'North America' },
{ label: 'Los Angeles', value: 'us-west2', geography: 'North America' },
{ label: 'Salt Lake City', value: 'us-west3', geography: 'North America' },
{ label: 'Las Vegas', value: 'us-west4', geography: 'North America' },
];

View File

@@ -6,7 +6,6 @@ import {
export enum IntegrationType {
AWS_SERVICES = 'aws',
AZURE_SERVICES = 'azure',
GCP_SERVICES = 'gcp',
}
interface LogField {
@@ -88,10 +87,7 @@ export interface ServiceData {
export interface CloudAccount {
id: string;
cloud_account_id: string;
config:
| AzureCloudAccountConfig
| AWSCloudAccountConfig
| GCPCloudAccountConfig;
config: AzureCloudAccountConfig | AWSCloudAccountConfig;
status: AccountStatus | IServiceStatus;
providerAccountId: string;
}
@@ -101,12 +97,6 @@ export interface AzureCloudAccountConfig {
resource_groups: string[];
}
export interface GCPCloudAccountConfig {
deployment_region: string;
deployment_project_id: string;
project_ids: string[];
}
export interface AccountStatus {
integration: IntegrationStatus;
}
@@ -121,12 +111,6 @@ export interface AzureRegion {
value: string;
}
export interface GCPRegion {
label: string;
geography: string;
value: string;
}
export interface UpdateServiceConfigPayload {
cloud_account_id: string;
config: AzureServicesConfig;

View File

@@ -9,6 +9,4 @@
:global([class*='dashboardPageHeader']) {
display: none;
}
// remove margin added by the hidden header to avoid extra whitespace at the top of the page
margin-top: calc(var(--spacing-8) * -1);
}

View File

@@ -18,7 +18,7 @@
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
display: flex;
overflow-y: auto;
overflow: hidden;
// The drawer body — children render inside [data-slot='drawer-description']
// (this is the @signozhq drawer, not antd, so .ant-drawer-body was a no-op).
@@ -27,6 +27,8 @@
flex-direction: column;
gap: var(--spacing-12);
padding: var(--spacing-10) var(--spacing-12);
min-height: 0;
overflow-y: auto;
}
[data-slot='select-content'] {

View File

@@ -51,7 +51,6 @@
gap: var(--spacing-5);
padding: var(--spacing-6);
border-radius: 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
}

View File

@@ -9,7 +9,6 @@
.patternBox {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-6);
border-radius: 6px;
border: 1px solid var(--l2-border);
@@ -18,14 +17,14 @@
.patternChips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-3);
min-height: 28px;
gap: var(--spacing-0) var(--spacing-3);
}
.patternChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
margin-bottom: var(--spacing-4);
}
.patternChipRemove {

View File

@@ -30,7 +30,6 @@
padding: var(--spacing-5) var(--spacing-6);
border-radius: var(--radius-2);
border: 1px solid transparent;
background: var(--l3-background);
margin: 0;
width: 100%;
// Include padding + border in the 100% width so the card fits inside
@@ -67,16 +66,11 @@
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
// Use :has() to highlight the wrapper card when its inner button is checked.
&.sourceRadioAuto:has(button[data-state='checked']) {
&:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
}
&.sourceRadioOverride:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-amber) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-amber) 30%, transparent);
}
&:hover {
background: var(--l3-background-hover);
}

View File

@@ -60,7 +60,7 @@ function SourceSelector({
>
<RadioGroupItem
value="auto"
containerClassName={cx(styles.sourceRadio, styles.sourceRadioAuto)}
containerClassName={styles.sourceRadio}
testId="drawer-source-auto"
disabled={disableAuto}
>
@@ -73,7 +73,7 @@ function SourceSelector({
</RadioGroupItem>
<RadioGroupItem
value="override"
containerClassName={cx(styles.sourceRadio, styles.sourceRadioOverride)}
containerClassName={styles.sourceRadio}
testId="drawer-source-override"
>
<div className={styles.sourceRadioTitle}>User override</div>

View File

@@ -13,6 +13,7 @@
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 30%, transparent);
background: color-mix(in srgb, var(--bg-amber-400) 8%, transparent);
color: var(--bg-amber-300, var(--bg-amber-400));
margin-top: var(--spacing-4);
}
.bannerIcon {

View File

@@ -37,7 +37,7 @@ describe('LLMObservability (integration)', () => {
it('renders the overview panel and the tab bar on the overview route', () => {
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
initialRoute: ROUTES.AI_OBSERVABILITY_OVERVIEW,
});
expect(screen.getByTestId('llm-observability-tabs')).toBeInTheDocument();
@@ -55,32 +55,32 @@ describe('LLMObservability (integration)', () => {
it('navigates to the configuration route when the Model pricing tab is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
initialRoute: ROUTES.AI_OBSERVABILITY_OVERVIEW,
});
await user.click(screen.getByRole('tab', { name: 'Model pricing' }));
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
ROUTES.AI_OBSERVABILITY_CONFIGURATION,
);
});
it('navigates to the attribute mapping route when that tab is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
initialRoute: ROUTES.AI_OBSERVABILITY_OVERVIEW,
});
await user.click(screen.getByRole('tab', { name: 'Attribute Mapping' }));
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
);
});
it('renders the attribute mapping page on the attribute mapping route', () => {
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
initialRoute: ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
});
expect(
@@ -91,7 +91,7 @@ describe('LLMObservability (integration)', () => {
it('renders the model-pricing page on the configuration route', async () => {
setupList();
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
initialRoute: ROUTES.AI_OBSERVABILITY_CONFIGURATION,
});
await waitFor(() =>

View File

@@ -8,9 +8,9 @@ import LLMObservabilityAttributeMapping from '../AttributeMapping/LLMObservabili
import Overview from '../Overview/Overview';
import LLMObservabilityModelPricing from '../Settings/ModelPricing/LLMObservabilityModelPricing';
const OVERVIEW_KEY = ROUTES.LLM_OBSERVABILITY_OVERVIEW;
const CONFIGURATION_KEY = ROUTES.LLM_OBSERVABILITY_CONFIGURATION;
const ATTRIBUTE_MAPPING_KEY = ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING;
const OVERVIEW_KEY = ROUTES.AI_OBSERVABILITY_OVERVIEW;
const CONFIGURATION_KEY = ROUTES.AI_OBSERVABILITY_CONFIGURATION;
const ATTRIBUTE_MAPPING_KEY = ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING;
interface UseLLMObservabilityTabsResult {
items: TabItemProps[];

View File

@@ -20,6 +20,7 @@
.ant-table-cell {
border: 1px solid var(--l1-border);
background: var(--l2-background);
vertical-align: top;
}
.attribute-name {
@@ -33,12 +34,12 @@
.attribute-pin {
cursor: pointer;
padding: 0;
vertical-align: middle;
padding: 14px 8px 8px;
vertical-align: top;
text-align: center;
.log-attribute-pin {
padding: 8px;
padding: 0;
display: flex;
justify-content: center;

View File

@@ -15,11 +15,13 @@
.ant-tree-node-content-wrapper {
user-select: text !important;
cursor: text !important;
min-width: 0;
}
.ant-tree-title {
user-select: text !important;
cursor: text !important;
overflow-wrap: anywhere;
}
}

View File

@@ -5262,6 +5262,24 @@ const onboardingConfigWithLinks = [
],
},
},
{
dataSource: 'temporal-cloud-metrics',
label: 'Temporal Cloud Metrics',
imgUrl: temporalUrl,
tags: ['metrics'],
module: 'metrics',
relatedSearchKeywords: [
'metrics',
'integrations',
'temporal',
'temporal cloud',
'temporal cloud metrics',
'temporal metrics',
'openmetrics',
'prometheus',
],
link: '/docs/integrations/temporal-cloud-metrics/',
},
{
dataSource: 'temporal',
label: 'Temporal',
@@ -5273,9 +5291,6 @@ const onboardingConfigWithLinks = [
'application performance monitoring',
'integrations',
'temporal',
'temporal cloud',
'temporal logs',
'temporal metrics',
'temporal traces',
'traces',
'tracing',
@@ -5284,12 +5299,6 @@ const onboardingConfigWithLinks = [
desc: 'What are you using ?',
type: 'select',
options: [
{
key: 'temporal-cloud',
label: 'Cloud Metrics',
imgUrl: temporalUrl,
link: '/docs/integrations/temporal-cloud-metrics/',
},
{
key: 'temporal-golang',
label: 'Go',

View File

@@ -22,7 +22,7 @@ import {
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { ExtendedSelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
@@ -39,6 +39,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
signalSource,
setAttributeKeys,
}: AgregatorFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const queryClient = useQueryClient();
const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]);
@@ -289,7 +290,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
return (
<AutoComplete
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
placeholder={getPlaceholder()}
style={selectStyle}
filterOption={false}

View File

@@ -2,7 +2,7 @@ import { Select, SelectProps, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { getCategorySelectOptionByName } from 'container/NewWidget/RightContainer/alertFomatCategories';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { categoryToSupport } from './config';
import { selectStyles } from './styles';
@@ -13,6 +13,7 @@ function BuilderUnitsFilter({
onChange,
yAxisUnit,
}: IBuilderUnitsFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { currentQuery, handleOnUnitsChange } = useQueryBuilder();
const selectedValue = yAxisUnit || currentQuery?.unit;
@@ -36,7 +37,7 @@ function BuilderUnitsFilter({
Y-axis unit
</Typography.Text>
<Select
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
style={selectStyles}
onChange={onChangeHandler}
value={selectedValue}

View File

@@ -9,12 +9,13 @@ import {
} from 'lib/query/transformQueryBuilderData';
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
import { SelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { getHavingObject, isValidHavingValue } from '../../utils';
import { HavingFilterProps, HavingTagRenderProps } from './types';
function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { having } = formula;
const [searchText, setSearchText] = useState<string>('');
const [localValues, setLocalValues] = useState<string[]>([]);
@@ -171,7 +172,7 @@ function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
return (
<Select
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
autoClearSearchValue={false}
mode="multiple"
onSearch={handleSearch}

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { Select, Spin } from 'antd';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { MetricAggregateOperator } from 'types/common/queryBuilder';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../../QueryBuilderSearch/config';
import { OrderByProps } from './types';
@@ -13,6 +13,7 @@ function OrderByFilter({
onChange,
query,
}: OrderByProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const {
debouncedSearchText,
createOptions,
@@ -64,7 +65,7 @@ function OrderByFilter({
return (
<Select
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}

View File

@@ -21,7 +21,7 @@ import { isEqual, uniqWith } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
@@ -33,6 +33,7 @@ export const GroupByFilter = memo(function GroupByFilter({
disabled,
signalSource,
}: GroupByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const queryClient = useQueryClient();
const [searchText, setSearchText] = useState<string>('');
const [optionsData, setOptionsData] = useState<
@@ -174,7 +175,7 @@ export const GroupByFilter = memo(function GroupByFilter({
return (
<Select
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}

View File

@@ -16,7 +16,7 @@ import {
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { getHavingObject, isValidHavingValue } from '../utils';
// ** Types
@@ -27,6 +27,7 @@ export function HavingFilter({
query,
onChange,
}: HavingFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { having } = query;
const [searchText, setSearchText] = useState<string>('');
const [options, setOptions] = useState<SelectOption<string, string>[]>([]);
@@ -231,7 +232,7 @@ export function HavingFilter({
return (
<>
<Select
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
autoClearSearchValue={false}
mode="multiple"
onSearch={handleSearch}

View File

@@ -14,7 +14,7 @@ import {
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { ExtendedSelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
@@ -85,6 +85,7 @@ export const MetricNameSelector = memo(function MetricNameSelector({
signalSource,
'data-testid': dataTestId,
}: MetricNameSelectorProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const currentMetricName =
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
query.aggregateAttribute?.key ||
@@ -272,7 +273,7 @@ export const MetricNameSelector = memo(function MetricNameSelector({
return (
<AutoComplete
className="metric-name-selector"
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
style={selectStyle}
filterOption={false}
placeholder={placeholder}

View File

@@ -3,7 +3,7 @@ import { Select, Spin } from 'antd';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import { OrderByFilterProps } from './OrderByFilter.interfaces';
@@ -16,6 +16,7 @@ export function OrderByFilter({
entityVersion,
isNewQueryV2 = false,
}: OrderByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const {
debouncedSearchText,
selectedValue,
@@ -78,7 +79,7 @@ export function OrderByFilter({
return (
<Select
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}

View File

@@ -50,7 +50,7 @@ import {
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../../constants/features';
@@ -95,6 +95,7 @@ function QueryBuilderSearch({
disableNavigationShortcuts,
entity,
}: QueryBuilderSearchProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { pathname } = useLocation();
const isLogsExplorerPage = useMemo(
() => pathname === ROUTES.LOGS_EXPLORER,
@@ -397,7 +398,7 @@ function QueryBuilderSearch({
<Select
data-testid={'qb-search-select'}
ref={selectRef}
getPopupContainer={popupContainer}
getPopupContainer={getPopupContainer}
transitionName=""
choiceTransitionName=""
virtual={false}

View File

@@ -50,7 +50,7 @@ import {
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { popupContainer } from 'utils/selectPopupContainer';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { selectStyle } from '../QueryBuilderSearch/config';
@@ -157,6 +157,8 @@ function QueryBuilderSearchV2(
selectProps,
} = props;
const getPopupContainer = useSelectPopupContainer();
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
const { handleRunQuery, currentQuery } = useQueryBuilder();
@@ -989,7 +991,7 @@ function QueryBuilderSearchV2(
{...selectProps}
data-testid={'qb-search-select'}
ref={selectRef}
{...(hasPopupContainer ? { getPopupContainer: popupContainer } : {})}
{...(hasPopupContainer ? { getPopupContainer } : {})}
{...(maxTagCount ? { maxTagCount } : {})}
key={queryTags.join('.')}
virtual={false}

View File

@@ -294,7 +294,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
if (item.key === ROUTES.INTEGRATIONS) {
return shouldShowIntegrationsValue;
}
if (item.key === ROUTES.LLM_OBSERVABILITY_OVERVIEW) {
if (item.key === ROUTES.AI_OBSERVABILITY_OVERVIEW) {
return isAIObservabilityEnabled;
}
return item.isEnabled;

View File

@@ -290,14 +290,14 @@ export const defaultMoreMenuItems: SidebarItem[] = [
itemKey: 'external-apis',
},
{
key: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
label: 'LLM Observability',
key: ROUTES.AI_OBSERVABILITY_OVERVIEW,
label: 'AI Observability',
icon: <Brain size={16} />,
isNew: true,
// Gated behind the `enable_ai_observability` feature flag in
// SideNav's `computedSecondaryMenuItems`; disabled by default.
isEnabled: false,
itemKey: 'llm-observability',
itemKey: 'ai-observability',
},
{
key: ROUTES.METER,
@@ -576,7 +576,7 @@ export const NEW_ROUTES_MENU_ITEM_KEY_MAP: Record<string, string> = {
ROUTES.INFRASTRUCTURE_MONITORING_HOSTS,
[ROUTES.API_MONITORING_BASE]: ROUTES.API_MONITORING,
[ROUTES.MESSAGING_QUEUES_BASE]: ROUTES.MESSAGING_QUEUES_OVERVIEW,
[ROUTES.LLM_OBSERVABILITY_BASE]: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
[ROUTES.AI_OBSERVABILITY_BASE]: ROUTES.AI_OBSERVABILITY_OVERVIEW,
// `getActiveMenuKeyFromPath` strips the URL down to its first segment;
// `/ai-assistant/<id>` reduces to `/ai-assistant`, which we point back
// to the AI Assistant menu item's concrete key.

View File

@@ -203,9 +203,9 @@ export const routesToSkip = [
ROUTES.METER_EXPLORER_VIEWS,
ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
ROUTES.SOMETHING_WENT_WRONG,
ROUTES.LLM_OBSERVABILITY_OVERVIEW,
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
ROUTES.AI_OBSERVABILITY_OVERVIEW,
ROUTES.AI_OBSERVABILITY_CONFIGURATION,
ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -39,12 +39,8 @@ export function useAccountSettingsModal({
}: UseAccountSettingsModalProps): UseAccountSettingsModal {
const [form] = Form.useForm();
const { mutate: updateAccount, isLoading } = useUpdateAccount();
// `account.config` is the shared per-provider union (Azure | AWS | GCP).
// Narrow to Azure by `resource_groups` (Azure-only) rather than
// `deployment_region`, which GCP also has — so it no longer identifies
// Azure uniquely.
const accountConfig = useMemo(
() => ('resource_groups' in account.config ? account.config : null),
() => ('deployment_region' in account.config ? account.config : null),
[account.config],
);
const [resourceGroups, setResourceGroups] = useState<string[]>(

View File

@@ -1,148 +0,0 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { toast } from '@signozhq/ui/sonner';
import { Form } from 'antd';
import { FormInstance } from 'antd/lib';
import { useUpdateAccount } from 'api/generated/services/cloudintegration';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { CloudAccount } from 'container/Integrations/types';
import { isEqual } from 'lodash-es';
import logEvent from '../../../api/common/logEvent';
interface UseAccountSettingsDrawerProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
interface UseAccountSettingsDrawer {
form: FormInstance;
isLoading: boolean;
projectIds: string[];
isSaveDisabled: boolean;
setProjectIds: Dispatch<SetStateAction<string[]>>;
handleSubmit: () => Promise<void>;
handleClose: () => void;
}
export function useAccountSettingsDrawer({
onClose,
account,
setActiveAccount,
}: UseAccountSettingsDrawerProps): UseAccountSettingsDrawer {
const [form] = Form.useForm();
const { mutate: updateAccount, isLoading } = useUpdateAccount();
const accountConfig = useMemo(
() => ('project_ids' in account.config ? account.config : null),
[account.config],
);
const [projectIds, setProjectIds] = useState<string[]>(
accountConfig?.project_ids || [],
);
useEffect(() => {
if (!accountConfig) {
return;
}
form.setFieldsValue({
projectIds: accountConfig.project_ids,
});
setProjectIds(accountConfig.project_ids);
}, [accountConfig, form]);
const handleSubmit = useCallback(async (): Promise<void> => {
try {
const values = await form.validateFields();
if (!accountConfig) {
return;
}
updateAccount(
{
pathParams: {
cloudProvider: INTEGRATION_TYPES.GCP,
id: account?.id || '',
},
data: {
config: {
gcp: {
// Deployment region & project ID are immutable in the UI, but the
// Updatable GCP DTO requires all three fields to be sent.
deploymentRegion: accountConfig.deployment_region,
deploymentProjectId: accountConfig.deployment_project_id,
projectIds: values.projectIds || [],
},
},
},
},
{
onSuccess: () => {
const nextConfig = {
deployment_region: accountConfig.deployment_region,
deployment_project_id: accountConfig.deployment_project_id,
project_ids: values.projectIds || [],
};
setActiveAccount({
...account,
config: nextConfig,
});
onClose();
toast.success('Account settings updated successfully', {
position: 'bottom-right',
});
void logEvent('GCP Integration: Account settings updated', {
cloudAccountId: account.cloud_account_id,
deploymentRegion: nextConfig.deployment_region,
projectIds: nextConfig.project_ids,
});
},
onError: (error) => {
toast.error('Failed to update account settings', {
description: error?.message,
position: 'bottom-right',
});
},
},
);
} catch (error) {
console.error('Form submission failed:', error);
}
}, [form, updateAccount, account, accountConfig, setActiveAccount, onClose]);
const isSaveDisabled = useMemo(() => {
if (!accountConfig) {
return true;
}
return isEqual(
[...(projectIds || [])].sort(),
[...accountConfig.project_ids].sort(),
);
}, [accountConfig, projectIds]);
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
return {
form,
isLoading,
projectIds,
isSaveDisabled,
setProjectIds,
handleSubmit,
handleClose,
};
}

View File

@@ -1,165 +0,0 @@
import { useCallback, useState } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
CreateAccountMutationResult,
GetConnectionCredentialsQueryResult,
invalidateListAccounts,
useAgentCheckIn,
useCreateAccount,
useGetConnectionCredentials,
} from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesPostableAccountDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
import useAxiosError from 'hooks/useAxiosError';
import { toAPIError } from 'utils/errorUtils';
import logEvent from '../../../api/common/logEvent';
interface UseCloudAccountSetupDrawerProps {
onClose: () => void;
}
interface UseCloudAccountSetupDrawer {
isLoading: boolean;
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
handleClose: () => void;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
submitError: string | null;
clearSubmitError: () => void;
}
export function useCloudAccountSetupDrawer({
onClose,
}: UseCloudAccountSetupDrawerProps): UseCloudAccountSetupDrawer {
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const clearSubmitError = useCallback((): void => {
setSubmitError(null);
}, []);
const { mutateAsync: createAccount } = useCreateAccount();
const { mutateAsync: checkIn } = useAgentCheckIn();
const handleError = useAxiosError();
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
useGetConnectionCredentials<GetConnectionCredentialsQueryResult>(
{
cloudProvider: INTEGRATION_TYPES.GCP,
},
{
query: {
onError: handleError,
},
},
);
const handleClose = useCallback((): void => {
onClose();
}, [onClose]);
const handleConnectionSuccess = useCallback(
(payload: {
cloudIntegrationId: string;
providerAccountId: string;
}): void => {
void logEvent('GCP Integration: Account connected', {
cloudIntegrationId: payload.cloudIntegrationId,
providerAccountId: payload.providerAccountId,
});
toast.success('GCP account connected successfully', {
position: 'bottom-right',
});
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.GCP,
});
handleClose();
},
[handleClose, queryClient],
);
const connectAccount = useCallback(
async (values: GcpSetupFormValues): Promise<void> => {
try {
setIsLoading(true);
setSubmitError(null);
const payload: CloudintegrationtypesPostableAccountDTO = {
config: {
gcp: {
deploymentRegion: values.deploymentRegion,
deploymentProjectId: values.deploymentProjectId,
projectIds: values.projectIds || [],
},
},
credentials: {
// Cloud users can't edit these — the backend-provided credentials are
// authoritative. Enterprise users have no backend defaults and enter
// their own (validated non-empty), so their form values are used.
ingestionUrl: connectionParams?.data?.ingestionUrl || values.ingestionUrl,
ingestionKey: connectionParams?.data?.ingestionKey || values.ingestionKey,
sigNozApiUrl: connectionParams?.data?.sigNozApiUrl || values.sigNozApiUrl,
sigNozApiKey: connectionParams?.data?.sigNozApiKey || values.sigNozApiKey,
},
};
// Step 1: create the integration account.
const createResponse: CreateAccountMutationResult = await createAccount({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: payload,
});
const cloudIntegrationId = createResponse.data.id;
const providerAccountId = values.accountName;
void logEvent('GCP Integration: Account created', {
id: cloudIntegrationId,
});
// Step 2: mimic the agent by checking in from the frontend (manual flow).
await checkIn({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: {
providerAccountId,
cloudIntegrationId,
data: {},
},
});
handleConnectionSuccess({ cloudIntegrationId, providerAccountId });
} catch (error) {
// Surface the backend's message inline in the drawer instead of a
// generic failure string.
const message = toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Failed to connect GCP account',
).getErrorMessage();
setSubmitError(message);
} finally {
setIsLoading(false);
}
},
[connectionParams, createAccount, checkIn, handleConnectionSuccess],
);
return {
isLoading,
connectAccount,
handleClose,
connectionParams: connectionParams?.data as
| CloudintegrationtypesCredentialsDTO
| undefined,
isConnectionParamsLoading,
submitError,
clearSubmitError,
};
}

View File

@@ -7,6 +7,7 @@ import AppRoutes from 'AppRoutes';
import { AxiosError } from 'axios';
import { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
import { ThemeProvider } from 'hooks/useDarkMode';
import { configureOverlayScrollbars } from 'lib/configureOverlayScrollbars';
import { NuqsAdapter } from 'nuqs/adapters/react';
import { AppProvider } from 'providers/App/App';
import TimezoneProvider from 'providers/Timezone';
@@ -17,6 +18,8 @@ import './ReactI18';
import 'styles.scss';
configureOverlayScrollbars();
const queryClient = new QueryClient({
defaultOptions: {
queries: {

View File

@@ -0,0 +1,22 @@
import { OverlayScrollbars } from 'overlayscrollbars';
/**
* Disables the content `elementEvents` option (default: `[['img', 'load']]`)
* for every OverlayScrollbars instance.
*
* The per-element event cleanups OverlayScrollbars stores in its internal
* WeakMap close over the MutationObserver callback scope, which holds arrays
* with every node added/removed in that mutation batch. A single long-lived
* reference to one of those elements (e.g. CodeMirror's EditContext or its
* module-level scratch Range pinning a detached editor) then retains entire
* unmounted subtrees as detached DOM — ~1.3k nodes per InfraMonitoring
* category switch.
*
* Content size changes from loading images are still handled by the
* scrollbars' size observer, so scrollbar geometry stays correct.
*/
export function configureOverlayScrollbars(): void {
OverlayScrollbars.env().setDefaultOptions({
update: { elementEvents: null },
});
}

View File

@@ -126,6 +126,15 @@ export default function UPlotChart({
}
}, [isDataEmpty, destroyPlot]);
/**
* Destroy the plot on unmount. Without this, uPlot's window-level
* `dppxchange` listener keeps the instance (and its whole detached DOM
* subtree) alive after the component is gone.
*/
const destroyPlotRef = useRef(destroyPlot);
destroyPlotRef.current = destroyPlot;
useEffect(() => (): void => destroyPlotRef.current(), []);
/**
* Handle initialization and prop changes
*/

View File

@@ -327,6 +327,32 @@ describe('UPlotChart', () => {
expect(firstInstance.destroy).toHaveBeenCalled();
expect(instances).toHaveLength(2);
});
it('destroys the instance and notifies callbacks on unmount', () => {
const plotRef = jest.fn();
const onDestroy = jest.fn();
const { unmount } = render(
<UPlotChart
config={createMockConfig()}
data={validData}
width={600}
height={400}
plotRef={plotRef}
onDestroy={onDestroy}
/>,
{ wrapper: Wrapper },
);
const firstInstance = instances[0];
plotRef.mockClear();
unmount();
expect(onDestroy).toHaveBeenCalledWith(firstInstance);
expect(firstInstance.destroy).toHaveBeenCalledTimes(1);
expect(plotRef).toHaveBeenCalledWith(null);
});
});
describe('spanGaps data transformation', () => {

View File

@@ -23,12 +23,14 @@ import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { useDeleteDashboard } from 'hooks/dashboard/useDeleteDashboard';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import history from 'lib/history';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
@@ -69,6 +71,7 @@ function DashboardActions({
}: DashboardActionsProps): JSX.Element {
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
const isLocked = useDashboardStore((s) => s.isLocked);
const isEditable = useDashboardStore((s) => s.isEditable);
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
const { user } = useAppContext();
@@ -112,6 +115,11 @@ function DashboardActions({
setIsCloning(true);
const response = await cloneDashboardV2({ id: dashboard.id });
toast.success('Dashboard cloned');
void logEvent(DashboardDetailEvents.Cloned, {
dashboardId: dashboard.id,
dashboardName: title,
source: 'detail',
});
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
);
@@ -120,16 +128,43 @@ function DashboardActions({
} finally {
setIsCloning(false);
}
}, [dashboard.id, safeNavigate, showErrorModal]);
}, [dashboard.id, title, safeNavigate, showErrorModal]);
const handleConfirmDelete = useCallback((): void => {
deleteDashboardMutation.mutate(undefined, {
onSuccess: () => {
void logEvent(DashboardDetailEvents.Deleted, {
dashboardId: dashboard.id,
panelCount: Object.keys(dashboard.spec.panels).length,
});
setIsDeleteOpen(false);
history.replace(ROUTES.ALL_DASHBOARD);
},
});
}, [deleteDashboardMutation]);
}, [deleteDashboardMutation, dashboard.id, dashboard.spec.panels]);
const handleOpenSettings = useCallback((): void => {
void logEvent(DashboardDetailEvents.SettingsOpened, {
dashboardId: dashboard.id,
});
setIsSettingsDrawerOpen(true);
}, [dashboard.id]);
const handleOpenJsonEditor = useCallback((): void => {
void logEvent(DashboardDetailEvents.JsonEditorOpened, {
dashboardId: dashboard.id,
readOnly: !isEditable,
});
setIsJsonEditorOpen(true);
}, [dashboard.id, isEditable]);
const handleEnterFullScreen = useCallback((): void => {
void logEvent(DashboardDetailEvents.FullScreenToggled, {
dashboardId: dashboard.id,
enabled: true,
});
void handle.enter();
}, [dashboard.id, handle]);
// Shown only to edit-permitted users, so the only disabled reason is the lock.
const editLabel = useCallback(
@@ -176,7 +211,7 @@ function DashboardActions({
key: 'fullscreen',
label: 'Full screen',
icon: <Fullscreen size={14} />,
onClick: handle.enter,
onClick: handleEnterFullScreen,
});
const items: MenuItem[] = [
@@ -228,7 +263,7 @@ function DashboardActions({
onOpenRename,
handleClone,
onLockToggle,
handle.enter,
handleEnterFullScreen,
]);
return (
@@ -258,7 +293,7 @@ function DashboardActions({
prefix={<Configure size="md" />}
testId="show-drawer"
disabled={isLocked}
onClick={(): void => setIsSettingsDrawerOpen(true)}
onClick={handleOpenSettings}
size="md"
>
Configure
@@ -283,7 +318,7 @@ function DashboardActions({
className={styles.toolbarButton}
prefix={<Braces size="md" />}
testId="edit-json"
onClick={(): void => setIsJsonEditorOpen(true)}
onClick={handleOpenJsonEditor}
size="md"
>
JSON

View File

@@ -12,8 +12,10 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import { isEmpty } from 'lodash-es';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import { linkifyText } from 'utils/linkifyText';
import { openInNewTab } from 'utils/navigation';
@@ -61,6 +63,7 @@ function DashboardInfo({
onCancel,
}: DashboardInfoProps): JSX.Element {
const canEdit = useDashboardStore((s) => s.isEditable);
const dashboardId = useDashboardStore((s) => s.dashboardId);
const hasTags = tags.length > 0;
const hasDescription = !isEmpty(description);
@@ -98,6 +101,14 @@ function DashboardInfo({
}
};
const handleOpenPublicUrl = (): void => {
void logEvent(DashboardDetailEvents.PublicUrlOpened, {
dashboardId,
dashboardName: title,
});
openInNewTab(publicUrl);
};
return (
<div className={styles.dashboardInfo}>
<img src={image} alt={title} className={styles.dashboardImage} />
@@ -182,7 +193,7 @@ function DashboardInfo({
className={styles.publicLink}
aria-label="Open public dashboard"
testId="dashboard-public-link"
onClick={(): void => openInNewTab(publicUrl)}
onClick={handleOpenPublicUrl}
>
<Globe size={14} />
</Button>

View File

@@ -6,7 +6,9 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { Drawer } from 'antd';
import logEvent from 'api/common/logEvent';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
@@ -51,9 +53,17 @@ function JsonEditorDrawer({
const onCopy = useCallback((): void => {
copyToClipboard(draft);
toast.success('JSON copied to clipboard');
}, [copyToClipboard, draft]);
void logEvent(DashboardDetailEvents.JsonEditorAction, {
action: 'copy',
dashboardId: dashboard.id,
});
}, [copyToClipboard, draft, dashboard.id]);
const onDownload = useCallback((): void => {
void logEvent(DashboardDetailEvents.JsonEditorAction, {
action: 'download',
dashboardId: dashboard.id,
});
const blob = new Blob([draft], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
@@ -63,7 +73,7 @@ function JsonEditorDrawer({
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, [draft, dashboard.name]);
}, [draft, dashboard.name, dashboard.id]);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {

View File

@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import { updateDashboardV2 } from 'api/generated/services/dashboard';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import type {
DashboardtypesDashboardSpecDTO,
DashboardtypesGettableDashboardV2DTO,
@@ -140,14 +142,22 @@ export function useJsonEditor({
const format = useCallback((): void => {
try {
setDraft(JSON.stringify(JSON.parse(draft), null, 2));
void logEvent(DashboardDetailEvents.JsonEditorAction, {
action: 'format',
dashboardId,
});
} catch {
// Leave the draft untouched when it can't be parsed.
}
}, [draft]);
}, [draft, dashboardId]);
const reset = useCallback((): void => {
setDraft(appliedText);
}, [appliedText]);
void logEvent(DashboardDetailEvents.JsonEditorAction, {
action: 'reset',
dashboardId,
});
}, [appliedText, dashboardId]);
const apply = useCallback(async (): Promise<void> => {
if (readOnly || !validity.valid || !isDirty) {
@@ -163,6 +173,10 @@ export function useJsonEditor({
dashboardToUpdatable({ ...dashboard, ...edited }),
);
toast.success('Dashboard updated');
void logEvent(DashboardDetailEvents.JsonEditorAction, {
action: 'apply',
dashboardId,
});
refetch();
onApplied();
} catch (error) {

View File

@@ -15,6 +15,7 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
@@ -87,38 +88,47 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
if (!id) {
return;
}
const next = !isDashboardLocked;
setIsDashboardLocked(next);
if (next) {
setShowLockToggle(true);
}
try {
const handleLockDashboardToggle = useCallback(
async (source: 'menu' | 'header'): Promise<void> => {
if (!id) {
return;
}
const next = !isDashboardLocked;
setIsDashboardLocked(next);
if (next) {
await lockDashboardV2({ id });
toast.success('Dashboard locked');
} else {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
setShowLockToggle(true);
}
// Patch just the `locked` flag in the cache — a full refetch would reload
// every panel's chart data for a metadata-only change.
const key = getGetDashboardV2QueryKey({ id });
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
if (cached) {
queryClient.setQueryData<GetDashboardV2200>(key, {
...cached,
data: { ...cached.data, locked: next },
try {
if (next) {
await lockDashboardV2({ id });
toast.success('Dashboard locked');
} else {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
}
// Patch just the `locked` flag in the cache — a full refetch would reload
// every panel's chart data for a metadata-only change.
const key = getGetDashboardV2QueryKey({ id });
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
if (cached) {
queryClient.setQueryData<GetDashboardV2200>(key, {
...cached,
data: { ...cached.data, locked: next },
});
}
void logEvent(DashboardDetailEvents.LockToggled, {
dashboardId: id,
dashboardName: title,
locked: next,
source,
});
} catch (error) {
setIsDashboardLocked(!next);
showErrorModal(error as APIError);
}
} catch (error) {
setIsDashboardLocked(!next);
showErrorModal(error as APIError);
}
}, [id, isDashboardLocked, queryClient, showErrorModal]);
},
[id, title, isDashboardLocked, queryClient, showErrorModal],
);
const onNameSave = useCallback(
async (next: string): Promise<void> => {
@@ -135,6 +145,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
];
await patchAsync(patch);
toast.success('Dashboard renamed successfully');
void logEvent(DashboardDetailEvents.Renamed, {
dashboardId: id,
dashboardName: next,
source: 'inline',
});
} catch (error) {
showErrorModal(error as APIError);
}
@@ -167,7 +182,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
publicUrl={publicUrl}
isDashboardLocked={isDashboardLocked}
showLockToggle={showLockToggle}
onToggleLock={canToggleLock ? handleLockDashboardToggle : undefined}
onToggleLock={
canToggleLock
? (): void => void handleLockDashboardToggle('header')
: undefined
}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}
@@ -182,7 +201,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
isDashboardLocked={isDashboardLocked}
isAuthor={isAuthor}
onAddPanel={onAddPanel}
onLockToggle={handleLockDashboardToggle}
onLockToggle={(): void => void handleLockDashboardToggle('menu')}
onOpenRename={startEdit}
/>
</div>

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