Compare commits

...

12 Commits

Author SHA1 Message Date
srikanthccv
9ec2800184 refactor(prometheus): serve the Prometheus query API from a /prometheus prefix
Moves the Prometheus HTTP query API out of the legacy query-service handler
into pkg/prometheus/promapi, served under /prometheus:

- GET|POST /prometheus/api/v1/query_range and /query: Prometheus parameter
  parsing (float-unix or RFC3339 times, float-seconds or duration-string
  durations), the Prometheus error envelope ({status, errorType, error}
  with 400/422/503), the 11,000-point cap, documented in openapi.yml.
- Removes the legacy GET /api/v1/query_range and /api/v1/query handlers
  and their now-dead plumbing (parseMetricsTime, parseMetricsDuration,
  parseInstantQueryMetricsRequest, parseQueryRangeRequest,
  GetInstantQueryMetricsResult, InstantQueryMetricsParams).
  GetQueryRangeResult stays - legacy dashboard queriers use it.

This is the breaking slice of the stack, deliberately last: everything
before it is invisible to API consumers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 19:02:41 +05:30
srikanthccv
93f3cd011e feat(promql): transpile allowlisted query shapes to ClickHouse grid statements
An allowlist compiler (classify/rewrite) evaluates proven PromQL shapes
entirely inside ClickHouse on the timeSeries*ToGrid aggregate functions
(CH >= 25.6): one row per output series comes back instead of every raw
sample. Everything not provably equivalent falls back to the engine over
the native querier; transpilable subtrees under non-transpilable nodes run
hybrid (materialized as synthetic series, engine on top). TryExecuteRange
slots into the serve/shadow paths, which until now ran engine-only.

Window-sliver filtering folded in: when the window is narrower than the
step, a lattice predicate admits only the samples any grid point can see -
measured 74s/28GiB -> 16s/4.3GiB on a 36k-series 1w rate, and a
2.67B-sample 1w case that died at 150GiB completes in 19s/17GiB. Over
slivered rows the last-style gates lift and disjoint over_time forms drop
the divisibility gate.

The classification golden freezes the routing decision (full/hybrid/
fallback + reason) for every conformance-corpus expression: silently
falling back costs the pushdown, silently transpiling an unproven shape
risks wrong numbers - both now surface in review as a golden diff.

The dual-leg conformance suite already earned its keep on its first
transpiled run:

- It caught the classifier reading a duration expression's offset
  (x offset step()) as zero - offset expressions parse WITHOUT the
  experimental-parser flag, so they reach production. Such selectors are
  now refused and the engine evaluates them exactly.
- It caught name-drop assembly treating temporally-disjoint same-labelset
  twins as separate series (-{job="api"} spanning http_requests and
  http_errors 400'd; hybrid -metric_a or -metric_b returned duplicate {}
  series). Both paths now merge by labelset slot-wise, raising the
  engine's duplicate error only on a same-timestamp conflict - the
  engine's actual rule.
- The 12 remaining divergences are one class, recorded with causes in
  known_divergences_v2.json (the swap scorecard): the engine sums with
  Kahan compensation and an overflow-free incremental mean, ClickHouse's
  sum/avg/arraySum are naive - visible only at 1e100-class cancellation
  and near-max-float overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 19:02:41 +05:30
srikanthccv
8a3a32ef28 test(promql): replay the conformance corpus on both providers
Every corpus case now runs twice — default provider, and pinned to
clickhousev2 via the flag-gated X-SigNoz-PromQL-Provider header — each leg
asserted against the same frozen expectations, each leg with its own
known-divergences ledger enforced in both directions. The new
known_divergences_v2.json is the rollout scorecard: the provider swap is
measured by burning it to empty.

The legs are never asserted against each other: both can sit within one
rounding quantum of the expected value yet differ by up to two quanta at a
rounding boundary, so a leg-vs-leg check would reintroduce the boundary
noise the tolerance absorbs. Anchoring both to the same oracle over the
same ingested bytes already localizes any disagreement to a provider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 19:02:11 +05:30
srikanthccv
a480f76e3c feat(querier): wire clickhouseprometheusv2 for shadow comparison and pinned serving
Stand up the v2 provider next to the default one and give the querier two
flag-gated ways to exercise it, neither affecting default serving:

- shadow: with use_prometheus_clickhouse_v2 on, every PromQL query re-runs
  on v2 after the response is sent; result diffs are logged. Bounded by a
  small per-process admission cap (skip, not queue, at the cap).
- pin: the X-SigNoz-PromQL-Provider header serves the response from v2
  directly, for side-by-side comparison by integration tests and support.
  Pinned requests bypass the cache in both directions.

Typed storage errors (series/sample budgets) survive the engine's wrapping
and surface as 4xx instead of internal 500s. PromQL results now carry
ClickHouse scan stats.

