Compare commits

..

23 Commits

Author SHA1 Message Date
Ashwin Bhatkal
fc6f398828 fix: show the backend error message in the error modal instead of a generic axios error (#12215)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
2026-07-21 19:29:13 +00:00
Ashwin Bhatkal
8cd2077522 fix(dashboard-v2): validate key:value tags in the editor (inline-edit errors + backend-rule space/char/length checks) (#12062) 2026-07-21 19:27:24 +00:00
Vinicius Lourenço
7b23286aab refactor(infrastructure-monitoring-v2): ui/ux improvements (#12191)
* fix(pods): min width for pod restarts

* fix(disk-usage): not have minimum width correctly

* perf(infra-monitoring-v2): set cache before opening details modal

* feat(tanstack): add custom markers for perf review

* feat(tanstack): add hover tooltip + integrate on infra monitoring

* perf(grouped-status-counts): use tanstack tooltip hover

* fix(k8s-base-list): adding table def to classnames

* perf(grouped-status-counts): reduce node count on DOM

* feat(infrastructure-monitoring): use single component instead of unmount/mount all content

* chore(infrastructure-monitoring-k8s): remove old list components

* fix(hosts): make it double line header

* fix(infrastructure-monitoring): show scrollbar only on hover

* chore(pod-metrics): add docs for charts

* fix(perf-lib): address comments

* fix(infra-monitoring): rename entityConfig to entity.config
2026-07-21 18:51:20 +00:00
Ashwin Bhatkal
6bdf054a05 test(e2e): fix flaky dashboards-detail specs (sidenav overlay + slow hydration) (#12180)
* test(e2e): pin the sidenav so its flyout stops occluding dashboard content

* test(e2e): harden dashboards-detail specs against slow CI hydration
2026-07-21 17:50:44 +00:00
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
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
300 changed files with 7326 additions and 5266 deletions

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

@@ -8558,6 +8558,7 @@ components:
- resource
- attribute
- body
- ""
type: string
TelemetrytypesFieldDataType:
enum:
@@ -8566,6 +8567,7 @@ components:
- float64
- int64
- number
- ""
type: string
TelemetrytypesGettableFieldKeys:
properties:
@@ -8597,6 +8599,7 @@ components:
- traces
- logs
- metrics
- ""
type: string
TelemetrytypesSource:
enum:

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 {
/**

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

@@ -1,8 +1,6 @@
.quick-filters-container {
display: flex;
flex-direction: row;
height: 100%;
min-height: 0;
position: relative;
.quick-filters-settings-container {

View File

@@ -0,0 +1,112 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TagKeyValueInput from './TagKeyValueInput';
const TID = 'tag-key-value-input';
type User = ReturnType<typeof userEvent.setup>;
const startEditingFirstChip = async (user: User): Promise<HTMLElement> => {
await user.dblClick(screen.getAllByTestId(`${TID}-chip`)[0]);
return screen.getByTestId(`${TID}-edit`);
};
describe('TagKeyValueInput — inline chip edit', () => {
it('shows an error and stays in edit mode when Enter commits an invalid value', async () => {
const user = userEvent.setup();
const onTagsChange = jest.fn();
render(<TagKeyValueInput tags={['env:prod']} onTagsChange={onTagsChange} />);
const input = await startEditingFirstChip(user);
await user.clear(input);
await user.type(input, 'novalue{Enter}');
expect(screen.getByTestId(`${TID}-error`)).toHaveTextContent(
'key:value format',
);
// Still editing (input present), and no change committed.
expect(screen.getByTestId(`${TID}-edit`)).toBeInTheDocument();
expect(onTagsChange).not.toHaveBeenCalled();
});
it('shows a duplicate error when Enter commits an existing tag', async () => {
const user = userEvent.setup();
const onTagsChange = jest.fn();
render(
<TagKeyValueInput
tags={['env:prod', 'team:pulse']}
onTagsChange={onTagsChange}
/>,
);
const input = await startEditingFirstChip(user);
await user.clear(input);
await user.type(input, 'team:pulse{Enter}');
expect(screen.getByTestId(`${TID}-error`)).toHaveTextContent(
'already exists',
);
expect(onTagsChange).not.toHaveBeenCalled();
});
it('commits a valid edit on Enter', async () => {
const user = userEvent.setup();
const onTagsChange = jest.fn();
render(<TagKeyValueInput tags={['env:prod']} onTagsChange={onTagsChange} />);
const input = await startEditingFirstChip(user);
await user.clear(input);
await user.type(input, 'env:staging{Enter}');
expect(onTagsChange).toHaveBeenCalledWith(['env:staging']);
expect(screen.queryByTestId(`${TID}-error`)).not.toBeInTheDocument();
});
it('reverts silently (no error) when blurring an invalid edit', async () => {
const user = userEvent.setup();
const onTagsChange = jest.fn();
render(<TagKeyValueInput tags={['env:prod']} onTagsChange={onTagsChange} />);
const input = await startEditingFirstChip(user);
await user.clear(input);
await user.type(input, 'novalue');
await user.tab(); // blur off the edit input
expect(screen.queryByTestId(`${TID}-error`)).not.toBeInTheDocument();
expect(screen.queryByTestId(`${TID}-edit`)).not.toBeInTheDocument();
expect(onTagsChange).not.toHaveBeenCalled();
});
});
describe('TagKeyValueInput — backend-rule validation', () => {
it('rejects a key containing a space on inline edit', async () => {
const user = userEvent.setup();
const onTagsChange = jest.fn();
render(<TagKeyValueInput tags={['env:prod']} onTagsChange={onTagsChange} />);
const input = await startEditingFirstChip(user);
await user.clear(input);
await user.type(input, 'my key:prod{Enter}');
expect(screen.getByTestId(`${TID}-error`)).toHaveTextContent(
'spaces or special characters',
);
expect(screen.getByTestId(`${TID}-edit`)).toBeInTheDocument();
expect(onTagsChange).not.toHaveBeenCalled();
});
it('rejects a value containing a space in the new-tag input', async () => {
const user = userEvent.setup();
const onTagsChange = jest.fn();
render(<TagKeyValueInput tags={[]} onTagsChange={onTagsChange} />);
const input = screen.getByTestId(TID);
await user.type(input, 'env:pro d{Enter}');
expect(screen.getByTestId(`${TID}-error`)).toHaveTextContent(
'spaces or special characters',
);
expect(onTagsChange).not.toHaveBeenCalled();
});
});

View File

@@ -5,7 +5,7 @@ import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TagBadge from '../TagBadge/TagBadge';
import { parseKeyValueTag } from './utils';
import { validateTag } from './utils';
import styles from './TagKeyValueInput.module.scss';
@@ -43,16 +43,12 @@ function TagKeyValueInput({
if (!raw) {
return;
}
const normalized = parseKeyValueTag(raw);
if (!normalized) {
setError('Tags must be in key:value format (both sides required).');
const result = validateTag(raw, tags);
if ('error' in result) {
setError(result.error);
return;
}
if (tags.includes(normalized)) {
setError('This tag already exists.');
return;
}
onTagsChange([...tags, normalized]);
onTagsChange([...tags, result.tag]);
setInputValue('');
setError('');
};
@@ -82,15 +78,20 @@ function TagKeyValueInput({
const cancelEdit = (): void => {
setEditIndex(-1);
setEditValue('');
setError('');
};
const commitEdit = (): void => {
const normalized = parseKeyValueTag(editValue);
// Drop into a no-op (revert) on invalid or duplicate edits rather than
// stranding the user in an un-exitable edit box.
if (normalized && !tags.some((t, i) => t === normalized && i !== editIndex)) {
onTagsChange(tags.map((t, i) => (i === editIndex ? normalized : t)));
const commitEdit = (revertOnInvalid = false): void => {
const result = validateTag(editValue, tags, editIndex);
if ('error' in result) {
if (revertOnInvalid) {
cancelEdit();
} else {
setError(result.error);
}
return;
}
onTagsChange(tags.map((t, i) => (i === editIndex ? result.tag : t)));
cancelEdit();
};
@@ -121,11 +122,14 @@ function TagKeyValueInput({
value={editValue}
autoFocus
testId={`${testId}-edit`}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setEditValue(e.target.value)
}
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
setEditValue(e.target.value);
if (error) {
setError('');
}
}}
onKeyDown={handleEditKeyDown}
onBlur={commitEdit}
onBlur={(): void => commitEdit(true)}
/>
) : (
<TagBadge

View File

@@ -15,3 +15,42 @@ export function parseKeyValueTag(raw: string): string | null {
}
return `${key}:${value}`;
}
const TAG_KEY_REGEX = new RegExp('^[a-zA-Z$_@{#][a-zA-Z0-9$_@#{}:/-]*$');
const TAG_VALUE_REGEX = new RegExp('^[a-zA-Z0-9$_@#{}:.+=/-]*$');
const MAX_TAG_LEN = 32;
export type TagValidation = { tag: string } | { error: string };
export function validateTag(
raw: string,
existingTags: string[],
excludeIndex = -1,
): TagValidation {
const normalized = parseKeyValueTag(raw);
if (!normalized) {
return { error: 'Tags must be in key:value format (both sides required).' };
}
const separator = normalized.indexOf(':');
const key = normalized.slice(0, separator);
const value = normalized.slice(separator + 1);
if (!TAG_KEY_REGEX.test(key)) {
return { error: 'Tag keys cannot contain spaces or special characters.' };
}
if (!TAG_VALUE_REGEX.test(value)) {
return { error: 'Tag values cannot contain spaces or special characters.' };
}
if (key.length > MAX_TAG_LEN || value.length > MAX_TAG_LEN) {
return {
error: `Tag key and value must each be ${MAX_TAG_LEN} characters or fewer.`,
};
}
if (
existingTags.some(
(tag, index) => tag === normalized && index !== excludeIndex,
)
) {
return { error: 'This tag already exists.' };
}
return { tag: normalized };
}

View File

@@ -1,7 +1,11 @@
import { ComponentProps, memo } from 'react';
import { ComponentProps, memo, useCallback, useLayoutEffect } from 'react';
import { TableComponents } from 'react-virtuoso';
import cx from 'classnames';
import {
chromePerformanceTanstackTableBeginHover,
chromePerformanceMeasureTanstackTable,
} from './perfDevtools';
import TanStackRowCells from './TanStackRow';
import {
useClearRowHovered,
@@ -10,6 +14,7 @@ import {
import { FlatItem, TableRowContext } from './types';
import tableStyles from './TanStackTable.module.scss';
import { chromePerformanceNow } from 'lib/chromePerformanceDevTools';
type VirtuosoTableRowProps<TData, TItemKey = string> = ComponentProps<
NonNullable<
@@ -22,6 +27,7 @@ function TanStackCustomTableRow<TData, TItemKey = string>({
context,
...props
}: VirtuosoTableRowProps<TData, TItemKey>): JSX.Element {
const renderStart = chromePerformanceNow();
const rowId = item.row.id;
const rowData = item.row.original;
@@ -29,6 +35,23 @@ function TanStackCustomTableRow<TData, TItemKey = string>({
const setHovered = useSetRowHovered(rowId);
const clearHovered = useClearRowHovered(rowId);
const handleMouseEnter = useCallback((): void => {
chromePerformanceTanstackTableBeginHover(rowId);
setHovered();
}, [rowId, setHovered]);
useLayoutEffect(() => {
chromePerformanceMeasureTanstackTable('Row render', renderStart, {
track: 'Row render',
color: 'secondary',
tooltipText: 'Row render + commit',
properties: [
['rowId', rowId],
['kind', item.kind],
],
});
});
if (item.kind === 'expansion') {
return (
<tr {...props} className={tableStyles.tableRowExpansion}>
@@ -65,7 +88,7 @@ function TanStackCustomTableRow<TData, TItemKey = string>({
{...props}
className={rowClassName}
style={rowStyle}
onMouseEnter={setHovered}
onMouseEnter={handleMouseEnter}
onMouseLeave={clearHovered}
>
<TanStackRowCells

View File

@@ -0,0 +1,30 @@
import { useLayoutEffect, type ReactNode } from 'react';
import { chromePerformanceTanstackTableEndHover } from './perfDevtools';
import { useIsRowHovered } from './TanStackTableStateContext';
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
export type HoverTooltipProps = Omit<TooltipSimpleProps, 'open'> & {
rowId: string;
children: ReactNode;
};
export function TanStackHoverTooltip({
rowId,
children,
...tooltipProps
}: HoverTooltipProps): JSX.Element {
const isHovered = useIsRowHovered(rowId);
useLayoutEffect(() => {
if (isHovered) {
chromePerformanceTanstackTableEndHover(rowId);
}
}, [isHovered, rowId]);
if (!isHovered) {
return <>{children}</>;
}
return <TooltipSimple {...tooltipProps}>{children}</TooltipSimple>;
}

View File

@@ -5,6 +5,7 @@ import {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
@@ -44,6 +45,7 @@ import {
TanStackTableHandle,
TanStackTableProps,
} from './types';
import { chromePerformanceMeasureTanstackTable } from './perfDevtools';
import { useColumnDnd } from './useColumnDnd';
import { useColumnHandlers } from './useColumnHandlers';
import { useColumnState } from './useColumnState';
@@ -56,6 +58,7 @@ import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
import tableStyles from './TanStackTable.module.scss';
import viewStyles from './TanStackTableView.module.scss';
import { chromePerformanceNow } from 'lib/chromePerformanceDevTools';
const COLUMN_DND_AUTO_SCROLL = {
layoutShiftCompensation: false as const,
@@ -116,6 +119,8 @@ function TanStackTableInner<TData, TItemKey = string>(
);
}
const renderStart = chromePerformanceNow();
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const isDarkMode = useIsDarkMode();
@@ -344,6 +349,19 @@ function TanStackTableInner<TData, TItemKey = string>(
const visibleColumnsCount = table.getVisibleFlatColumns().length;
useLayoutEffect(() => {
chromePerformanceMeasureTanstackTable('Table render', renderStart, {
track: 'Table render',
color: 'primary',
tooltipText: 'TanStackTable render + commit',
properties: [
['rows', String(flatItems.length)],
['columns', String(visibleColumnsCount)],
['loading', String(isLoading)],
],
});
});
const columnOrderKey = useMemo(() => columnIds.join(','), [columnIds]);
const columnVisibilityKey = useMemo(
() =>

View File

@@ -1,6 +1,8 @@
import { TanStackHoverTooltip } from './TanStackHoverTooltip';
import { TanStackTableBase } from './TanStackTable';
import TanStackTableText from './TanStackTableText';
export * from './TanStackHoverTooltip';
export * from './TanStackTableStateContext';
export * from './types';
export * from './useCalculatedPageSize';
@@ -274,6 +276,7 @@ export * from './useTableParams';
*/
const TanStackTable = Object.assign(TanStackTableBase, {
Text: TanStackTableText,
HoverTooltip: TanStackHoverTooltip,
});
export default TanStackTable;

View File

@@ -0,0 +1,35 @@
import {
createChromePerformanceInteractionTracker,
chromePerformanceMeasure,
ChromePerformanceTrackEntryOptions,
} from 'lib/chromePerformanceDevTools';
const TABLE_TRACK_GROUP = 'SigNoz Table';
export function chromePerformanceMeasureTanstackTable(
name: string,
start: number,
{
track,
color,
tooltipText,
properties,
}: Omit<ChromePerformanceTrackEntryOptions, 'trackGroup'>,
): void {
chromePerformanceMeasure(name, start, {
trackGroup: TABLE_TRACK_GROUP,
track,
color,
tooltipText,
properties,
});
}
const hoverTracker = createChromePerformanceInteractionTracker(
TABLE_TRACK_GROUP,
'Hover',
'Row hover',
);
export const chromePerformanceTanstackTableBeginHover = hoverTracker.begin;
export const chromePerformanceTanstackTableEndHover = hoverTracker.end;

View File

@@ -21,6 +21,7 @@ export type TableCellContext<TData, TValue> = {
value: TValue;
isActive: boolean;
rowIndex: number;
rowId: string;
isExpanded: boolean;
canExpand: boolean;
toggleExpanded: () => void;

View File

@@ -135,6 +135,7 @@ export function buildTanstackColumnDef<TData, TItemKey = string>(
value: getValue() as TData[any],
isActive: isRowActive?.(rowData) ?? false,
rowIndex: row.index,
rowId: row.id,
isExpanded: row.getIsExpanded(),
canExpand: row.getCanExpand(),
toggleExpanded: (): void => {

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

@@ -51,6 +51,7 @@ import { getHostsQuickFiltersConfig } from './utils';
import styles from './InfraMonitoringHosts.module.scss';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
@@ -211,27 +212,31 @@ function Hosts(): JSX.Element {
<div className={styles.infraContentRow}>
{showFilters && (
<div className={styles.quickFiltersContainer}>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ rotate: '270deg', cursor: 'pointer' }}
onClick={handleFilterVisibilityChange}
size="md"
<OverlayScrollbar>
<>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ rotate: '270deg', cursor: 'pointer' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
</div>
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={getHostsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={{
metricNamespace:
METRIC_NAMESPACE_BY_ENTITY[InfraMonitoringEntity.HOSTS],
startUnixMilli,
endUnixMilli,
}}
/>
</Tooltip>
</div>
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={getHostsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={{
metricNamespace:
METRIC_NAMESPACE_BY_ENTITY[InfraMonitoringEntity.HOSTS],
startUnixMilli,
endUnixMilli,
}}
/>
</>
</OverlayScrollbar>
</div>
)}
<div
@@ -248,6 +253,7 @@ function Hosts(): JSX.Element {
getRowKey={getHostRowKey}
getItemKey={getHostItemKey}
eventCategory={InfraMonitoringEvents.HostEntity}
detailsQueryKeyPrefix="hosts"
/>
</div>
</div>

View File

@@ -48,24 +48,6 @@
width: 280px;
min-width: 280px;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.ant-collapse-header) {
border-bottom: 1px solid var(--l1-border);
@@ -80,28 +62,6 @@
padding-left: 18px;
}
}
:global(.quick-filters) {
overflow-y: auto;
overflow-x: hidden;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--accent-primary);
}
}
}
.quickFiltersContainerHeader {

View File

@@ -130,12 +130,13 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
accessorFn: (row): string => row.status,
width: { min: 140 },
enableSort: false,
cell: ({ value, groupMeta, row }): React.ReactNode => {
cell: ({ value, groupMeta, row, rowId }): React.ReactNode => {
const status = value as InframonitoringtypesHostStatusDTO;
if (groupMeta) {
return (
<GroupedStatusCounts
rowId={rowId}
items={[
{
value: row.activeHostCount,
@@ -195,13 +196,13 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
<ColumnHeader
tooltip="Excluding cache memory."
docPath="/infrastructure-monitoring/host-monitoring#memory-usage"
className={styles.memoryUsageHeader}
>
Memory Usage (WSS)
Memory Usage
<br /> (WSS)
</ColumnHeader>
),
accessorFn: (row): number => row.memory,
width: { min: 240 },
width: { min: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
@@ -248,11 +249,12 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
id: 'load15',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#load-avg">
Load Avg (15min)
Load Avg
<br /> (15min)
</ColumnHeader>
),
accessorFn: (row): number => row.load15,
width: { min: 200 },
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const load15 = Number(value);
@@ -276,7 +278,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
</ColumnHeader>
),
accessorFn: (row): number => row.diskUsage,
width: { min: 160 },
width: { min: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const diskUsage = value as number;

View File

@@ -13,14 +13,6 @@
text-align: right;
}
.memoryUsageHeader {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-2);
width: 100%;
}
.statusTag {
width: fit-content;
font-family: Inter, sans-serif;

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%"
@@ -480,8 +163,9 @@ export default function K8sBaseDetails<T>({
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
{isEntityLoading && <LoadingContainer />}
{(isEntityError || hasResponseError) && (
{(isEntityLoading || !selectedItem) && <LoadingContainer />}
{selectedItem && (isEntityError || hasResponseError) && (
<div className="entity-error-container">
<ErrorContent
error={
@@ -497,208 +181,34 @@ 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,
)}
</>
{selectedItem && entity && !isEntityLoading && !hasResponseError && (
<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

@@ -74,14 +74,15 @@ export type K8sBaseListProps<
warning?: Querybuildertypesv5QueryWarnDataDTO | null;
}>;
/** Function to get the unique key for a row. */
getRowKey?: (record: T) => string;
getRowKey: (record: T) => string;
/** Function to get the item key used for selection. Can return string or SelectedItemParams. */
getItemKey?: (record: T) => TItemKey;
getItemKey: (record: T) => TItemKey;
eventCategory: InfraMonitoringEvents;
renderEmptyState?: (
context: K8sBaseListEmptyStateContext,
) => React.ReactNode | null;
extraQueryKeyParts?: string[];
detailsQueryKeyPrefix: string;
};
export function K8sBaseList<
@@ -98,6 +99,7 @@ export function K8sBaseList<
eventCategory,
renderEmptyState,
extraQueryKeyParts = [],
detailsQueryKeyPrefix,
}: K8sBaseListProps<T, TItemKey>): JSX.Element {
const { currentQuery } = useQueryBuilder();
const expression = currentQuery.builder.queryData[0]?.filter?.expression || '';
@@ -234,17 +236,29 @@ export function K8sBaseList<
}, [eventCategory, totalCount]);
const handleRowClick = useCallback(
(_record: T, itemKey: TItemKey): void => {
(record: T, itemKey: TItemKey): void => {
if (groupBy.length === 0) {
if (typeof itemKey === 'object' && itemKey !== null) {
setSelectedItemParams(itemKey);
} else {
setSelectedItemParams({
selectedItem: itemKey,
clusterName: null,
namespaceName: null,
});
const params: SelectedItemParams =
typeof itemKey === 'object'
? itemKey
: {
selectedItem: itemKey,
clusterName: null,
namespaceName: null,
};
if (detailsQueryKeyPrefix) {
const detailQueryKey = getAutoRefreshQueryKey(
selectedTime,
`${detailsQueryKeyPrefix}EntityDetails`,
params.selectedItem,
params.clusterName,
params.namespaceName,
);
queryClient.setQueryData(detailQueryKey, { data: record });
}
setSelectedItemParams(params);
}
void logEvent(InfraMonitoringEvents.ItemClicked, {
@@ -253,7 +267,15 @@ export function K8sBaseList<
category: eventCategory,
});
},
[eventCategory, groupBy.length, setSelectedItemParams],
[
eventCategory,
groupBy.length,
setSelectedItemParams,
detailsQueryKeyPrefix,
getAutoRefreshQueryKey,
selectedTime,
queryClient,
],
);
const handleRowClickNewTab = useCallback(
@@ -403,7 +425,7 @@ export function K8sBaseList<
onRowClickNewTab={handleRowClickNewTab}
renderExpandedRow={isGroupedByAttribute ? renderExpandedRow : undefined}
getRowCanExpand={isGroupedByAttribute ? getRowCanExpand : undefined}
className={cx(styles.k8SListTable, expandedRowColumns)}
className={cx(styles.k8SListTable)}
enableQueryParams={{
page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
limit: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE_SIZE,

View File

@@ -0,0 +1,43 @@
import { useMemo } from 'react';
import { useInfraMonitoringCategory } from '../hooks';
import { getEntityConfig } from './entity.registry';
import { K8sBaseList } from './K8sBaseList';
import K8sBaseDetails from './K8sBaseDetails';
export interface K8sDynamicListProps {
controlListPrefix?: React.ReactNode;
leftFilters?: React.ReactNode;
}
export function K8sDynamicList({
controlListPrefix,
leftFilters,
}: K8sDynamicListProps): JSX.Element | null {
const [selectedCategory] = useInfraMonitoringCategory();
const config = useMemo(
() => getEntityConfig(selectedCategory),
[selectedCategory],
);
if (!config) {
return null;
}
const { list, details } = config;
return (
<>
<K8sBaseList
{...list}
controlListPrefix={controlListPrefix}
leftFilters={leftFilters}
/>
<K8sBaseDetails {...details} />
</>
);
}
export default K8sDynamicList;

View File

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

View File

@@ -1,5 +1,6 @@
import { Box } from '@signozhq/icons';
import { screen } from '@testing-library/react';
import { InfraMonitoringEvents } from 'constants/events';
import userEvent from '@testing-library/user-event';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { act, render, waitFor } from 'tests/test-utils';
@@ -31,7 +32,7 @@ const mockEntity: TestEntity = {
function createBaseProps() {
return {
category: InfraMonitoringEntity.PODS,
eventCategory: 'Pod',
eventCategory: InfraMonitoringEvents.Pod,
getSelectedItemExpression: (): string => 'k8s.pod.name = "test-pod"',
fetchEntityData: jest
.fn()

View File

@@ -175,8 +175,10 @@ function renderComponent<
>({
queryParams,
onUrlUpdate,
detailsQueryKeyPrefix = 'testEntity',
...props
}: K8sBaseListProps<T, TItemKey> & {
}: Omit<K8sBaseListProps<T, TItemKey>, 'detailsQueryKeyPrefix'> & {
detailsQueryKeyPrefix?: string;
queryParams?: Record<string, string>;
onUrlUpdate?: OnUrlUpdateFunction;
}) {
@@ -203,7 +205,10 @@ function renderComponent<
value={{ viewportHeight: 800, itemHeight: 50 }}
>
<TooltipProvider>
<K8sBaseList<T, TItemKey> {...props} />
<K8sBaseList<T, TItemKey>
{...props}
detailsQueryKeyPrefix={detailsQueryKeyPrefix}
/>
</TooltipProvider>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>

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

@@ -0,0 +1,21 @@
import { SelectedItemParams } from '../hooks';
import { K8sBaseListProps } from './K8sBaseList';
import { K8sBaseDetailsProps } from './types';
export type K8sEntityData = { meta?: Record<string, string> | null };
export type K8sEntityListConfig<
T extends K8sEntityData,
TItemKey extends string | SelectedItemParams = string,
> = Omit<K8sBaseListProps<T, TItemKey>, 'controlListPrefix' | 'leftFilters'>;
export type K8sEntityDetailsConfig<T extends K8sEntityData> =
K8sBaseDetailsProps<T>;
export interface K8sEntityConfig<
T extends K8sEntityData,
TItemKey extends string | SelectedItemParams = string,
> {
list: K8sEntityListConfig<T, TItemKey>;
details: K8sEntityDetailsConfig<T>;
}

View File

@@ -0,0 +1,41 @@
import { K8sCategories } from '../constants';
import { SelectedItemParams } from '../hooks';
import { K8sEntityConfig, K8sEntityData } from './entity.config.types';
import { podEntityConfig } from '../Pods/entity.config';
import { nodeEntityConfig } from '../Nodes/entity.config';
import { clusterEntityConfig } from '../Clusters/entity.config';
import { deploymentEntityConfig } from '../Deployments/entity.config';
import { namespaceEntityConfig } from '../Namespaces/entity.config';
import { jobEntityConfig } from '../Jobs/entity.config';
import { daemonSetEntityConfig } from '../DaemonSets/entity.config';
import { statefulSetEntityConfig } from '../StatefulSets/entity.config';
import { volumeEntityConfig } from '../Volumes/entity.config';
type AnyEntityConfig = K8sEntityConfig<
K8sEntityData,
string | SelectedItemParams
>;
function registerConfig<
T extends K8sEntityData,
TItemKey extends string | SelectedItemParams,
>(config: K8sEntityConfig<T, TItemKey>): AnyEntityConfig {
return config as unknown as AnyEntityConfig;
}
export const entityRegistry: Record<string, AnyEntityConfig> = {
[K8sCategories.PODS]: registerConfig(podEntityConfig),
[K8sCategories.NODES]: registerConfig(nodeEntityConfig),
[K8sCategories.CLUSTERS]: registerConfig(clusterEntityConfig),
[K8sCategories.DEPLOYMENTS]: registerConfig(deploymentEntityConfig),
[K8sCategories.NAMESPACES]: registerConfig(namespaceEntityConfig),
[K8sCategories.JOBS]: registerConfig(jobEntityConfig),
[K8sCategories.DAEMONSETS]: registerConfig(daemonSetEntityConfig),
[K8sCategories.STATEFULSETS]: registerConfig(statefulSetEntityConfig),
[K8sCategories.VOLUMES]: registerConfig(volumeEntityConfig),
};
export function getEntityConfig(category: string): AnyEntityConfig | undefined {
return entityRegistry[category];
}

View File

@@ -1,3 +1,18 @@
import { ReactNode } from 'react';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { InfraMonitoringEvents } from 'constants/events';
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 +48,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: InfraMonitoringEvents;
// 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: InfraMonitoringEvents;
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

@@ -1,153 +0,0 @@
import { useCallback } from 'react';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listClusters } from 'api/generated/services/inframonitoring';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import {
InframonitoringtypesClusterRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { InfraMonitoringEvents } from 'constants/events';
import APIError from 'types/api/error';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import {
clusterWidgetInfo,
getClusterMetricsQueryPayload,
k8sClusterDetailsCountsConfig,
k8sClusterDetailsMetadataConfig,
k8sClusterGetCountsFilterExpression,
k8sClusterGetEntityName,
k8sClusterGetSelectedItemExpression,
k8sClusterInitialEventsExpression,
k8sClusterInitialLogTracesExpression,
} from './constants';
import {
getK8sClusterItemKey,
getK8sClusterRowKey,
k8sClustersColumnsConfig,
} from './table.config';
function K8sClustersList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
try {
const response = await listClusters(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesClusterRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{
data: InframonitoringtypesClusterRecordDTO | null;
error?: APIError | null;
}> => {
try {
const response = await listClusters(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
return (
<>
<K8sBaseList<InframonitoringtypesClusterRecordDTO>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.CLUSTERS}
tableColumns={k8sClustersColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sClusterRowKey}
getItemKey={getK8sClusterItemKey}
eventCategory={InfraMonitoringEvents.Cluster}
/>
<K8sBaseDetails<InframonitoringtypesClusterRecordDTO>
category={InfraMonitoringEntity.CLUSTERS}
eventCategory={InfraMonitoringEvents.Cluster}
getSelectedItemExpression={k8sClusterGetSelectedItemExpression}
fetchEntityData={fetchEntityData}
getEntityName={k8sClusterGetEntityName}
getInitialLogTracesExpression={k8sClusterInitialLogTracesExpression}
getInitialEventsExpression={k8sClusterInitialEventsExpression}
metadataConfig={k8sClusterDetailsMetadataConfig}
countsConfig={k8sClusterDetailsCountsConfig}
getCountsFilterExpression={k8sClusterGetCountsFilterExpression}
entityWidgetInfo={clusterWidgetInfo}
getEntityQueryPayload={getClusterMetricsQueryPayload}
queryKeyPrefix="cluster"
/>
</>
);
}
export default K8sClustersList;

View File

@@ -0,0 +1,138 @@
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listClusters } from 'api/generated/services/inframonitoring';
import {
InframonitoringtypesClusterRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { InfraMonitoringEvents } from 'constants/events';
import { K8sEntityConfig } from '../Base/entity.config.types';
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import {
clusterWidgetInfo,
getClusterMetricsQueryPayload,
k8sClusterDetailsCountsConfig,
k8sClusterDetailsMetadataConfig,
k8sClusterGetCountsFilterExpression,
k8sClusterGetEntityName,
k8sClusterGetSelectedItemExpression,
k8sClusterInitialEventsExpression,
k8sClusterInitialLogTracesExpression,
} from './constants';
import {
getK8sClusterItemKey,
getK8sClusterRowKey,
k8sClustersColumnsConfig,
} from './table.config';
async function fetchListData(
filters: K8sBaseFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<InframonitoringtypesClusterRecordDTO>['list']['fetchListData']
> {
try {
const response = await listClusters(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesClusterRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
async function fetchEntityData(
filters: K8sDetailsFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<InframonitoringtypesClusterRecordDTO>['details']['fetchEntityData']
> {
try {
const response = await listClusters(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
export const clusterEntityConfig: K8sEntityConfig<InframonitoringtypesClusterRecordDTO> =
{
list: {
entity: InfraMonitoringEntity.CLUSTERS,
eventCategory: InfraMonitoringEvents.Cluster,
tableColumns: k8sClustersColumnsConfig,
fetchListData,
getRowKey: getK8sClusterRowKey,
getItemKey: getK8sClusterItemKey,
detailsQueryKeyPrefix: 'cluster',
},
details: {
category: InfraMonitoringEntity.CLUSTERS,
eventCategory: InfraMonitoringEvents.Cluster,
queryKeyPrefix: 'cluster',
getSelectedItemExpression: k8sClusterGetSelectedItemExpression,
fetchEntityData,
getEntityName: k8sClusterGetEntityName,
getInitialLogTracesExpression: k8sClusterInitialLogTracesExpression,
getInitialEventsExpression: k8sClusterInitialEventsExpression,
metadataConfig: k8sClusterDetailsMetadataConfig,
entityWidgetInfo: clusterWidgetInfo,
getEntityQueryPayload: getClusterMetricsQueryPayload,
countsConfig: k8sClusterDetailsCountsConfig,
getCountsFilterExpression: k8sClusterGetCountsFilterExpression,
},
};

View File

@@ -93,13 +93,14 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
row.nodeCountsByReadiness,
width: { min: 180 },
enableSort: false,
cell: ({ row }): React.ReactNode => {
cell: ({ row, rowId }): React.ReactNode => {
if (!row.nodeCountsByReadiness) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts
rowId={rowId}
items={[
{
value: row.nodeCountsByReadiness.ready,
@@ -129,13 +130,16 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
row.podCountsByStatus,
width: { min: 250 },
enableSort: false,
cell: ({ row }): React.ReactNode => {
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts items={getPodStatusItems(row.podCountsByStatus)} />
<GroupedStatusCounts
rowId={rowId}
items={getPodStatusItems(row.podCountsByStatus)}
/>
);
},
},

View File

@@ -1,158 +0,0 @@
import { useCallback, useMemo } from 'react';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listDaemonSets } from 'api/generated/services/inframonitoring';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import {
InframonitoringtypesDaemonSetRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { InfraMonitoringEvents } from 'constants/events';
import APIError from 'types/api/error';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
daemonSetWidgetInfo,
getDaemonSetMetricsQueryPayload,
getDaemonSetPodMetricsQueryPayload,
k8sDaemonSetDetailsMetadataConfig,
k8sDaemonSetGetEntityName,
k8sDaemonSetGetSelectedItemExpression,
k8sDaemonSetInitialEventsExpression,
k8sDaemonSetInitialLogTracesExpression,
} from './constants';
import {
getK8sDaemonSetItemKey,
getK8sDaemonSetRowKey,
k8sDaemonSetsColumnsConfig,
} from './table.config';
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
function K8sDaemonSetsList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
try {
const response = await listDaemonSets(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesDaemonSetRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{
data: InframonitoringtypesDaemonSetRecordDTO | null;
error?: APIError | null;
}> => {
try {
const response = await listDaemonSets(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
const customTabs = useMemo(
() => [
createPodMetricsTab<InframonitoringtypesDaemonSetRecordDTO>({
getQueryPayload: getDaemonSetPodMetricsQueryPayload,
category: InfraMonitoringEntity.DAEMONSETS,
queryKey: 'daemonSetPodMetrics',
}),
],
[],
);
return (
<>
<K8sBaseList<InframonitoringtypesDaemonSetRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.DAEMONSETS}
tableColumns={k8sDaemonSetsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sDaemonSetRowKey}
getItemKey={getK8sDaemonSetItemKey}
eventCategory={InfraMonitoringEvents.DaemonSet}
/>
<K8sBaseDetails<InframonitoringtypesDaemonSetRecordDTO>
category={InfraMonitoringEntity.DAEMONSETS}
eventCategory={InfraMonitoringEvents.DaemonSet}
getSelectedItemExpression={k8sDaemonSetGetSelectedItemExpression}
fetchEntityData={fetchEntityData}
getEntityName={k8sDaemonSetGetEntityName}
getInitialLogTracesExpression={k8sDaemonSetInitialLogTracesExpression}
getInitialEventsExpression={k8sDaemonSetInitialEventsExpression}
metadataConfig={k8sDaemonSetDetailsMetadataConfig}
entityWidgetInfo={daemonSetWidgetInfo}
getEntityQueryPayload={getDaemonSetMetricsQueryPayload}
queryKeyPrefix="daemonset"
customTabs={customTabs}
/>
</>
);
}
export default K8sDaemonSetsList;

View File

@@ -0,0 +1,153 @@
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listDaemonSets } from 'api/generated/services/inframonitoring';
import {
InframonitoringtypesDaemonSetRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { InfraMonitoringEvents } from 'constants/events';
import { K8sEntityConfig } from '../Base/entity.config.types';
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { createPodMetricsTab } from '../EntityDetailsUtils/createPodMetricsTab';
import { SelectedItemParams } from '../hooks';
import {
daemonSetWidgetInfo,
getDaemonSetMetricsQueryPayload,
getDaemonSetPodMetricsQueryPayload,
k8sDaemonSetDetailsMetadataConfig,
k8sDaemonSetGetEntityName,
k8sDaemonSetGetSelectedItemExpression,
k8sDaemonSetInitialEventsExpression,
k8sDaemonSetInitialLogTracesExpression,
} from './constants';
import {
getK8sDaemonSetItemKey,
getK8sDaemonSetRowKey,
k8sDaemonSetsColumnsConfig,
} from './table.config';
async function fetchListData(
filters: K8sBaseFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<
InframonitoringtypesDaemonSetRecordDTO,
SelectedItemParams
>['list']['fetchListData']
> {
try {
const response = await listDaemonSets(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesDaemonSetRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
async function fetchEntityData(
filters: K8sDetailsFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<
InframonitoringtypesDaemonSetRecordDTO,
SelectedItemParams
>['details']['fetchEntityData']
> {
try {
const response = await listDaemonSets(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
export const daemonSetEntityConfig: K8sEntityConfig<
InframonitoringtypesDaemonSetRecordDTO,
SelectedItemParams
> = {
list: {
entity: InfraMonitoringEntity.DAEMONSETS,
eventCategory: InfraMonitoringEvents.DaemonSet,
tableColumns: k8sDaemonSetsColumnsConfig,
fetchListData,
getRowKey: getK8sDaemonSetRowKey,
getItemKey: getK8sDaemonSetItemKey,
detailsQueryKeyPrefix: 'daemonset',
},
details: {
category: InfraMonitoringEntity.DAEMONSETS,
eventCategory: InfraMonitoringEvents.DaemonSet,
queryKeyPrefix: 'daemonset',
getSelectedItemExpression: k8sDaemonSetGetSelectedItemExpression,
fetchEntityData,
getEntityName: k8sDaemonSetGetEntityName,
getInitialLogTracesExpression: k8sDaemonSetInitialLogTracesExpression,
getInitialEventsExpression: k8sDaemonSetInitialEventsExpression,
metadataConfig: k8sDaemonSetDetailsMetadataConfig,
entityWidgetInfo: daemonSetWidgetInfo,
getEntityQueryPayload: getDaemonSetMetricsQueryPayload,
customTabs: [
createPodMetricsTab<InframonitoringtypesDaemonSetRecordDTO>({
getQueryPayload: getDaemonSetPodMetricsQueryPayload,
category: InfraMonitoringEntity.DAEMONSETS,
queryKey: 'daemonSetPodMetrics',
docBasePath: '/infrastructure-monitoring/kubernetes/daemonsets/',
}),
],
},
};

View File

@@ -121,12 +121,17 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
width: { min: 250 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => {
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return <GroupedStatusCounts items={getPodStatusItems(podCountsByStatus)} />;
return (
<GroupedStatusCounts
rowId={rowId}
items={getPodStatusItems(podCountsByStatus)}
/>
);
},
},
{
@@ -140,8 +145,9 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
width: { min: 210 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => (
cell: ({ row, rowId }): React.ReactNode => (
<GroupedStatusCounts
rowId={rowId}
items={[
{
value: row.readyNodes,

View File

@@ -1,164 +0,0 @@
import { useCallback, useMemo } from 'react';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listDeployments } from 'api/generated/services/inframonitoring';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import {
InframonitoringtypesDeploymentRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { InfraMonitoringEvents } from 'constants/events';
import APIError from 'types/api/error';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
deploymentWidgetInfo,
getDeploymentMetricsQueryPayload,
getDeploymentPodMetricsQueryPayload,
k8sDeploymentDetailsMetadataConfig,
k8sDeploymentGetEntityName,
k8sDeploymentGetSelectedItemExpression,
k8sDeploymentInitialEventsExpression,
k8sDeploymentInitialLogTracesExpression,
} from './constants';
import {
getK8sDeploymentItemKey,
getK8sDeploymentRowKey,
k8sDeploymentsColumnsConfig,
} from './table.config';
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
function K8sDeploymentsList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
try {
const response = await listDeployments(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesDeploymentRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{
data: InframonitoringtypesDeploymentRecordDTO | null;
error?: APIError | null;
}> => {
try {
const response = await listDeployments(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
const customTabs = useMemo(
() => [
createPodMetricsTab<InframonitoringtypesDeploymentRecordDTO>({
getQueryPayload: getDeploymentPodMetricsQueryPayload,
category: InfraMonitoringEntity.DEPLOYMENTS,
queryKey: 'deploymentPodMetrics',
}),
],
[],
);
return (
<>
<K8sBaseList<InframonitoringtypesDeploymentRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.DEPLOYMENTS}
tableColumns={k8sDeploymentsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sDeploymentRowKey}
getItemKey={getK8sDeploymentItemKey}
eventCategory={InfraMonitoringEvents.Deployment}
/>
<K8sBaseDetails<InframonitoringtypesDeploymentRecordDTO>
category={InfraMonitoringEntity.DEPLOYMENTS}
eventCategory={InfraMonitoringEvents.Deployment}
getSelectedItemExpression={k8sDeploymentGetSelectedItemExpression}
fetchEntityData={fetchEntityData}
getEntityName={k8sDeploymentGetEntityName}
getInitialLogTracesExpression={k8sDeploymentInitialLogTracesExpression}
getInitialEventsExpression={k8sDeploymentInitialEventsExpression}
metadataConfig={k8sDeploymentDetailsMetadataConfig}
entityWidgetInfo={deploymentWidgetInfo}
getEntityQueryPayload={getDeploymentMetricsQueryPayload}
queryKeyPrefix="deployment"
customTabs={customTabs}
/>
</>
);
}
export default K8sDeploymentsList;

View File

@@ -0,0 +1,161 @@
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listDeployments } from 'api/generated/services/inframonitoring';
import {
InframonitoringtypesDeploymentRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { InfraMonitoringEvents } from 'constants/events';
import { K8sEntityConfig } from '../Base/entity.config.types';
import {
K8sBaseFilters,
K8sDetailsCustomTab,
K8sDetailsFilters,
} from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { createPodMetricsTab } from '../EntityDetailsUtils/createPodMetricsTab';
import { SelectedItemParams } from '../hooks';
import {
deploymentWidgetInfo,
getDeploymentMetricsQueryPayload,
getDeploymentPodMetricsQueryPayload,
k8sDeploymentDetailsMetadataConfig,
k8sDeploymentGetEntityName,
k8sDeploymentGetSelectedItemExpression,
k8sDeploymentInitialEventsExpression,
k8sDeploymentInitialLogTracesExpression,
} from './constants';
import {
getK8sDeploymentItemKey,
getK8sDeploymentRowKey,
k8sDeploymentsColumnsConfig,
} from './table.config';
async function fetchListData(
filters: K8sBaseFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<
InframonitoringtypesDeploymentRecordDTO,
SelectedItemParams
>['list']['fetchListData']
> {
try {
const response = await listDeployments(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesDeploymentRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
async function fetchEntityData(
filters: K8sDetailsFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<
InframonitoringtypesDeploymentRecordDTO,
SelectedItemParams
>['details']['fetchEntityData']
> {
try {
const response = await listDeployments(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
export function createCustomTabs(): K8sDetailsCustomTab<InframonitoringtypesDeploymentRecordDTO>[] {
return [
createPodMetricsTab<InframonitoringtypesDeploymentRecordDTO>({
getQueryPayload: getDeploymentPodMetricsQueryPayload,
category: InfraMonitoringEntity.DEPLOYMENTS,
queryKey: 'deploymentPodMetrics',
docBasePath: '/infrastructure-monitoring/kubernetes/deployments/',
}),
];
}
export const deploymentEntityConfig: K8sEntityConfig<
InframonitoringtypesDeploymentRecordDTO,
SelectedItemParams
> = {
list: {
entity: InfraMonitoringEntity.DEPLOYMENTS,
eventCategory: InfraMonitoringEvents.Deployment,
tableColumns: k8sDeploymentsColumnsConfig,
fetchListData,
getRowKey: getK8sDeploymentRowKey,
getItemKey: getK8sDeploymentItemKey,
detailsQueryKeyPrefix: 'deployment',
},
details: {
category: InfraMonitoringEntity.DEPLOYMENTS,
eventCategory: InfraMonitoringEvents.Deployment,
queryKeyPrefix: 'deployment',
getSelectedItemExpression: k8sDeploymentGetSelectedItemExpression,
fetchEntityData,
getEntityName: k8sDeploymentGetEntityName,
getInitialLogTracesExpression: k8sDeploymentInitialLogTracesExpression,
getInitialEventsExpression: k8sDeploymentInitialEventsExpression,
metadataConfig: k8sDeploymentDetailsMetadataConfig,
entityWidgetInfo: deploymentWidgetInfo,
getEntityQueryPayload: getDeploymentMetricsQueryPayload,
customTabs: createCustomTabs(),
},
};

View File

@@ -118,12 +118,17 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
width: { min: 250 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => {
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return <GroupedStatusCounts items={getPodStatusItems(podCountsByStatus)} />;
return (
<GroupedStatusCounts
rowId={rowId}
items={getPodStatusItems(podCountsByStatus)}
/>
);
},
},
{
@@ -137,8 +142,9 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
width: { min: 180 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => (
cell: ({ row, rowId }): React.ReactNode => (
<GroupedStatusCounts
rowId={rowId}
items={[
{
value: row.availablePods,

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,38 @@ interface CreatePodMetricsTabParams<T> {
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
category: InfraMonitoringEntity;
queryKey: string;
category: InfraMonitoringEntity;
docBasePath?: string;
}
export function createPodMetricsTab<T>({
getQueryPayload,
category,
queryKey,
category,
docBasePath,
}: CreatePodMetricsTabParams<T>): CustomTab<T> {
const eventEntity = categoryToEventEntity[category];
const widgetInfo = docBasePath
? podUtilizationByPodWidgetInfo.map((widget) => ({
...widget,
docPath: widget.docPath ? `${docBasePath}${widget.docPath}` : undefined,
}))
: podUtilizationByPodWidgetInfo;
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
entityWidgetInfo={podUtilizationByPodWidgetInfo}
eventEntity={eventEntity}
entityWidgetInfo={widgetInfo}
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

@@ -48,46 +48,6 @@
width: 280px;
min-width: 280px;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.quick-filters) {
overflow-y: auto;
overflow-x: hidden;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--accent-primary);
}
}
}
.quickFiltersContainerHeader {

View File

@@ -28,7 +28,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { FeatureKeys } from '../../constants/features';
import { useAppContext } from '../../providers/App/App';
import K8sClustersList from './Clusters/K8sClustersList';
import { K8sDynamicList } from './Base/K8sDynamicList';
import {
GetClustersQuickFiltersConfig,
GetDaemonsetsQuickFiltersConfig,
@@ -43,25 +43,18 @@ import {
K8sCategories,
METRIC_NAMESPACE_BY_ENTITY,
} from './constants';
import K8sDaemonSetsList from './DaemonSets/K8sDaemonSetsList';
import K8sDeploymentsList from './Deployments/K8sDeploymentsList';
import {
useInfraMonitoringCategory,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringSelectedItemParams,
} from './hooks';
import K8sJobsList from './Jobs/K8sJobsList';
import K8sNamespacesList from './Namespaces/K8sNamespacesList';
import K8sNodesList from './Nodes/K8sNodesList';
import K8sPodLists from './Pods/K8sPodLists';
import K8sStatefulSetsList from './StatefulSets/K8sStatefulSetsList';
import K8sVolumesList from './Volumes/K8sVolumesList';
import styles from './InfraMonitoringK8s.module.scss';
import { InfraMonitoringEvents } from 'constants/events';
import logEvent from 'api/common/logEvent';
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
export default function InfraMonitoringK8s(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
@@ -252,58 +245,62 @@ export default function InfraMonitoringK8s(): JSX.Element {
<div className={styles.infraContentRow}>
{showFilters && (
<div className={styles.quickFiltersContainer}>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
Viewing · Resource
</Typography.Text>
<div className={styles.sectionLine} />
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
</div>
<div className={styles.categoryCard}>
<div className={styles.categoryList}>
{categories.map((category) => (
<button
key={category.key}
type="button"
className={`${styles.categoryItem} ${
selectedCategory === category.key
? styles.categoryItemSelected
: ''
}`}
onClick={(): void => handleCategorySelect(category.key)}
data-testid={`category-${category.key}`}
>
{category.icon}
<Typography.Text>{category.label}</Typography.Text>
</button>
))}
<OverlayScrollbar>
<>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
Viewing · Resource
</Typography.Text>
<div className={styles.sectionLine} />
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
</div>
<div className={styles.categoryCard}>
<div className={styles.categoryList}>
{categories.map((category) => (
<button
key={category.key}
type="button"
className={`${styles.categoryItem} ${
selectedCategory === category.key
? styles.categoryItemSelected
: ''
}`}
onClick={(): void => handleCategorySelect(category.key)}
data-testid={`category-${category.key}`}
>
{category.icon}
<Typography.Text>{category.label}</Typography.Text>
</button>
))}
</div>
</div>
</div>
</div>
</div>
<div className={styles.quickFiltersSection}>
<div className={styles.sectionHeader} data-type="filter">
<Typography.Text className={styles.sectionLabel}>
Filter by
</Typography.Text>
<div className={styles.sectionLine} />
</div>
{selectedCategoryConfig && (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={selectedCategoryConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={selectedCategoryUseFieldApis}
/>
)}
</div>
<div className={styles.quickFiltersSection}>
<div className={styles.sectionHeader} data-type="filter">
<Typography.Text className={styles.sectionLabel}>
Filter by
</Typography.Text>
<div className={styles.sectionLine} />
</div>
{selectedCategoryConfig && (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={selectedCategoryConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={selectedCategoryUseFieldApis}
/>
)}
</div>
</>
</OverlayScrollbar>
</div>
)}
@@ -312,41 +309,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
showFilters ? styles.listContainerFiltersVisible : ''
}`}
>
{selectedCategory === K8sCategories.PODS && (
<K8sPodLists controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.NODES && (
<K8sNodesList controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.CLUSTERS && (
<K8sClustersList controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.DEPLOYMENTS && (
<K8sDeploymentsList controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.NAMESPACES && (
<K8sNamespacesList controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.STATEFULSETS && (
<K8sStatefulSetsList controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.JOBS && (
<K8sJobsList controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.DAEMONSETS && (
<K8sDaemonSetsList controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.VOLUMES && (
<K8sVolumesList controlListPrefix={showFiltersComp} />
)}
<K8sDynamicList controlListPrefix={showFiltersComp} />
</div>
</div>
</div>

View File

@@ -1,164 +0,0 @@
import { useCallback, useMemo } from 'react';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listJobs } from 'api/generated/services/inframonitoring';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import {
InframonitoringtypesJobRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { InfraMonitoringEvents } from 'constants/events';
import APIError from 'types/api/error';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
getJobMetricsQueryPayload,
getJobPodMetricsQueryPayload,
jobWidgetInfo,
k8sJobDetailsMetadataConfig,
k8sJobGetEntityName,
k8sJobGetSelectedItemExpression,
k8sJobInitialEventsExpression,
k8sJobInitialLogTracesExpression,
} from './constants';
import {
getK8sJobItemKey,
getK8sJobRowKey,
k8sJobsColumnsConfig,
} from './table.config';
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
function K8sJobsList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
try {
const response = await listJobs(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesJobRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{
data: InframonitoringtypesJobRecordDTO | null;
error?: APIError | null;
}> => {
try {
const response = await listJobs(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
const customTabs = useMemo(
() => [
createPodMetricsTab<InframonitoringtypesJobRecordDTO>({
getQueryPayload: getJobPodMetricsQueryPayload,
category: InfraMonitoringEntity.JOBS,
queryKey: 'jobPodMetrics',
}),
],
[],
);
return (
<>
<K8sBaseList<InframonitoringtypesJobRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.JOBS}
tableColumns={k8sJobsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sJobRowKey}
getItemKey={getK8sJobItemKey}
eventCategory={InfraMonitoringEvents.Job}
/>
<K8sBaseDetails<InframonitoringtypesJobRecordDTO>
category={InfraMonitoringEntity.JOBS}
eventCategory={InfraMonitoringEvents.Job}
getSelectedItemExpression={k8sJobGetSelectedItemExpression}
fetchEntityData={fetchEntityData}
getEntityName={k8sJobGetEntityName}
getInitialLogTracesExpression={k8sJobInitialLogTracesExpression}
getInitialEventsExpression={k8sJobInitialEventsExpression}
metadataConfig={k8sJobDetailsMetadataConfig}
entityWidgetInfo={jobWidgetInfo}
getEntityQueryPayload={getJobMetricsQueryPayload}
queryKeyPrefix="job"
customTabs={customTabs}
/>
</>
);
}
export default K8sJobsList;

View File

@@ -0,0 +1,153 @@
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listJobs } from 'api/generated/services/inframonitoring';
import {
InframonitoringtypesJobRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { InfraMonitoringEvents } from 'constants/events';
import { K8sEntityConfig } from '../Base/entity.config.types';
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { createPodMetricsTab } from '../EntityDetailsUtils/createPodMetricsTab';
import { SelectedItemParams } from '../hooks';
import {
getJobMetricsQueryPayload,
getJobPodMetricsQueryPayload,
jobWidgetInfo,
k8sJobDetailsMetadataConfig,
k8sJobGetEntityName,
k8sJobGetSelectedItemExpression,
k8sJobInitialEventsExpression,
k8sJobInitialLogTracesExpression,
} from './constants';
import {
getK8sJobItemKey,
getK8sJobRowKey,
k8sJobsColumnsConfig,
} from './table.config';
async function fetchListData(
filters: K8sBaseFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<
InframonitoringtypesJobRecordDTO,
SelectedItemParams
>['list']['fetchListData']
> {
try {
const response = await listJobs(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesJobRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
async function fetchEntityData(
filters: K8sDetailsFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<
InframonitoringtypesJobRecordDTO,
SelectedItemParams
>['details']['fetchEntityData']
> {
try {
const response = await listJobs(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
export const jobEntityConfig: K8sEntityConfig<
InframonitoringtypesJobRecordDTO,
SelectedItemParams
> = {
list: {
entity: InfraMonitoringEntity.JOBS,
eventCategory: InfraMonitoringEvents.Job,
tableColumns: k8sJobsColumnsConfig,
fetchListData,
getRowKey: getK8sJobRowKey,
getItemKey: getK8sJobItemKey,
detailsQueryKeyPrefix: 'job',
},
details: {
category: InfraMonitoringEntity.JOBS,
eventCategory: InfraMonitoringEvents.Job,
queryKeyPrefix: 'job',
getSelectedItemExpression: k8sJobGetSelectedItemExpression,
fetchEntityData,
getEntityName: k8sJobGetEntityName,
getInitialLogTracesExpression: k8sJobInitialLogTracesExpression,
getInitialEventsExpression: k8sJobInitialEventsExpression,
metadataConfig: k8sJobDetailsMetadataConfig,
entityWidgetInfo: jobWidgetInfo,
getEntityQueryPayload: getJobMetricsQueryPayload,
customTabs: [
createPodMetricsTab<InframonitoringtypesJobRecordDTO>({
getQueryPayload: getJobPodMetricsQueryPayload,
category: InfraMonitoringEntity.JOBS,
queryKey: 'jobPodMetrics',
docBasePath: '/infrastructure-monitoring/kubernetes/jobs/',
}),
],
},
};

View File

@@ -113,12 +113,17 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
width: { min: 250 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => {
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return <GroupedStatusCounts items={getPodStatusItems(podCountsByStatus)} />;
return (
<GroupedStatusCounts
items={getPodStatusItems(podCountsByStatus)}
rowId={rowId}
/>
);
},
},
{
@@ -132,7 +137,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
width: { min: 210 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => (
cell: ({ row, rowId }): React.ReactNode => (
<GroupedStatusCounts
items={[
{ value: row.activePods, label: 'Active', color: Color.BG_ROBIN_500 },
@@ -148,6 +153,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
color: Color.BG_AMBER_500,
},
]}
rowId={rowId}
/>
),
},

View File

@@ -1,168 +0,0 @@
import { useCallback, useMemo } from 'react';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listNamespaces } from 'api/generated/services/inframonitoring';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import {
InframonitoringtypesNamespaceRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { InfraMonitoringEvents } from 'constants/events';
import APIError from 'types/api/error';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
getNamespaceMetricsQueryPayload,
getNamespacePodMetricsQueryPayload,
k8sNamespaceDetailsCountsConfig,
k8sNamespaceDetailsMetadataConfig,
k8sNamespaceGetCountsFilterExpression,
k8sNamespaceGetEntityName,
k8sNamespaceGetSelectedItemExpression,
k8sNamespaceInitialEventsExpression,
k8sNamespaceInitialLogTracesExpression,
namespaceWidgetInfo,
} from './constants';
import {
getK8sNamespaceItemKey,
getK8sNamespaceRowKey,
k8sNamespacesColumnsConfig,
} from './table.config';
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
function K8sNamespacesList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
try {
const response = await listNamespaces(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesNamespaceRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{
data: InframonitoringtypesNamespaceRecordDTO | null;
error?: APIError | null;
}> => {
try {
const response = await listNamespaces(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
const customTabs = useMemo(
() => [
createPodMetricsTab<InframonitoringtypesNamespaceRecordDTO>({
getQueryPayload: getNamespacePodMetricsQueryPayload,
category: InfraMonitoringEntity.NAMESPACES,
queryKey: 'namespacePodMetrics',
}),
],
[],
);
return (
<>
<K8sBaseList<InframonitoringtypesNamespaceRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.NAMESPACES}
tableColumns={k8sNamespacesColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sNamespaceRowKey}
getItemKey={getK8sNamespaceItemKey}
eventCategory={InfraMonitoringEvents.Namespace}
/>
<K8sBaseDetails<InframonitoringtypesNamespaceRecordDTO>
category={InfraMonitoringEntity.NAMESPACES}
eventCategory={InfraMonitoringEvents.Namespace}
getSelectedItemExpression={k8sNamespaceGetSelectedItemExpression}
fetchEntityData={fetchEntityData}
getEntityName={k8sNamespaceGetEntityName}
getInitialLogTracesExpression={k8sNamespaceInitialLogTracesExpression}
getInitialEventsExpression={k8sNamespaceInitialEventsExpression}
metadataConfig={k8sNamespaceDetailsMetadataConfig}
countsConfig={k8sNamespaceDetailsCountsConfig}
getCountsFilterExpression={k8sNamespaceGetCountsFilterExpression}
entityWidgetInfo={namespaceWidgetInfo}
getEntityQueryPayload={getNamespaceMetricsQueryPayload}
queryKeyPrefix="namespace"
customTabs={customTabs}
/>
</>
);
}
export default K8sNamespacesList;

View File

@@ -0,0 +1,159 @@
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listNamespaces } from 'api/generated/services/inframonitoring';
import {
InframonitoringtypesNamespaceRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { InfraMonitoringEvents } from 'constants/events';
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
import { K8sEntityConfig } from '../Base/entity.config.types';
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
getNamespaceMetricsQueryPayload,
getNamespacePodMetricsQueryPayload,
k8sNamespaceDetailsCountsConfig,
k8sNamespaceDetailsMetadataConfig,
k8sNamespaceGetCountsFilterExpression,
k8sNamespaceGetEntityName,
k8sNamespaceGetSelectedItemExpression,
k8sNamespaceInitialEventsExpression,
k8sNamespaceInitialLogTracesExpression,
namespaceWidgetInfo,
} from './constants';
import {
getK8sNamespaceItemKey,
getK8sNamespaceRowKey,
k8sNamespacesColumnsConfig,
} from './table.config';
async function fetchListData(
filters: K8sBaseFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<
InframonitoringtypesNamespaceRecordDTO,
SelectedItemParams
>['list']['fetchListData']
> {
try {
const response = await listNamespaces(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesNamespaceRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
async function fetchEntityData(
filters: K8sDetailsFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<
InframonitoringtypesNamespaceRecordDTO,
SelectedItemParams
>['details']['fetchEntityData']
> {
try {
const response = await listNamespaces(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
const namespaceCustomTabs = [
createPodMetricsTab<InframonitoringtypesNamespaceRecordDTO>({
getQueryPayload: getNamespacePodMetricsQueryPayload,
category: InfraMonitoringEntity.NAMESPACES,
queryKey: 'namespacePodMetrics',
docBasePath: '/infrastructure-monitoring/kubernetes/namespaces/',
}),
];
export const namespaceEntityConfig: K8sEntityConfig<
InframonitoringtypesNamespaceRecordDTO,
SelectedItemParams
> = {
list: {
entity: InfraMonitoringEntity.NAMESPACES,
eventCategory: InfraMonitoringEvents.Namespace,
tableColumns: k8sNamespacesColumnsConfig,
fetchListData,
getRowKey: getK8sNamespaceRowKey,
getItemKey: getK8sNamespaceItemKey,
detailsQueryKeyPrefix: 'namespace',
},
details: {
category: InfraMonitoringEntity.NAMESPACES,
eventCategory: InfraMonitoringEvents.Namespace,
queryKeyPrefix: 'namespace',
getSelectedItemExpression: k8sNamespaceGetSelectedItemExpression,
fetchEntityData,
getEntityName: k8sNamespaceGetEntityName,
getInitialLogTracesExpression: k8sNamespaceInitialLogTracesExpression,
getInitialEventsExpression: k8sNamespaceInitialEventsExpression,
metadataConfig: k8sNamespaceDetailsMetadataConfig,
countsConfig: k8sNamespaceDetailsCountsConfig,
getCountsFilterExpression: k8sNamespaceGetCountsFilterExpression,
entityWidgetInfo: namespaceWidgetInfo,
getEntityQueryPayload: getNamespaceMetricsQueryPayload,
customTabs: namespaceCustomTabs,
},
};

View File

@@ -114,13 +114,16 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
row.podCountsByStatus,
width: { min: 250 },
enableSort: false,
cell: ({ row }): React.ReactNode => {
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts items={getPodStatusItems(row.podCountsByStatus)} />
<GroupedStatusCounts
items={getPodStatusItems(row.podCountsByStatus)}
rowId={rowId}
/>
);
},
},

View File

@@ -1,149 +0,0 @@
import { useCallback } from 'react';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listNodes } from 'api/generated/services/inframonitoring';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import {
InframonitoringtypesNodeRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { InfraMonitoringEvents } from 'constants/events';
import APIError from 'types/api/error';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import {
getNodeMetricsQueryPayload,
k8sNodeDetailsMetadataConfig,
k8sNodeGetEntityName,
k8sNodeGetSelectedItemExpression,
k8sNodeInitialEventsExpression,
k8sNodeInitialLogTracesExpression,
nodeWidgetInfo,
} from './constants';
import {
getK8sNodeItemKey,
getK8sNodeRowKey,
k8sNodesColumnsConfig,
} from './table.config';
function K8sNodesList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
try {
const response = await listNodes(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesNodeRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{
data: InframonitoringtypesNodeRecordDTO | null;
error?: APIError | null;
}> => {
try {
const response = await listNodes(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
return (
<>
<K8sBaseList<InframonitoringtypesNodeRecordDTO>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.NODES}
tableColumns={k8sNodesColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sNodeRowKey}
getItemKey={getK8sNodeItemKey}
eventCategory={InfraMonitoringEvents.Node}
/>
<K8sBaseDetails<InframonitoringtypesNodeRecordDTO>
category={InfraMonitoringEntity.NODES}
eventCategory={InfraMonitoringEvents.Node}
getSelectedItemExpression={k8sNodeGetSelectedItemExpression}
fetchEntityData={fetchEntityData}
getEntityName={k8sNodeGetEntityName}
getInitialLogTracesExpression={k8sNodeInitialLogTracesExpression}
getInitialEventsExpression={k8sNodeInitialEventsExpression}
metadataConfig={k8sNodeDetailsMetadataConfig}
entityWidgetInfo={nodeWidgetInfo}
getEntityQueryPayload={getNodeMetricsQueryPayload}
queryKeyPrefix="node"
/>
</>
);
}
export default K8sNodesList;

View File

@@ -0,0 +1,142 @@
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listNodes } from 'api/generated/services/inframonitoring';
import {
InframonitoringtypesNodeRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { InfraMonitoringEvents } from 'constants/events';
import { K8sEntityConfig } from '../Base/entity.config.types';
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import {
getNodeMetricsQueryPayload,
k8sNodeDetailsMetadataConfig,
k8sNodeGetEntityName,
k8sNodeGetSelectedItemExpression,
k8sNodeInitialEventsExpression,
k8sNodeInitialLogTracesExpression,
nodeWidgetInfo,
} from './constants';
import {
getK8sNodeItemKey,
getK8sNodeRowKey,
k8sNodesColumnsConfig,
} from './table.config';
async function fetchListData(
filters: K8sBaseFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<
InframonitoringtypesNodeRecordDTO,
string
>['list']['fetchListData']
> {
try {
const response = await listNodes(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesNodeRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
async function fetchEntityData(
filters: K8sDetailsFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<
InframonitoringtypesNodeRecordDTO,
string
>['details']['fetchEntityData']
> {
try {
const response = await listNodes(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
export const nodeEntityConfig: K8sEntityConfig<
InframonitoringtypesNodeRecordDTO,
string
> = {
list: {
entity: InfraMonitoringEntity.NODES,
eventCategory: InfraMonitoringEvents.Node,
tableColumns: k8sNodesColumnsConfig,
fetchListData,
getRowKey: getK8sNodeRowKey,
getItemKey: getK8sNodeItemKey,
detailsQueryKeyPrefix: 'node',
},
details: {
category: InfraMonitoringEntity.NODES,
eventCategory: InfraMonitoringEvents.Node,
queryKeyPrefix: 'node',
getSelectedItemExpression: k8sNodeGetSelectedItemExpression,
fetchEntityData,
getEntityName: k8sNodeGetEntityName,
getInitialLogTracesExpression: k8sNodeInitialLogTracesExpression,
getInitialEventsExpression: k8sNodeInitialEventsExpression,
metadataConfig: k8sNodeDetailsMetadataConfig,
entityWidgetInfo: nodeWidgetInfo,
getEntityQueryPayload: getNodeMetricsQueryPayload,
},
};

View File

@@ -98,7 +98,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
accessorFn: (row): string => row.condition,
width: { min: 120 },
enableSort: false,
cell: ({ row, groupMeta }): React.ReactNode => {
cell: ({ row, groupMeta, rowId }): React.ReactNode => {
if (!groupMeta) {
const color =
NODE_CONDITION_COLORS[row.condition] || NODE_CONDITION_COLORS.no_data;
@@ -123,6 +123,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
color: Color.BG_AMBER_500,
},
]}
rowId={rowId}
/>
);
},
@@ -138,13 +139,16 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
row.podCountsByStatus,
width: { min: 250 },
enableSort: false,
cell: ({ row }): React.ReactNode => {
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts items={getPodStatusItems(row.podCountsByStatus)} />
<GroupedStatusCounts
items={getPodStatusItems(row.podCountsByStatus)}
rowId={rowId}
/>
);
},
},

View File

@@ -1,149 +0,0 @@
import { useCallback } from 'react';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listPods } from 'api/generated/services/inframonitoring';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import {
InframonitoringtypesPodRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { InfraMonitoringEvents } from 'constants/events';
import APIError from 'types/api/error';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import {
getPodMetricsQueryPayload,
k8sPodDetailsMetadataConfig,
k8sPodGetEntityName,
k8sPodGetSelectedItemExpression,
k8sPodInitialEventsExpression,
k8sPodInitialLogTracesExpression,
podWidgetInfo,
} from './constants';
import {
getK8sPodItemKey,
getK8sPodRowKey,
k8sPodColumnsConfig,
} from './table.config';
function K8sPodsList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
try {
const response = await listPods(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesPodRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{
data: InframonitoringtypesPodRecordDTO | null;
error?: APIError | null;
}> => {
try {
const response = await listPods(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
return (
<>
<K8sBaseList<InframonitoringtypesPodRecordDTO>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.PODS}
tableColumns={k8sPodColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sPodRowKey}
getItemKey={getK8sPodItemKey}
eventCategory={InfraMonitoringEvents.Pod}
/>
<K8sBaseDetails<InframonitoringtypesPodRecordDTO>
category={InfraMonitoringEntity.PODS}
eventCategory={InfraMonitoringEvents.Pod}
getSelectedItemExpression={k8sPodGetSelectedItemExpression}
fetchEntityData={fetchEntityData}
getEntityName={k8sPodGetEntityName}
getInitialLogTracesExpression={k8sPodInitialLogTracesExpression}
getInitialEventsExpression={k8sPodInitialEventsExpression}
metadataConfig={k8sPodDetailsMetadataConfig}
entityWidgetInfo={podWidgetInfo}
getEntityQueryPayload={getPodMetricsQueryPayload}
queryKeyPrefix="pod"
/>
</>
);
}
export default K8sPodsList;

View File

@@ -0,0 +1,134 @@
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listPods } from 'api/generated/services/inframonitoring';
import {
InframonitoringtypesPodRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { InfraMonitoringEvents } from 'constants/events';
import { K8sEntityConfig } from '../Base/entity.config.types';
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import {
getPodMetricsQueryPayload,
k8sPodDetailsMetadataConfig,
k8sPodGetEntityName,
k8sPodGetSelectedItemExpression,
k8sPodInitialEventsExpression,
k8sPodInitialLogTracesExpression,
podWidgetInfo,
} from './constants';
import {
getK8sPodItemKey,
getK8sPodRowKey,
k8sPodColumnsConfig,
} from './table.config';
async function fetchListData(
filters: K8sBaseFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<InframonitoringtypesPodRecordDTO>['list']['fetchListData']
> {
try {
const response = await listPods(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesPodRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
async function fetchEntityData(
filters: K8sDetailsFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<InframonitoringtypesPodRecordDTO>['details']['fetchEntityData']
> {
try {
const response = await listPods(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
export const podEntityConfig: K8sEntityConfig<InframonitoringtypesPodRecordDTO> =
{
list: {
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
tableColumns: k8sPodColumnsConfig,
fetchListData,
getRowKey: getK8sPodRowKey,
getItemKey: getK8sPodItemKey,
detailsQueryKeyPrefix: 'pod',
},
details: {
category: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
queryKeyPrefix: 'pod',
getSelectedItemExpression: k8sPodGetSelectedItemExpression,
fetchEntityData,
getEntityName: k8sPodGetEntityName,
getInitialLogTracesExpression: k8sPodInitialLogTracesExpression,
getInitialEventsExpression: k8sPodInitialEventsExpression,
metadataConfig: k8sPodDetailsMetadataConfig,
entityWidgetInfo: podWidgetInfo,
getEntityQueryPayload: getPodMetricsQueryPayload,
},
};

View File

@@ -131,13 +131,16 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
width: { min: 250 },
enableSort: false,
visibilityBehavior: 'hidden-on-collapse',
cell: ({ row }): React.ReactNode => {
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts items={getPodStatusItems(row.podCountsByStatus)} />
<GroupedStatusCounts
items={getPodStatusItems(row.podCountsByStatus)}
rowId={rowId}
/>
);
},
},
@@ -171,7 +174,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
</ColumnHeader>
),
accessorFn: (row): number => row.podRestarts,
width: { min: 100 },
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const restarts = value as number;

View File

@@ -1,164 +0,0 @@
import { useCallback, useMemo } from 'react';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listStatefulSets } from 'api/generated/services/inframonitoring';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import {
InframonitoringtypesResponseTypeDTO,
InframonitoringtypesStatefulSetRecordDTO,
Querybuildertypesv5OrderDirectionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { InfraMonitoringEvents } from 'constants/events';
import APIError from 'types/api/error';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
getStatefulSetMetricsQueryPayload,
getStatefulSetPodMetricsQueryPayload,
k8sStatefulSetDetailsMetadataConfig,
k8sStatefulSetGetEntityName,
k8sStatefulSetGetSelectedItemExpression,
k8sStatefulSetInitialEventsExpression,
k8sStatefulSetInitialLogTracesExpression,
statefulSetWidgetInfo,
} from './constants';
import {
getK8sStatefulSetItemKey,
getK8sStatefulSetRowKey,
k8sStatefulSetsColumnsConfig,
} from './table.config';
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
function K8sStatefulSetsList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
try {
const response = await listStatefulSets(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesStatefulSetRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{
data: InframonitoringtypesStatefulSetRecordDTO | null;
error?: APIError | null;
}> => {
try {
const response = await listStatefulSets(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
const customTabs = useMemo(
() => [
createPodMetricsTab<InframonitoringtypesStatefulSetRecordDTO>({
getQueryPayload: getStatefulSetPodMetricsQueryPayload,
category: InfraMonitoringEntity.STATEFULSETS,
queryKey: 'statefulSetPodMetrics',
}),
],
[],
);
return (
<>
<K8sBaseList<InframonitoringtypesStatefulSetRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.STATEFULSETS}
tableColumns={k8sStatefulSetsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sStatefulSetRowKey}
getItemKey={getK8sStatefulSetItemKey}
eventCategory={InfraMonitoringEvents.StatefulSet}
/>
<K8sBaseDetails<InframonitoringtypesStatefulSetRecordDTO>
category={InfraMonitoringEntity.STATEFULSETS}
eventCategory={InfraMonitoringEvents.StatefulSet}
getSelectedItemExpression={k8sStatefulSetGetSelectedItemExpression}
fetchEntityData={fetchEntityData}
getEntityName={k8sStatefulSetGetEntityName}
getInitialLogTracesExpression={k8sStatefulSetInitialLogTracesExpression}
getInitialEventsExpression={k8sStatefulSetInitialEventsExpression}
metadataConfig={k8sStatefulSetDetailsMetadataConfig}
entityWidgetInfo={statefulSetWidgetInfo}
getEntityQueryPayload={getStatefulSetMetricsQueryPayload}
queryKeyPrefix="statefulSet"
customTabs={customTabs}
/>
</>
);
}
export default K8sStatefulSetsList;

View File

@@ -0,0 +1,153 @@
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listStatefulSets } from 'api/generated/services/inframonitoring';
import {
InframonitoringtypesResponseTypeDTO,
InframonitoringtypesStatefulSetRecordDTO,
Querybuildertypesv5OrderDirectionDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { InfraMonitoringEvents } from 'constants/events';
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
import { K8sEntityConfig } from '../Base/entity.config.types';
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
getStatefulSetMetricsQueryPayload,
getStatefulSetPodMetricsQueryPayload,
k8sStatefulSetDetailsMetadataConfig,
k8sStatefulSetGetEntityName,
k8sStatefulSetGetSelectedItemExpression,
k8sStatefulSetInitialEventsExpression,
k8sStatefulSetInitialLogTracesExpression,
statefulSetWidgetInfo,
} from './constants';
import {
getK8sStatefulSetItemKey,
getK8sStatefulSetRowKey,
k8sStatefulSetsColumnsConfig,
} from './table.config';
async function fetchListData(
filters: K8sBaseFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<
InframonitoringtypesStatefulSetRecordDTO,
SelectedItemParams
>['list']['fetchListData']
> {
try {
const response = await listStatefulSets(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesStatefulSetRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
async function fetchEntityData(
filters: K8sDetailsFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<
InframonitoringtypesStatefulSetRecordDTO,
SelectedItemParams
>['details']['fetchEntityData']
> {
try {
const response = await listStatefulSets(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
export const statefulSetEntityConfig: K8sEntityConfig<
InframonitoringtypesStatefulSetRecordDTO,
SelectedItemParams
> = {
list: {
entity: InfraMonitoringEntity.STATEFULSETS,
eventCategory: InfraMonitoringEvents.StatefulSet,
tableColumns: k8sStatefulSetsColumnsConfig,
fetchListData,
getRowKey: getK8sStatefulSetRowKey,
getItemKey: getK8sStatefulSetItemKey,
detailsQueryKeyPrefix: 'statefulSet',
},
details: {
category: InfraMonitoringEntity.STATEFULSETS,
eventCategory: InfraMonitoringEvents.StatefulSet,
queryKeyPrefix: 'statefulSet',
getSelectedItemExpression: k8sStatefulSetGetSelectedItemExpression,
fetchEntityData,
getEntityName: k8sStatefulSetGetEntityName,
getInitialLogTracesExpression: k8sStatefulSetInitialLogTracesExpression,
getInitialEventsExpression: k8sStatefulSetInitialEventsExpression,
metadataConfig: k8sStatefulSetDetailsMetadataConfig,
entityWidgetInfo: statefulSetWidgetInfo,
getEntityQueryPayload: getStatefulSetMetricsQueryPayload,
customTabs: [
createPodMetricsTab<InframonitoringtypesStatefulSetRecordDTO>({
getQueryPayload: getStatefulSetPodMetricsQueryPayload,
category: InfraMonitoringEntity.STATEFULSETS,
queryKey: 'statefulSetPodMetrics',
docBasePath: '/infrastructure-monitoring/kubernetes/statefulsets/',
}),
],
},
};

View File

@@ -122,12 +122,17 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
width: { min: 250 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => {
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return <GroupedStatusCounts items={getPodStatusItems(podCountsByStatus)} />;
return (
<GroupedStatusCounts
items={getPodStatusItems(podCountsByStatus)}
rowId={rowId}
/>
);
},
},
{
@@ -141,7 +146,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
width: { min: 140 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => (
cell: ({ row, rowId }): React.ReactNode => (
<GroupedStatusCounts
items={[
{
@@ -155,6 +160,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
color: Color.BG_ROBIN_500,
},
]}
rowId={rowId}
/>
),
},

View File

@@ -1,151 +0,0 @@
import { useCallback } from 'react';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listVolumes } from 'api/generated/services/inframonitoring';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import {
InframonitoringtypesResponseTypeDTO,
InframonitoringtypesVolumeRecordDTO,
Querybuildertypesv5OrderDirectionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { InfraMonitoringEvents } from 'constants/events';
import APIError from 'types/api/error';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
getVolumeMetricsQueryPayload,
k8sVolumeDetailsMetadataConfig,
k8sVolumeGetEntityName,
k8sVolumeGetSelectedItemExpression,
k8sVolumeInitialEventsExpression,
k8sVolumeInitialLogTracesExpression,
volumeWidgetInfo,
} from './constants';
import {
getK8sVolumeItemKey,
getK8sVolumeRowKey,
k8sVolumesColumnsConfig,
} from './table.config';
function K8sVolumesList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
try {
const response = await listVolumes(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesVolumeRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{
data: InframonitoringtypesVolumeRecordDTO | null;
error?: APIError | null;
}> => {
try {
const response = await listVolumes(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
},
[],
);
return (
<>
<K8sBaseList<InframonitoringtypesVolumeRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.VOLUMES}
tableColumns={k8sVolumesColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sVolumeRowKey}
getItemKey={getK8sVolumeItemKey}
eventCategory={InfraMonitoringEvents.Volumes}
/>
<K8sBaseDetails<InframonitoringtypesVolumeRecordDTO>
category={InfraMonitoringEntity.VOLUMES}
eventCategory={InfraMonitoringEvents.Volume}
getSelectedItemExpression={k8sVolumeGetSelectedItemExpression}
fetchEntityData={fetchEntityData}
getEntityName={k8sVolumeGetEntityName}
getInitialLogTracesExpression={k8sVolumeInitialLogTracesExpression}
getInitialEventsExpression={k8sVolumeInitialEventsExpression}
metadataConfig={k8sVolumeDetailsMetadataConfig}
entityWidgetInfo={volumeWidgetInfo}
getEntityQueryPayload={getVolumeMetricsQueryPayload}
queryKeyPrefix="volume"
hideDetailViewTabs
/>
</>
);
}
export default K8sVolumesList;

View File

@@ -0,0 +1,141 @@
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listVolumes } from 'api/generated/services/inframonitoring';
import {
InframonitoringtypesResponseTypeDTO,
InframonitoringtypesVolumeRecordDTO,
Querybuildertypesv5OrderDirectionDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { InfraMonitoringEvents } from 'constants/events';
import { K8sEntityConfig } from '../Base/entity.config.types';
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
getVolumeMetricsQueryPayload,
k8sVolumeDetailsMetadataConfig,
k8sVolumeGetEntityName,
k8sVolumeGetSelectedItemExpression,
k8sVolumeInitialEventsExpression,
k8sVolumeInitialLogTracesExpression,
volumeWidgetInfo,
} from './constants';
import {
getK8sVolumeItemKey,
getK8sVolumeRowKey,
k8sVolumesColumnsConfig,
} from './table.config';
async function fetchListData(
filters: K8sBaseFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<
InframonitoringtypesVolumeRecordDTO,
SelectedItemParams
>['list']['fetchListData']
> {
try {
const response = await listVolumes(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesVolumeRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
async function fetchEntityData(
filters: K8sDetailsFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<InframonitoringtypesVolumeRecordDTO>['details']['fetchEntityData']
> {
try {
const response = await listVolumes(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
export const volumeEntityConfig: K8sEntityConfig<
InframonitoringtypesVolumeRecordDTO,
SelectedItemParams
> = {
list: {
entity: InfraMonitoringEntity.VOLUMES,
eventCategory: InfraMonitoringEvents.Volumes,
tableColumns: k8sVolumesColumnsConfig,
fetchListData,
getRowKey: getK8sVolumeRowKey,
getItemKey: getK8sVolumeItemKey,
detailsQueryKeyPrefix: 'volume',
},
details: {
category: InfraMonitoringEntity.VOLUMES,
eventCategory: InfraMonitoringEvents.Volume,
queryKeyPrefix: 'volume',
getSelectedItemExpression: k8sVolumeGetSelectedItemExpression,
fetchEntityData,
getEntityName: k8sVolumeGetEntityName,
getInitialLogTracesExpression: k8sVolumeInitialLogTracesExpression,
getInitialEventsExpression: k8sVolumeInitialEventsExpression,
metadataConfig: k8sVolumeDetailsMetadataConfig,
entityWidgetInfo: volumeWidgetInfo,
getEntityQueryPayload: getVolumeMetricsQueryPayload,
hideDetailViewTabs: true,
},
};

View File

@@ -4,33 +4,24 @@
gap: 4px;
}
.itemWrapper {
display: flex;
.item {
display: inline-flex;
align-items: center;
gap: 4px;
// 4ch value slot (old .valueWrapper) + 3px color bar + 4px gap
min-width: calc(4ch + 7px);
font-variant-numeric: tabular-nums;
text-align: left;
cursor: default;
}
.separator {
.item::before {
content: '';
width: 3px;
height: 14px;
border-radius: 1px;
flex-shrink: 0;
}
.valueWrapper {
min-width: 4ch;
}
.valueWrapperTooltip {
display: block;
width: fit-content;
}
.value {
font-variant-numeric: tabular-nums;
text-align: left;
cursor: default;
min-width: min-content;
background-color: var(--gsc-color);
}
.tooltipContent {

View File

@@ -1,5 +1,3 @@
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './GroupedStatusCounts.module.scss';
import TanStackTable from 'components/TanStackTableView';
import { Typography } from '@signozhq/ui/typography';
@@ -18,6 +16,7 @@ export interface StatusCountItem {
interface GroupedStatusCountsProps {
items: StatusCountItem[];
rowId: string;
showZeroValues?: boolean;
}
@@ -62,6 +61,7 @@ function buildTooltipContent(item: StatusCountItem): React.ReactNode {
export function GroupedStatusCounts({
items,
rowId,
showZeroValues = true,
}: GroupedStatusCountsProps): JSX.Element {
const visibleItems =
@@ -74,21 +74,20 @@ export function GroupedStatusCounts({
return (
<div className={styles.container}>
{visibleItems.map((item) => (
<div key={item.label} className={styles.itemWrapper}>
<div
className={styles.separator}
style={{ backgroundColor: item.color }}
/>
<div className={styles.valueWrapper}>
<TooltipSimple title={buildTooltipContent(item)} arrow align="start">
<span className={styles.valueWrapperTooltip}>
<TanStackTable.Text className={styles.value}>
{item.value || '-'}
</TanStackTable.Text>
</span>
</TooltipSimple>
</div>
</div>
<TanStackTable.HoverTooltip
key={item.label}
rowId={rowId}
title={buildTooltipContent(item)}
arrow
align="start"
>
<TanStackTable.Text
className={styles.item}
style={{ '--gsc-color': item.color } as React.CSSProperties}
>
{item.value || '-'}
</TanStackTable.Text>
</TanStackTable.HoverTooltip>
))}
</div>
);

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 */
@@ -934,22 +937,27 @@ export const podUtilizationByPodWidgetInfo = [
{
title: 'CPU Limit Utilization By Pod Name',
yAxisUnit: 'percentunit',
docPath: '#cpu-limit-utilization-by-pod-name',
},
{
title: 'CPU Request Utilization By Pod Name',
yAxisUnit: 'percentunit',
docPath: '#cpu-request-utilization-by-pod-name',
},
{
title: 'Memory Limit Utilization By Pod Name',
yAxisUnit: 'percentunit',
docPath: '#memory-limit-utilization-by-pod-name',
},
{
title: 'Memory Request Utilization By Pod Name',
yAxisUnit: 'percentunit',
docPath: '#memory-request-utilization-by-pod-name',
},
{
title: 'FileSystem Usage Percentage By Pod Name',
yAxisUnit: 'percentunit',
docPath: '#filesystem-usage-percentage-by-pod-name',
},
];

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;
}

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