prometheus::provider: clickhousev2 also makes v2 the serving provider
outright (no shadow in that mode - nothing to compare against).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 19:02:11 +05:30
Srikanth Chekuri
052255abf5 feat(prometheus): add clickhouseprometheusv2 native read path (#12323)
Second-generation ClickHouse-backed Prometheus provider: the stock engine
evaluates over a native storage.Querier instead of the v1 remote-read
adapter. Per-selector fetch windows, last-sample-per-step reduction for
subquery-free instant selectors (gated on prometheus.QueryTraits),
identical-labelset merge, per-type __name__ matchers and anchored regexes,
inclusive series-lookup bounds for the exporter's hour-floored registration
rows.

Not wired: no factory registration, no config selection, nothing serves
from this package yet. Fetch budgets (series/sample ceilings) are
deliberately left out for now and will come separately.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 13:15:07 +00:00
Pandey
afc90f532f chore: remove doc.go files (#12365) 2026-08-01 12:35:54 +00:00
Pandey
24390d7192 fix(ruletypes): expose above_or_equal and below_or_equal in CompareOperator enum (#12360)
* fix(ruletypes): expose above_or_equal and below_or_equal in CompareOperator enum

The operators are accepted by Validate(), normalized, evaluated and
returned by the rules API, but were commented out of Enum(), so the
generated OpenAPI spec (and clients generated from it, e.g.
terraform-provider-signoz) rejected rules the server itself creates.

* fix(alerts): support above_or_equal and below_or_equal operators in CreateAlertV2

Adds the two inclusive operators to the v2 alert form: selectable in the
threshold operator dropdown, normalized from all backend aliases
(5/6, above_or_eq/below_or_eq, >=/<=), rendered with their symbols in
threshold rows and match-type tooltips, and prefilled losslessly from
dashboard panel thresholds instead of collapsing onto the strict
variants. The v1 form is left untouched.
2026-08-01 11:07:51 +00:00
Nikhil Mantri
16c6fdc600 chore: combined function for goroutines (#12362)
Some checks failed
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
2026-08-01 07:06:09 +00:00
Vinicius Lourenço
159354a511 refactor(authz): ensure roles/service-accounts follow the guidelines (#12348)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(authz): add withPortal prop for button/tooltip

To be able to fix the z-index issue when
using drawer/modals of our lib

Without this, the tooltip is positioned on
the back of the modal

* fix(authz-tooltip): always forward the testId

* fix(create-service-account): ensure this modal follows the authz guideline

* fix(service-account-drawer): ensure tooltips has withPortal false

Otherwise, the tooltip can appear behind the modal/drawer

* fix(service-account-drawer): ensure it follows the authz guideline

* fix(roles-listing): use correct component to guard content

* fix(create-edit-role): ensure edit follows the authz guidelines

Should show header when no permission to read

* fix(view-role): ensure edit follows the authz guidelines

Should show header when no permission to read

* fix(pr): address comments

* fix(view-header): have a better name for this component
2026-07-31 15:13:01 +00:00
Vinicius Lourenço
30dcb944eb refactor(infrastructure-monitoring-v2): ui/ux improvements after bug hunting (#12245)
* fix(tanstack-table): reset prev column id ref when changing storageKey

Since all tables we have some far unmounts enterely
we didn't notice this bug until I refactor
the infrastructure monitoring to be shared
and dynamic via config

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=090fcc6bcd198251bf3c883f842d8798&p=3a5fcc6bcd19800e92a3dd70bb3a08fd&pm=s

* refactor(infrastructure-monitoring): remove cellvalue tooltip component

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a5fcc6bcd19803581aff083c0a65717&pm=s

* feat(k8s-base-details): add copy link

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a5fcc6bcd19802aa8e4cc69b88d1fce&pm=s

* refactor(hosts): move disk usage closer to cpu/memory usage

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a5fcc6bcd1980b292facdfb92b95fa0&pm=s

* refactor(k8s-base-details): change wording to go to explorer, and move explorer to right

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a5fcc6bcd1980f19e61fedee47a5d5b&pm=s

* fix(k8s-base): use text-wrap instead of forcing line break

To be able to collapse line to one line on bigger screens

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a5fcc6bcd19808a8a30ed9ddbfa029a&pm=s

* feat(tanstack-table): add support to reset scroll

Without this prop, infra monitoring
will keep the same scroll on horizontal
when changing category

* fix(infrastructure-monitoring): reset horizontal scroll on change category

To simulate, just move horizontal scroll
to right and then change category that
can have horizontal scroll as well

* refactor(k8s-list): reorganize filters

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a5fcc6bcd19809a9a7fca0138558551&pm=s

* refactor(infra-monitoring): reorganize table options

Add support to specify font size, max lines per row, and the columns

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a5fcc6bcd1980cfadcdeb2f590c3dc3&pm=s

* fix(infra-monitoring): add delay to show hover & do not close on click to copy

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a5fcc6bcd19801c8ae8c6283357021b&pm=s

* refactor(infra-monitoring): handle no data in a consistent way

With opacity: 0.6 as recommended by design team

* refactor(infra-monitoring): move details drawer to signozhq/ui

Few problems with integration but I'm planning
to fix it later on our component lib

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a5fcc6bcd1980babfc2f29686e86d91&pm=s

* fix(infra-monitoring-details-logs): disable group by/replace filter actions

This is so deep in the prop that is better to
 disable this than trying to support part
 of this (replace filter)

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a6fcc6bcd1980d59ba1dd7d1658e9b3&pm=s

* fix(infra-monitoring-details-logs): explorer action inherit filters

On logs, if you add custom logs and then click in a
specific log to see the details, and you click in the
go to explorer, it will inherit filters you had added
earlier

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a6fcc6bcd1980a68bedfd8bbc17e137&pm=s

* fix(infra-monitoring-logs): scroll not working & search not allowing type

This issue is caused because radix lock the scroll
for any component not under the drawer.
This also fixed the issue with search, not sure why
this was related

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a6fcc6bcd1980648061f29a990712dd&pm=s

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a6fcc6bcd19805d9404d3d0f78f2c75&pm=s

* fix(infra-monitoring-logs): issue with double scroll

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a6fcc6bcd198099b764f8e03bc20ac1&pm=s

* refactor(infra-monitoring): remove more usages of antd components

* refactor(infra-monitoring): use toggle to hide columns

This is a similar pattern we have on dashboards

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a6fcc6bcd198000b535ca511e4e8c97&pm=s

* fix(infra-monitoring): do not refetch when click in the row

To prevent doing two requests when data in memory
is already enough

* fix(infra-monitoring): docs path

Ensure all the docs path goes to correct column/chart docs

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a6fcc6bcd19809581f3d96dd6ecdeab&pm=s

Ref: https://app.notion.com/p/signoz/3a5fcc6bcd19805f8929e549c646985d?v=65cfcc6bcd1982a6bee688d8fd55420c&p=3a6fcc6bcd19809d9c2bf2ec41a4c92b&pm=s

* chore(volumes): fix docs path for charts

* feat(infra-monitoring-v2-events): add event for page visited

* feat(infra-monitoring-v2-events): add event for filter customized

* feat(infra-monitoring-v2-events): add event for time range customized

* feat(infra-monitoring-v2-events): add event for column customized

* feat(infra-monitoring-v2-events): add event for column sorted

* feat(infra-monitoring-v2-events): add event for drawer time range customized

* feat(infra-monitoring-v2-events): add event for group by change

* feat(infra-monitoring-v2-events): add event for filter customized

* feat(infra-monitoring-v2-events): add event for tab viewed

* feat(infra-monitoring-v2-events): add event for explorer visited

* fix(infrastructure-monitoring-v2): migrate tooltip to be from tanstack

* fix(k8s-expanded-row): add missing pre-cache when row is expanded

* fix(infra-monitoring-v2): copy ID via react-use after hook removal

frontend/src/hooks/useCopyToClipboard.ts was deleted on main (#12333) in
favour of react-use's execCommand-based copy, leaving this import
dangling and breaking tsc and the frontend build.

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-07-31 10:40:41 +00:00
Nikhil Mantri
e9726776ab feat(alert-channel-integrations): google chat backend (#12314)
* chore: added googlechat notify function

* chore: added content extraction and formatting for google chat content

* chore: added tests

* chore: adding processing logic for makrdown to google chat format

* chore: added utf 8 sanitization and capping the limit

* chore: comment correction

* chore: added one more test

* chore: error on empty title body with non-retryable error and give 429 retryable error due to limit

* chore: cardsv2 scaffolding

* chore: truncation logic optimized for both body and title

* fix(googlechat): guard nil HTTPConfig in New with a clean error

* feat(googlechat): render grouped alerts as per-alert card sections

Render each alert in its own card section with its own View Related
Logs/Traces buttons, and move Open in SigNoz to a shared footer button
(it's per-rule). Matches the slack/msteams per-alert layout; the default
template still produces one combined section.

Cap sections at 30 with a "+N more alerts" note: Google Chat allows 100
widgets per card and silently drops the section that crosses that count
and every section after it, so capping keeps the footer and trailing
alerts from vanishing. Cap is applied before the size guard so the trim
loop only touches rendered bodies.

Generalize the 32 KB size guard to trim the longest body, then the title.

* docs(googlechat): clarify content.bodies default-vs-custom alignment

* test(googlechat): cover default-body grouped alerts (single combined section)

* chore: modified comment

* fix(googlechat): keep Open-in-SigNoz button when no body renders

The footer button was sourced from the capped alert set, which is empty
when every body renders empty (cleared Description, or a custom body
template that expands to nothing). Source it from the original first
alert instead, since the rule-source link is always available.

* refactor(googlechat): fold types.go into googlechat.go

Other notifier packages (slack, msteamsv2, pagerduty, etc.) keep types
and logic in a single file. Match that convention instead of a separate
types.go.

* refactor(googlechat): validate webhook URL in UnmarshalYAML

Move the webhook-URL validation into GoogleChatReceiverConfig.UnmarshalYAML
so the config self-validates on unmarshal, matching the upstream receiver
convention. Drop the separate validateGoogleChatConfig pass in receiver.go;
validation now runs via the defaultedNotifierConfig yaml round-trip.

* chore(googlechat): note size-guard button-URL edge as TODO

The serialized-size loop trims bodies then title but not button URLs, so a
card with many long-URL buttons could still overflow. Not reachable in the
default path (few short buttons); add a TODO to drop trailing sections to
fit once per-alert templating makes it reachable.

* refactor(googlechat): inline webhook URL validation in UnmarshalYAML

Fold the validation checks directly into UnmarshalYAML instead of a
separate helper, and drive the test through NewReceiver.

* test(googlechat): use assert for value checks, require for guards

Split testify usage per convention: require for error/nil checks and
length guards before indexing, assert for the independent value
assertions so one failure doesn't mask the rest.
2026-07-31 10:04:44 +00:00
Naman Verma
a238ed367d fix: choose first panel during migration (#12353)
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: choose first panel during migration

* test: add unit test
2026-07-31 08:59:13 +00:00
190 changed files with 15505 additions and 2773 deletions

View File

@@ -7430,6 +7430,8 @@ components:
- below
- equal
- not_equal
- above_or_equal
- below_or_equal
- outside_bounds
type: string
RuletypesCumulativeSchedule:
@@ -24698,6 +24700,149 @@ paths:
summary: Replace variables
tags:
- querier
/prometheus/api/v1/query:
get:
deprecated: false
description: Evaluate a PromQL expression at a single instant. Request and
response follow the Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/);
the /prometheus prefix distinguishes these PromQL-only endpoints from the
SigNoz query APIs. Also accepts POST with form-encoded parameters.
operationId: PrometheusInstantQuery
parameters:
- description: PromQL expression to evaluate
in: query
name: query
required: true
schema:
type: string
- description: 'Evaluation timestamp: float unix seconds or RFC3339. Defaults
to the server''s current time.'
in: query
name: time
schema:
type: string
- description: 'Evaluation timeout: float seconds or a Prometheus duration
string (e.g. 30s).'
in: query
name: timeout
schema:
type: string
- description: Set to any value to include query statistics in the response.
in: query
name: stats
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
properties:
result: {}
resultType:
enum:
- matrix
- vector
- scalar
- string
type: string
stats: {}
type: object
status:
enum:
- success
type: string
type: object
description: Query evaluated successfully
"400":
description: Unparsable expression or parameters (errorType bad_data)
"422":
description: Expression failed to evaluate (errorType execution)
"503":
description: Query timed out or was canceled
summary: Prometheus instant query
tags:
- prometheus
/prometheus/api/v1/query_range:
get:
deprecated: false
description: Evaluate a PromQL expression over a range of time on a fixed
step grid. Request and response follow the Prometheus HTTP API
(https://prometheus.io/docs/prometheus/latest/querying/api/); the
/prometheus prefix distinguishes these PromQL-only endpoints from the
SigNoz query APIs. Grids are capped at 11,000 points per series. Also
accepts POST with form-encoded parameters.
operationId: PrometheusRangeQuery
parameters:
- description: PromQL expression to evaluate
in: query
name: query
required: true
schema:
type: string
- description: 'Start timestamp: float unix seconds or RFC3339.'
in: query
name: start
required: true
schema:
type: string
- description: 'End timestamp: float unix seconds or RFC3339.'
in: query
name: end
required: true
schema:
type: string
- description: 'Grid step: float seconds or a Prometheus duration string
(e.g. 30s). Must be positive.'
in: query
name: step
required: true
schema:
type: string
- description: 'Evaluation timeout: float seconds or a Prometheus duration
string (e.g. 30s).'
in: query
name: timeout
schema:
type: string
- description: Set to any value to include query statistics in the response.
in: query
name: stats
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
properties:
result: {}
resultType:
enum:
- matrix
type: string
stats: {}
type: object
status:
enum:
- success
type: string
type: object
description: Query evaluated successfully
"400":
description: Unparsable expression or parameters, or a grid past the 11,000-point
cap (errorType bad_data)
"422":
description: Expression failed to evaluate (errorType execution)
"503":
description: Query timed out or was canceled
summary: Prometheus range query
tags:
- prometheus
servers:
- description: The fully qualified URL to the SigNoz APIServer.
url: https://{host}:{port}{base_path}

View File

@@ -0,0 +1,353 @@
# PromQL Serving — clickhouseprometheusv2
This document is the subsystem context for `pkg/prometheus/clickhouseprometheusv2`,
the second-generation ClickHouse-backed Prometheus provider. It explains why the
package exists, the correctness constraints that shaped it, and how each
construct is proven not to change results. Any change to the provider must keep
these invariants; if a change would violate one, it must be flagged and
discussed.
---
## Why a second provider
The v1 provider (`pkg/prometheus/clickhouseprometheus`) serves the promql engine
through the remote-read protobuf adapter: every raw sample of a query's union
window is fetched, serialized, and handed to the engine. The cost is a function
of ingested data, not of the question asked — which is how a dashboard of PromQL
panels can take an instance down.
In v2, every query runs in one of two ways, decided per query:
- **Transpiled**: the query is evaluated entirely inside ClickHouse and only
final (or near-final) per-group grid arrays come back, built on the
`timeSeries*ToGrid` aggregate functions (the supported ClickHouse floor is
>= 25.6, so they are assumed available).
- **Engine**: the stock promql engine evaluates over this package's native
`storage.Querier`. This is the path for everything not transpilable.
**The core constraint: a PromQL result that differs from upstream Prometheus is
a lost user, so anything that cannot reproduce engine semantics exactly falls
back rather than approximate.** The conformance suite
(`tests/integration/tests/promqlconformance/`) replays Prometheus' own test
corpus against both providers and is the arbiter; the classification golden
(`testdata/classification_golden.json`) freezes which of the two ways each
corpus expression takes. The rest of this document is the PromQL -> SQL story,
because that mapping is where correctness is won or lost.
---
## The evaluation model the SQL must reproduce
A PromQL range query is an instant query evaluated at every grid point
t_i = start + i*step, i = 0..(end-start)/step. At each t_i:
- an instant selector resolves to the latest sample in the left-open
lookback window (t_i - lookback, t_i], and to nothing when that latest
sample is a stale marker — even if older real samples sit inside the
window;
- a range selector [r] collects every sample in (t_i - r, t_i], stale
markers excluded;
- offset d shifts both windows to (t_i - d - w, t_i - d].
The transpilation invariant follows from this: every transpiled construct
produces, per output series, one array with exactly one slot per grid
point — slot i holds the value at t_i, NULL means absent. This is what
makes composition correct, not just convenient: the engine evaluates
these operators independently per t_i, so any representation that gets
every slot right gets the whole query right, and spatial aggregation over
arrays is sound because it combines values that belong to the same t_i by
construction. Slot index i maps back to t_i = start + i*step at scan time
(toMatrix). Everything below is about filling those slots with exactly
the numbers the engine would compute — and each equivalence was validated
against the vendored engine on live data before its shape entered the
allowlist; anything unproven stays on the engine path.
## Classification: finding what a statement can answer
classify walks the parsed AST looking for "core units" — maximal subtrees
of the shape
[agg by/without (...)] [fn(] selector[range] [offset d] [)] [op scalar]...
classifyCore peels that chain from the outside in: an optional
sum/min/max/avg/count aggregation, then one of the allowlisted functions
or a bare instant selector, then the selector with its offset; on the way
out it accumulates number-literal arithmetic, comparisons (including
bool) and unary minus into a scalar-op pipeline. A node qualifies only if
its type, arguments and children are in the proven set — an allowlist, so
an overlooked construct becomes a fallback instead of a wrong number.
Three unit kinds come out of this, each with its own SQL form:
unitRange (rate, irate, increase, delta, idelta over a range selector),
unitInstant (instant vector selection, bare or comparison-filtered) and
unitOverTime (avg/min/max/sum/count/last _over_time).
If the entire tree is one unit, the plan is "full": the statement's rows
are the query result. Otherwise every maximal unit is cut out and replaced
in the expression with a synthetic selector __signoz_transpiled_N__, and
the rewritten expression runs in the engine over the units' materialized
results ("hybrid") — histogram_quantile, topk, or/and/unless and vector
matching keep exact engine semantics while their expensive inputs were
aggregated server-side.
Classification refuses when exact semantics cannot be guaranteed
server-side: the @ modifier anywhere and default-resolution subqueries
(their resolution is a server runtime setting the transpiler cannot see);
duration expressions (offset step(), [range()], ...) anywhere — they are
resolved into the selector's static fields only at evaluation time, so at
classification time those fields still hold their zero values and
transpiling would silently use the wrong offset or range;
steps or ranges that are not whole seconds (the grid functions take
whole-second parameters); grouping by or matching on __name__ in hybrid
plans (the synthetic name would leak into results); name-keeping units —
bare/comparison instant selectors and last_over_time keep their real
__name__ (keepsName), which substitution would replace, so they transpile
only as full plans; and every function outside the allowlist (changes,
resets, quantile_over_time, absent, native-histogram functions, ...).
Units inside a fixed-resolution subquery evaluate on the subquery's own
grid instead of the query grid: epoch-aligned multiples of the resolution
strictly after outerStart - offset - range, ending at outer end - offset —
the exact derivation the engine uses, because a grid shifted by one step
changes which samples every window sees.
## From one unit to one statement
buildUnitSQL renders each unit as a single statement. For
sum by (pod) (rate(m{job="api"}[5m])) the skeleton is:
SELECT gkey, sumForEach(grid) AS grid FROM (
SELECT series.gkey AS gkey,
timeSeriesRateToGrid(<start>, <end>, <step>, <range>)(fromUnixTimestamp64Milli(unix_milli), value) AS grid
FROM signoz_metrics.distributed_samples_v4 AS points
INNER JOIN (
SELECT fingerprint, <group key expr> AS gkey
FROM signoz_metrics.time_series_v4
WHERE <series predicates>
GROUP BY fingerprint, gkey
) AS series ON points.fingerprint = series.fingerprint
WHERE metric_name = ? AND temporality IN ['Cumulative', 'Unspecified']
AND points.fingerprint IN (<matched fingerprints>)
AND unix_milli > <start - range> AND unix_milli <= <end>
AND bitAnd(flags, 1) = 0
GROUP BY points.fingerprint, series.gkey
) GROUP BY gkey
SETTINGS allow_experimental_ts_to_grid_aggregate_function = 1
Reading it inside out:
The time window is the selector's semantics verbatim: strict > on the
lower bound and <= on the upper is the left-open (t - w, t] rule, with the
whole window shifted by the offset. bitAnd(flags, 1) = 0 drops stale
markers, which PromQL excludes from range vectors.
The inner GROUP BY computes one grid array per series.
timeSeriesRateToGrid(start, end, step, range) is a parametric aggregate:
fed (timestamp, value) pairs it produces Array(Nullable(Float64)) with one
slot per grid point. Correct because it implements the engine's
extrapolatedRate decision for decision — counter resets, the zero-point
clamp, the extrapolation thresholds, the >= 2 samples rule, the left-open
window — verified by feeding identical samples to both and comparing
slot for slot: the only difference ever observed is the last bit
(ClickHouse's C++ and Go round the same formula differently), which is
the floating-point floor, not a semantic gap. irate/delta/idelta map to
their own timeSeries*ToGrid functions with the same verification;
increase has no function of its own and is emitted as
arrayMap(x -> x * <range seconds>, <rate expr>), exact by definition —
extrapolatedRate computes the same extrapolated delta for both and
divides by the range only when isRate, so multiplying it back is the
identity, not an approximation. The grid parameters are rendered as
literals, not bound args — they are aggregate-function parameters — and
the experimental gate rides as a SETTINGS clause on the statement itself
so telemetrystore hooks cannot clobber it.
The join annotates each series with its group key, in one of two forms.
by (...) extracts each listed label as a plain column
(JSONExtractString(labels, 'pod') AS g0) and groups on the columns
directly: the projection is a known short list and the label names live
in Go, so building, sorting and stringifying every label pair per row
would be waste. Correct because column-tuple equality is label-set
equality on the projection, and an extracted '' is the label being
absent — Prometheus semantics for by() over missing labels, and empties
are skipped when the columns turn back into labels. without and
no-aggregation project a label SET that varies per series, so they get
the canonical key: toJSONString of the sorted [label, value] pairs the
unit projects (without excludes the listed labels plus __name__; no
aggregation keeps everything, the name coming off in Go per the engine's
name-dropping rules). There the sort is load-bearing — stored JSON key
order is not canonical across fingerprints, and two orderings of the same
labels must land in one group — empty values are filtered for the same
absent-label reason, and the same string parses back into the output
label set (labelsFromGroupKey).
The outer GROUP BY is the spatial aggregation: sum/min/max/avg/count
by/without become the -ForEach combinators. Element-wise aggregation over
grid arrays is the engine's per-t_i aggregation, because slot i of every
input array refers to the same t_i; the combinators skip NULLs, which is
the engine aggregating only the series present at t_i, and an index where
every series is absent stays NULL. Two edges need explicit handling:
countForEach wraps in a mapping of 0 back to NULL, because a count over
an all-absent index is an absent point, not 0; and a unit without
aggregation still passes through maxForEach — the identity for the common
one-fingerprint group, and a deterministic NULL-skipping merge when a
regex __name__ selector collapses distinct metrics onto one projected
label set. One caveat is inherent: summation order over series differs
from the engine's, so spatial aggregates can differ in the last ULP —
float addition is not associative; no ordering reproduces the engine's
bit-exactly from inside a GROUP BY.
## Instant selectors: staleness needs two aggregates
unitInstant uses window = lookback and must reproduce the shadowing rule:
the point is absent when the latest in-window sample is a stale marker.
timeSeriesLastToGrid alone cannot express that — skipping stale rows in
WHERE would resurrect the older real sample the marker was written to
bury. So stale rows stay in the scan for this kind only, and the grid
expression compares three aggregates per slot:
arrayMap((tall, tok, vok) -> if(tall IS NULL OR tok IS NULL OR tall != tok, NULL, vok),
timeSeriesLastToGrid(...)(ts, toFloat64(unix_milli)), -- last sample overall
timeSeriesLastToGridIf(...)(ts, toFloat64(unix_milli), bitAnd(flags, 1) = 0), -- last non-stale, its timestamp
timeSeriesLastToGridIf(...)(ts, value, bitAnd(flags, 1) = 0)) -- last non-stale, its value
Correct by cases on a slot's window. No samples at all: both timestamp
aggregates are NULL, the slot is NULL — absent, as the engine says. Latest
sample non-stale: it is the latest overall and the latest non-stale, the
timestamps agree, the slot takes its value — the engine's pick. Latest
sample stale: the last-overall timestamp is the marker's, the
last-non-stale timestamp is older (or NULL when only markers are in
window), they disagree, the slot is NULL — the marker shadows, exactly
the engine's rule. Timestamps are unique per series (ingest dedups), so
timestamp equality identifies "the same sample" without ambiguity. The
-If combinator's applicability to these experimental aggregates was
probed before being trusted, not assumed.
## Windowed *_over_time: whole buckets instead of a grid function
avg/min/max/sum/count _over_time aggregate every raw sample in the window,
and no timeSeries*ToGrid function computes them. (last_over_time is the
exception: the last sample of a range vector — stale markers excluded from
range vectors by PromQL, excluded here in WHERE — is exactly
timeSeriesLastToGrid.) These transpile only when the range is a whole
multiple of the step, and then the window needs no per-sample fan-out at
all: with W = range/step, the window (t_k - range, t_k] is exactly the
union of W step buckets — both are left-open on the same boundaries — so
bucket membership fully determines window membership. Each sample lands
in exactly one bucket by a plain GROUP BY:
intDiv(unix_milli - <start> + <range> - 1, <step>) AS jj
(ceil((ts - start)/step) shifted by W-1 so the earliest in-window sample
sits at 0; slot k's window is buckets jj in [k, k+W-1]). The alternative —
fanning each sample into all W windows that cover it — multiplies rows by
W, which for a long range over a short step is a row explosion measured
in billions; the bucketed form's row count is series x buckets, the size
of the output, regardless of W.
The shard level aggregates per (series, group key, bucket): a bucket
count plus the function's value aggregate (sum for sum/avg, min, max).
The assembly level places the partials into dense arrays
(groupArrayInsertAt — positions are unique, one row per bucket; counts
and sums default to 0, which contributes nothing) and slides: slot k
combines its at-most-W bucket partials by direct aggregation, so window
sums are added the way the engine adds them — no prefix-sum differencing,
whose large-minus-large cancellation would drift past the shadow
tolerance on counter-sized values. Correct per slot because the bucket
union is the exact window multiset and avg/min/max/sum/count are
order-insensitive on a multiset (sum/avg up to summation order, the float
caveat above). A slot with zero window count is absent; min/max filter
their slices on the bucket counts, so an empty bucket's default can never
be mistaken for a value — a real sample can legitimately be +Inf.
Ranges that don't divide the step, and windows wider than
maxWindowBuckets buckets (the slide costs W combines per slot), fall back
to the engine path, which is exact.
## Scalar ops, full plans, hybrid plans
The scalar-op pipeline applies in Go to the returned arrays
(applyScalarOps), slot by slot: arithmetic operators compute, comparisons
filter (the slot keeps the vector-side value or becomes NULL) or return
0/1 under bool. Correct trivially: it is the same float64 operation the
engine would apply to the same slot value, in the same operator order the
AST dictates — running it in Go instead of another SQL layer changes
where, not what.
A full plan's arrays map straight to the result matrix. A hybrid plan
materializes each unit's arrays as synthetic series under its
__signoz_transpiled_N__ name and evaluates the rewritten expression over
a storage that serves synthetic names from memory and everything else
live. Substitution is sound because a unit's output is a plain instant
vector to the engine — same values at same timestamps under a different
name, and the name cannot matter: plans that group by or match on
__name__ were refused at classification, and name-keeping units are never
substituted. One subtlety makes it exact: stale markers are written at
absent grid points, because the engine's lookback would otherwise
resurrect a point from up to lookback earlier — the marker encodes
"absent here" the way the engine itself encodes it. Units evaluate
concurrently; each is one series lookup plus one grid statement. A step
of 0 is an instant query: a single evaluation at end.
## Series lookup
Both paths resolve matchers the same way, once per selector
(selectSeries), against the series tables holding one row per
(fingerprint, bucket) at 1h/6h/1d/1w granularities; timeSeriesTableFor
picks the table whose bucket fits the window and rounds the window start
down to the bucket boundary. How matchers become SQL, and why regexes are
anchored, is documented at applySeriesConditions. Empty-valued labels come
off at this boundary: an empty value means "label absent" in Prometheus,
but stored attribute JSON can carry them.
## The engine path
Queries that do not transpile run in the stock engine over this package's
storage.Querier, which is still not the v1 path. Samples are fetched per
selector using the engine's per-selector hints, not the query-wide union
window, so foo / foo offset 1d reads two narrow windows instead of the
widest one twice. Instant selectors of subquery-free queries fetch only
the last sample per step bucket (lastSamplePerStep): buckets anchor at the
selector's first evaluation timestamp — recovered from the hints as
hints.Start + lookback - 1ms, the inverse of how the engine derives
hints.Start — so bucket boundaries coincide with evaluation timestamps and
a non-final sample of a bucket can never be the latest sample in
(t - lookback, t] for any grid t. Real timestamps are preserved, so the
engine's own lookback and staleness handling stay exact. Range selectors
always fetch raw — every sample feeds the range function — and the
subquery-free proof travels in the context as prometheus.QueryTraits,
because subquery selectors evaluate at the subquery's step while the
hints carry the top-level step. Row assembly maps stale flags to the
engine's StaleNaN and merges series with identical label sets
(sortAndMerge) — the engine assumes storages never emit duplicates.
## Sharding
samples_v4 and time_series_v4 (and all their rollups) shard on the same
key — cityHash64(env, temporality, metric_name, fingerprint) — so a
series' samples and catalog rows live on the same shard. The transpiled
statement above exploits that: the distributed samples table at the
top-level FROM makes ClickHouse rewrite the whole inner query per shard,
where the join against the shard-local series table and the per-series
grid aggregation run next to the data; the initiator only merges
aggregate states and applies the spatial -ForEach step. Same layout as
the telemetrymetrics statement builder. The group-key join alone
restricts the transpiled scan to the matched series; the engine path's
samples fetch restricts by the same predicates as a shard-local
semi-join, not a GLOBAL broadcast of the matched set. The temporality
filter on every
samples statement is a semantic no-op — the matched fingerprints already
come from those temporalities — that engages the leading samples
primary-key column. Delta-temporality series stay invisible to PromQL
here exactly as they are in v1: the rollout gate is parity with v1, and
making Delta visible is its own change with its own semantics to design —
a Delta stream fed to rate() as-if-cumulative would be wrong, not just
new.
## Observability
Every statement carries a log_comment with
code.namespace=clickhouse-prometheus-v2 and code.function.name naming the
call site (selectSeries, selectSamples, transpiledUnit, LabelValues,
LabelNames), so this provider's work is attributable in system.query_log
without guessing from query text.

View File

@@ -8479,6 +8479,8 @@ export enum RuletypesCompareOperatorDTO {
below = 'below',
equal = 'equal',
not_equal = 'not_equal',
above_or_equal = 'above_or_equal',
below_or_equal = 'below_or_equal',
outside_bounds = 'outside_bounds',
}
export interface RuletypesBasicRuleThresholdDTO {

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from 'react-query';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
@@ -20,6 +21,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import './CreateServiceAccountModal.styles.scss';
import { Skeleton } from 'antd';
interface FormValues {
name: string;
@@ -95,33 +97,39 @@ function CreateServiceAccountModal(): JSX.Element {
testId="create-service-account-modal"
>
<div className="create-sa-modal__content">
<form
id="create-sa-form"
className="create-sa-form"
onSubmit={handleSubmit(handleCreate)}
<AuthZGuardContent
checks={[SACreatePermission]}
fallbackOnLoading={<Skeleton active paragraph={{ rows: 1 }} />}
>
<div className="create-sa-form__item">
<label htmlFor="sa-name">Name</label>
<Controller
name="name"
control={control}
rules={{ required: 'Name is required' }}
render={({ field }): JSX.Element => (
<Input
id="sa-name"
placeholder="Enter a name"
className="create-sa-form__input"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
<form
id="create-sa-form"
className="create-sa-form"
onSubmit={handleSubmit(handleCreate)}
>
<div className="create-sa-form__item">
<label htmlFor="sa-name">Name</label>
<Controller
name="name"
control={control}
rules={{ required: 'Name is required' }}
render={({ field }): JSX.Element => (
<Input
id="sa-name"
placeholder="Enter a name"
className="create-sa-form__input"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
data-testid="create-sa-name-input"
/>
)}
/>
{errors.name && (
<p className="create-sa-form__error">{errors.name.message}</p>
)}
/>
{errors.name && (
<p className="create-sa-form__error">{errors.name.message}</p>
)}
</div>
</form>
</div>
</form>
</AuthZGuardContent>
</div>
<DialogFooter className="create-sa-modal__footer">
@@ -130,6 +138,7 @@ function CreateServiceAccountModal(): JSX.Element {
variant="solid"
color="secondary"
onClick={handleClose}
data-testid="create-sa-cancel-btn"
>
<X size={12} />
Cancel
@@ -137,12 +146,14 @@ function CreateServiceAccountModal(): JSX.Element {
<AuthZButton
checks={[SACreatePermission]}
withPortal={false}
type="submit"
form="create-sa-form"
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
data-testid="create-sa-submit-btn"
>
Create Service Account
</AuthZButton>

View File

@@ -1,19 +1,14 @@
import { toast } from '@signozhq/ui/sonner';
import {
setupAuthzAdmin,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CreateServiceAccountModal from '../CreateServiceAccountModal';
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,
}: {
children: React.ReactElement;
}): React.ReactElement => children,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
@@ -45,6 +40,7 @@ describe('CreateServiceAccountModal', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
setupAuthzAdmin(),
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: {} })),
),
@@ -55,23 +51,41 @@ describe('CreateServiceAccountModal', () => {
server.resetHandlers();
});
it('submit button is disabled when form is empty', () => {
it('submit button is disabled while the form is empty', async () => {
renderModal();
expect(
screen.getByRole('button', { name: /Create Service Account/i }),
).toBeDisabled();
// The form only renders once the create check resolves, and the name field
// registers its `required` rule on mount, so the empty-form invalid state
// settles a tick later.
await screen.findByTestId('create-sa-name-input');
await waitFor(() =>
expect(screen.getByTestId('create-sa-submit-btn')).toBeDisabled(),
);
});
it('submit button becomes disabled after clearing the name field', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const nameInput = await screen.findByTestId('create-sa-name-input');
const submitBtn = await screen.findByTestId('create-sa-submit-btn');
await user.type(nameInput, 'test');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.clear(nameInput);
await waitFor(() => expect(submitBtn).toBeDisabled());
});
it('successful submit shows toast.success and closes modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await user.type(screen.getByPlaceholderText('Enter a name'), 'Deploy Bot');
const nameInput = await screen.findByTestId('create-sa-name-input');
await user.type(nameInput, 'Deploy Bot');
const submitBtn = screen.getByRole('button', {
name: /Create Service Account/i,
});
const submitBtn = screen.getByTestId('create-sa-submit-btn');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
@@ -102,11 +116,10 @@ describe('CreateServiceAccountModal', () => {
renderModal();
await user.type(screen.getByPlaceholderText('Enter a name'), 'Dupe Bot');
const nameInput = await screen.findByTestId('create-sa-name-input');
await user.type(nameInput, 'Dupe Bot');
const submitBtn = screen.getByRole('button', {
name: /Create Service Account/i,
});
const submitBtn = screen.getByTestId('create-sa-submit-btn');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
@@ -131,8 +144,45 @@ describe('CreateServiceAccountModal', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByTestId('create-service-account-modal');
await user.click(screen.getByRole('button', { name: /Cancel/i }));
const cancelBtn = await screen.findByTestId('create-sa-cancel-btn');
await user.click(cancelBtn);
await waitFor(() => {
expect(
screen.queryByTestId('create-service-account-modal'),
).not.toBeInTheDocument();
});
});
it('shows inline permission denial and hides the form when create permission is denied', async () => {
server.use(setupAuthzDenyAll());
renderModal();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('create-sa-name-input')).not.toBeInTheDocument();
expect(
screen.getByTestId('create-service-account-modal'),
).toBeInTheDocument();
});
it('keeps the footer usable when create permission is denied', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(setupAuthzDenyAll());
renderModal();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
// The footer lives outside the guard: submit is gated, Cancel still works.
expect(screen.getByTestId('create-sa-submit-btn')).toBeDisabled();
await user.click(screen.getByTestId('create-sa-cancel-btn'));
await waitFor(() => {
expect(
@@ -145,7 +195,7 @@ describe('CreateServiceAccountModal', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const nameInput = screen.getByPlaceholderText('Enter a name');
const nameInput = await screen.findByTestId('create-sa-name-input');
await user.type(nameInput, 'Bot');
await user.clear(nameInput);

View File

@@ -6,6 +6,7 @@ import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { VIEWS } from './constants';
import { MouseEventHandler } from 'react';
export type LogDetailProps = {
log: ILog | null;
@@ -16,6 +17,8 @@ export type LogDetailProps = {
logs?: ILog[];
onNavigateLog?: (log: ILog) => void;
onScrollToLog?: (logId: string) => void;
handleOpenInExplorer?: MouseEventHandler;
getContainer?: DrawerProps['getContainer'];
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
Pick<DrawerProps, 'onClose'>;

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux'; // old code, TODO: fix this correctly
import { useCopyToClipboard, useLocation } from 'react-use';
import { useCopyToClipboard } from 'react-use';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui/button';
import { Drawer, Tooltip } from 'antd';
@@ -14,9 +13,6 @@ import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySear
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import Overview from 'container/LogDetailedView/Overview';
@@ -31,8 +27,6 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import { cloneDeep } from 'lodash-es';
import {
ArrowDown,
@@ -50,12 +44,9 @@ import {
} from '@signozhq/icons';
import { JsonView } from 'periscope/components/JsonView';
import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { isModifierKeyPressed } from 'utils/app';
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
@@ -75,6 +66,8 @@ function LogDetailInner({
logs,
onNavigateLog,
onScrollToLog,
handleOpenInExplorer,
getContainer,
}: LogDetailInnerProps): JSX.Element {
const initialContextQuery = useInitialQuery(log);
const [contextQuery, setContextQuery] = useState<Query | undefined>(
@@ -91,7 +84,7 @@ function LogDetailInner({
const [filters, setFilters] = useState<TagFilter | null>(null);
const [isEdit, setIsEdit] = useState<boolean>(false);
const { stagedQuery, updateAllQueriesOperators } = useQueryBuilder();
const { stagedQuery } = useQueryBuilder();
// Handle clicks outside to close drawer, except on explicitly ignored regions
useEffect(() => {
@@ -186,11 +179,6 @@ function LogDetailInner({
});
const isDarkMode = useIsDarkMode();
const location = useLocation();
const { safeNavigate } = useSafeNavigate();
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { notifications } = useNotifications();
@@ -246,25 +234,6 @@ function LogDetailInner({
});
};
// Go to logs explorer page with the log data
const handleOpenInExplorer = (e?: React.MouseEvent): void => {
const queryParams = {
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: minTime?.toString() || '',
[QueryParams.endTime]: maxTime?.toString() || '',
[QueryParams.compositeQuery]: JSON.stringify(
updateAllQueriesOperators(
initialQueriesMap[DataSource.LOGS],
PANEL_TYPES.LIST,
DataSource.LOGS,
),
),
};
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`, {
newTab: !!e && isModifierKeyPressed(e),
});
};
const handleQueryExpressionChange = useCallback(
(value: string, queryIndex: number) => {
// update the query at the given index
@@ -333,13 +302,6 @@ function LogDetailInner({
}
};
// Only show when opened from infra monitoring page
const showOpenInExplorerBtn = useMemo(
() => location.pathname?.includes('/infrastructure-monitoring'),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const logType = log?.attributes_string?.log_level || LogType.INFO;
const currentLogIndex = logs ? logs.findIndex((l) => l.id === log.id) : -1;
const isPrevDisabled =
@@ -374,6 +336,7 @@ function LogDetailInner({
width="60%"
mask={false}
maskClosable={false}
getContainer={getContainer}
title={
<div className="log-detail-drawer__title" data-log-detail-ignore="true">
<div className="log-detail-drawer__title-left">
@@ -411,7 +374,7 @@ function LogDetailInner({
/>
</Tooltip>
</div>
{showOpenInExplorerBtn && (
{handleOpenInExplorer && (
<div>
<Button
variant="outlined"

View File

@@ -5,6 +5,7 @@ import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import {
IQuickFiltersConfig,
QuickFilterChangeEventData,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
@@ -28,11 +29,12 @@ interface ICheckboxProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
const { source, filter, onFilterChange } = props;
const { source, filter, onFilterChange, onQuickFilterChange } = props;
const [searchText, setSearchText] = useState<string>('');
const activeQueryIndex = useActiveQueryIndex(source);
@@ -61,6 +63,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
attributeValues,
activeQueryIndex,
onFilterChange,
onQuickFilterChange,
});
const setSearchTextDebounced = useDebouncedFn((...args) => {

View File

@@ -1,5 +1,6 @@
import {
IQuickFiltersConfig,
QuickFilterChangeEventData,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -19,6 +20,7 @@ interface UseCheckboxFilterActionsProps {
attributeValues: string[];
activeQueryIndex: number;
onFilterChange?: ((query: Query) => void) | null;
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
}
interface UseCheckboxFilterActionsReturn {
@@ -42,6 +44,7 @@ function useCheckboxFilterActions({
attributeValues,
activeQueryIndex,
onFilterChange,
onQuickFilterChange,
}: UseCheckboxFilterActionsProps): UseCheckboxFilterActionsReturn {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
@@ -60,20 +63,34 @@ function useCheckboxFilterActions({
previousState?: CheckedState,
sectionType?: SectionType,
): void => {
dispatch(
applyCheckboxToggle({
currentQuery,
activeQueryIndex,
filter,
source,
attributeValues,
value,
checked,
isOnlyOrAllClicked,
previousState,
sectionType,
}),
);
const updatedQuery = applyCheckboxToggle({
currentQuery,
activeQueryIndex,
filter,
source,
attributeValues,
value,
checked,
isOnlyOrAllClicked,
previousState,
sectionType,
});
dispatch(updatedQuery);
if (onQuickFilterChange) {
const queryData = updatedQuery.builder.queryData[activeQueryIndex];
const expression = queryData?.filter?.expression || '';
const filterItemKeys = (queryData?.filters?.items || [])
.map((item) => item.key?.key)
.filter((key): key is string => !!key);
onQuickFilterChange({
filterKey: filter.attributeKey.key,
expression,
filterItemKeys,
});
}
};
const onClear = (): void => {

View File

@@ -5,6 +5,7 @@ import { Typography } from '@signozhq/ui/typography';
import { LoaderCircle } from '@signozhq/icons';
import {
IQuickFiltersConfig,
QuickFilterChangeEventData,
QuickFilterCheckboxUseFieldApis,
QuickFiltersSource,
} from 'components/QuickFilters/types';
@@ -33,13 +34,15 @@ interface CheckboxFilterV2Props {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
useFieldApis: QuickFilterCheckboxUseFieldApis;
}
export default function CheckboxFilterV2(
props: CheckboxFilterV2Props,
): JSX.Element {
const { source, filter, onFilterChange, useFieldApis } = props;
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
props;
const [searchText, setSearchText] = useState<string>('');
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
@@ -103,6 +106,7 @@ export default function CheckboxFilterV2(
attributeValues,
activeQueryIndex,
onFilterChange,
onQuickFilterChange,
});
const setSearchTextDebounced = useDebouncedFn((...args) => {

View File

@@ -49,6 +49,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
handleFilterVisibilityChange,
source,
onFilterChange,
onQuickFilterChange,
signal,
showFilterCollapse = true,
showQueryName = true,
@@ -305,6 +306,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
source={source}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
useFieldApis={useFieldApis}
/>
) : (
@@ -313,6 +315,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
source={source}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
/>
);
case FiltersType.DURATION:
@@ -333,6 +336,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
source={source}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
useFieldApis={useFieldApis}
/>
) : (
@@ -341,6 +345,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
source={source}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
/>
);
}

View File

@@ -42,11 +42,18 @@ export interface IQuickFiltersConfig {
defaultOpen: boolean;
}
export interface QuickFilterChangeEventData {
filterKey: string;
expression: string;
filterItemKeys: string[];
}
export interface IQuickFiltersProps {
config: IQuickFiltersConfig[];
handleFilterVisibilityChange: () => void;
source: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
signal?: SignalType;
className?: string;
showFilterCollapse?: boolean;

View File

@@ -5,6 +5,7 @@ import { Input } from '@signozhq/ui/input';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import {
APIKeyCreatePermission,
buildSAAttachPermission,
@@ -36,91 +37,104 @@ function KeyFormPhase({
onClose,
accountId,
}: KeyFormPhaseProps): JSX.Element {
const checks = accountId
? [APIKeyCreatePermission, buildSAAttachPermission(accountId)]
: [];
return (
<>
<form id={FORM_ID} className="add-key-modal__form" onSubmit={onSubmit}>
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="key-name">
Name <span style={{ color: 'var(--destructive)' }}>*</span>
</label>
<Input
id="key-name"
placeholder="Enter key name e.g.: Service Owner"
className="add-key-modal__input"
{...register('keyName', {
required: true,
validate: (v) => !!v.trim(),
})}
/>
</div>
<div className="add-key-modal__field">
<span className="add-key-modal__label">Expiration</span>
<Controller
name="expiryMode"
control={control}
render={({ field }): JSX.Element => (
<ToggleGroupSimple
type="single"
value={field.value}
onChange={(val: string): void => {
if (val) {
field.onChange(val);
}
}}
size="sm"
className="add-key-modal__expiry-toggle"
items={[
{ value: ExpiryMode.NONE, label: 'No Expiration' },
{ value: ExpiryMode.DATE, label: 'Set Expiration Date' },
]}
<AuthZGuardContent checks={checks}>
<>
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="key-name">
Name <span style={{ color: 'var(--destructive)' }}>*</span>
</label>
<Input
id="key-name"
placeholder="Enter key name e.g.: Service Owner"
className="add-key-modal__input"
testId="add-key-name-input"
{...register('keyName', {
required: true,
validate: (v) => !!v.trim(),
})}
/>
)}
/>
</div>
</div>
{expiryMode === ExpiryMode.DATE && (
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="expiry-date">
Expiration Date
</label>
<div className="add-key-modal__datepicker">
<div className="add-key-modal__field">
<span className="add-key-modal__label">Expiration</span>
<Controller
name="expiryDate"
name="expiryMode"
control={control}
render={({ field }): JSX.Element => (
<DatePicker
id="expiry-date"
<ToggleGroupSimple
type="single"
value={field.value}
onChange={field.onChange}
popupClassName="add-key-modal-datepicker-popup"
getPopupContainer={popupContainer}
disabledDate={disabledDate}
onChange={(val: string): void => {
if (val) {
field.onChange(val);
}
}}
size="sm"
className="add-key-modal__expiry-toggle"
items={[
{ value: ExpiryMode.NONE, label: 'No Expiration' },
{ value: ExpiryMode.DATE, label: 'Set Expiration Date' },
]}
/>
)}
/>
</div>
</div>
)}
{expiryMode === ExpiryMode.DATE && (
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="expiry-date">
Expiration Date
</label>
<div className="add-key-modal__datepicker">
<Controller
name="expiryDate"
control={control}
render={({ field }): JSX.Element => (
<DatePicker
id="expiry-date"
value={field.value}
onChange={field.onChange}
popupClassName="add-key-modal-datepicker-popup"
getPopupContainer={popupContainer}
disabledDate={disabledDate}
/>
)}
/>
</div>
</div>
)}
</>
</AuthZGuardContent>
</form>
<div className="add-key-modal__footer">
<div className="add-key-modal__footer-right">
<Button variant="solid" color="secondary" onClick={onClose}>
<Button
variant="solid"
color="secondary"
onClick={onClose}
testId="add-key-cancel-btn"
>
Cancel
</Button>
<AuthZButton
checks={[
APIKeyCreatePermission,
buildSAAttachPermission(accountId ?? ''),
]}
checks={checks}
authZEnabled={!!accountId}
withPortal={false}
type="submit"
form={FORM_ID}
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
testId="add-key-submit-btn"
>
Create Key
</AuthZButton>

View File

@@ -92,6 +92,7 @@ function DeleteAccountModal(): JSX.Element {
loading={isDeleting}
onClick={handleConfirm}
data-testid="confirm-delete-btn"
withPortal={false}
>
<Trash2 size={12} />
Delete

View File

@@ -60,6 +60,7 @@ function EditKeyForm({
<AuthZTooltip
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
enabled={!!keyItem?.id}
withPortal={false}
>
<div className="edit-key-modal__key-display">
<span className="edit-key-modal__id-text">{keyItem?.name || '—'}</span>
@@ -168,6 +169,7 @@ function EditKeyForm({
variant="link"
color="destructive"
onClick={onRevokeClick}
withPortal={false}
>
<Trash2 size={12} />
Revoke Key
@@ -186,6 +188,7 @@ function EditKeyForm({
color="primary"
loading={isSaving}
disabled={!isDirty}
withPortal={false}
>
Save Changes
</AuthZButton>

View File

@@ -114,13 +114,14 @@ function buildColumns({
render: (_, record): JSX.Element => {
const tooltipTitle = isDisabled ? 'Service account disabled' : 'Revoke Key';
return (
<Tooltip title={tooltipTitle}>
<Tooltip title={tooltipTitle} placement="bottom">
<AuthZButton
checks={[
buildAPIKeyDeletePermission(record.id),
buildSADetachPermission(accountId),
]}
authZEnabled={!isDisabled && !!accountId}
withPortal={false}
variant="ghost"
size="sm"
color="destructive"
@@ -214,6 +215,7 @@ function KeysTab({
<AuthZButton
checks={[APIKeyCreatePermission, buildSAAttachPermission(accountId)]}
authZEnabled={!isDisabled && !!accountId}
withPortal={false}
variant="link"
color="primary"
onClick={async (): Promise<void> => {

View File

@@ -90,14 +90,20 @@ function OverviewTab({
Name
</label>
{isDisabled ? (
<AuthZTooltip checks={[buildSAUpdatePermission(account.id)]}>
<AuthZTooltip
checks={[buildSAUpdatePermission(account.id)]}
withPortal={false}
>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{localName || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</AuthZTooltip>
) : (
<AuthZTooltip checks={[buildSAUpdatePermission(account.id)]}>
<AuthZTooltip
checks={[buildSAUpdatePermission(account.id)]}
withPortal={false}
>
<Input
id="sa-name"
value={localName}

View File

@@ -55,6 +55,7 @@ export function RevokeKeyFooter({
color="destructive"
loading={isRevoking}
onClick={onConfirm}
withPortal={false}
>
<Trash2 size={12} />
Revoke Key

View File

@@ -38,6 +38,7 @@ import {
APIKeyCreatePermission,
buildSAAttachPermission,
buildSADeletePermission,
buildSAReadPermission,
buildSAUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import {
@@ -376,6 +377,7 @@ function ServiceAccountDrawer({
<AuthZButton
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
authZEnabled={!!selectedAccountId}
withPortal={false}
variant="link"
color="destructive"
onClick={(): void => {
@@ -391,8 +393,12 @@ function ServiceAccountDrawer({
Cancel
</Button>
<AuthZButton
checks={[buildSAUpdatePermission(selectedAccountId ?? '')]}
checks={[
buildSAReadPermission(selectedAccountId ?? ''),
buildSAUpdatePermission(selectedAccountId ?? ''),
]}
authZEnabled={!!selectedAccountId}
withPortal={false}
variant="solid"
color="primary"
loading={isSaving}
@@ -465,6 +471,7 @@ function ServiceAccountDrawer({
buildSAAttachPermission(selectedAccountId ?? ''),
]}
authZEnabled={!isDeleted && !!selectedAccountId}
withPortal={false}
variant="outlined"
size="sm"
color="secondary"

View File

@@ -1,5 +1,10 @@
import { toast } from '@signozhq/ui/sonner';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { buildSAAttachPermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import {
setupAuthzAdmin,
setupAuthzDeny,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
@@ -66,28 +71,29 @@ describe('AddKeyModal', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
expect(screen.getByRole('button', { name: /Create Key/i })).toBeDisabled();
// The form only renders once the checks resolve, so waiting for it also
// guarantees the button is no longer in its authz-loading state.
const nameInput = await screen.findByTestId('add-key-name-input');
const createBtn = await screen.findByTestId('add-key-submit-btn');
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'My Key');
expect(createBtn).toBeDisabled();
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Key/i }),
).not.toBeDisabled(),
);
await user.type(nameInput, 'My Key');
await waitFor(() => expect(createBtn).not.toBeDisabled());
await user.clear(nameInput);
await waitFor(() => expect(createBtn).toBeDisabled());
});
it('successful creation transitions to phase 2 with key displayed and security callout', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Key/i }),
).not.toBeDisabled(),
);
await user.click(screen.getByRole('button', { name: /Create Key/i }));
const nameInput = await screen.findByTestId('add-key-name-input');
const submitBtn = await screen.findByTestId('add-key-submit-btn');
await user.type(nameInput, 'Deploy Key');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
await screen.findByText('snz_abc123xyz456secret');
expect(screen.getByText(/Store the key securely/i)).toBeInTheDocument();
@@ -99,13 +105,11 @@ describe('AddKeyModal', () => {
renderModal();
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Key/i }),
).not.toBeDisabled(),
);
await user.click(screen.getByRole('button', { name: /Create Key/i }));
const nameInput = await screen.findByTestId('add-key-name-input');
const submitBtn = await screen.findByTestId('add-key-submit-btn');
await user.type(nameInput, 'Deploy Key');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
await screen.findByText('snz_abc123xyz456secret');
@@ -123,12 +127,57 @@ describe('AddKeyModal', () => {
});
});
it('shows inline permission denial and hides the form when key create is denied', async () => {
server.use(setupAuthzDenyAll());
renderModal();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('add-key-name-input')).not.toBeInTheDocument();
expect(screen.getByTestId('add-key-modal')).toBeInTheDocument();
});
it('keeps the footer usable when key create is denied', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(setupAuthzDenyAll());
renderModal();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
// The footer lives outside the guard: submit is gated, Cancel still works.
expect(screen.getByTestId('add-key-submit-btn')).toBeDisabled();
await user.click(screen.getByTestId('add-key-cancel-btn'));
await waitFor(() => {
expect(screen.queryByTestId('add-key-modal')).not.toBeInTheDocument();
});
});
it('shows inline permission denial when attach on the service account is denied', async () => {
server.use(setupAuthzDeny(buildSAAttachPermission('sa-1')));
renderModal();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('add-key-name-input')).not.toBeInTheDocument();
});
it('Cancel button closes the modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByTestId('add-key-modal');
await user.click(screen.getByRole('button', { name: /Cancel/i }));
const cancelBtn = await screen.findByTestId('add-key-cancel-btn');
await user.click(cancelBtn);
await waitFor(() => {
expect(screen.queryByTestId('add-key-modal')).not.toBeInTheDocument();

View File

@@ -1,8 +1,12 @@
import { useLayoutEffect, type ReactNode } from 'react';
import { type ReactNode, useLayoutEffect, useMemo } from 'react';
import { chromePerformanceTanstackTableEndHover } from './perfDevtools';
import { useIsRowHovered } from './TanStackTableStateContext';
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
import {
TooltipContentProps,
TooltipSimple,
TooltipSimpleProps,
} from '@signozhq/ui/tooltip';
export type HoverTooltipProps = Omit<TooltipSimpleProps, 'open'> & {
rowId: string;
@@ -22,9 +26,28 @@ export function TanStackHoverTooltip({
}
}, [isHovered, rowId]);
const tooltipContentProps = useMemo(
() =>
({
onPointerDownOutside: (e): void => {
e.preventDefault();
e.stopPropagation();
},
}) satisfies TooltipContentProps,
[],
);
if (!isHovered) {
return <>{children}</>;
}
return <TooltipSimple {...tooltipProps}>{children}</TooltipSimple>;
return (
<TooltipSimple
delayDuration={700}
tooltipContentProps={tooltipContentProps}
{...tooltipProps}
>
{children}
</TooltipSimple>
);
}

View File

@@ -51,6 +51,7 @@ import { useColumnHandlers } from './useColumnHandlers';
import { useColumnState } from './useColumnState';
import { useEffectiveData } from './useEffectiveData';
import { useFlatItems } from './useFlatItems';
import { useResetScroll } from './useResetScroll';
import { useRowKeyData } from './useRowKeyData';
import { useTableParams } from './useTableParams';
import { buildPageSizeItems, buildTanstackColumnDef } from './utils';
@@ -110,6 +111,7 @@ function TanStackTableInner<TData, TItemKey = string>(
suffixPaginationContent,
enableAlternatingRowColors,
disableVirtualScroll,
resetScrollKey,
}: TanStackTableProps<TData, TItemKey>,
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
): JSX.Element {
@@ -324,6 +326,8 @@ function TanStackTableInner<TData, TItemKey = string>(
});
}, [flatIndexForActiveRow]);
useResetScroll(virtuosoRef, resetScrollKey);
const { sensors, columnIds, handleDragEnd } = useColumnDnd({
columns: effectiveColumns,
onColumnOrderChange: handleColumnOrderChange,

View File

@@ -217,6 +217,55 @@ describe('useColumnState', () => {
});
});
describe('storageKey changes', () => {
it('does not auto-show hidden columns when switching between different storageKeys', () => {
// Regression test: when switching Pods→Nodes→Pods, hidden columns were
// incorrectly shown because prevColumnIdsRef wasn't reset on storageKey change.
// The autoShowNewlyAddedColumns effect would see pod columns as "new" (not in
// node columns) and unhide them.
const KEY_PODS = 'k8s-pods';
const KEY_NODES = 'k8s-nodes';
const podColumns = [
col('podName'),
col('podStatus'),
col('cpu_request', { defaultVisibility: false }),
col('memory_request', { defaultVisibility: false }),
];
const nodeColumns = [
col('nodeName'),
col('nodeStatus'),
col('cpu'),
col('memory'),
];
// Initialize both tables
act(() => {
useColumnStore.getState().initializeFromDefaults(KEY_PODS, podColumns);
useColumnStore.getState().initializeFromDefaults(KEY_NODES, nodeColumns);
});
// Start with pods - cpu_request and memory_request should be hidden
const { result, rerender } = renderHook(
({ storageKey, columns }) => useColumnState({ storageKey, columns }),
{ initialProps: { storageKey: KEY_PODS, columns: podColumns } },
);
expect(result.current.hiddenColumnIds).toContain('cpu_request');
expect(result.current.hiddenColumnIds).toContain('memory_request');
// Switch to nodes
rerender({ storageKey: KEY_NODES, columns: nodeColumns });
// Switch back to pods - hidden columns should STILL be hidden
rerender({ storageKey: KEY_PODS, columns: podColumns });
expect(result.current.hiddenColumnIds).toContain('cpu_request');
expect(result.current.hiddenColumnIds).toContain('memory_request');
});
});
describe('actions', () => {
it('hideColumn hides a column', () => {
const columns = [col('a'), col('b')];

View File

@@ -0,0 +1,103 @@
import { RefObject } from 'react';
import { renderHook } from '@testing-library/react';
import type { TableVirtuosoHandle } from 'react-virtuoso';
import { useResetScroll } from '../useResetScroll';
function createMockRef(scrollTo: jest.Mock): RefObject<TableVirtuosoHandle> {
return {
current: {
scrollToIndex: jest.fn(),
scrollIntoView: jest.fn(),
scrollTo,
scrollBy: jest.fn(),
},
};
}
describe('useResetScroll', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('does not call scrollTo on initial mount', () => {
const scrollTo = jest.fn();
const ref = createMockRef(scrollTo);
renderHook(() => useResetScroll(ref, 'initial-key'));
expect(scrollTo).not.toHaveBeenCalled();
});
it('calls scrollTo when key changes', () => {
const scrollTo = jest.fn();
const ref = createMockRef(scrollTo);
const { rerender } = renderHook(
({ resetKey }) => useResetScroll(ref, resetKey),
{ initialProps: { resetKey: 'key-1' } },
);
expect(scrollTo).not.toHaveBeenCalled();
rerender({ resetKey: 'key-2' });
expect(scrollTo).toHaveBeenCalledWith({ left: 0 });
});
it('calls scrollTo once per key change', () => {
const scrollTo = jest.fn();
const ref = createMockRef(scrollTo);
const { rerender } = renderHook(
({ resetKey }) => useResetScroll(ref, resetKey),
{ initialProps: { resetKey: 'key-1' } },
);
rerender({ resetKey: 'key-2' });
rerender({ resetKey: 'key-3' });
rerender({ resetKey: 'key-4' });
expect(scrollTo).toHaveBeenCalledTimes(3);
});
it('does not call scrollTo when key unchanged', () => {
const scrollTo = jest.fn();
const ref = createMockRef(scrollTo);
const { rerender } = renderHook(
({ resetKey }) => useResetScroll(ref, resetKey),
{ initialProps: { resetKey: 'same-key' } },
);
rerender({ resetKey: 'same-key' });
rerender({ resetKey: 'same-key' });
expect(scrollTo).not.toHaveBeenCalled();
});
it('handles null ref.current gracefully', () => {
const ref: RefObject<TableVirtuosoHandle | null> = { current: null };
const { rerender } = renderHook(
({ resetKey }) => useResetScroll(ref, resetKey),
{ initialProps: { resetKey: 'key-1' } },
);
expect(() => rerender({ resetKey: 'key-2' })).not.toThrow();
});
it('calls scrollTo when changing from undefined to defined', () => {
const scrollTo = jest.fn();
const ref = createMockRef(scrollTo);
const { rerender } = renderHook(
({ resetKey }) => useResetScroll(ref, resetKey),
{ initialProps: { resetKey: undefined as string | undefined } },
);
rerender({ resetKey: 'new-key' });
expect(scrollTo).toHaveBeenCalledWith({ left: 0 });
});
});

View File

@@ -215,6 +215,16 @@ export * from './useTableParams';
* />
* ```
*
* @example Reset scroll on context change — use `resetScrollKey` to scroll back to start
* when the data context changes (e.g., switching between categories or tabs).
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* resetScrollKey={selectedCategory}
* />
* ```
*
* @example useTableParams — manages pagination state with URL sync and persistence
*
* The `useTableParams` hook handles page, limit, orderBy, and expanded state. It can sync

View File

@@ -213,6 +213,8 @@ export type TanStackTableProps<TData, TItemKey = string> = {
enableAlternatingRowColors?: boolean;
/** Disable virtual scrolling and render all rows at once. Cannot be used with onEndReached. */
disableVirtualScroll?: boolean;
/** When this value changes, the table's scroll resets to the start. Useful for resetting scroll when the data context changes (e.g., switching categories). */
resetScrollKey?: string;
};
export type TanStackTableHandle = TableVirtuosoHandle & {

View File

@@ -67,6 +67,13 @@ export function useColumnState<TData>({
const columnSizing = useStoreSizing(storageKey ?? '');
const prevColumnIdsRef = useRef<Set<string> | null>(null);
const prevStorageKeyRef = useRef<string | undefined>(storageKey);
// Reset prevColumnIdsRef when storageKey changes to avoid cross-table interference
if (prevStorageKeyRef.current !== storageKey) {
prevColumnIdsRef.current = null;
prevStorageKeyRef.current = storageKey;
}
useEffect(
function autoShowNewlyAddedColumns(): void {

View File

@@ -0,0 +1,18 @@
import { RefObject, useEffect, useRef } from 'react';
import type { TableVirtuosoHandle } from 'react-virtuoso';
export function useResetScroll(
scrollRef: RefObject<TableVirtuosoHandle | null>,
resetKey: string | undefined,
): void {
const prevKeyRef = useRef(resetKey);
useEffect(() => {
if (prevKeyRef.current !== resetKey) {
prevKeyRef.current = resetKey;
scrollRef.current?.scrollTo({
left: 0,
});
}
}, [resetKey, scrollRef]);
}

View File

@@ -1,3 +1,9 @@
import logEvent from 'api/common/logEvent';
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { getNavigationReferrer } from 'lib/navigation';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isCustomTimeRange } from 'store/globalTime';
export enum Events {
UPDATE_GRAPH_VISIBILITY_STATE = 'UPDATE_GRAPH_VISIBILITY_STATE',
UPDATE_GRAPH_MANAGER_TABLE = 'UPDATE_GRAPH_MANAGER_TABLE',
@@ -39,3 +45,155 @@ export enum InfraMonitoringEvents {
StatefulSet = 'statefulSet',
Volumes = 'volumes',
}
export function logInfraFilterCustomizedEvent(
entityType: InfraMonitoringEntity,
source: 'quick_filter' | 'search' | 'host_status_toggle',
expression: string,
extraKeys?: string[],
): void {
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
(pair) => pair.key,
);
if (extraKeys) {
extraKeys.forEach((key) => expressionKeys.push(key));
}
if (expressionKeys.length === 0) {
return;
}
void logEvent('infra_filter_customized', {
entity_type: entityType,
source,
expression_keys: [...new Set(expressionKeys)],
});
}
export function logInfraMonitoringListViewedEvent(
entity: InfraMonitoringEntity,
): void {
const referrer = getNavigationReferrer();
void logEvent('infra_list_viewed', {
entity,
referrer,
});
}
export function logInfraTimeRangeCustomizedEvent(
entityType: InfraMonitoringEntity,
rangeLabel: string,
): void {
void logEvent('infra_time_range_customized', {
entity_type: entityType,
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
});
}
export function logInfraColumnCustomizedEvent(
entityType: InfraMonitoringEntity,
columnsList: string[],
fontSize: string,
maxLinesPerRow: number,
source: 'list' | 'expanded',
): void {
void logEvent('infra_column_customized', {
entity_type: entityType,
columns_list: columnsList,
font_size: fontSize,
max_lines_per_row: maxLinesPerRow,
source,
});
}
export function logInfraColumnSortedEvent(
entityType: InfraMonitoringEntity,
columnKey: string,
direction: 'asc' | 'desc',
source: 'list' | 'expanded',
): void {
void logEvent('infra_column_sorted', {
entity_type: entityType,
column_key: columnKey,
direction,
source,
});
}
export function logInfraDrawerTimeRangeCustomizedEvent(
entityType: InfraMonitoringEntity,
rangeLabel: string,
): void {
void logEvent('infra_drawer_time_range_customized', {
entity_type: entityType,
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
});
}
export function logInfraDrawerFilterCustomizedEvent(
entityType: InfraMonitoringEntity,
tab: 'metrics' | 'logs' | 'traces' | 'events' | 'pod_metrics',
expression: string,
filterSource: 'search' | 'logs',
): void {
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
(pair) => pair.key,
);
if (expressionKeys.length === 0) {
return;
}
void logEvent('infra_drawer_filter_customized', {
entity_type: entityType,
tab,
expression_keys: [...new Set(expressionKeys)],
filter_source: filterSource,
});
}
export function logInfraGroupByCustomizedEvent(
entityType: InfraMonitoringEntity,
groupByKeysList: string[],
): void {
void logEvent('infra_group_by_customized', {
entity_type: entityType,
group_by_keys_list: groupByKeysList,
});
}
export function logInfraDrawerTabViewedEvent(
entityType: InfraMonitoringEntity,
tab: string,
isDefaultTab: boolean,
): void {
void logEvent('infra_drawer_tab_viewed', {
entity_type: entityType,
tab,
is_default_tab: isDefaultTab,
});
}
export function logInfraExplorerNavigatedEvent(params: {
entityType: InfraMonitoringEntity;
destination:
| 'metrics_explorer'
| 'logs_explorer'
| 'traces_explorer'
| 'k8s_list';
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
tab: string;
sourceKey: string | null;
drawerDurationMsAtNavigation: number | null;
}): void {
void logEvent('infra_explorer_navigated', {
entity_type: params.entityType,
destination: params.destination,
source: params.source,
tab: params.tab,
source_key: params.sourceKey,
drawer_duration_ms_at_navigation: params.drawerDurationMsAtNavigation,
});
}

View File

@@ -3,3 +3,7 @@ export const DASHBOARD_CACHE_TIME = 30_000;
export const DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED = 0;
export const FIELD_API_CACHE_TIME = 60_000;
// intentionally lower since I only want to prevent refresh in background
// after click in the row
export const INFRA_MONITORING_DETAILS_CACHE_TIME = 1_000;

View File

@@ -66,6 +66,10 @@ function ThresholdItem({
return '=';
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return '!=';
case AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO:
return '>=';
case AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO:
return '<=';
default:
return '';
}

View File

@@ -83,6 +83,10 @@ const getOperatorWord = (op: AlertThresholdOperator): string => {
return 'equal';
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return 'not equal';
case AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO:
return 'equal or exceed';
case AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO:
return 'equal or fall below';
default:
return 'exceed';
}
@@ -98,6 +102,10 @@ const getThresholdValue = (op: AlertThresholdOperator): number => {
return 100;
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return 0;
case AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO:
return 80;
case AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO:
return 50;
default:
return 80;
}
@@ -116,6 +124,8 @@ const getDataPoints = (
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 100, 105, 90, 100],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 0, 10, 15, 0],
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: [75, 80, 90, 78, 95],
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: [60, 50, 40, 55, 35],
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
},
[AlertThresholdMatchType.ALL_THE_TIME]: {
@@ -123,6 +133,8 @@ const getDataPoints = (
[AlertThresholdOperator.IS_EQUAL_TO]: [100, 100, 100, 100, 100],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
[AlertThresholdOperator.IS_ABOVE]: [85, 87, 90, 88, 95],
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: [80, 87, 90, 88, 95],
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: [50, 40, 35, 42, 38],
[AlertThresholdOperator.ABOVE_BELOW]: [85, 87, 90, 88, 95],
},
[AlertThresholdMatchType.ON_AVERAGE]: {
@@ -130,6 +142,8 @@ const getDataPoints = (
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 105, 100, 95, 105],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: [70, 85, 90, 75, 80],
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: [60, 40, 55, 45, 50],
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
},
[AlertThresholdMatchType.IN_TOTAL]: {
@@ -137,6 +151,8 @@ const getDataPoints = (
[AlertThresholdOperator.IS_EQUAL_TO]: [20, 20, 20, 20, 20],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [10, 15, 25, 5, 30],
[AlertThresholdOperator.IS_ABOVE]: [10, 15, 25, 5, 30],
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: [10, 15, 25, 5, 25],
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: [8, 5, 10, 12, 15],
[AlertThresholdOperator.ABOVE_BELOW]: [10, 15, 25, 5, 30],
},
[AlertThresholdMatchType.LAST]: {
@@ -144,6 +160,8 @@ const getDataPoints = (
[AlertThresholdOperator.IS_EQUAL_TO]: [75, 85, 90, 78, 100],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [75, 85, 90, 78, 25],
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: [75, 85, 90, 78, 80],
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: [75, 85, 90, 78, 50],
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
},
};
@@ -157,6 +175,8 @@ const getTooltipOperatorSymbol = (op: AlertThresholdOperator): string => {
[AlertThresholdOperator.IS_BELOW]: '<',
[AlertThresholdOperator.IS_EQUAL_TO]: '=',
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: '!=',
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: '>=',
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: '<=',
[AlertThresholdOperator.ABOVE_BELOW]: '>',
};
return symbolMap[op] || '>';
@@ -252,6 +272,10 @@ export const getMatchTypeTooltip = (
return p === thresholdValue;
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return p !== thresholdValue;
case AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO:
return p >= thresholdValue;
case AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO:
return p <= thresholdValue;
default:
return p > thresholdValue;
}
@@ -294,7 +318,8 @@ export const getMatchTypeTooltip = (
matchType={matchType}
>
Alert triggers (all points {operatorWord} {thresholdValue})<br />
If any point was {thresholdValue}, no alert would fire
If any point didn&apos;t {operatorWord} {thresholdValue}, no alert would
fire
</TooltipExample>
<TooltipLink />
</TooltipContent>

View File

@@ -532,7 +532,7 @@ describe('Footer utils', () => {
['symbol', '>', 'at_least_once'],
['literal', 'above', 'at_least_once'],
['short', 'eq', 'avg'],
['UI-unexposed', 'above_or_equal', 'at_least_once'],
['inclusive', 'above_or_equal', 'at_least_once'],
])(
'round-trips %s op/matchType unchanged through the submit payload (%s / %s)',
(_desc, op, matchType) => {

View File

@@ -332,25 +332,20 @@ describe('CreateAlertV2 utils', () => {
['not_equal', AlertThresholdOperator.IS_NOT_EQUAL_TO],
['not_eq', AlertThresholdOperator.IS_NOT_EQUAL_TO],
['!=', AlertThresholdOperator.IS_NOT_EQUAL_TO],
['5', AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO],
['above_or_equal', AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO],
['above_or_eq', AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO],
['>=', AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO],
['6', AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO],
['below_or_equal', AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO],
['below_or_eq', AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO],
['<=', AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO],
['7', AlertThresholdOperator.ABOVE_BELOW],
['outside_bounds', AlertThresholdOperator.ABOVE_BELOW],
])('maps backend alias %s to canonical enum', (alias, expected) => {
expect(normalizeOperator(alias)).toBe(expected);
});
it.each([
['5', 'above_or_equal'],
['above_or_equal', 'above_or_equal'],
['above_or_eq', 'above_or_equal'],
['>=', 'above_or_equal'],
['6', 'below_or_equal'],
['below_or_equal', 'below_or_equal'],
['below_or_eq', 'below_or_equal'],
['<=', 'below_or_equal'],
])('returns undefined for UI-unexposed alias %s (%s family)', (alias) => {
expect(normalizeOperator(alias)).toBeUndefined();
});
it('returns undefined for unknown values', () => {
expect(normalizeOperator('gibberish')).toBeUndefined();
expect(normalizeOperator(undefined)).toBeUndefined();
@@ -413,8 +408,8 @@ describe('CreateAlertV2 utils', () => {
['symbol', '>', 'at_least_once'],
['short form', 'eq', 'avg'],
['mixed numeric and literal', '7', 'last'],
['UI-unexposed operator', 'above_or_equal', 'at_least_once'],
['UI-unexposed numeric operator', '5', 'at_least_once'],
['inclusive literal operator', 'above_or_equal', 'at_least_once'],
['inclusive numeric operator', '5', 'at_least_once'],
])('preserves %s op/matchType verbatim (%s / %s)', (_desc, op, matchType) => {
const state = getThresholdStateFromAlertDef(buildDef(op, matchType));
expect(state.operator).toBe(op);

View File

@@ -2,9 +2,8 @@ import { AlertThresholdMatchType, AlertThresholdOperator } from './types';
// Mirrors the backend's CompareOperator.Normalize() in
// pkg/types/ruletypes/compare.go. Maps any accepted alias to the enum value
// the dropdown understands. Returns undefined for aliases the UI does not
// expose (e.g. above_or_equal, below_or_equal) so callers can keep the raw
// value on screen instead of silently rewriting it.
// the dropdown understands. Returns undefined for unknown values so callers
// can keep the raw value on screen instead of silently rewriting it.
export function normalizeOperator(
raw: string | undefined,
): AlertThresholdOperator | undefined {
@@ -27,6 +26,16 @@ export function normalizeOperator(
case 'not_eq':
case '!=':
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
case '5':
case 'above_or_equal':
case 'above_or_eq':
case '>=':
return AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO;
case '6':
case 'below_or_equal':
case 'below_or_eq':
case '<=':
return AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO;
case '7':
case 'outside_bounds':
return AlertThresholdOperator.ABOVE_BELOW;

View File

@@ -125,6 +125,14 @@ export const THRESHOLD_OPERATOR_OPTIONS = [
{ value: AlertThresholdOperator.IS_BELOW, label: 'BELOW' },
{ value: AlertThresholdOperator.IS_EQUAL_TO, label: 'EQUAL TO' },
{ value: AlertThresholdOperator.IS_NOT_EQUAL_TO, label: 'NOT EQUAL TO' },
{
value: AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO,
label: 'ABOVE OR EQUAL TO',
},
{
value: AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO,
label: 'BELOW OR EQUAL TO',
},
];
export const ANOMALY_THRESHOLD_OPERATOR_OPTIONS = [

View File

@@ -99,6 +99,8 @@ export enum AlertThresholdOperator {
IS_BELOW = 'below',
IS_EQUAL_TO = 'equal',
IS_NOT_EQUAL_TO = 'not_equal',
IS_ABOVE_OR_EQUAL_TO = 'above_or_equal',
IS_BELOW_OR_EQUAL_TO = 'below_or_equal',
ABOVE_BELOW = 'outside_bounds',
}

View File

@@ -13,8 +13,15 @@ import {
import { AxiosError } from 'axios';
import APIError from 'types/api/error';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import {
QuickFilterChangeEventData,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import {
InfraMonitoringEvents,
logInfraFilterCustomizedEvent,
logInfraMonitoringListViewedEvent,
} from 'constants/events';
import { initialQueriesMap } from 'constants/queryBuilder';
import K8sBaseDetails, {
K8sDetailsFilters,
@@ -186,6 +193,19 @@ function Hosts(): JSX.Element {
hostInitialLogTracesExpression(host),
[],
);
const handleQuickFilterChange = useCallback(
(data: QuickFilterChangeEventData): void => {
logInfraFilterCustomizedEvent(
InfraMonitoringEntity.HOSTS,
'quick_filter',
data.expression,
data.filterItemKeys,
);
},
[],
);
const controlListPrefix = !showFilters ? (
<div className={styles.quickFiltersToggleContainer}>
<Button
@@ -199,6 +219,10 @@ function Hosts(): JSX.Element {
</div>
) : undefined;
useEffect(() => {
logInfraMonitoringListViewedEvent(InfraMonitoringEntity.HOSTS);
}, []);
return (
<>
<div className={styles.infraMonitoringContainer}>
@@ -227,6 +251,7 @@ function Hosts(): JSX.Element {
startUnixMilli,
endUnixMilli,
}}
onQuickFilterChange={handleQuickFilterChange}
/>
</>
</OverlayScrollbar>

View File

@@ -78,7 +78,7 @@
display: flex;
flex-direction: column;
align-items: stretch;
min-width: 595px;
min-width: 800px;
> * {
min-width: 0;

View File

@@ -1,9 +1,12 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { logInfraFilterCustomizedEvent } from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
StatusFilterValue,
useInfraMonitoringPageListing,
useInfraMonitoringStatusFilter,
} from 'container/InfraMonitoringK8sV2/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import styles from './StatusFilter.module.scss';
@@ -19,11 +22,21 @@ const statusOptions: Array<{
function StatusFilter(): JSX.Element {
const [statusFilter, setStatusFilter] = useInfraMonitoringStatusFilter();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const { currentQuery } = useQueryBuilder();
const handleChange = (value: string): void => {
if (value !== undefined) {
void setStatusFilter(value === 'all' ? '' : (value as StatusFilterValue));
void setCurrentPage(1);
const expression =
currentQuery.builder.queryData[0]?.filter?.expression || '';
logInfraFilterCustomizedEvent(
InfraMonitoringEntity.HOSTS,
'host_status_toggle',
expression,
value === 'all' ? [] : ['host_status'],
);
}
};

View File

@@ -2,7 +2,6 @@ import React from 'react';
import { Color } from '@signozhq/design-tokens';
import { Badge } from '@signozhq/ui/badge';
import { Progress } from '@signozhq/ui/progress';
import { Typography } from '@signozhq/ui/typography';
import {
InframonitoringtypesHostRecordDTO,
InframonitoringtypesHostStatusDTO,
@@ -10,6 +9,7 @@ import {
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { TextNoData } from 'container/InfraMonitoringK8sV2/components';
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
import {
getHostQueryPayload,
@@ -70,7 +70,7 @@ export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
{value}
</Badge>
) : (
<Typography.Text>-</Typography.Text>
<TextNoData type="typography" />
),
},
{

View File

@@ -175,7 +175,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
accessorFn: (row): number => row.cpu,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpu = value as number;
return (
<div className={styles.progressContainer}>
@@ -183,6 +183,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
value={cpu}
entity={InfraMonitoringEntity.HOSTS}
attribute="CPU metric"
rowId={rowId}
>
<EntityProgressBar value={cpu} type="cpu" />
</ValidateColumnValueWrapper>
@@ -197,14 +198,13 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
tooltip="Excluding cache memory."
docPath="/infrastructure-monitoring/host-monitoring#memory-usage"
>
Memory Usage
<br /> (WSS)
Memory Usage (WSS)
</ColumnHeader>
),
accessorFn: (row): number => row.memory,
width: { min: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memory = value as number;
return (
<div className={styles.progressContainer}>
@@ -212,6 +212,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
value={memory}
entity={InfraMonitoringEntity.HOSTS}
attribute="memory metric"
rowId={rowId}
>
<EntityProgressBar value={memory} type="memory" />
</ValidateColumnValueWrapper>
@@ -219,6 +220,33 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
);
},
},
{
id: 'diskUsage',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#disk-usage">
Disk Usage
</ColumnHeader>
),
accessorFn: (row): number => row.diskUsage,
width: { min: 200 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => {
const diskUsage = value as number;
return (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={diskUsage}
entity={InfraMonitoringEntity.HOSTS}
attribute="disk usage metric"
rowId={rowId}
>
<EntityProgressBar value={diskUsage} type="disk" />
</ValidateColumnValueWrapper>
</div>
);
},
},
{
id: 'wait',
header: (): React.ReactNode => (
@@ -229,7 +257,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
accessorFn: (row): number => row.wait,
width: { min: 120 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const wait = value as number;
return (
@@ -237,6 +265,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
value={wait}
entity={InfraMonitoringEntity.HOSTS}
attribute="IOWait metric"
rowId={rowId}
>
<TanStackTable.Text>
{`${Number((wait * 100).toFixed(1))}%`}
@@ -249,14 +278,13 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
id: 'load15',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#load-avg">
Load Avg
<br /> (15min)
Load Avg (15min)
</ColumnHeader>
),
accessorFn: (row): number => row.load15,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const load15 = Number(value);
return (
@@ -264,36 +292,11 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
value={load15}
entity={InfraMonitoringEntity.HOSTS}
attribute="load average metric"
rowId={rowId}
>
<TanStackTable.Text>{load15.toFixed(2)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'diskUsage',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#disk-usage">
Disk Usage
</ColumnHeader>
),
accessorFn: (row): number => row.diskUsage,
width: { min: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const diskUsage = value as number;
return (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={diskUsage}
entity={InfraMonitoringEntity.HOSTS}
attribute="disk usage metric"
>
<EntityProgressBar value={diskUsage} type="disk" />
</ValidateColumnValueWrapper>
</div>
);
},
},
];

View File

@@ -7,7 +7,7 @@ import {
IQuickFiltersConfig,
} from 'components/QuickFilters/types';
import { TriangleAlert } from '@signozhq/icons';
import { CellValueTooltip } from 'container/InfraMonitoringK8sV2/components';
import TanStackTable from 'components/TanStackTableView';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
@@ -21,7 +21,7 @@ export function HostnameCell({
}): React.ReactElement {
const isEmpty = !hostName || !hostName.trim();
if (!isEmpty) {
return <CellValueTooltip value={hostName} />;
return <TanStackTable.Text>{hostName}</TanStackTable.Text>;
}
return (
<>

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
@@ -21,6 +21,9 @@ import {
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
@@ -33,9 +36,14 @@ import {
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
import { ILog } from 'types/api/logs/log';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { isModifierKeyPressed } from 'utils/app';
import { validateQuery } from 'utils/queryValidationUtils';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
@@ -134,7 +142,7 @@ function EntityLogsContent({
if (validation.isValid) {
querySearchOnRun(newUserExpression);
logEvent(InfraMonitoringEvents.FilterApplied, {
void logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
@@ -162,6 +170,45 @@ function EntityLogsContent({
virtuosoRef,
});
const { updateAllQueriesOperators } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const handleOpenInExplorer = useCallback(
(e: React.MouseEvent<Element, MouseEvent>, log: ILog) => {
const baseQuery = updateAllQueriesOperators(
initialQueriesMap[DataSource.LOGS],
PANEL_TYPES.LIST,
DataSource.LOGS,
);
const queryParams = {
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
[QueryParams.compositeQuery]: JSON.stringify({
...baseQuery,
builder: {
...baseQuery.builder,
queryData: baseQuery.builder.queryData.map((item) => ({
...item,
filter: { expression },
})),
},
} satisfies Query),
};
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`, {
newTab: !!e && isModifierKeyPressed(e),
});
},
[
timeRange.startTime,
timeRange.endTime,
expression,
safeNavigate,
updateAllQueriesOperators,
],
);
const getItemContent = useCallback(
(_: number, logToRender: ILog): JSX.Element => {
return (
@@ -287,6 +334,7 @@ function EntityLogsContent({
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
handleOpenInExplorer={(e): void => handleOpenInExplorer(e, activeLog)}
/>
)}
</div>

View File

@@ -2,6 +2,7 @@
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
text-wrap: auto;
}
.infoIcon {

View File

@@ -8,7 +8,6 @@ const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
interface ColumnHeaderProps {
children?: React.ReactNode;
title?: string;
docPath?: string;
tooltip?: string;
className?: string;
@@ -16,7 +15,6 @@ interface ColumnHeaderProps {
function ColumnHeader({
children,
title,
docPath,
tooltip,
className,
@@ -26,16 +24,6 @@ function ColumnHeader({
return children;
}
if (title) {
const parts = title.split('\n');
return parts.map((part, index) => (
<div key={`${part}-${index}`}>
{part}
{index < parts.length - 1 && <br />}
</div>
));
}
return null;
};

View File

@@ -1,15 +1,17 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { Color, Spacing } from '@signozhq/design-tokens';
import { X } from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import { Copy, X } from '@signozhq/icons';
import { Divider } from '@signozhq/ui/divider';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
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 { InfraMonitoringEvents } from 'constants/events';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
GlobalTimeProvider,
NANO_SECOND_MULTIPLIER,
@@ -22,8 +24,10 @@ import LoadingContainer from '../LoadingContainer';
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
import { K8sBaseDetailsProps } from './types';
import { useDrawerLifecycleStore } from './useDrawerLifecycleStore';
import '../EntityDetailsUtils/entityDetails.styles.scss';
import styles from '../EntityDetailsUtils/entityDetails.module.scss';
import { INFRA_MONITORING_DETAILS_CACHE_TIME } from 'constants/queryCacheTime';
export type {
CustomTab,
@@ -34,6 +38,23 @@ export type {
K8sDetailsMetadataConfig,
} from './types';
// TODO(H4ad): Improve this on component level
const DRAWER_TRANSITION = { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] };
const DRAWER_MOTION_PROPS = {
onOpenAutoFocus: (e: Event): void => e.preventDefault(),
initial: { opacity: 0, transform: 'translateX(100%)' },
animate: {
opacity: 1,
transform: 'translateX(0%)',
transition: DRAWER_TRANSITION,
},
exit: {
opacity: 0,
transform: 'translateX(100%)',
transition: DRAWER_TRANSITION,
},
} as unknown as DrawerWrapperProps['drawerContentProps'];
export default function K8sBaseDetails<T>({
category,
eventCategory,
@@ -58,8 +79,6 @@ export default function K8sBaseDetails<T>({
(s) => s.getAutoRefreshQueryKey,
);
const isDarkMode = useIsDarkMode();
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
const selectedItem = selectedItemParams.selectedItem;
@@ -101,6 +120,8 @@ export default function K8sBaseDetails<T>({
return fetchEntityData({ filter: { expression }, start, end }, signal);
},
cacheTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
staleTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
enabled: !!selectedItem,
});
@@ -121,10 +142,39 @@ export default function K8sBaseDetails<T>({
return getInitialEventsExpression(entity);
}, [entity, getInitialEventsExpression]);
const markDrawerOpened = useDrawerLifecycleStore((s) => s.markOpened);
const markDrawerClosed = useDrawerLifecycleStore((s) => s.markClosed);
useEffect(() => {
if (selectedItem) {
markDrawerOpened();
} else {
markDrawerClosed();
}
}, [selectedItem, markDrawerOpened, markDrawerClosed]);
const handleClose = useCallback((): void => {
setSelectedItemParams(null);
}, [setSelectedItemParams]);
const handleOpenChange = useCallback(
(open: boolean): void => {
if (!open) {
handleClose();
}
},
[handleClose],
);
const [, copyToClipboard] = useCopyToClipboard();
const handleCopyId = useCallback((): void => {
if (selectedItem) {
copyToClipboard(selectedItem);
toast.success('ID copied to clipboard', { position: 'bottom-left' });
}
}, [copyToClipboard, selectedItem]);
const entityName = entity ? getEntityName(entity) : '';
useEffect(() => {
@@ -137,31 +187,49 @@ export default function K8sBaseDetails<T>({
}
}, [entity, eventCategory]);
// TODO(H4ad): Improve this on component level
// DrawerWrapper types `title` as string but renders any ReactNode.
const drawerTitle = (
<>
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleClose}
data-testid="close-drawer-button"
className={styles.closeButton}
prefix={<X />}
/>
<Divider type="vertical" />
<Typography.Text className={styles.title}>
{entityName ||
((isEntityError || hasResponseError) && 'Failed to load entity details') ||
(isEntityLoading && 'Loading...') ||
'-'}
</Typography.Text>
<TooltipSimple title="Copy ID">
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleCopyId}
data-testid="copy-id-button"
>
<Copy size={14} />
</Button>
</TooltipSimple>
</>
) as unknown as string;
return (
<Drawer
width="70%"
title={
<>
<Divider type="vertical" />
<Typography.Text className="title">
{entityName ||
((isEntityError || hasResponseError) &&
'Failed to load entity details') ||
(isEntityLoading && 'Loading...') ||
'-'}
</Typography.Text>
</>
}
placement="right"
onClose={handleClose}
<DrawerWrapper
open={!!selectedItem}
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
}}
className="entity-detail-drawer"
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
onOpenChange={handleOpenChange}
direction="right"
title={drawerTitle}
showCloseButton={false}
drawerContentProps={DRAWER_MOTION_PROPS}
className={styles.entityDetailDrawer}
>
{(isEntityLoading || !selectedItem) && <LoadingContainer />}
@@ -210,6 +278,6 @@ export default function K8sBaseDetails<T>({
/>
</GlobalTimeProvider>
)}
</Drawer>
</DrawerWrapper>
);
}

View File

@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo } from 'react';
import { Fragment, useEffect, useMemo, useRef } from 'react';
import {
BarChart,
ChevronsLeftRight,
@@ -6,12 +6,17 @@ import {
DraftingCompass,
ScrollText,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { TooltipSimple } from '@signozhq/ui/tooltip';
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 {
InfraMonitoringEvents,
logInfraDrawerTabViewedEvent,
logInfraExplorerNavigatedEvent,
} from 'constants/events';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
@@ -42,6 +47,9 @@ import {
import { EntityCountsSection } from './components/EntityCountsSection/EntityCountsSection';
import { K8sBaseDetailsContentProps } from './types';
import { getDrawerDurationMs } from './useDrawerLifecycleStore';
import styles from '../EntityDetailsUtils/entityDetails.module.scss';
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function K8sBaseDetailsContent<T>({
@@ -107,6 +115,17 @@ export default function K8sBaseDetailsContent<T>({
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
// Wait until the view is a valid tab so we don't log a pending value before
// the validation effect above corrects an out-of-scope query param
const hasLoggedDefaultTab = useRef(false);
useEffect(() => {
if (hasLoggedDefaultTab.current || !validTabs.includes(effectiveView)) {
return;
}
hasLoggedDefaultTab.current = true;
logInfraDrawerTabViewedEvent(category, effectiveView, true);
}, [validTabs, effectiveView, category]);
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
const [, setEventsFiltersParam] = useInfraMonitoringEventsFilters();
@@ -133,6 +152,7 @@ export default function K8sBaseDetailsContent<T>({
category: eventCategory,
view: value,
});
logInfraDrawerTabViewedEvent(category, value, false);
};
const handleExplorePagesRedirect = (): void => {
@@ -154,6 +174,16 @@ export default function K8sBaseDetailsContent<T>({
view: selectedView,
});
logInfraExplorerNavigatedEvent({
entityType: category,
destination:
selectedView === VIEW_TYPES.LOGS ? 'logs_explorer' : 'traces_explorer',
source: 'tab_cta_button',
tab: selectedView,
sourceKey: null,
drawerDurationMsAtNavigation: getDrawerDurationMs(),
});
if (selectedView === VIEW_TYPES.LOGS) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
@@ -209,34 +239,39 @@ export default function K8sBaseDetailsContent<T>({
return (
<>
<div className="entity-detail-drawer__entity">
<div className="entity-details-grid">
<div className="labels-row">
<div className={styles.entityDetailsEntity}>
<div className={styles.entityDetailsGrid}>
<div className={styles.labelsRow}>
{metadataConfig.map((config) => (
<Typography.Text
key={config.label}
color="muted"
className="entity-details-metadata-label"
size="small"
weight="medium"
className={styles.entityDetailsMetadataLabel}
>
{config.label}
</Typography.Text>
))}
</div>
<div className="values-row">
<div className={styles.valuesRow}>
{metadataConfig.map((config) => {
const value = config.getValue(entity);
if (config.render) {
return config.render(value, entity);
}
const displayValue = String(value);
return (
<Typography.Text
key={config.label}
className="entity-details-metadata-value"
size="small"
weight="medium"
className={styles.entityDetailsMetadataValue}
>
{config.render ? (
config.render(value, entity)
) : (
<Tooltip title={displayValue}>{displayValue}</Tooltip>
)}
{displayValue}
</Typography.Text>
);
})}
@@ -253,15 +288,17 @@ export default function K8sBaseDetailsContent<T>({
selectedItem={selectedItem}
filterExpression={getCountsFilterExpression(entity)}
closeDrawer={handleClose}
entityType={category}
activeTab={selectedView}
/>
)}
</div>
{!hideDetailViewTabs && (
<div className="views-tabs-container">
<div className={styles.viewsTabsContainer}>
<ToggleGroupSimple
type="single"
className="views-tabs"
className={styles.viewsTabs}
onChange={handleTabChange}
value={selectedView}
items={[
@@ -270,7 +307,7 @@ export default function K8sBaseDetailsContent<T>({
{
value: VIEW_TYPES.METRICS,
label: (
<div className="view-title">
<div className={styles.viewTitle}>
<BarChart size={14} />
Metrics
</div>
@@ -283,7 +320,7 @@ export default function K8sBaseDetailsContent<T>({
{
value: VIEW_TYPES.LOGS,
label: (
<div className="view-title">
<div className={styles.viewTitle}>
<ScrollText size={14} />
Logs
</div>
@@ -296,7 +333,7 @@ export default function K8sBaseDetailsContent<T>({
{
value: VIEW_TYPES.TRACES,
label: (
<div className="view-title">
<div className={styles.viewTitle}>
<DraftingCompass size={14} />
Traces
</div>
@@ -309,7 +346,7 @@ export default function K8sBaseDetailsContent<T>({
{
value: VIEW_TYPES.EVENTS,
label: (
<div className="view-title">
<div className={styles.viewTitle}>
<ChevronsLeftRight size={14} />
Events
</div>
@@ -320,7 +357,7 @@ export default function K8sBaseDetailsContent<T>({
...(customTabs?.map((tab) => ({
value: tab.key,
label: (
<div className="view-title">
<div className={styles.viewTitle}>
{tab.icon}
{tab.label}
</div>
@@ -330,22 +367,30 @@ export default function K8sBaseDetailsContent<T>({
/>
{selectedView === VIEW_TYPES.LOGS && (
<Tooltip title="Go to Logs Explorer" placement="left">
<TooltipSimple title="Go to Logs Explorer" side="left" arrow>
<Button
icon={<Compass size={18} />}
className="compass-button"
variant="ghost"
size="icon"
color="secondary"
className={styles.compassButton}
onClick={handleExplorePagesRedirect}
/>
</Tooltip>
>
<Compass size={18} />
</Button>
</TooltipSimple>
)}
{selectedView === VIEW_TYPES.TRACES && (
<Tooltip title="Go to Traces Explorer" placement="left">
<TooltipSimple title="Go to Traces Explorer" side="left" arrow>
<Button
icon={<Compass size={18} />}
className="compass-button"
variant="ghost"
size="icon"
color="secondary"
className={styles.compassButton}
onClick={handleExplorePagesRedirect}
/>
</Tooltip>
>
<Compass size={18} />
</Button>
</TooltipSimple>
)}
</div>
)}
@@ -358,6 +403,7 @@ export default function K8sBaseDetailsContent<T>({
getEntityQueryPayload={getEntityQueryPayload}
category={category}
queryKey={`${queryKeyPrefix}Metrics`}
view={VIEW_TYPES.METRICS}
/>
)}
{effectiveView === VIEW_TYPES.LOGS && (

View File

@@ -1,14 +1,19 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import TanStackTable, {
SortState,
TableColumnDef,
useCalculatedPageSize,
useHiddenColumnIds,
useTableParams,
} from 'components/TanStackTableView';
import { InfraMonitoringEvents } from 'constants/events';
import {
InfraMonitoringEvents,
logInfraColumnSortedEvent,
logInfraTimeRangeCustomizedEvent,
} from 'constants/events';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
@@ -27,11 +32,16 @@ import {
useInfraMonitoringSelectedItemParams,
useInfraMonitoringStatusFilter,
} from '../hooks';
import { useInfraMonitoringLineClamp } from '../components';
import {
useInfraMonitoringFontSize,
useInfraMonitoringLineClamp,
} from './useInfraMonitoringTablePreferencesStore';
import { K8sEmptyState } from './K8sEmptyState';
import { K8sExpandedRow } from './K8sExpandedRow';
import K8sOptionsSidePanel from './K8sOptionsSidePanel';
import K8sHeader from './K8sHeader';
import { K8sPaginationWarning } from './K8sPaginationWarning';
import K8sTableToolbar from './K8sTableToolbar';
import { K8sBaseFilters } from './types';
import { getGroupedByMeta } from './utils';
import { K8sInstrumentationChecksCallout } from './components/K8sInstrumentationChecksCallout/K8sInstrumentationChecksCallout';
@@ -104,6 +114,7 @@ export function K8sBaseList<
const { currentQuery } = useQueryBuilder();
const expression = currentQuery.builder.queryData[0]?.filter?.expression || '';
const lineClamp = useInfraMonitoringLineClamp();
const fontSize = useInfraMonitoringFontSize();
const [groupBy] = useInfraMonitoringGroupBy();
const [orderBy] = useInfraMonitoringOrderBy();
const [statusFilter] = useInfraMonitoringStatusFilter();
@@ -113,6 +124,7 @@ export function K8sBaseList<
const columnStorageKey = `k8s-${entity}-columns`;
const hiddenColumnIds = useHiddenColumnIds(columnStorageKey);
const [isOptionsDrawerOpen, setIsOptionsDrawerOpen] = useState(false);
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
rowHeight: 42,
@@ -217,6 +229,14 @@ export function K8sBaseList<
void queryClient.cancelQueries({ queryKey });
}, [queryClient, queryKey]);
const handleOpenOptionsDrawer = useCallback((): void => {
setIsOptionsDrawerOpen(true);
}, []);
const handleCloseOptionsDrawer = useCallback((): void => {
setIsOptionsDrawerOpen(false);
}, []);
const pageData = data?.data ?? [];
const totalCount = data?.total || 0;
const hasFilters = !!expression?.trim();
@@ -235,6 +255,14 @@ export function K8sBaseList<
});
}, [eventCategory, totalCount]);
const prevSelectedTimeRef = useRef(selectedTime);
useEffect(() => {
if (prevSelectedTimeRef.current !== selectedTime) {
logInfraTimeRangeCustomizedEvent(entity, selectedTime);
prevSelectedTimeRef.current = selectedTime;
}
}, [selectedTime, entity]);
const handleRowClick = useCallback(
(record: T, itemKey: TItemKey): void => {
if (groupBy.length === 0) {
@@ -346,6 +374,7 @@ export function K8sBaseList<
extraQueryKeyParts={extraQueryKeyParts}
getRowKey={getRowKey}
getItemKey={getItemKey}
detailsQueryKeyPrefix={detailsQueryKeyPrefix}
/>
),
[
@@ -355,6 +384,7 @@ export function K8sBaseList<
getItemKey,
expandedRowColumns,
extraQueryKeyParts,
detailsQueryKeyPrefix,
],
);
@@ -363,6 +393,15 @@ export function K8sBaseList<
[isGroupedByAttribute],
);
const handleSort = useCallback(
(sort: SortState | null): void => {
if (sort) {
logInfraColumnSortedEvent(entity, sort.columnName, sort.order, 'list');
}
},
[entity],
);
const showTableLoadingState = isLoading;
const emptyTableMessage: React.ReactNode = renderEmptyState?.({
@@ -392,17 +431,21 @@ export function K8sBaseList<
<>
<K8sHeader
controlListPrefix={controlListPrefix}
leftFilters={leftFilters}
entity={entity}
showAutoRefresh={!selectedItem}
columns={tableColumns}
columnStorageKey={columnStorageKey}
isFetching={isFetching}
cancelQuery={cancelQuery}
/>
<div ref={containerRef} className={styles.tableContainer}>
<K8sInstrumentationChecksCallout entity={entity} />
<K8sTableToolbar
entity={entity}
eventCategory={eventCategory}
leftFilters={leftFilters}
onOpenOptionsDrawer={handleOpenOptionsDrawer}
/>
{isError && (
<Typography>
{data?.error?.toString() || 'Something went wrong'}
@@ -423,6 +466,7 @@ export function K8sBaseList<
getGroupKey={getGroupKeyFn}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
onSort={handleSort}
renderExpandedRow={isGroupedByAttribute ? renderExpandedRow : undefined}
getRowCanExpand={isGroupedByAttribute ? getRowCanExpand : undefined}
className={cx(styles.k8SListTable)}
@@ -440,11 +484,21 @@ export function K8sBaseList<
onLimitChange: setLimit,
}}
plainTextCellLineClamp={lineClamp}
cellTypographySize={fontSize}
prefixPaginationContent={paginationWarningContent}
paginationClassname={styles.paginationContainer}
resetScrollKey={entity}
/>
)}
</div>
<K8sOptionsSidePanel
open={isOptionsDrawerOpen}
columns={tableColumns}
storageKey={columnStorageKey}
entity={entity}
onClose={handleCloseOptionsDrawer}
/>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { useQuery, useQueryClient } from 'react-query';
import { useLocation } from 'react-router-dom';
import { Querybuildertypesv5QueryWarnDataDTO } from 'api/generated/services/sigNoz.schemas';
import { Button } from '@signozhq/ui/button';
@@ -21,6 +21,7 @@ import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { logInfraColumnSortedEvent } from 'constants/events';
import { InfraMonitoringEntity } from '../constants';
import {
SelectedItemParams,
@@ -30,6 +31,8 @@ import {
useInfraMonitoringSelectedItemParams,
} from '../hooks';
import { K8sBaseFilters } from './types';
import { useLogEventForColumnCustomized } from './useLogEventForColumnCustomized';
import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferencesStore';
import styles from './K8sExpandedRow.module.scss';
import { buildExpressionFromGroupMeta } from './utils';
@@ -62,9 +65,14 @@ export type K8sExpandedRowProps<T, TItemKey = string> = {
getRowKey?: (record: T) => string;
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
getItemKey?: (record: T) => TItemKey;
/** Query key prefix for pre-caching detail data on row click */
detailsQueryKeyPrefix?: string;
};
export function K8sExpandedRow<T, TItemKey = string>({
export function K8sExpandedRow<
T,
TItemKey extends string | SelectedItemParams = string,
>({
rowKey,
groupMeta,
entity,
@@ -73,7 +81,9 @@ export function K8sExpandedRow<T, TItemKey = string>({
extraQueryKeyParts = [],
getRowKey,
getItemKey,
detailsQueryKeyPrefix,
}: K8sExpandedRowProps<T, TItemKey>): JSX.Element {
const fontSize = useInfraMonitoringFontSize();
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const { currentQuery } = useQueryBuilder();
@@ -84,6 +94,7 @@ export function K8sExpandedRow<T, TItemKey = string>({
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const location = useLocation();
const queryClient = useQueryClient();
const orderByParamKey = useMemo(
() => `orderBy_${rowKey.replace(/[^a-zA-Z0-9]/g, '_')}`,
@@ -105,6 +116,13 @@ export function K8sExpandedRow<T, TItemKey = string>({
const storageKey = `k8s-${entity}-columns-expanded`;
useLogEventForColumnCustomized({
entity,
source: 'expanded',
storageKey,
columns: tableColumns,
});
const expressionForRecord = useMemo(
() => buildExpressionFromGroupMeta(parentExpression || '', groupMeta),
[parentExpression, groupMeta],
@@ -177,18 +195,45 @@ export function K8sExpandedRow<T, TItemKey = string>({
const expandedData = data?.data ?? [];
const handleRowClick = useCallback(
(_row: T, itemKey: TItemKey): void => {
if (typeof itemKey === 'object' && itemKey !== null) {
setSelectedItemParams(itemKey as unknown as SelectedItemParams);
} else {
setSelectedItemParams({
selectedItem: itemKey as string,
clusterName: null,
namespaceName: null,
});
(row: T, itemKey: TItemKey): void => {
const params: SelectedItemParams =
typeof itemKey === 'object' && itemKey !== null
? 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: row });
}
setSelectedItemParams(params);
},
[
setSelectedItemParams,
detailsQueryKeyPrefix,
getAutoRefreshQueryKey,
selectedTime,
queryClient,
],
);
const handleSort = useCallback(
(sort: SortState | null): void => {
if (sort) {
logInfraColumnSortedEvent(entity, sort.columnName, sort.order, 'expanded');
}
},
[setSelectedItemParams],
[entity],
);
const handleViewAllClick = (): void => {
@@ -257,6 +302,7 @@ export function K8sExpandedRow<T, TItemKey = string>({
getRowKey={getRowKey}
getItemKey={getItemKey}
onRowClick={handleRowClick}
onSort={handleSort}
enableQueryParams={{
orderBy: orderByParamKey,
}}
@@ -264,7 +310,7 @@ export function K8sExpandedRow<T, TItemKey = string>({
className: styles.expandedTable,
}}
disableVirtualScroll
cellTypographySize="medium"
cellTypographySize={fontSize}
/>
</TanStackTableStateProvider>
{!isLoading && expandedData.length > 0 && (

View File

@@ -1,62 +0,0 @@
.drawer {
--dialog-description-padding: 0px 0px var(--spacing-8) 0px;
}
.columnItem {
display: flex;
align-items: center;
}
.columnsTitle {
color: var(--l2-foreground);
font-size: var(--periscope-font-size-small, 11px);
font-weight: var(--periscope-font-weight-medium, 500);
text-transform: uppercase;
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--l2-border);
&:not(:first-of-type) {
border-top: 1px solid var(--l2-border);
}
}
.columnsList {
display: flex;
flex-direction: column;
[data-slot='icon'] {
display: none;
}
[data-slot='entity-group-header'] {
padding-left: 0;
}
[data-slot='column-header'] {
display: flex;
width: auto;
span {
padding: 0;
text-align: left;
}
br {
display: none;
}
}
}
.columnItem {
justify-content: flex-start !important;
width: 100%;
> span {
width: auto !important;
}
}
.horizontalDivider {
border-top: 1px solid var(--l1-border);
}

View File

@@ -1,143 +0,0 @@
import { ReactNode, useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import {
hideColumn,
showColumn,
TableColumnDef,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import styles from './K8sFiltersSidePanel.module.scss';
type ColumnPickerItem = {
id: string;
label: ReactNode;
canBeHidden: boolean;
visibilityBehavior:
| 'hidden-on-expand'
| 'hidden-on-collapse'
| 'always-visible';
};
function renderHeader(header: string | (() => ReactNode)): ReactNode {
return typeof header === 'function' ? header() : header;
}
function toColumnPickerItems<T>(
columns: TableColumnDef<T>[],
): ColumnPickerItem[] {
return columns.map((col) => ({
id: col.id,
label: renderHeader(col.header),
canBeHidden: col.canBeHidden !== false && col.enableRemove !== false,
visibilityBehavior: col.visibilityBehavior ?? 'always-visible',
}));
}
function K8sFiltersSidePanel<TData>({
open,
onClose,
columns,
storageKey,
}: {
open: boolean;
onClose: () => void;
columns: TableColumnDef<TData>[];
storageKey: string;
}): JSX.Element {
const columnPickerItems = useMemo(
() => toColumnPickerItems(columns),
[columns],
);
const hiddenColumnIds = useHiddenColumnIds(storageKey);
const addedColumns = useMemo(
() =>
columnPickerItems.filter(
(column) =>
!hiddenColumnIds.includes(column.id) &&
column.visibilityBehavior !== 'hidden-on-collapse',
),
[columnPickerItems, hiddenColumnIds],
);
const hiddenColumns = useMemo(
() =>
columnPickerItems.filter((column) => hiddenColumnIds.includes(column.id)),
[columnPickerItems, hiddenColumnIds],
);
const handleRemoveColumn = (columnId: string): void => {
hideColumn(storageKey, columnId);
};
const handleAddColumn = (columnId: string): void => {
showColumn(storageKey, columnId);
};
const drawerContent = (
<>
<div className={styles.columnsTitle}>Added Columns (Click to remove)</div>
<div className={styles.columnsList}>
{addedColumns.map((column) => (
<div className={styles.columnItem} key={column.id}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
disabled={!column.canBeHidden}
data-testid={`remove-column-${column.id}`}
onClick={(): void => handleRemoveColumn(column.id)}
>
{column.label}
</Button>
</div>
))}
</div>
<div className={styles.horizontalDivider} />
<div className={styles.columnsTitle}>Other Columns (Click to add)</div>
<div className={styles.columnsList}>
{hiddenColumns.map((column) => (
<div className={styles.columnItem} key={column.id}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
data-can-be-added="true"
data-testid={`add-column-${column.id}`}
onClick={(): void => handleAddColumn(column.id)}
tabIndex={0}
>
{column.label}
</Button>
</div>
))}
</div>
</>
);
return (
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="Columns"
direction="right"
showCloseButton
showOverlay={false}
className={styles.drawer}
>
{drawerContent}
</DrawerWrapper>
);
}
export default K8sFiltersSidePanel;

View File

@@ -5,25 +5,10 @@
align-items: stretch;
gap: var(--spacing-4);
width: 100%;
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--l2-border) !important;
background-color: var(--l2-background) !important;
input {
font-size: 12px;
}
:global([data-slot='badge'] .ant-typography) {
font-size: 12px;
}
}
}
.k8SListControlsRow {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
width: 100%;
align-items: center;
@@ -34,27 +19,6 @@
width: 100%;
}
.k8SFiltersGroupByRow {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-4);
min-width: 0;
@media (max-width: 1600px) {
flex: 1 1 100%;
order: 9;
}
}
.k8SAttributeSearchContainer {
flex: 1 1 240px;
max-width: 400px;
display: flex;
align-items: center;
}
.k8SRunButton {
flex: 0 0 auto;
}
@@ -64,17 +28,12 @@
}
.k8SDateTimeSelection {
flex: 0 0 auto;
margin-left: auto;
display: flex;
justify-content: flex-end;
--date-time-selector-border-color: var(--l2-border);
@media (max-width: 1200px) {
flex: 1 1 100%;
order: 10;
}
:global(.timeSelection-input) {
height: 32px;
}
@@ -95,42 +54,3 @@
}
}
}
.groupByLabel {
min-width: max-content;
font-size: var(--periscope-font-size-base, 13px);
font-weight: var(--periscope-font-weight-regular, 400);
line-height: 18px;
letter-spacing: -0.07px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l2-border);
border-right: none;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
display: flex;
height: 32px;
padding: var(--spacing-3) var(--spacing-3) var(--spacing-3) var(--spacing-4);
justify-content: center;
align-items: center;
gap: var(--spacing-2);
}
.groupBySelect {
width: 100%;
:global(.ant-select-selector) {
border-left: none;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
}
.k8SListControlsRight {
min-width: 240px;
display: flex;
align-items: center;
gap: var(--spacing-2);
}

View File

@@ -1,29 +1,19 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Select } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TableColumnDef } from 'components/TanStackTableView';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { InfraMonitoringEvents } from 'constants/events';
import {
InfraMonitoringEvents,
logInfraFilterCustomizedEvent,
} from 'constants/events';
import { QueryParams } from 'constants/query';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { SlidersHorizontal } from '@signozhq/icons';
import { v4 as uuid } from 'uuid';
import {
useGlobalTimeQueryInvalidate,
useGlobalTimeStore,
} from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { useGlobalTimeQueryInvalidate } from 'store/globalTime';
import { DataSource } from 'types/common/queryBuilder';
import {
@@ -31,37 +21,25 @@ import {
InfraMonitoringEntity,
METRIC_NAMESPACE_BY_ENTITY,
} from '../constants';
import {
useInfraMonitoringGroupBy,
useInfraMonitoringPageListing,
} from '../hooks';
import K8sFiltersSidePanel from './K8sFiltersSidePanel';
import { useInfraMonitoringPageListing } from '../hooks';
import styles from './K8sHeader.module.scss';
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface K8sHeaderProps<TData> {
interface K8sHeaderProps {
controlListPrefix?: React.ReactNode;
leftFilters?: React.ReactNode;
entity: InfraMonitoringEntity;
showAutoRefresh: boolean;
columns: TableColumnDef<TData>[];
columnStorageKey: string;
isFetching?: boolean;
cancelQuery: () => void;
}
function K8sHeader<TData>({
function K8sHeader({
controlListPrefix,
leftFilters,
entity,
showAutoRefresh,
columns,
columnStorageKey,
isFetching = false,
cancelQuery,
}: K8sHeaderProps<TData>): JSX.Element {
const [isFiltersSidePanelOpen, setIsFiltersSidePanelOpen] = useState(false);
}: K8sHeaderProps): JSX.Element {
// null = user never touched the search box; fall back to the current
// query expression on Run so an untouched search doesn't wipe filters
const stagedExpressionRef = useRef<string | null>(null);
@@ -121,6 +99,8 @@ function K8sHeader<TData>({
page: InfraMonitoringEvents.ListPage,
category: entity,
});
logInfraFilterCustomizedEvent(entity, 'search', finalExpression);
}
},
[
@@ -145,101 +125,11 @@ function K8sHeader<TData>({
cancelQuery();
}, [cancelQuery]);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const { minTime, maxTime } = getMinMaxTime();
const startUnixMilli = Math.floor(minTime / NANO_SECOND_MULTIPLIER);
const endUnixMilli = Math.floor(maxTime / NANO_SECOND_MULTIPLIER);
const { data: groupByFiltersData, isLoading: isLoadingGroupByFilters } =
useGetFieldsKeys(
{
signal: TelemetrytypesSignalDTO.metrics,
metricNamespace: METRIC_NAMESPACE_BY_ENTITY[entity],
limit: 100,
startUnixMilli,
endUnixMilli,
fieldContext: TelemetrytypesFieldContextDTO.resource,
// the search text is intentionally not included
// searchText: expression,
},
{
query: {
queryKey: ['getFieldsKeys', entity, startUnixMilli, endUnixMilli],
},
},
);
const flatFieldKeys = useMemo(() => {
const keys = groupByFiltersData?.data?.keys;
if (!keys) {
return [];
}
const allKeys = Object.values(keys).flat();
const seen = new Set<string>();
return allKeys.filter((field) => {
if (seen.has(field.name)) {
return false;
}
seen.add(field.name);
return true;
});
}, [groupByFiltersData]);
const groupByOptions = useMemo(
() =>
flatFieldKeys.map((field) => ({
value: field.name,
label: field.name,
})),
[flatFieldKeys],
);
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const handleGroupByChange = useCallback(
(value: string[]) => {
void setCurrentPage(1);
void setGroupBy(value);
void logEvent(InfraMonitoringEvents.GroupByChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
},
[setCurrentPage, setGroupBy],
);
const onClickOutside = useCallback(() => {
setIsFiltersSidePanelOpen(false);
}, []);
return (
<div className={styles.k8SListControls}>
<div className={styles.k8SListControlsRow}>
{controlListPrefix}
<div className={styles.k8SFiltersGroupByRow}>
{leftFilters}
<div className={styles.k8SAttributeSearchContainer}>
<div className={styles.groupByLabel}>Group by</div>
<Select
className={styles.groupBySelect}
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}
allowClear
maxTagCount="responsive"
placeholder="Search for attribute"
options={groupByOptions}
onChange={handleGroupByChange}
/>
</div>
</div>
<div className={styles.k8SDateTimeSelection}>
<DateTimeSelectionV2
showAutoRefresh={showAutoRefresh}
@@ -255,20 +145,6 @@ function K8sHeader<TData>({
handleCancelQuery={handleCancelQuery}
className={styles.k8SRunButton}
/>
<TooltipSimple title="Click to add more columns to this table">
<Button
type="button"
variant="outlined"
size="icon"
color="secondary"
data-testid="k8s-list-filters-button"
onClick={(): void => setIsFiltersSidePanelOpen(true)}
className={styles.k8SFiltersButton}
>
<SlidersHorizontal size={14} />
</Button>
</TooltipSimple>
</div>
<div className={styles.k8SQbSearchContainer}>
@@ -283,13 +159,6 @@ function K8sHeader<TData>({
metricNamespace={METRIC_NAMESPACE_BY_ENTITY[entity]}
/>
</div>
<K8sFiltersSidePanel
open={isFiltersSidePanelOpen}
columns={columns}
storageKey={columnStorageKey}
onClose={onClickOutside}
/>
</div>
);
}

View File

@@ -0,0 +1,105 @@
.drawer {
// for some reason, the scrollbar is overriding this
// for now, I will keep forcing but needs to investigate a global level
// why all drawers are being override with l1-background
background-color: var(--l2-background) !important;
border-color: var(--l2-border) !important;
--dialog-description-padding: 0px 0px var(--spacing-8) 0px;
[data-slot='drawer-description'] {
overflow-y: auto;
}
}
.sectionTitle {
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--l2-border);
&:not(:first-of-type) {
border-top: 1px solid var(--l2-border);
}
}
.sectionTitleText {
text-transform: uppercase;
}
.fontSizeContainer {
display: flex;
flex-direction: column;
padding: var(--spacing-4) 0;
}
.fontSizeOption {
width: 100%;
--button-display: flex;
--button-justify-content: space-between;
--button-align-items: center;
--button-padding: var(--spacing-4) var(--spacing-8);
}
.fontSizeLabel {
text-transform: capitalize;
}
.checkIcon {
color: var(--accent-foreground);
}
.lineClampContainer {
padding: 12px;
--input-prefix-padding: var(--spacing-3);
--input-suffix-padding: var(--spacing-3);
}
.columnsList {
display: flex;
flex-direction: column;
[data-slot='icon'] {
display: none;
}
[data-slot='entity-group-header'] {
padding-left: 0;
}
[data-slot='column-header'] {
display: flex;
width: auto;
span {
padding: 0;
text-align: left;
}
br {
display: none;
}
}
}
.columnItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2) var(--spacing-8);
> span {
width: auto !important;
}
&:first-child {
padding-top: var(--spacing-8);
}
}
.columnLabel {
flex: 1;
text-align: left;
}
.horizontalDivider {
border-top: 1px solid var(--l1-border);
}

View File

@@ -0,0 +1,256 @@
import { ChangeEvent, ReactNode, useCallback, useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Check, Minus, Plus } from '@signozhq/icons';
import {
hideColumn,
showColumn,
TableColumnDef,
useColumnOrder,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import { InfraMonitoringEntity } from '../constants';
import {
FontSize,
useInfraMonitoringTablePreferencesStore,
} from './useInfraMonitoringTablePreferencesStore';
import { useLogEventForColumnCustomized } from './useLogEventForColumnCustomized';
import { sortByColumnOrder } from './utils';
import styles from './K8sOptionsSidePanel.module.scss';
import { Typography } from '@signozhq/ui/typography';
type ColumnPickerItem = {
id: string;
label: ReactNode;
canBeHidden: boolean;
visibilityBehavior:
| 'hidden-on-expand'
| 'hidden-on-collapse'
| 'always-visible';
};
function renderHeader(header: string | (() => ReactNode)): ReactNode {
return typeof header === 'function' ? header() : header;
}
function toColumnPickerItems<T>(
columns: TableColumnDef<T>[],
): ColumnPickerItem[] {
return columns.map((col) => ({
id: col.id,
label: renderHeader(col.header),
canBeHidden: col.canBeHidden !== false && col.enableRemove !== false,
visibilityBehavior: col.visibilityBehavior ?? 'always-visible',
}));
}
const FONT_SIZE_OPTIONS: { value: FontSize; label: string }[] = [
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
];
function K8sOptionsSidePanel<TData>({
open,
onClose,
columns,
storageKey,
entity,
}: {
open: boolean;
onClose: () => void;
columns: TableColumnDef<TData>[];
storageKey: string;
entity: InfraMonitoringEntity;
}): JSX.Element {
const columnPickerItems = useMemo(
() => toColumnPickerItems(columns),
[columns],
);
const hiddenColumnIds = useHiddenColumnIds(storageKey);
const columnOrder = useColumnOrder(storageKey);
const lineClamp = useInfraMonitoringTablePreferencesStore((s) => s.lineClamp);
const fontSize = useInfraMonitoringTablePreferencesStore((s) => s.fontSize);
const increaseLineClamp = useInfraMonitoringTablePreferencesStore(
(s) => s.increaseLineClamp,
);
const decreaseLineClamp = useInfraMonitoringTablePreferencesStore(
(s) => s.decreaseLineClamp,
);
const setLineClamp = useInfraMonitoringTablePreferencesStore(
(s) => s.setLineClamp,
);
const setFontSize = useInfraMonitoringTablePreferencesStore(
(s) => s.setFontSize,
);
const visibleColumnItems = useMemo(
() =>
columnPickerItems.filter(
(column) => column.visibilityBehavior !== 'hidden-on-collapse',
),
[columnPickerItems],
);
const orderedVisibleColumnItems = useMemo(
() => sortByColumnOrder(visibleColumnItems, (col) => col.id, columnOrder),
[visibleColumnItems, columnOrder],
);
useLogEventForColumnCustomized({
entity,
source: 'list',
storageKey,
columns,
});
const handleToggleColumn = (columnId: string, checked: boolean): void => {
if (checked) {
showColumn(storageKey, columnId);
} else {
hideColumn(storageKey, columnId);
}
};
const handleLineClampChange = useCallback(
(value: ChangeEvent<HTMLInputElement>): void => {
const valueAsNumber = value.target.valueAsNumber;
if (value && Number.isInteger(valueAsNumber)) {
setLineClamp(valueAsNumber);
}
},
[setLineClamp],
);
const drawerContent = (
<>
<div className={styles.sectionTitle}>
<Typography.Text size="sm" className={styles.sectionTitleText}>
Font Size
</Typography.Text>
</div>
<div className={styles.fontSizeContainer}>
{FONT_SIZE_OPTIONS.map((option) => (
<Button
key={option.value}
variant="ghost"
color="none"
className={styles.fontSizeOption}
data-testid={`font-size-${option.value}`}
onClick={(): void => setFontSize(option.value)}
>
{option.label}
{fontSize === option.value && (
<Check size={14} className={styles.checkIcon} />
)}
</Button>
))}
</div>
<div className={styles.horizontalDivider} />
<div className={styles.sectionTitle}>
<Typography.Text size="sm" className={styles.sectionTitleText}>
Max lines per row
</Typography.Text>
</div>
<div className={styles.lineClampContainer}>
<Input
min={1}
max={10}
value={lineClamp}
onChange={handleLineClampChange}
data-testid="line-clamp-input"
type="number"
prefix={
<Button
variant="solid"
color="primary"
size="sm"
className={styles.lineClampButton}
data-testid="line-clamp-decrease"
onClick={decreaseLineClamp}
prefix={<Minus />}
disabled={lineClamp <= 1}
/>
}
suffix={
<Button
variant="solid"
color="primary"
size="sm"
className={styles.lineClampButton}
data-testid="line-clamp-increase"
onClick={increaseLineClamp}
prefix={<Plus />}
disabled={lineClamp >= 10}
/>
}
/>
</div>
<div className={styles.horizontalDivider} />
<div className={styles.sectionTitle}>
<Typography.Text size="sm" className={styles.sectionTitleText}>
Columns
</Typography.Text>
</div>
<div className={styles.columnsList}>
{orderedVisibleColumnItems.map((column) => {
const isVisible = !hiddenColumnIds.includes(column.id);
const switchElement = (
<Switch
value={isVisible}
disabled={!column.canBeHidden}
data-testid={`toggle-column-${column.id}`}
onChange={(checked): void => handleToggleColumn(column.id, checked)}
/>
);
return (
<div className={styles.columnItem} key={column.id}>
<Typography.Text size="sm" className={styles.columnLabel}>
{column.label}
</Typography.Text>
{column.canBeHidden ? (
switchElement
) : (
<TooltipSimple title="Required column cannot be hidden" arrow>
{switchElement}
</TooltipSimple>
)}
</div>
);
})}
</div>
</>
);
return (
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="Options"
direction="right"
width="narrow"
showCloseButton
showOverlay={false}
className={styles.drawer}
>
{drawerContent}
</DrawerWrapper>
);
}
export default K8sOptionsSidePanel;

View File

@@ -0,0 +1,65 @@
.toolbar {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: 0px var(--spacing-4) var(--spacing-4) var(--spacing-4);
background: var(--l1-background);
border-bottom: 1px solid var(--l1-border);
}
.groupByContainer {
display: flex;
align-items: center;
min-width: 300px;
max-width: 400px;
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--l2-border) !important;
background-color: var(--l2-background) !important;
input {
font-size: 12px;
}
:global([data-slot='badge'] .ant-typography) {
font-size: 12px;
}
}
}
.groupByLabel {
min-width: max-content;
border-top-left-radius: 2px;
border-bottom-left-radius: 2px;
border: 1px solid var(--l2-border);
border-right: none;
display: flex;
height: 32px;
padding: var(--spacing-3) var(--spacing-3) var(--spacing-3) var(--spacing-4);
justify-content: center;
align-items: center;
gap: var(--spacing-2);
}
.groupBySelect {
width: 100%;
:global(.ant-select-selector) {
border-left: none;
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;
}
}
.spacer {
flex: 1;
}
.toolbarButton {
flex-shrink: 0;
--button-padding: 0px;
}

View File

@@ -0,0 +1,112 @@
import { useCallback } from 'react';
import { Button } from '@signozhq/ui/button';
import { Select } from 'antd';
import { Download, SlidersVertical } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import {
InfraMonitoringEvents,
logInfraGroupByCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEntity } from '../constants';
import {
useInfraMonitoringGroupBy,
useInfraMonitoringPageListing,
} from '../hooks';
import { useInfraMonitoringGroupByData } from './useInfraMonitoringGroupByData';
import styles from './K8sTableToolbar.module.scss';
interface K8sTableToolbarProps {
entity: InfraMonitoringEntity;
eventCategory: InfraMonitoringEvents;
leftFilters?: React.ReactNode;
onOpenOptionsDrawer: () => void;
onDownload?: () => void;
}
function K8sTableToolbar({
entity,
eventCategory,
leftFilters,
onOpenOptionsDrawer,
onDownload,
}: K8sTableToolbarProps): JSX.Element {
const { groupByOptions, isLoading: isLoadingGroupByFilters } =
useInfraMonitoringGroupByData(entity);
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const handleGroupByChange = useCallback(
(value: string[]) => {
void setCurrentPage(1);
void setGroupBy(value);
void logEvent(InfraMonitoringEvents.GroupByChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
logInfraGroupByCustomizedEvent(entity, value);
},
[entity, eventCategory, setCurrentPage, setGroupBy],
);
return (
<div className={styles.toolbar}>
<div className={styles.groupByContainer}>
<div className={styles.groupByLabel}>Group by</div>
<Select
className={styles.groupBySelect}
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}
allowClear
maxTagCount="responsive"
placeholder="Search for attribute"
options={groupByOptions}
onChange={handleGroupByChange}
/>
</div>
<div className={styles.spacer} />
{leftFilters}
{onDownload && (
<TooltipSimple title="Download">
<Button
type="button"
variant="ghost"
size="icon"
color="secondary"
data-testid="k8s-table-download-button"
onClick={onDownload}
className={styles.toolbarButton}
>
<Download size={14} />
</Button>
</TooltipSimple>
)}
<TooltipSimple title="Options">
<Button
type="button"
variant="ghost"
size="icon"
color="secondary"
data-testid="k8s-table-options-button"
onClick={onOpenOptionsDrawer}
className={styles.toolbarButton}
>
<SlidersVertical size={14} />
</Button>
</TooltipSimple>
</div>
);
}
export default K8sTableToolbar;

View File

@@ -0,0 +1,187 @@
/* eslint-disable no-restricted-syntax */
import { act, renderHook } from '@testing-library/react';
import { TableColumnDef, useColumnStore } from 'components/TanStackTableView';
import { logInfraColumnCustomizedEvent } from 'constants/events';
import { InfraMonitoringEntity } from '../../constants';
import { useInfraMonitoringTablePreferencesStore } from '../useInfraMonitoringTablePreferencesStore';
import { useLogEventForColumnCustomized } from '../useLogEventForColumnCustomized';
jest.mock('constants/events', () => ({
logInfraColumnCustomizedEvent: jest.fn(),
}));
const mockLogEvent = logInfraColumnCustomizedEvent as jest.Mock;
type TestRow = { id: string; name: string };
const col = (id: string): TableColumnDef<TestRow> => ({
id,
header: id,
cell: (): null => null,
});
const columns = [col('a'), col('b'), col('c')];
describe('useLogEventForColumnCustomized', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
useInfraMonitoringTablePreferencesStore.setState({
lineClamp: 1,
fontSize: 'medium',
});
localStorage.clear();
mockLogEvent.mockClear();
});
it('does not emit on mount', () => {
const storageKey = 'test-no-emit-on-mount';
act(() => {
useColumnStore.getState().initializeFromDefaults(storageKey, columns);
});
renderHook(() =>
useLogEventForColumnCustomized({
entity: InfraMonitoringEntity.PODS,
source: 'list',
storageKey,
columns,
}),
);
expect(mockLogEvent).not.toHaveBeenCalled();
});
it('emits when a column is hidden after mount', () => {
const storageKey = 'test-emit-on-hide';
act(() => {
useColumnStore.getState().initializeFromDefaults(storageKey, columns);
});
renderHook(() =>
useLogEventForColumnCustomized({
entity: InfraMonitoringEntity.PODS,
source: 'list',
storageKey,
columns,
}),
);
act(() => {
useColumnStore.getState().hideColumn(storageKey, 'b');
});
expect(mockLogEvent).toHaveBeenCalledTimes(1);
expect(mockLogEvent).toHaveBeenCalledWith(
InfraMonitoringEntity.PODS,
['a', 'c'],
'medium',
1,
'list',
);
});
it('emits when font size changes after mount', () => {
const storageKey = 'test-emit-on-font-size';
act(() => {
useColumnStore.getState().initializeFromDefaults(storageKey, columns);
});
renderHook(() =>
useLogEventForColumnCustomized({
entity: InfraMonitoringEntity.PODS,
source: 'list',
storageKey,
columns,
}),
);
act(() => {
useInfraMonitoringTablePreferencesStore.getState().setFontSize('small');
});
expect(mockLogEvent).toHaveBeenCalledTimes(1);
});
// Regression: K8sDynamicList keeps K8sBaseList (and the options side panel)
// mounted across category switches, so this hook survives a Pods -> Nodes
// switch with every input changed. The switch itself is not a customization
// and must not emit.
it('does not emit when the storage key changes (category switch)', () => {
const podsKey = 'test-switch-pods';
const nodesKey = 'test-switch-nodes';
const nodeColumns = [col('x'), col('y')];
act(() => {
useColumnStore.getState().initializeFromDefaults(podsKey, columns);
useColumnStore.getState().initializeFromDefaults(nodesKey, nodeColumns);
});
const { rerender } = renderHook(
(props) => useLogEventForColumnCustomized(props),
{
initialProps: {
entity: InfraMonitoringEntity.PODS,
source: 'list' as const,
storageKey: podsKey,
columns,
},
},
);
rerender({
entity: InfraMonitoringEntity.NODES,
source: 'list' as const,
storageKey: nodesKey,
columns: nodeColumns,
});
expect(mockLogEvent).not.toHaveBeenCalled();
// A real customization after the switch still emits, for the new entity
act(() => {
useColumnStore.getState().hideColumn(nodesKey, 'y');
});
expect(mockLogEvent).toHaveBeenCalledTimes(1);
expect(mockLogEvent).toHaveBeenCalledWith(
InfraMonitoringEntity.NODES,
['x'],
'medium',
1,
'list',
);
});
// Regression: every expanded group row mounts its own instance sharing the
// `k8s-<entity>-columns-expanded` storage key; one customization must not
// emit once per row.
it('emits once when multiple instances share a storage key', () => {
const storageKey = 'test-shared-key';
act(() => {
useColumnStore.getState().initializeFromDefaults(storageKey, columns);
});
const params = {
entity: InfraMonitoringEntity.PODS,
source: 'expanded' as const,
storageKey,
columns,
};
renderHook(() => useLogEventForColumnCustomized(params));
renderHook(() => useLogEventForColumnCustomized(params));
renderHook(() => useLogEventForColumnCustomized(params));
act(() => {
useColumnStore.getState().hideColumn(storageKey, 'a');
});
expect(mockLogEvent).toHaveBeenCalledTimes(1);
// A subsequent distinct customization emits again, exactly once
act(() => {
useColumnStore.getState().hideColumn(storageKey, 'b');
});
expect(mockLogEvent).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,7 +1,9 @@
import { Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { Compass } from '@signozhq/icons';
import { TextNoData } from '../../../components/TextNoData';
import { logInfraExplorerNavigatedEvent } from 'constants/events';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
@@ -13,6 +15,7 @@ import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../../../constants';
import { getDrawerDurationMs } from '../../useDrawerLifecycleStore';
import styles from './EntityCountsSection.module.scss';
export interface EntityCountConfig<T> {
@@ -27,6 +30,8 @@ interface EntityCountsSectionProps<T> {
selectedItem: string;
filterExpression: string;
closeDrawer: () => void;
entityType: InfraMonitoringEntity;
activeTab: string;
}
export function EntityCountsSection<T>({
@@ -35,7 +40,21 @@ export function EntityCountsSection<T>({
selectedItem,
filterExpression,
closeDrawer,
entityType,
activeTab,
}: EntityCountsSectionProps<T>): JSX.Element {
const handleCardNavigate = (cardLabel: string): void => {
logInfraExplorerNavigatedEvent({
entityType,
destination: 'k8s_list',
source: 'stats_card',
tab: activeTab,
sourceKey: cardLabel,
drawerDurationMsAtNavigation: getDrawerDurationMs(),
});
closeDrawer();
};
const buildNavigationUrl = (targetCategory: InfraMonitoringEntity): string => {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
@@ -116,17 +135,26 @@ export function EntityCountsSection<T>({
>
{config.label}
</Typography.Text>
<Typography.Text className={styles.countValue} size="xl" weight="semibold">
{config.getValue(entity) || '-'}
</Typography.Text>
{config.getValue(entity) ? (
<Typography.Text
className={styles.countValue}
size="xl"
weight="semibold"
>
{config.getValue(entity)}
</Typography.Text>
) : (
<TextNoData type="typography" className={styles.countValue} />
)}
<Link
to={buildNavigationUrl(config.targetCategory)}
onClick={closeDrawer}
onClick={(): void => handleCardNavigate(config.label)}
data-testid={`navigate-${config.label.toLowerCase().replace(/\s+/g, '-')}`}
>
<Tooltip
<TooltipSimple
title={`View ${config.label.toLowerCase()} of '${selectedItem}'`}
placement="top"
side="top"
arrow
>
<Button
size="icon"
@@ -135,7 +163,7 @@ export function EntityCountsSection<T>({
className={styles.navigateButton}
prefix={<Compass size={14} />}
/>
</Tooltip>
</TooltipSimple>
</Link>
</div>
))}

View File

@@ -0,0 +1,23 @@
import { create } from 'zustand';
export interface IDrawerLifecycleStore {
openedAt: number | null;
markOpened: () => void;
markClosed: () => void;
getDrawerDurationMs: () => number | null;
}
export const useDrawerLifecycleStore = create<IDrawerLifecycleStore>()(
(set, get) => ({
openedAt: null,
markOpened: (): void => set({ openedAt: Date.now() }),
markClosed: (): void => set({ openedAt: null }),
getDrawerDurationMs: (): number | null => {
const { openedAt } = get();
return openedAt == null ? null : Date.now() - openedAt;
},
}),
);
export const getDrawerDurationMs = (): number | null =>
useDrawerLifecycleStore.getState().getDrawerDurationMs();

View File

@@ -0,0 +1,73 @@
import { useMemo } from 'react';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import {
InfraMonitoringEntity,
METRIC_NAMESPACE_BY_ENTITY,
} from '../constants';
export interface GroupByOption {
value: string;
label: string;
}
export interface UseInfraMonitoringGroupByDataReturn {
groupByOptions: GroupByOption[];
isLoading: boolean;
}
export function useInfraMonitoringGroupByData(
entity: InfraMonitoringEntity,
): UseInfraMonitoringGroupByDataReturn {
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const { minTime, maxTime } = getMinMaxTime();
const startUnixMilli = Math.floor(minTime / NANO_SECOND_MULTIPLIER);
const endUnixMilli = Math.floor(maxTime / NANO_SECOND_MULTIPLIER);
const { data: groupByFiltersData, isLoading } = useGetFieldsKeys(
{
signal: TelemetrytypesSignalDTO.metrics,
metricNamespace: METRIC_NAMESPACE_BY_ENTITY[entity],
limit: 100,
startUnixMilli,
endUnixMilli,
fieldContext: TelemetrytypesFieldContextDTO.resource,
},
{
query: {
queryKey: ['getFieldsKeys', entity, startUnixMilli, endUnixMilli],
},
},
);
const groupByOptions = useMemo(() => {
const keys = groupByFiltersData?.data?.keys;
if (!keys) {
return [];
}
const allKeys = Object.values(keys).flat();
const seen = new Set<string>();
return allKeys
.filter((field) => {
if (seen.has(field.name)) {
return false;
}
seen.add(field.name);
return true;
})
.map((field) => ({
value: field.name,
label: field.name,
}));
}, [groupByFiltersData]);
return { groupByOptions, isLoading };
}

View File

@@ -0,0 +1,58 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { CellTypographySize } from 'components/TanStackTableView';
export type FontSize = CellTypographySize;
export interface IInfraMonitoringTablePreferencesStore {
lineClamp: number;
fontSize: FontSize;
increaseLineClamp: () => void;
decreaseLineClamp: () => void;
setLineClamp: (value: number) => void;
setFontSize: (value: FontSize) => void;
}
const MIN_LINE_CLAMP = 1;
const MAX_LINE_CLAMP = 10;
const DEFAULT_LINE_CLAMP = 1;
const DEFAULT_FONT_SIZE: FontSize = 'medium';
export const useInfraMonitoringTablePreferencesStore =
create<IInfraMonitoringTablePreferencesStore>()(
persist(
(set, get) => ({
lineClamp: DEFAULT_LINE_CLAMP,
fontSize: DEFAULT_FONT_SIZE,
increaseLineClamp: (): void => {
const current = get().lineClamp;
if (current < MAX_LINE_CLAMP) {
set({ lineClamp: current + 1 });
}
},
decreaseLineClamp: (): void => {
const current = get().lineClamp;
if (current > MIN_LINE_CLAMP) {
set({ lineClamp: current - 1 });
}
},
setLineClamp: (value: number): void => {
if (value >= MIN_LINE_CLAMP && value <= MAX_LINE_CLAMP) {
set({ lineClamp: value });
}
},
setFontSize: (value: FontSize): void => {
set({ fontSize: value });
},
}),
{
name: '@signoz/infra-monitoring-table-preferences',
},
),
);
export const useInfraMonitoringLineClamp = (): number =>
useInfraMonitoringTablePreferencesStore((s) => s.lineClamp);
export const useInfraMonitoringFontSize = (): FontSize =>
useInfraMonitoringTablePreferencesStore((s) => s.fontSize);

View File

@@ -0,0 +1,89 @@
import { useEffect, useMemo, useRef } from 'react';
import {
TableColumnDef,
useColumnOrder,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import { logInfraColumnCustomizedEvent } from 'constants/events';
import { InfraMonitoringEntity } from '../constants';
import {
useInfraMonitoringFontSize,
useInfraMonitoringLineClamp,
} from './useInfraMonitoringTablePreferencesStore';
import { sortByColumnOrder } from './utils';
interface UseEmitColumnCustomizedParams<TData> {
entity: InfraMonitoringEntity;
source: 'list' | 'expanded';
storageKey: string;
columns: TableColumnDef<TData>[];
}
// Multiple instances of this hook can share a storageKey (every expanded group
// row mounts one), so the last emitted payload is tracked per key to collapse
// their simultaneous effect runs into a single event.
const lastEmittedPayloadByKey = new Map<string, string>();
export function useLogEventForColumnCustomized<TData>({
entity,
source,
storageKey,
columns,
}: UseEmitColumnCustomizedParams<TData>): void {
const hiddenColumnIds = useHiddenColumnIds(storageKey);
const columnOrder = useColumnOrder(storageKey);
const fontSize = useInfraMonitoringFontSize();
const lineClamp = useInfraMonitoringLineClamp();
const hiddenBehavior =
source === 'expanded' ? 'hidden-on-expand' : 'hidden-on-collapse';
const orderedVisibleColumnIds = useMemo(() => {
const visibleColumns = columns.filter(
(col) =>
(col.visibilityBehavior ?? 'always-visible') !== hiddenBehavior &&
!hiddenColumnIds.includes(col.id),
);
return sortByColumnOrder(visibleColumns, (col) => col.id, columnOrder).map(
(col) => col.id,
);
}, [columns, hiddenColumnIds, columnOrder, hiddenBehavior]);
const isFirstRender = useRef(true);
const prevStorageKeyRef = useRef(storageKey);
// Re-arm the first-render skip when the table identity changes (the hook
// survives category switches because K8sDynamicList keeps K8sBaseList
// mounted), so the switch itself is not reported as a customization
if (prevStorageKeyRef.current !== storageKey) {
prevStorageKeyRef.current = storageKey;
isFirstRender.current = true;
}
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
const dedupeKey = `${storageKey}:${source}`;
const payload = JSON.stringify([
entity,
orderedVisibleColumnIds,
fontSize,
lineClamp,
]);
if (lastEmittedPayloadByKey.get(dedupeKey) === payload) {
return;
}
lastEmittedPayloadByKey.set(dedupeKey, payload);
logInfraColumnCustomizedEvent(
entity,
orderedVisibleColumnIds,
fontSize,
lineClamp,
source,
);
}, [entity, source, storageKey, orderedVisibleColumnIds, fontSize, lineClamp]);
}

View File

@@ -9,6 +9,22 @@ import {
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
export function sortByColumnOrder<T>(
items: T[],
getId: (item: T) => string,
columnOrder: string[],
): T[] {
if (columnOrder.length === 0) {
return items;
}
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
return [...items].sort(
(a, b) =>
(orderIndex.get(getId(a)) ?? Number.MAX_SAFE_INTEGER) -
(orderIndex.get(getId(b)) ?? Number.MAX_SAFE_INTEGER),
);
}
export function getGroupedByMeta<
T extends { meta?: Record<string, string> | null },
>(itemData: T, groupBy: string[]): Record<string, string> {

View File

@@ -10,8 +10,8 @@ import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
CellValueTooltip,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
@@ -75,10 +75,9 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const clusterName = value as string;
return <CellValueTooltip value={clusterName} />;
},
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
},
{
id: 'nodeCountsByReadiness',
@@ -95,7 +94,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
enableSort: false,
cell: ({ row, rowId }): React.ReactNode => {
if (!row.nodeCountsByReadiness) {
return <TanStackTable.Text>-</TanStackTable.Text>;
return <TextNoData type="tanstack" />;
}
return (
@@ -133,7 +132,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
@@ -147,17 +146,17 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
id: 'cpu',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/clusters#cpu-usage-cores">
CPU Usage
<br /> (cores)
CPU Usage (cores)
</ColumnHeader>
),
accessorFn: (row): number => row.clusterCPU,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpu = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpu}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="CPU metric"
@@ -171,17 +170,17 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
id: 'cpu_allocatable',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/clusters#cpu-alloc-cores">
CPU Allocatable
<br /> (cores)
CPU Allocatable (cores)
</ColumnHeader>
),
accessorFn: (row): number => row.clusterCPUAllocatable,
width: { min: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpuAllocatable = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpuAllocatable}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="CPU allocatable metric"
@@ -195,17 +194,17 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
id: 'memory',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/clusters#memory-usage-wss">
Memory Usage
<br /> (WSS)
Memory Usage (WSS)
</ColumnHeader>
),
accessorFn: (row): number => row.clusterMemory,
width: { min: 180 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memory}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="memory metric"
@@ -219,17 +218,17 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
id: 'memory_allocatable',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/clusters#memory-allocatable">
Memory
<br /> Allocatable
Memory Allocatable
</ColumnHeader>
),
accessorFn: (row): number => row.clusterMemoryAllocatable,
width: { min: 180 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memoryAllocatable = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memoryAllocatable}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="memory allocatable metric"

View File

@@ -9,9 +9,9 @@ import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
CellValueTooltip,
EntityProgressBar,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
@@ -85,10 +85,9 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const daemonsetName = value as string;
return <CellValueTooltip value={daemonsetName} />;
},
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
},
{
id: 'namespaceName',
@@ -102,10 +101,9 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
width: { min: 160 },
enableSort: false,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return <CellValueTooltip value={namespaceName} />;
},
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
},
{
id: 'pod_counts_by_status',
@@ -124,7 +122,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
@@ -177,8 +175,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-req-usage-">
CPU Request
<br /> Usage (%)
CPU Request Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.daemonSetCPURequest,
@@ -186,10 +183,11 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpuRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Request"
@@ -203,18 +201,18 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-limit-usage-">
CPU Limit
<br /> Usage (%)
CPU Limit Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.daemonSetCPULimit,
width: { min: 160 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpuLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Limit"
@@ -228,8 +226,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
id: 'cpu',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-usage-cores">
CPU Usage
<br /> (cores)
CPU Usage (cores)
</ColumnHeader>
),
accessorFn: (row): number => row.daemonSetCPU,
@@ -237,10 +234,11 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpu = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpu}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU metric"
@@ -254,8 +252,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-req-usage-">
Memory Request
<br /> Usage (%)
Memory Request Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.daemonSetMemoryRequest,
@@ -263,10 +260,11 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memoryRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Request"
@@ -280,18 +278,18 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-limit-usage-">
Memory Limit
<br /> Usage (%)
Memory Limit Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.daemonSetMemoryLimit,
width: { min: 190 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memoryLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Limit"
@@ -305,18 +303,18 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
id: 'memory',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-usage-wss">
Memory Usage
<br /> (WSS)
Memory Usage (WSS)
</ColumnHeader>
),
accessorFn: (row): number => row.daemonSetMemory,
width: { min: 180 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memory}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="memory metric"
@@ -337,10 +335,11 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const readyNodes = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={readyNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="ready node"
@@ -361,10 +360,11 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const currentNodes = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={currentNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="current node"
@@ -385,10 +385,11 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const desiredNodes = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={desiredNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="desired node"
@@ -409,10 +410,11 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const misscheduledNodes = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={misscheduledNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="misscheduled node"

View File

@@ -9,9 +9,9 @@ import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
CellValueTooltip,
EntityProgressBar,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
@@ -86,10 +86,9 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const deploymentName = value as string;
return <CellValueTooltip value={deploymentName} />;
},
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
},
{
id: 'namespaceName',
@@ -121,7 +120,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
@@ -164,8 +163,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-req-usage-">
CPU Request
<br /> Usage (%)
CPU Request Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.deploymentCPURequest,
@@ -173,10 +171,11 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpuRequest}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Request"
@@ -190,18 +189,18 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-limit-usage-">
CPU Limit
<br /> Usage (%)
CPU Limit Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.deploymentCPULimit,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpuLimit = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpuLimit}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Limit"
@@ -215,18 +214,18 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
id: 'cpu',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-usage-cores">
CPU Usage
<br /> (cores)
CPU Usage (cores)
</ColumnHeader>
),
accessorFn: (row): number => row.deploymentCPU,
width: { min: 160 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpu = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpu}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU metric"
@@ -240,8 +239,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-req-usage-">
Memory Request
<br /> Usage (%)
Memory Request Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.deploymentMemoryRequest,
@@ -249,10 +247,11 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memoryRequest = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memoryRequest}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Request"
@@ -266,18 +265,18 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-limit-usage-">
Memory Limit
<br /> Usage (%)
Memory Limit Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.deploymentMemoryLimit,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memoryLimit = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memoryLimit}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Limit"
@@ -291,18 +290,18 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
id: 'memory',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-usage-wss">
Memory Usage
<br /> (WSS)
Memory Usage (WSS)
</ColumnHeader>
),
accessorFn: (row): number => row.deploymentMemory,
width: { min: 180 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memory}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="memory metric"
@@ -323,10 +322,11 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
width: { min: 120 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const availablePods = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={availablePods}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="available pod"
@@ -347,10 +347,11 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
width: { min: 120 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const desiredPods = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={desiredPods}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="desired pod"

View File

@@ -0,0 +1,5 @@
.container {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -3,7 +3,10 @@ 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 {
InfraMonitoringEvents,
logInfraDrawerTimeRangeCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
@@ -13,6 +16,8 @@ import {
import { useEntityDetailsTime } from './useEntityDetailsTime';
import styles from './EntityDateTimeSelector.module.scss';
interface EntityDateTimeSelectorProps {
eventEntity: string;
category: InfraMonitoringEntity;
@@ -41,13 +46,14 @@ function EntityDateTimeSelector({
view,
interval,
});
logInfraDrawerTimeRangeCustomizedEvent(category, interval);
handleTimeChange(interval, dateTimeRange);
},
[category, view, eventEntity, handleTimeChange],
);
return (
<div className="entity-date-time-selector">
<div className={styles.container}>
{hasTimeChanged && (
<TooltipSimple title="Reset to list time" side="bottom">
<Button

View File

@@ -16,7 +16,10 @@ import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import {
InfraMonitoringEvents,
logInfraDrawerFilterCustomizedEvent,
} from 'constants/events';
import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
@@ -124,6 +127,13 @@ function EntityEventsContent({
view: InfraMonitoringEvents.EventsView,
});
logInfraDrawerFilterCustomizedEvent(
category,
'events',
newUserExpression || '',
'search',
);
refetch();
}
},

View File

@@ -73,7 +73,7 @@
.listContainer {
flex: 1;
height: calc(100vh - 312px) !important;
height: calc(100vh - 330px) !important;
display: flex;
height: 100%;

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
@@ -20,7 +20,10 @@ import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import {
InfraMonitoringEvents,
logInfraDrawerFilterCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
@@ -42,6 +45,14 @@ import { K8S_ENTITY_LOGS_EXPRESSION_KEY, useInfiniteEntityLogs } from './hooks';
import { getEntityLogsQueryPayload } from './utils';
import styles from './EntityLogs.module.scss';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import createQueryParams from 'lib/createQueryParams';
import { isModifierKeyPressed } from 'utils/app';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
interface Props {
eventEntity: string;
@@ -57,6 +68,9 @@ function EntityLogsContent({
}: Omit<Props, 'initialExpression'>): JSX.Element {
const { timeRange } = useEntityDetailsTime();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const logDetailContainerRef = useRef<HTMLDivElement>(null);
const { safeNavigate } = useSafeNavigate();
const expression = useExpression();
const inputExpression = useInputExpression();
@@ -85,8 +99,10 @@ function EntityLogsContent({
: partExpression;
querySearchOnRun(newUser);
logInfraDrawerFilterCustomizedEvent(category, 'logs', newUser, 'logs');
},
[userExpression, querySearchOnRun, handleCloseLogDetail],
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
);
const {
@@ -127,6 +143,13 @@ function EntityLogsContent({
view: InfraMonitoringEvents.LogsView,
});
logInfraDrawerFilterCustomizedEvent(
category,
'logs',
newUserExpression || '',
'search',
);
refetch();
}
},
@@ -148,6 +171,44 @@ function EntityLogsContent({
virtuosoRef,
});
const { updateAllQueriesOperators } = useQueryBuilder();
const handleOpenInExplorer = useCallback(
(e: React.MouseEvent<Element, MouseEvent>, log: ILog) => {
const baseQuery = updateAllQueriesOperators(
initialQueriesMap[DataSource.LOGS],
PANEL_TYPES.LIST,
DataSource.LOGS,
);
const queryParams = {
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
[QueryParams.compositeQuery]: JSON.stringify({
...baseQuery,
builder: {
...baseQuery.builder,
queryData: baseQuery.builder.queryData.map((item) => ({
...item,
filter: { expression },
})),
},
} satisfies Query),
};
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`, {
newTab: !!e && isModifierKeyPressed(e),
});
},
[
timeRange.startTime,
timeRange.endTime,
expression,
safeNavigate,
updateAllQueriesOperators,
],
);
const getItemContent = useCallback(
(_: number, logToRender: ILog): JSX.Element => {
return (
@@ -257,6 +318,7 @@ function EntityLogsContent({
{renderContent}
</div>
)}
<div ref={logDetailContainerRef} data-log-detail-ignore="true" />
{selectedTab && activeLog && (
<LogDetail
log={activeLog}
@@ -267,6 +329,10 @@ function EntityLogsContent({
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
handleOpenInExplorer={(e) => handleOpenInExplorer(e, activeLog)}
getContainer={(): HTMLElement =>
logDetailContainerRef.current || document.body
}
/>
)}
</div>

View File

@@ -2,6 +2,7 @@
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
width: 100%;
}
.infoIcon {
@@ -20,6 +21,7 @@
align-items: center;
color: var(--l2-foreground);
transition: opacity 0.2s;
margin-left: auto;
&:hover {
color: var(--l3-foreground);

View File

@@ -1,6 +1,6 @@
import { Link } from 'react-router-dom';
import { Compass, Info } from '@signozhq/icons';
import { Tooltip } from 'antd';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './ChartHeader.module.scss';
@@ -12,6 +12,7 @@ interface ChartHeaderProps {
tooltip?: string;
metricsExplorerUrl?: string;
metricsExplorerTestId?: string;
onExploreClick?: () => void;
}
function ChartHeader({
@@ -20,12 +21,13 @@ function ChartHeader({
tooltip,
metricsExplorerUrl,
metricsExplorerTestId = 'open-metrics-explorer',
onExploreClick,
}: ChartHeaderProps): JSX.Element {
const renderInfoIcon = (): React.ReactNode => {
if (docPath) {
const tooltipTitle = tooltip || 'Not sure what this represents?';
return (
<Tooltip
<TooltipSimple
arrow
title={
<>
@@ -44,17 +46,17 @@ function ChartHeader({
<span className={styles.infoIcon} data-testid="chart-header-info-icon">
<Info size="md" />
</span>
</Tooltip>
</TooltipSimple>
);
}
if (tooltip) {
return (
<Tooltip title={tooltip}>
<TooltipSimple title={tooltip} arrow>
<span className={styles.infoIcon} data-testid="chart-header-info-icon">
<Info size="md" />
</span>
</Tooltip>
</TooltipSimple>
);
}
@@ -66,15 +68,16 @@ function ChartHeader({
<span className={styles.chartHeaderLabel}>{title}</span>
{renderInfoIcon()}
{metricsExplorerUrl && (
<Tooltip title="Open in Metrics Explorer">
<TooltipSimple title="Go to Metrics Explorer" arrow>
<Link
to={metricsExplorerUrl}
className={styles.metricsExplorerLink}
data-testid={metricsExplorerTestId}
onClick={onExploreClick}
>
<Compass size={14} />
</Link>
</Tooltip>
</TooltipSimple>
)}
</div>
);

View File

@@ -2,11 +2,17 @@ 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 {
InfraMonitoringEvents,
logInfraExplorerNavigatedEvent,
} 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 {
InfraMonitoringEntity,
VIEW_TYPES,
} from 'container/InfraMonitoringK8sV2/constants';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -17,6 +23,8 @@ import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { getMetricsExplorerUrl } from 'utils/explorerUtils';
import { getDrawerDurationMs } from 'container/InfraMonitoringK8sV2/Base/useDrawerLifecycleStore';
import { buildEntityMetricsChartConfig } from './configBuilder';
import ChartHeader from './ChartHeader';
@@ -43,6 +51,7 @@ interface EntityMetricsProps<T> {
) => GetQueryResultsProps[];
queryKey: string;
category: InfraMonitoringEntity;
view?: string;
}
function EntityMetrics<T>({
@@ -52,6 +61,7 @@ function EntityMetrics<T>({
getEntityQueryPayload,
queryKey,
category,
view = VIEW_TYPES.METRICS,
}: EntityMetricsProps<T>): JSX.Element {
const { timeRange, selectedInterval, handleTimeChange } =
useEntityDetailsTime();
@@ -203,6 +213,16 @@ function EntityMetrics<T>({
: undefined
}
metricsExplorerTestId={`open-metrics-explorer-${idx}`}
onExploreClick={(): void =>
logInfraExplorerNavigatedEvent({
entityType: category,
destination: 'metrics_explorer',
source: 'chart_compass_icon',
tab: view,
sourceKey: entityWidgetInfo[idx].title,
drawerDurationMsAtNavigation: getDrawerDurationMs(),
})
}
/>
<div className={styles.entityMetricsCard} ref={graphRef}>
{renderCardContent(query, idx)}

View File

@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import * as appContextHooks from 'providers/App/App';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
@@ -300,14 +301,16 @@ const renderEntityMetrics = (overrides = {}): any => {
return render(
<MemoryRouter>
<EntityMetrics
entity={defaultProps.entity}
eventEntity="test"
entityWidgetInfo={defaultProps.entityWidgetInfo}
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
queryKey={defaultProps.queryKey}
category={defaultProps.category}
/>
<TooltipProvider>
<EntityMetrics
entity={defaultProps.entity}
eventEntity="test"
entityWidgetInfo={defaultProps.entityWidgetInfo}
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
queryKey={defaultProps.queryKey}
category={defaultProps.category}
/>
</TooltipProvider>
</MemoryRouter>,
);
};

View File

@@ -16,7 +16,10 @@ import {
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { ResizeTable } from 'components/ResizeTable';
import { InfraMonitoringEvents } from 'constants/events';
import {
InfraMonitoringEvents,
logInfraDrawerFilterCustomizedEvent,
} from 'constants/events';
import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
@@ -106,6 +109,13 @@ function EntityTracesContent({
view: InfraMonitoringEvents.TracesView,
});
logInfraDrawerFilterCustomizedEvent(
category,
'traces',
newUserExpression || '',
'search',
);
refetch();
}
},

View File

@@ -2,6 +2,7 @@ import { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { TextNoData } from '../../components/TextNoData';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import {
BlockLink,
@@ -135,12 +136,11 @@ export const getTraceListColumns = (
if (!isValidCode) {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography
className={styles.cellText}
data-novalue={numericCode === 0 || !statusCode}
>
{numericCode === 0 || !statusCode ? '-' : statusCode}
</Typography>
{numericCode === 0 || !statusCode ? (
<TextNoData type="typography" className={styles.cellText} />
) : (
<Typography className={styles.cellText}>{statusCode}</Typography>
)}
</BlockLink>
);
}

View File

@@ -63,6 +63,7 @@ export function createPodMetricsTab<T>({
getEntityQueryPayload={getQueryPayload}
queryKey={queryKey}
category={category}
view={VIEW_TYPES.POD_METRICS}
/>
),
};

View File

@@ -0,0 +1,103 @@
.entityDetailDrawer {
border-color: var(--l2-border) !important;
background: var(--l2-background) !important;
box-shadow: -4px 10px 16px 2px var(--l1-shadow);
width: 70%;
// TODO(H4ad): Improve this in a component level
max-width: 70% !important;
overscroll-behavior: contain;
&:focus-visible,
&:focus {
outline: none;
}
[data-slot='drawer-header'] {
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--l1-border);
background: var(--l2-background);
}
[data-slot='drawer-description'] {
display: flex;
flex: 1;
flex-direction: column;
overflow-y: auto;
padding: var(--spacing-8);
margin: 0;
min-width: 900px;
}
}
.closeButton {
--button-padding: var(--spacing-2);
}
.title {
font-family: var(--periscope-font-family-mono);
--typography-margin: 0px var(--spacing-4) 0px 0px;
}
.entityDetailsEntity {
display: flex;
flex-direction: column;
}
.entityDetailsGrid {
display: flex;
flex-direction: column;
}
.labelsRow,
.valuesRow {
display: grid;
grid-template-columns: 1.5fr 1.5fr 1.5fr 1.5fr;
gap: 30px;
align-items: center;
}
.labelsRow {
margin-bottom: var(--spacing-4);
}
.entityDetailsMetadataLabel {
letter-spacing: 0.44px;
text-transform: uppercase;
}
.entityDetailsMetadataValue {
font-family: var(--periscope-font-family-mono);
}
.viewsTabsContainer {
margin-top: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
[data-slot='toggle-group-item'] {
width: 114px;
}
}
.viewsTabs {
color: var(--l2-foreground);
}
.viewTitle {
display: flex;
gap: var(--margin-2);
align-items: center;
justify-content: center;
}
.compassButton {
width: 30px;
height: 30px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
box-shadow: 0 0 8px 0 var(--l1-shadow);
}

View File

@@ -1,462 +0,0 @@
// Entity Details
.entity-detail-drawer {
border-left: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
.ant-drawer-header {
padding: 8px 16px;
border-bottom: none;
align-items: stretch;
border-bottom: 1px solid var(--l1-border);
background: var(--l2-background);
}
.ant-drawer-close {
margin-inline-end: 0px;
}
.ant-drawer-body {
display: flex;
flex-direction: column;
padding: 16px;
}
.title {
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.radio-button {
display: flex;
align-items: center;
justify-content: center;
padding-top: var(--padding-1);
border: 1px solid var(--l1-border);
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
.entity-detail-drawer__entity {
.entity-details-grid {
.labels-row,
.values-row {
display: grid;
grid-template-columns: 1.5fr 1.5fr 1.5fr 1.5fr;
gap: 30px;
align-items: center;
}
.labels-row {
margin-bottom: 8px;
}
.entity-details-metadata-label {
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 163.636% */
letter-spacing: 0.44px;
text-transform: uppercase;
}
.entity-details-metadata-value {
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-tag {
margin: 0;
&.active {
color: var(--success-500);
background: var(--success-100);
border-color: var(--success-500);
}
&.inactive {
color: var(--error-500);
background: var(--error-100);
border-color: var(--error-500);
}
}
.progress-container {
width: 158px;
span {
font-weight: 600;
}
}
.ant-card {
&.ant-card-bordered {
border: 1px solid var(--l1-border) !important;
}
}
}
}
.tabs-and-search {
display: flex;
justify-content: space-between;
align-items: center;
margin: 16px 0;
.action-btn {
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
}
}
.views-tabs-container {
margin-top: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
.views-tabs {
color: var(--l2-foreground);
.view-title {
display: flex;
gap: var(--margin-2);
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-style: normal;
font-weight: var(--font-weight-normal);
}
> button {
border: 1px solid var(--l1-border);
width: 114px;
&::before {
background: var(--l1-border);
}
&[data-state='on'] {
background: var(--l1-border);
color: var(--l1-foreground);
border: 1px solid var(--l1-border);
&::before {
background: var(--l1-border);
}
}
}
}
.compass-button {
width: 30px;
height: 30px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
}
.ant-drawer-close {
padding: 0px;
}
}
.entity-metric-traces {
margin-top: 1rem;
.entity-metric-traces-header {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
gap: 8px;
padding: 12px;
border-radius: 3px;
border: 1px solid var(--l1-border);
.filter-section {
flex: 1;
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
[data-slot='badge'] .ant-typography {
font-size: 12px;
}
}
}
}
.entity-metric-traces-table {
.ant-table-content {
overflow: hidden !important;
}
.ant-table {
border-radius: 3px;
border: 1px solid var(--l1-border);
.ant-table-thead > tr > th {
padding: 12px;
font-weight: 500;
font-size: 12px;
line-height: 18px;
background: color-mix(in srgb, var(--bg-robin-200) 1%, transparent);
border-bottom: none;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px; /* 163.636% */
letter-spacing: 0.44px;
text-transform: uppercase;
&::before {
background-color: transparent;
}
}
.ant-table-thead > tr > th:has(.entityname-column-header) {
background: var(--l2-background);
}
.ant-table-cell {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--l1-foreground);
background: color-mix(in srgb, var(--bg-robin-200) 1%, transparent);
}
.ant-table-cell:has(.entityname-column-value) {
background: var(--l2-background);
}
.entityname-column-value {
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-style: normal;
font-weight: 600;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.status-cell {
.active-tag {
color: var(--bg-forest-500);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
}
.progress-container {
}
.ant-table-tbody > tr:hover > td {
background: color-mix(in srgb, var(--l1-foreground) 4%, transparent);
}
.ant-table-cell:first-child {
text-align: justify;
}
.ant-table-cell:nth-child(2) {
padding-left: 16px;
padding-right: 16px;
}
.ant-table-cell:nth-child(n + 3) {
padding-right: 24px;
}
.column-header-right {
text-align: right;
}
.ant-table-tbody > tr > td {
border-bottom: none;
}
.ant-table-thead
> tr
> th:not(:last-child):not(.ant-table-selection-column):not(
.ant-table-row-expand-icon-cell
):not([colspan])::before {
background-color: transparent;
}
.ant-empty-normal {
visibility: hidden;
}
}
.ant-table-container::after {
content: none;
}
}
}
.entity-metrics-logs-container {
margin-top: 1rem;
.filter-section {
flex: 1;
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
[data-slot='badge'] .ant-typography {
font-size: 12px;
}
}
}
.entity-metrics-logs-header {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 12px;
border-radius: 3px;
border: 1px solid var(--l1-border);
}
.entity-metrics-logs {
margin-top: 1rem;
.virtuoso-list {
overflow-y: hidden !important;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--l3-background);
}
.ant-row {
width: fit-content;
}
}
.skeleton-container {
height: 100%;
padding: 16px;
}
}
}
.entity-metrics-logs-list-container {
flex: 1;
height: calc(100vh - 272px) !important;
display: flex;
height: 100%;
.raw-log-content {
width: 100%;
text-wrap: inherit;
word-wrap: break-word;
}
}
.entity-metrics-logs-list-card {
width: 100%;
margin-top: 12px;
.ant-card-body {
padding: 0;
height: 100%;
width: 100%;
}
}
.logs-loading-skeleton {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 0;
.ant-skeleton-input-sm {
height: 18px;
}
}
.no-logs-found {
height: 50vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
box-sizing: border-box;
.ant-typography {
display: flex;
align-items: center;
gap: 16px;
}
}
.entity-date-time-selector {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -64,7 +64,7 @@
display: flex;
flex-direction: column;
align-items: stretch;
min-width: 595px;
min-width: 800px;
overflow-x: auto;
> * {

View File

@@ -1,9 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as Sentry from '@sentry/react';
import { Button, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import {
QuickFilterChangeEventData,
QuickFilterCheckboxUseFieldApis,
QuickFiltersSource,
} from 'components/QuickFilters/types';
@@ -49,7 +51,11 @@ import {
} from './hooks';
import styles from './InfraMonitoringK8s.module.scss';
import { InfraMonitoringEvents } from 'constants/events';
import {
logInfraFilterCustomizedEvent,
logInfraMonitoringListViewedEvent,
InfraMonitoringEvents,
} from 'constants/events';
import logEvent from 'api/common/logEvent';
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
@@ -120,12 +126,28 @@ export default function InfraMonitoringK8s(): JSX.Element {
category: selectedCategory,
view: InfraMonitoringEvents.QuickFiltersView,
});
// The category query param is a plain string; entities are validated by
// getEntityConfig before rendering
logInfraMonitoringListViewedEvent(selectedCategory as InfraMonitoringEntity);
}, [selectedCategory]);
const handleFilterVisibilityChange = useCallback((): void => {
setShowFilters((show) => !show);
}, []);
const handleQuickFilterChange = useCallback(
(data: QuickFilterChangeEventData): void => {
logInfraFilterCustomizedEvent(
selectedCategory as InfraMonitoringEntity,
'quick_filter',
data.expression,
data.filterItemKeys,
);
},
[selectedCategory],
);
const categories = useMemo(
() => [
{
@@ -218,14 +240,15 @@ export default function InfraMonitoringK8s(): JSX.Element {
<>
{!showFilters && (
<div className={styles.k8SOpenQuickFilters}>
<Button
className="periscope-btn ghost"
type="text"
size="small"
onClick={handleFilterVisibilityChange}
>
<Filter size={14} />
</Button>
<TooltipSimple title="Open Filters" arrow side="left">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleFilterVisibilityChange}
prefix={<Filter size={14} />}
/>
</TooltipSimple>
</div>
)}
</>
@@ -246,13 +269,13 @@ export default function InfraMonitoringK8s(): JSX.Element {
Viewing · Resource
</Typography.Text>
<div className={styles.sectionLine} />
<Tooltip title="Collapse Filters">
<TooltipSimple title="Collapse Filters" arrow>
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
</TooltipSimple>
</div>
<div className={styles.categoryCard}>
<div className={styles.categoryList}>
@@ -289,6 +312,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
config={selectedCategoryConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={selectedCategoryUseFieldApis}
onQuickFilterChange={handleQuickFilterChange}
/>
)}
</div>

View File

@@ -9,9 +9,9 @@ import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
CellValueTooltip,
EntityProgressBar,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
@@ -79,10 +79,9 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const jobName = value as string;
return <CellValueTooltip value={jobName} />;
},
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
},
{
id: 'namespaceName',
@@ -96,10 +95,9 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
width: { min: 160 },
enableSort: false,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return <CellValueTooltip value={namespaceName} />;
},
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
},
{
id: 'pod_counts_by_status',
@@ -116,7 +114,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
@@ -129,7 +127,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
{
id: 'completion',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#completion">
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#completions">
Completions
</ColumnHeader>
),
@@ -161,8 +159,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-req-usage-">
CPU Request
<br /> Usage (%)
CPU Request Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.jobCPURequest,
@@ -170,13 +167,14 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.JOBS}
attribute="CPU Request"
rowId={rowId}
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
@@ -187,21 +185,21 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-limit-usage-">
CPU Limit
<br /> Usage (%)
CPU Limit Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.jobCPULimit,
width: { min: 200, default: 200 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.JOBS}
attribute="CPU Limit"
rowId={rowId}
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
@@ -212,21 +210,21 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
id: 'cpu',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-usage-cores">
CPU Usage
<br /> (cores)
CPU Usage (cores)
</ColumnHeader>
),
accessorFn: (row): number => row.jobCPU,
width: { min: 160 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpu = Number(value);
return (
<ValidateColumnValueWrapper
value={cpu}
entity={InfraMonitoringEntity.JOBS}
attribute="CPU metric"
rowId={rowId}
>
<TanStackTable.Text>{cpu.toFixed(2)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
@@ -237,8 +235,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#mem-req-usage-">
Memory Request
<br /> Usage (%)
Memory Request Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.jobMemoryRequest,
@@ -246,13 +243,14 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.JOBS}
attribute="Memory Request"
rowId={rowId}
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
@@ -263,21 +261,21 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#mem-limit-usage-">
Memory Limit
<br /> Usage (%)
Memory Limit Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.jobMemoryLimit,
width: { min: 180 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.JOBS}
attribute="Memory Limit"
rowId={rowId}
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
@@ -288,21 +286,21 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
id: 'memory',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#mem-usage-wss">
Memory Usage
<br /> (WSS)
Memory Usage (WSS)
</ColumnHeader>
),
accessorFn: (row): number => row.jobMemory,
width: { min: 160 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
value={memory}
entity={InfraMonitoringEntity.JOBS}
attribute="memory metric"
rowId={rowId}
>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
@@ -320,13 +318,14 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
width: { min: 100 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const activePods = value as number;
return (
<ValidateColumnValueWrapper
value={activePods}
entity={InfraMonitoringEntity.JOBS}
attribute="active pod"
rowId={rowId}
>
<TanStackTable.Text>{activePods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
@@ -344,13 +343,14 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
width: { min: 100 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const failedPods = value as number;
return (
<ValidateColumnValueWrapper
value={failedPods}
entity={InfraMonitoringEntity.JOBS}
attribute="failed pod"
rowId={rowId}
>
<TanStackTable.Text>{failedPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
@@ -368,13 +368,14 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
width: { min: 120 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const successfulPods = value as number;
return (
<ValidateColumnValueWrapper
value={successfulPods}
entity={InfraMonitoringEntity.JOBS}
attribute="successful pod"
rowId={rowId}
>
<TanStackTable.Text>{successfulPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
@@ -392,13 +393,14 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
width: { min: 160 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const desiredSuccessfulPods = value as number;
return (
<ValidateColumnValueWrapper
value={desiredSuccessfulPods}
entity={InfraMonitoringEntity.JOBS}
attribute="desired successful pod"
rowId={rowId}
>
<TanStackTable.Text>{desiredSuccessfulPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>

View File

@@ -8,8 +8,8 @@ import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
CellValueTooltip,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
@@ -81,10 +81,9 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return <CellValueTooltip value={namespaceName} />;
},
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
},
{
id: 'clusterName',
@@ -117,7 +116,7 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
@@ -137,13 +136,14 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
accessorFn: (row): number => row.namespaceCPU,
width: { min: 190 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpu = Number(value);
return (
<ValidateColumnValueWrapper
value={cpu}
entity={InfraMonitoringEntity.NAMESPACES}
attribute="CPU metric"
rowId={rowId}
>
<TanStackTable.Text>{cpu.toFixed(2)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
@@ -160,13 +160,14 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
accessorFn: (row): number => row.namespaceMemory,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memory = Number(value);
return (
<ValidateColumnValueWrapper
value={memory}
entity={InfraMonitoringEntity.NAMESPACES}
attribute="memory metric"
rowId={rowId}
>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>

View File

@@ -10,8 +10,8 @@ import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import {
CellValueTooltip,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import { InfraMonitoringEntity } from '../constants';
@@ -83,10 +83,9 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const nodeName = value as string;
return <CellValueTooltip value={nodeName} />;
},
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
},
{
id: 'condition',
@@ -102,6 +101,11 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
if (!groupMeta) {
const color =
NODE_CONDITION_COLORS[row.condition] || NODE_CONDITION_COLORS.no_data;
if (color === NODE_CONDITION_COLORS.no_data) {
return <TextNoData type="tanstack" />;
}
return (
<Badge color={color} variant="outline">
{NODE_CONDITION_LABEL_MAP[row.condition] || 'Unknown'}
@@ -142,7 +146,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
@@ -172,17 +176,17 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
id: 'cpu',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/nodes#cpu-usage-cores">
CPU Usage
<br /> (cores)
CPU Usage (cores)
</ColumnHeader>
),
accessorFn: (row): number => row.nodeCPU,
width: { min: 160, default: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpu = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpu}
entity={InfraMonitoringEntity.NODES}
attribute="CPU metric"
@@ -196,17 +200,17 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
id: 'cpu_allocatable',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/nodes#cpu-alloc-cores">
CPU Allocatable
<br /> (cores)
CPU Allocatable (cores)
</ColumnHeader>
),
accessorFn: (row): number => row.nodeCPUAllocatable,
width: { min: 160, default: 190 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpuAllocatable = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpuAllocatable}
entity={InfraMonitoringEntity.NODES}
attribute="CPU allocatable metric"
@@ -220,17 +224,17 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
id: 'memory',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/nodes#memory-usage-wss">
Memory Usage
<br /> (WSS)
Memory Usage (WSS)
</ColumnHeader>
),
accessorFn: (row): number => row.nodeMemory,
width: { min: 220, default: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memory}
entity={InfraMonitoringEntity.NODES}
attribute="memory metric"
@@ -244,17 +248,17 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
id: 'memory_allocatable',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/nodes#memory-allocatable">
Memory
<br /> Allocatable
Memory Allocatable
</ColumnHeader>
),
accessorFn: (row): number => row.nodeMemoryAllocatable,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memoryAllocatable = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memoryAllocatable}
entity={InfraMonitoringEntity.NODES}
attribute="memory allocatable metric"

View File

@@ -67,7 +67,7 @@ export const podWidgetInfo = [
{
title: 'CPU Usage (cores)',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/pods/#cpu-usage-cores-1',
docPath: '/infrastructure-monitoring/kubernetes/pods/#cpu-usage-cores',
},
{
title: 'CPU Request, Limit Utilization',

View File

@@ -1,6 +1,5 @@
import { Container } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import {
InframonitoringtypesPodRecordDTO,
InframonitoringtypesPodStatusDTO,
@@ -17,16 +16,15 @@ import {
POD_STATUS_COLORS,
} from '../commonUtils';
import {
CellValueTooltip,
EntityProgressBar,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
import { Typography } from '@signozhq/ui/typography';
import { formatAge } from 'utils/timeUtils';
export function getK8sPodRowKey(pod: InframonitoringtypesPodRecordDTO): string {
@@ -86,10 +84,9 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const podName = value as string;
return <CellValueTooltip value={podName} />;
},
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
},
{
id: 'podStatus',
@@ -107,11 +104,12 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
return <></>;
}
if (row.podStatus === InframonitoringtypesPodStatusDTO.no_data) {
return <TextNoData type="tanstack" />;
}
const color = POD_STATUS_COLORS[row.podStatus] || POD_STATUS_COLORS.unknown;
const label =
row.podStatus === InframonitoringtypesPodStatusDTO.no_data
? 'No Data'
: row.podStatus.charAt(0).toUpperCase() + row.podStatus.slice(1);
const label = row.podStatus.charAt(0).toUpperCase() + row.podStatus.slice(1);
return (
<Badge color={color} variant="outline">
{label}
@@ -134,7 +132,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
@@ -147,23 +145,25 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
{
id: 'podAge',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#age">
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#pod-age">
Age
</ColumnHeader>
),
accessorFn: (row): number => row.podAge,
width: { min: 100 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const age = value as number;
if (age === -1) {
return (
<TooltipSimple title="No data">
<Typography.Text>-</Typography.Text>
</TooltipSimple>
);
}
return <TanStackTable.Text>{formatAge(age)}</TanStackTable.Text>;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={age}
entity={InfraMonitoringEntity.PODS}
attribute="age"
>
<TanStackTable.Text>{formatAge(age)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
@@ -176,34 +176,36 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
accessorFn: (row): number => row.podRestarts,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const restarts = value as number;
if (restarts === -1) {
return (
<TooltipSimple title="No data">
<Typography.Text>-</Typography.Text>
</TooltipSimple>
);
}
return <TanStackTable.Text>{restarts}</TanStackTable.Text>;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={restarts}
entity={InfraMonitoringEntity.PODS}
attribute="Restarts"
>
<TanStackTable.Text>{restarts}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#cpu-req-usage-">
CPU Request
<br /> Usage (%)
CPU Request Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.podCPURequest,
width: { min: 210 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpuRequest}
entity={InfraMonitoringEntity.PODS}
attribute="CPU Request"
@@ -217,17 +219,17 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#cpu-limit-usage-">
CPU Limit
<br /> Usage (%)
CPU Limit Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.podCPULimit,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpuLimit}
entity={InfraMonitoringEntity.PODS}
attribute="CPU Limit"
@@ -241,17 +243,17 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
id: 'cpu',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#cpu-usage-cores">
CPU Usage
<br /> (cores)
CPU Usage (cores)
</ColumnHeader>
),
accessorFn: (row): number => row.podCPU,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpu = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpu}
entity={InfraMonitoringEntity.PODS}
attribute="CPU metric"
@@ -265,18 +267,18 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#mem-req-usage-">
Memory Request
<br /> Usage (%)
Memory Request Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.podMemoryRequest,
width: { min: 210 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memoryRequest}
entity={InfraMonitoringEntity.PODS}
attribute="Memory Request"
@@ -290,17 +292,17 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#mem-limit-usage-">
Memory Limit
<br /> Usage (%)
Memory Limit Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.podMemoryLimit,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memoryLimit}
entity={InfraMonitoringEntity.PODS}
attribute="Memory Limit"
@@ -314,17 +316,17 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
id: 'memory',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#mem-usage-wss">
Memory Usage
<br /> (WSS)
Memory Usage (WSS)
</ColumnHeader>
),
accessorFn: (row): number => row.podMemory,
width: { min: 210, default: '100%' },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memory}
entity={InfraMonitoringEntity.PODS}
attribute="memory metric"

View File

@@ -9,9 +9,9 @@ import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
CellValueTooltip,
EntityProgressBar,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
@@ -86,10 +86,9 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const statefulsetName = value as string;
return <CellValueTooltip value={statefulsetName} />;
},
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
},
{
id: 'namespaceName',
@@ -103,10 +102,9 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
width: { min: 180 },
enableSort: false,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return <CellValueTooltip value={namespaceName} />;
},
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
},
{
id: 'pod_counts_by_status',
@@ -125,7 +123,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
@@ -168,8 +166,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-req-usage-">
CPU Request
<br /> Usage (%)
CPU Request Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.statefulSetCPURequest,
@@ -177,10 +174,11 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpuRequest}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Request"
@@ -194,18 +192,18 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-limit-usage-">
CPU Limit
<br /> Usage (%)
CPU Limit Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.statefulSetCPULimit,
width: { min: 200, default: 200 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpuLimit}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Limit"
@@ -219,8 +217,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
id: 'cpu',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-usage-cores">
CPU Usage
<br /> (cores)
CPU Usage (cores)
</ColumnHeader>
),
accessorFn: (row): number => row.statefulSetCPU,
@@ -228,10 +225,11 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const cpu = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpu}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU metric"
@@ -245,8 +243,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-req-usage-">
Memory Request
<br /> Usage (%)
Memory Request Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.statefulSetMemoryRequest,
@@ -254,10 +251,11 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memoryRequest}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Request"
@@ -271,18 +269,18 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-limit-usage-">
Memory Limit
<br /> Usage (%)
Memory Limit Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.statefulSetMemoryLimit,
width: { min: 180 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memoryLimit}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Limit"
@@ -296,8 +294,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
id: 'memory',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-usage-wss">
Memory Usage
<br /> (WSS)
Memory Usage (WSS)
</ColumnHeader>
),
accessorFn: (row): number => row.statefulSetMemory,
@@ -305,10 +302,11 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memory}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="memory metric"
@@ -329,10 +327,11 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
width: { min: 120 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const currentPods = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={currentPods}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="current pod"
@@ -353,10 +352,11 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
width: { min: 120 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const desiredPods = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={desiredPods}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="desired pod"

View File

@@ -69,27 +69,29 @@ export const volumeWidgetInfo = [
{
title: 'Volume available',
yAxisUnit: 'bytes',
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-available',
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-available-1',
},
{
title: 'Volume capacity',
yAxisUnit: 'bytes',
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-capacity',
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-capacity-1',
},
{
title: 'Volume inodes used',
yAxisUnit: 'short',
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-inodes-used',
docPath:
'/infrastructure-monitoring/kubernetes/volumes/#volume-inodes-used-1',
},
{
title: 'Volume inodes',
yAxisUnit: 'short',
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-inodes',
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-inodes-1',
},
{
title: 'Volume inodes free',
yAxisUnit: 'short',
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-inodes-free',
docPath:
'/infrastructure-monitoring/kubernetes/volumes/#volume-inodes-free-1',
},
];

View File

@@ -6,7 +6,7 @@ import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { CellValueTooltip, ValidateColumnValueWrapper } from '../components';
import { ValidateColumnValueWrapper } from '../components';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
@@ -79,10 +79,9 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const pvcName = value as string;
return <CellValueTooltip value={pvcName} />;
},
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
},
{
id: 'namespaceName',
@@ -95,10 +94,9 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '',
width: { min: 220 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return <CellValueTooltip value={namespaceName} />;
},
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
},
{
id: 'capacity',
@@ -110,10 +108,11 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
accessorFn: (row): number => row.volumeCapacity,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const capacity = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={capacity}
entity={InfraMonitoringEntity.VOLUMES}
attribute="capacity metric"
@@ -133,10 +132,11 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
accessorFn: (row): number => row.volumeUsage,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const usage = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={usage}
entity={InfraMonitoringEntity.VOLUMES}
attribute="utilization metric"
@@ -156,10 +156,11 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
accessorFn: (row): number => row.volumeAvailable,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const available = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={available}
entity={InfraMonitoringEntity.VOLUMES}
attribute="available metric"
@@ -179,10 +180,11 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
accessorFn: (row): number => row.volumeInodes,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const inodes = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={inodes}
entity={InfraMonitoringEntity.VOLUMES}
attribute="inodes metric"
@@ -202,10 +204,11 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
accessorFn: (row): number => row.volumeInodesUsed,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const inodesUsed = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={inodesUsed}
entity={InfraMonitoringEntity.VOLUMES}
attribute="inodes used metric"
@@ -225,10 +228,11 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
accessorFn: (row): number => row.volumeInodesFree,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
cell: ({ value, rowId }): React.ReactNode => {
const inodesFree = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={inodesFree}
entity={InfraMonitoringEntity.VOLUMES}
attribute="inodes free metric"

View File

@@ -1,58 +0,0 @@
.tooltipContent {
display: flex;
flex-direction: column;
max-width: 280px;
}
.actions {
display: flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-2);
justify-content: flex-end;
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
}
.actionButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: none;
border-radius: var(--radius-1);
background: transparent;
color: var(--l2-foreground);
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
&:hover:not(:disabled) {
background: var(--l2-background-hover);
color: var(--l1-foreground);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.tooltipContentWrapper {
--tooltip-background: var(--l3-background);
--tooltip-border-radius: 4px;
--tooltip-border-width: 1px;
--tooltip-border-color: rgba(255, 255, 255, 0.14);
--tooltip-padding: 0px;
}
.divider {
--divider-color: rgba(255, 255, 255, 0.14);
}
.value {
width: fit-content;
}

View File

@@ -1,99 +0,0 @@
import { useCallback, type MouseEvent } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { toast } from '@signozhq/ui/sonner';
import { Copy, Minus, Plus } from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import TanStackTable from 'components/TanStackTableView';
import { useInfraMonitoringCellActionsStore } from './useInfraMonitoringCellActionsStore';
import styles from './CellValueTooltip.module.scss';
import { Divider } from '@signozhq/ui/divider';
export interface CellValueTooltipProps {
value: string;
}
export function CellValueTooltip({
value,
}: CellValueTooltipProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const { lineClamp, increaseLineClamp, decreaseLineClamp } =
useInfraMonitoringCellActionsStore();
const handleCopy = useCallback(
(e: MouseEvent): void => {
e.stopPropagation();
copyToClipboard(value);
toast.success('Copied to clipboard');
},
[copyToClipboard, value],
);
const handleIncreaseLineClamp = useCallback(
(e: MouseEvent): void => {
e.stopPropagation();
increaseLineClamp();
},
[increaseLineClamp],
);
const handleDecreaseLineClamp = useCallback(
(e: MouseEvent): void => {
e.stopPropagation();
decreaseLineClamp();
},
[decreaseLineClamp],
);
const tooltipContent = (
<div className={styles.tooltipContent}>
<div className={styles.actions}>
<button
type="button"
className={styles.actionButton}
onClick={handleDecreaseLineClamp}
disabled={lineClamp <= 1}
data-testid="cell-value-decrease-line-height"
title="Decrease line height"
>
<Minus size={14} />
</button>
<button
type="button"
className={styles.actionButton}
onClick={handleIncreaseLineClamp}
disabled={lineClamp >= 10}
data-testid="cell-value-increase-line-height"
title="Increase line height"
>
<Plus size={14} />
</button>
<Divider type="vertical" className={styles.divider} />
<button
type="button"
className={styles.actionButton}
onClick={handleCopy}
data-testid="cell-value-copy"
title="Copy value"
>
<Copy size={14} />
</button>
</div>
</div>
);
return (
<TooltipSimple
title={tooltipContent}
arrow
tooltipContentProps={{
className: styles.tooltipContentWrapper,
}}
>
<TanStackTable.Text className={styles.value}>{value}</TanStackTable.Text>
</TooltipSimple>
);
}

View File

@@ -1,8 +0,0 @@
export {
CellValueTooltip,
type CellValueTooltipProps,
} from './CellValueTooltip';
export {
useInfraMonitoringCellActionsStore,
useInfraMonitoringLineClamp,
} from './useInfraMonitoringCellActionsStore';

View File

@@ -1,39 +0,0 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface IInfraMonitoringCellActionsStore {
lineClamp: number;
increaseLineClamp: () => void;
decreaseLineClamp: () => void;
}
const MIN_LINE_CLAMP = 1;
const MAX_LINE_CLAMP = 10;
const DEFAULT_LINE_CLAMP = 2;
export const useInfraMonitoringCellActionsStore =
create<IInfraMonitoringCellActionsStore>()(
persist(
(set, get) => ({
lineClamp: DEFAULT_LINE_CLAMP,
increaseLineClamp: (): void => {
const current = get().lineClamp;
if (current < MAX_LINE_CLAMP) {
set({ lineClamp: current + 1 });
}
},
decreaseLineClamp: (): void => {
const current = get().lineClamp;
if (current > MIN_LINE_CLAMP) {
set({ lineClamp: current - 1 });
}
},
}),
{
name: '@signoz/infra-monitoring-cell-actions',
},
),
);
export const useInfraMonitoringLineClamp = (): number =>
useInfraMonitoringCellActionsStore((s) => s.lineClamp);

View File

@@ -1,6 +1,9 @@
// oxlint-disable jsx-a11y/click-events-have-key-events
import styles from './GroupedStatusCounts.module.scss';
import TanStackTable from 'components/TanStackTableView';
import { Typography } from '@signozhq/ui/typography';
import { TextNoData } from './TextNoData';
import { MouseEventHandler } from 'react';
export interface StatusBreakdownItem {
label: string;
@@ -21,18 +24,25 @@ interface GroupedStatusCountsProps {
}
function buildTooltipContent(item: StatusCountItem): React.ReactNode {
const onClickHandle: MouseEventHandler = (e) => {
e.preventDefault();
e.stopPropagation();
};
if (!item.breakdown || item.breakdown.length === 0) {
return (
<Typography.Text>
{item.label}: {item.value}
</Typography.Text>
<div onClick={onClickHandle}>
<Typography.Text>
{item.label}: {item.value}
</Typography.Text>
</div>
);
}
const nonZeroBreakdown = item.breakdown.filter((b) => b.value > 0);
if (nonZeroBreakdown.length === 0) {
return (
<div className={styles.tooltipContent}>
<div className={styles.tooltipContent} onClick={onClickHandle}>
<Typography.Text className={styles.tooltipHeader}>
{item.label}
</Typography.Text>
@@ -43,7 +53,7 @@ function buildTooltipContent(item: StatusCountItem): React.ReactNode {
}
return (
<div className={styles.tooltipContent}>
<div className={styles.tooltipContent} onClick={onClickHandle}>
<Typography.Text className={styles.tooltipHeader}>
{item.label}
</Typography.Text>
@@ -68,7 +78,7 @@ export function GroupedStatusCounts({
showZeroValues === false ? items.filter((item) => item.value > 0) : items;
if (visibleItems.length === 0) {
return <TanStackTable.Text>-</TanStackTable.Text>;
return <TextNoData type="tanstack" />;
}
return (
@@ -81,12 +91,20 @@ export function GroupedStatusCounts({
arrow
align="start"
>
<TanStackTable.Text
className={styles.item}
style={{ '--gsc-color': item.color } as React.CSSProperties}
>
{item.value || '-'}
</TanStackTable.Text>
{item.value ? (
<TanStackTable.Text
className={styles.item}
style={{ '--gsc-color': item.color } as React.CSSProperties}
>
{item.value}
</TanStackTable.Text>
) : (
<TextNoData
type="tanstack"
className={styles.item}
style={{ '--gsc-color': item.color } as React.CSSProperties}
/>
)}
</TanStackTable.HoverTooltip>
))}
</div>

View File

@@ -0,0 +1,39 @@
import { CSSProperties, forwardRef } from 'react';
import { Typography } from '@signozhq/ui/typography';
import TanStackTable from 'components/TanStackTableView';
export interface TextNoDataProps {
type: 'typography' | 'tanstack';
className?: string;
style?: CSSProperties;
}
export const TextNoData = forwardRef<HTMLSpanElement, TextNoDataProps>(
function TextNoData({ type, className, style, ...props }, ref) {
const combinedStyle = { opacity: 0.6, ...style };
if (type === 'tanstack') {
return (
<TanStackTable.Text
ref={ref}
className={className}
style={combinedStyle}
{...props}
>
-
</TanStackTable.Text>
);
}
return (
<Typography.Text
ref={ref}
className={className}
style={combinedStyle}
{...props}
>
-
</Typography.Text>
);
},
);

View File

@@ -1,29 +1,33 @@
import { Tooltip } from 'antd';
import TanStackTable from 'components/TanStackTableView';
import {
getInvalidValueTooltipText,
InfraMonitoringEntity,
} from '../constants';
import { TextNoData } from './TextNoData';
import TanStackTable from 'components/TanStackTableView';
export function ValidateColumnValueWrapper({
children,
value,
entity,
attribute,
rowId,
}: {
children: React.ReactNode;
value: number;
entity?: InfraMonitoringEntity;
attribute?: string;
rowId: string;
}): JSX.Element {
if (value === -1 || Number.isNaN(value)) {
let element = <TanStackTable.Text>-</TanStackTable.Text>;
let element = <TextNoData type="tanstack" />;
if (entity && attribute) {
element = (
<Tooltip title={getInvalidValueTooltipText(entity, attribute)}>
<TanStackTable.HoverTooltip
rowId={rowId}
title={getInvalidValueTooltipText(entity, attribute)}
>
{element}
</Tooltip>
</TanStackTable.HoverTooltip>
);
}

View File

@@ -5,8 +5,4 @@ export {
GroupedStatusCounts,
type StatusCountItem,
} from './GroupedStatusCounts';
export {
CellValueTooltip,
useInfraMonitoringCellActionsStore,
useInfraMonitoringLineClamp,
} from './CellValueTooltip';
export { TextNoData } from './TextNoData';

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