mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-01 18:50:35 +01:00
Compare commits
15 Commits
feat/alert
...
issue-4293
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ec2800184 | ||
|
|
93f3cd011e | ||
|
|
8a3a32ef28 | ||
|
|
a480f76e3c | ||
|
|
052255abf5 | ||
|
|
afc90f532f | ||
|
|
24390d7192 | ||
|
|
16c6fdc600 | ||
|
|
159354a511 | ||
|
|
30dcb944eb | ||
|
|
e9726776ab | ||
|
|
a238ed367d | ||
|
|
9197cb8b3b | ||
|
|
a0a17a99a6 | ||
|
|
8fb5703eb1 |
@@ -38,8 +38,7 @@ COPY ./pkg/ ./pkg/
|
||||
COPY ./templates /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
|
||||
|
||||
COPY --from=build /opt/build ./web/
|
||||
|
||||
@@ -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}
|
||||
|
||||
353
docs/contributing/prometheus.md
Normal file
353
docs/contributing/prometheus.md
Normal 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.
|
||||
@@ -227,14 +227,11 @@ cd tests/e2e
|
||||
# Single feature dir
|
||||
npx playwright test tests/alerts/ --project=chromium
|
||||
|
||||
# Single sub-area
|
||||
npx playwright test tests/alerts/history/ --project=chromium
|
||||
|
||||
# Single file
|
||||
npx playwright test tests/alerts/page.spec.ts --project=chromium
|
||||
npx playwright test tests/alerts/alerts.spec.ts --project=chromium
|
||||
|
||||
# Single test by title grep
|
||||
npx playwright test --project=chromium -g "AL-01"
|
||||
npx playwright test --project=chromium -g "TC-01"
|
||||
```
|
||||
|
||||
### Iterative modes
|
||||
@@ -268,14 +265,7 @@ yarn test:staging
|
||||
| `SIGNOZ_E2E_PASSWORD` | Admin password. Bootstrap writes the integration-test default. |
|
||||
| `SIGNOZ_E2E_SEEDER_URL` | Seeder HTTP base URL — hit by specs that need per-test telemetry. |
|
||||
|
||||
Precedence in `playwright.config.ts`, lowest to highest: `.env` (user-provided, staging) → `.env.local` (bootstrap-generated, local mode) → whatever is already in `process.env`. The config parses both files itself and only fills in keys the environment does not already define, so exporting a variable always wins:
|
||||
|
||||
```bash
|
||||
# runs against a locally served frontend, not whatever .env.local points at
|
||||
SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test tests/alerts
|
||||
```
|
||||
|
||||
This is deliberately not `dotenv.config({ override: true })`. That flag makes the *file* beat `process.env`, which silently discarded exported values — including the `SIGNOZ_E2E_BASE_URL` in `pnpm test:staging`, whenever a `.env.local` happened to exist.
|
||||
Loading order in `playwright.config.ts`: `.env` first (user-provided, staging), then `.env.local` with `override: true` (bootstrap-generated, local mode). Anything already set in `process.env` at yarn-test time wins because dotenv doesn't touch vars that are already present.
|
||||
|
||||
### Playwright options
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -92,6 +92,7 @@ function DeleteAccountModal(): JSX.Element {
|
||||
loading={isDeleting}
|
||||
onClick={handleConfirm}
|
||||
data-testid="confirm-delete-btn"
|
||||
withPortal={false}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -55,6 +55,7 @@ export function RevokeKeyFooter({
|
||||
color="destructive"
|
||||
loading={isRevoking}
|
||||
onClick={onConfirm}
|
||||
withPortal={false}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')];
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
18
frontend/src/components/TanStackTableView/useResetScroll.ts
Normal file
18
frontend/src/components/TanStackTableView/useResetScroll.ts
Normal 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]);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,7 +29,6 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-logs"
|
||||
>
|
||||
<div className="icon">
|
||||
<LogsIcon />
|
||||
@@ -41,7 +40,6 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.TRACES_EXPLORER}?${relatedTracesLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-traces"
|
||||
>
|
||||
<div className="icon">
|
||||
<DraftingCompass
|
||||
|
||||
@@ -26,10 +26,7 @@ function ChangePercentage({
|
||||
}: ChangePercentageProps): JSX.Element {
|
||||
if (direction > 0) {
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--success"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--success">
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
@@ -41,10 +38,7 @@ function ChangePercentage({
|
||||
}
|
||||
if (direction < 0) {
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--error"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--error">
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
|
||||
</div>
|
||||
@@ -56,10 +50,7 @@ function ChangePercentage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--no-previous-data"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--no-previous-data">
|
||||
<div className="change-percentage__label">no previous data</div>
|
||||
</div>
|
||||
);
|
||||
@@ -112,12 +103,7 @@ function StatsCard({
|
||||
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}
|
||||
data-testid="stats-card"
|
||||
data-stats-title={title}
|
||||
data-empty={isEmpty ? 'true' : 'false'}
|
||||
>
|
||||
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
|
||||
<div className="stats-card__title-wrapper">
|
||||
<div className="title">{title}</div>
|
||||
<div className="duration-indicator">
|
||||
@@ -137,7 +123,7 @@ function StatsCard({
|
||||
</div>
|
||||
|
||||
<div className="stats-card__stats">
|
||||
<div className="count-label" data-testid="stats-card-value">
|
||||
<div className="count-label">
|
||||
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -81,11 +81,7 @@ function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
ref={graphRef}
|
||||
data-testid="stats-card-sparkline"
|
||||
>
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot data={[xData, yData]} options={options} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -48,16 +48,11 @@ function TopContributorsCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="top-contributors-card" data-testid="top-contributors-card">
|
||||
<div className="top-contributors-card">
|
||||
<div className="top-contributors-card__header">
|
||||
<div className="title">top contributors</div>
|
||||
{topContributorsData.length > 3 && (
|
||||
<Button
|
||||
type="text"
|
||||
className="view-all"
|
||||
onClick={toggleViewAllDrawer}
|
||||
data-testid="top-contributors-view-all"
|
||||
>
|
||||
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
|
||||
<div className="label">View all</div>
|
||||
<div className="icon">
|
||||
<ArrowRight
|
||||
|
||||
@@ -68,10 +68,7 @@ function TopContributorsRows({
|
||||
relatedTracesLink={record.relatedTracesLink}
|
||||
relatedLogsLink={record.relatedLogsLink}
|
||||
>
|
||||
<div
|
||||
className="total-contribution"
|
||||
data-testid="top-contributors-row-count"
|
||||
>
|
||||
<div className="total-contribution">
|
||||
{count}/{totalCurrentTriggers}
|
||||
</div>
|
||||
</ConditionalAlertPopover>
|
||||
@@ -81,10 +78,7 @@ function TopContributorsRows({
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTopContributors,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'top-contributors-row',
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
onClick: (): void => {
|
||||
logEvent('Alert history: Top contributors row: Clicked', {
|
||||
labels: record.labels,
|
||||
|
||||
@@ -31,10 +31,7 @@ function ViewAllDrawer({
|
||||
}}
|
||||
title="Viewing All Contributors"
|
||||
>
|
||||
<div
|
||||
className="top-contributors-card--view-all"
|
||||
data-testid="top-contributors-drawer"
|
||||
>
|
||||
<div className="top-contributors-card--view-all">
|
||||
<div className="top-contributors-card__content">
|
||||
<TopContributorsRows
|
||||
topContributors={topContributorsData}
|
||||
|
||||
@@ -32,8 +32,8 @@ function GraphWrapper({
|
||||
}, [data?.data]);
|
||||
|
||||
return (
|
||||
<div className="timeline-graph" data-testid="timeline-graph">
|
||||
<div className="timeline-graph__title" data-testid="timeline-graph-title">
|
||||
<div className="timeline-graph">
|
||||
<div className="timeline-graph__title">
|
||||
{totalCurrentTriggers} triggers in {relativeTime}
|
||||
</div>
|
||||
<div className="timeline-graph__chart">
|
||||
|
||||
@@ -118,10 +118,7 @@ function TimelineTableContent(): JSX.Element {
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTimelineTableResponse,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'timeline-row',
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
onClick: (): void => {
|
||||
void logEvent('Alert history: Timeline table row: Clicked', {
|
||||
ruleId: record.ruleID,
|
||||
@@ -131,15 +128,12 @@ function TimelineTableContent(): JSX.Element {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="timeline-table" data-testid="timeline-table">
|
||||
<div className="timeline-table">
|
||||
{/* If we don't wait to have the keys, the QuerySearch will not render them at first usage */}
|
||||
{!isLoadingKeys && hardcodedAttributeKeys ? (
|
||||
<div className="timeline-table__filter">
|
||||
<div className="timeline-table__filter-row">
|
||||
<div
|
||||
className="timeline-table__filter-search"
|
||||
data-testid="timeline-filter-search"
|
||||
>
|
||||
<div className="timeline-table__filter-search">
|
||||
<QuerySearch
|
||||
onChange={querySearchOnChange}
|
||||
queryData={queryData}
|
||||
@@ -161,7 +155,6 @@ function TimelineTableContent(): JSX.Element {
|
||||
<Skeleton.Input
|
||||
className="timeline-table__filter--loading-skeleton"
|
||||
active
|
||||
data-testid="timeline-filter-skeleton"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -179,17 +172,14 @@ function TimelineTableContent(): JSX.Element {
|
||||
locale={{
|
||||
emptyText:
|
||||
isError && apiError ? (
|
||||
<div className="timeline-table__error" data-testid="timeline-error">
|
||||
<div className="timeline-table__error">
|
||||
<ErrorContent error={apiError} />
|
||||
</div>
|
||||
) : undefined,
|
||||
}}
|
||||
footer={(): JSX.Element => (
|
||||
<div className="timeline-table__pagination">
|
||||
<div
|
||||
className="timeline-table__pagination-info"
|
||||
data-testid="timeline-footer-range"
|
||||
>
|
||||
<div className="timeline-table__pagination-info">
|
||||
{paginationConfig.showTotal?.(totalItems, [
|
||||
totalItems === 0
|
||||
? 0
|
||||
|
||||
@@ -21,7 +21,7 @@ export const timelineTableColumns = ({
|
||||
sorter: true,
|
||||
width: 140,
|
||||
render: (value): JSX.Element => (
|
||||
<div className="alert-rule-state" data-testid="timeline-row-state">
|
||||
<div className="alert-rule-state">
|
||||
<AlertState state={value} showLabel />
|
||||
</div>
|
||||
),
|
||||
@@ -30,7 +30,7 @@ export const timelineTableColumns = ({
|
||||
title: 'LABELS',
|
||||
dataIndex: 'labels',
|
||||
render: (labels): JSX.Element => (
|
||||
<div className="alert-rule-labels" data-testid="timeline-row-labels">
|
||||
<div className="alert-rule-labels">
|
||||
<AlertLabels labels={labels} />
|
||||
</div>
|
||||
),
|
||||
@@ -40,10 +40,7 @@ export const timelineTableColumns = ({
|
||||
dataIndex: 'unixMilli',
|
||||
width: 200,
|
||||
render: (value): JSX.Element => (
|
||||
<div
|
||||
className="alert-rule__created-at"
|
||||
data-testid="timeline-row-created-at"
|
||||
>
|
||||
<div className="alert-rule__created-at">
|
||||
{formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.DASH_DATETIME)}
|
||||
</div>
|
||||
),
|
||||
@@ -56,7 +53,7 @@ export const timelineTableColumns = ({
|
||||
if (!record.relatedTracesLink && !record.relatedLogsLink) {
|
||||
return (
|
||||
<Tooltip title="No links available for this item">
|
||||
<Button type="text" ghost disabled data-testid="timeline-row-actions">
|
||||
<Button type="text" ghost disabled>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -68,7 +65,7 @@ export const timelineTableColumns = ({
|
||||
relatedTracesLink={record.relatedTracesLink ?? ''}
|
||||
relatedLogsLink={record.relatedLogsLink ?? ''}
|
||||
>
|
||||
<Button type="text" ghost data-testid="timeline-row-actions">
|
||||
<Button type="text" ghost>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
|
||||
@@ -23,7 +23,6 @@ function TimelineTabs(): JSX.Element {
|
||||
{
|
||||
value: TimelineTab.OVERALL_STATUS,
|
||||
label: 'Overall Status',
|
||||
testId: 'timeline-tab-overall-status',
|
||||
},
|
||||
{
|
||||
value: TimelineTab.TOP_5_CONTRIBUTORS,
|
||||
@@ -34,7 +33,6 @@ function TimelineTabs(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
testId: 'timeline-tab-top-contributors',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -59,17 +57,14 @@ function TimelineFilters(): JSX.Element {
|
||||
{
|
||||
value: TimelineFilter.ALL,
|
||||
label: 'All',
|
||||
testId: 'timeline-filter-all',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.FIRED,
|
||||
label: 'Fired',
|
||||
testId: 'timeline-filter-fired',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.RESOLVED,
|
||||
label: 'Resolved',
|
||||
testId: 'timeline-filter-resolved',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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't {operatorWord} {thresholdValue}, no alert would
|
||||
fire
|
||||
</TooltipExample>
|
||||
<TooltipLink />
|
||||
</TooltipContent>
|
||||
|
||||
@@ -34,7 +34,6 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
|
||||
testId="send-notification-if-data-is-missing-input"
|
||||
/>
|
||||
<Typography.Text>Minutes</Typography.Text>
|
||||
</div>
|
||||
@@ -67,7 +66,6 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
|
||||
testId="enforce-minimum-datapoints-input"
|
||||
/>
|
||||
<Typography.Text>Datapoints</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,6 @@ function EvaluationWindowPopover({
|
||||
tabIndex={0}
|
||||
data-value={option.value}
|
||||
data-section-id={sectionId}
|
||||
data-testid={`${sectionId}-option-${option.value}`}
|
||||
onClick={(): void => onChange(option.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
||||
@@ -186,7 +186,6 @@ function Footer(): JSX.Element {
|
||||
color="primary"
|
||||
onClick={handleSaveAlert}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
testId="save-alert-rule-button"
|
||||
>
|
||||
{isCreatingAlertRule || isUpdatingAlertRule ? (
|
||||
<Loader data-testid="save-alert-rule-loader-icon" size={14} />
|
||||
@@ -219,7 +218,6 @@ function Footer(): JSX.Element {
|
||||
color="secondary"
|
||||
onClick={handleTestNotification}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
testId="test-notification-button"
|
||||
>
|
||||
{isTestingAlertRule ? (
|
||||
<Loader data-testid="test-notification-loader-icon" size={14} />
|
||||
@@ -251,7 +249,6 @@ function Footer(): JSX.Element {
|
||||
color="secondary"
|
||||
onClick={handleDiscard}
|
||||
disabled={disableButtons}
|
||||
testId="discard-alert-rule-button"
|
||||
>
|
||||
<X size={14} /> Discard
|
||||
</Button>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { sortByMeanDesc } from '../sortByMeanDesc';
|
||||
|
||||
interface Item {
|
||||
name: string;
|
||||
values: (number | null)[];
|
||||
}
|
||||
|
||||
const item = (name: string, values: (number | null)[]): Item => ({
|
||||
name,
|
||||
values,
|
||||
});
|
||||
|
||||
const sorted = (items: Item[]): string[] =>
|
||||
sortByMeanDesc(items, {
|
||||
getValues: (entry) => entry.values,
|
||||
getKey: (entry) => entry.name,
|
||||
}).map((entry) => entry.name);
|
||||
|
||||
describe('sortByMeanDesc', () => {
|
||||
it('orders items by descending mean', () => {
|
||||
expect(
|
||||
sorted([item('a', [1, 1]), item('b', [10, 20]), item('c', [5, 5])]),
|
||||
).toStrictEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
it('sinks items with no finite values to the bottom', () => {
|
||||
expect(
|
||||
sorted([item('a', []), item('b', [NaN, Infinity]), item('c', [1])]),
|
||||
).toStrictEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('ignores non-finite and null values when averaging', () => {
|
||||
// Mean over the finite values only is 10, so NaN must not poison it to last place.
|
||||
expect(
|
||||
sorted([item('a', [2, 2]), item('b', [10, NaN]), item('c', [4, null])]),
|
||||
).toStrictEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
it('produces the same order whichever order the items arrive in', () => {
|
||||
const a = item('a', [5]);
|
||||
const b = item('b', [5]);
|
||||
const c = item('c', [9]);
|
||||
|
||||
expect(sorted([a, b, c])).toStrictEqual(sorted([c, b, a]));
|
||||
expect(sorted([b, a, c])).toStrictEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('tiebreaks equal means on the key', () => {
|
||||
expect(sorted([item('b', [1]), item('a', [1])])).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('tiebreaks on the key for items that have no finite values either', () => {
|
||||
expect(sorted([item('b', []), item('a', [NaN])])).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const items = [item('a', [1]), item('b', [9])];
|
||||
|
||||
expect(sorted(items)).toStrictEqual(['b', 'a']);
|
||||
expect(items.map((entry) => entry.name)).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('returns an empty array for no items', () => {
|
||||
expect(sorted([])).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
type MeanInput = number | null | undefined;
|
||||
|
||||
function finiteMean(values: Iterable<MeanInput>): number | null {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (const value of values) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
sum += value;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count > 0 ? sum / count : null;
|
||||
}
|
||||
|
||||
export interface SortByMeanDescOptions<T> {
|
||||
/** Numeric values of an item; non-finite entries are ignored. */
|
||||
getValues: (item: T) => Iterable<MeanInput>;
|
||||
/** Stable identity of an item, used to break ties on equal means. */
|
||||
getKey: (item: T) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders series by descending mean, on a copy (V1 parity: `utils/getSortedSeriesData`). The wire
|
||||
* order can't stand in for it: the backend returns series in Go map-iteration order.
|
||||
*
|
||||
* Call this before building the uPlot config and aligned data — those two and click attribution
|
||||
* all index the list positionally, and uPlot draws a stacked bar's segments in series-index order.
|
||||
*/
|
||||
export function sortByMeanDesc<T>(
|
||||
items: T[],
|
||||
{ getValues, getKey }: SortByMeanDescOptions<T>,
|
||||
): T[] {
|
||||
return items
|
||||
.map((item) => ({ item, mean: finiteMean(getValues(item)) }))
|
||||
.sort((a, b) => {
|
||||
if (a.mean !== null && b.mean !== null && a.mean !== b.mean) {
|
||||
return b.mean - a.mean;
|
||||
}
|
||||
if ((a.mean === null) !== (b.mean === null)) {
|
||||
return a.mean === null ? 1 : -1;
|
||||
}
|
||||
return getKey(a.item).localeCompare(getKey(b.item));
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
@@ -119,7 +119,6 @@ function BasicInfo({
|
||||
<SeveritySelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue="critical"
|
||||
data-testid="alert-severity-select"
|
||||
onChange={(value: unknown | string): void => {
|
||||
const s = (value as string) || 'critical';
|
||||
setAlertDef({
|
||||
@@ -148,7 +147,6 @@ function BasicInfo({
|
||||
]}
|
||||
>
|
||||
<InputSmall
|
||||
data-testid="alert-name-input-v1"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
@@ -163,7 +161,6 @@ function BasicInfo({
|
||||
name={['annotations', 'description']}
|
||||
>
|
||||
<TextareaMedium
|
||||
data-testid="alert-description-input"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
|
||||
@@ -105,7 +105,7 @@ function QuerySection({
|
||||
{
|
||||
label: (
|
||||
<Tooltip title="Query Builder">
|
||||
<Button className="nav-btns" data-testid="query-builder-tab">
|
||||
<Button className="nav-btns">
|
||||
<Atom size={14} />
|
||||
<Typography.Text>Query Builder</Typography.Text>
|
||||
</Button>
|
||||
@@ -122,11 +122,7 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -166,11 +162,7 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -188,11 +180,7 @@ function QuerySection({
|
||||
: 'PromQL'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="promql-tab"
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
/>
|
||||
|
||||
@@ -80,7 +80,6 @@ function RuleOptions({
|
||||
defaultValue={defaultCompareOp}
|
||||
value={alertDef.condition?.op}
|
||||
style={{ minWidth: '120px' }}
|
||||
data-testid="alert-threshold-op-select"
|
||||
onChange={(value: string | unknown): void => {
|
||||
const newOp = (value as string) || '';
|
||||
|
||||
@@ -117,7 +116,6 @@ function RuleOptions({
|
||||
defaultValue={defaultMatchType}
|
||||
style={{ minWidth: '130px' }}
|
||||
value={alertDef.condition?.matchType}
|
||||
data-testid="alert-threshold-match-type-select-v1"
|
||||
onChange={(value: string | unknown): void => handleMatchOptChange(value)}
|
||||
>
|
||||
<Select.Option value="1">{t('option_atleastonce')}</Select.Option>
|
||||
@@ -179,7 +177,6 @@ function RuleOptions({
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
@@ -197,7 +194,6 @@ function RuleOptions({
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
@@ -399,7 +395,6 @@ function RuleOptions({
|
||||
value={alertDef?.condition?.target}
|
||||
onChange={onChange}
|
||||
type="number"
|
||||
data-testid="alert-threshold-target-input"
|
||||
onWheel={(e): void => e.currentTarget.blur()}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -844,6 +844,8 @@ function FormAlertRules({
|
||||
|
||||
return (
|
||||
<>
|
||||
{Element}
|
||||
|
||||
<div
|
||||
id="top"
|
||||
className={`form-alert-rules-container ${
|
||||
@@ -966,7 +968,6 @@ function FormAlertRules({
|
||||
!isChannelConfigurationValid ||
|
||||
queryStatus === 'error'
|
||||
}
|
||||
data-testid="alert-save-button"
|
||||
>
|
||||
{isNewRule ? t('button_createrule') : t('button_savechanges')}
|
||||
</ActionButton>
|
||||
@@ -980,7 +981,6 @@ function FormAlertRules({
|
||||
}
|
||||
type="default"
|
||||
onClick={onTestRuleHandler}
|
||||
data-testid="alert-test-button"
|
||||
>
|
||||
{' '}
|
||||
{t('button_testrule')}
|
||||
@@ -989,7 +989,6 @@ function FormAlertRules({
|
||||
disabled={loading || false}
|
||||
type="default"
|
||||
onClick={onCancelHandler}
|
||||
data-testid="alert-cancel-button"
|
||||
>
|
||||
{isNewRule && t('button_cancelchanges')}
|
||||
{ruleId && !isEmpty(ruleId) && t('button_discard')}
|
||||
@@ -999,7 +998,6 @@ function FormAlertRules({
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
testId="alert-save-confirm-dialog"
|
||||
open={isConfirmSaveOpen}
|
||||
onOpenChange={setIsConfirmSaveOpen}
|
||||
title={t('confirm_save_title')}
|
||||
|
||||
@@ -174,7 +174,6 @@ function LabelSelect({
|
||||
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<Input
|
||||
data-testid="alert-labels-input-v1"
|
||||
placeholder={renderPlaceholder()}
|
||||
onChange={handleLabelChange}
|
||||
onKeyUp={(e): void => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-width: 595px;
|
||||
min-width: 800px;
|
||||
|
||||
> * {
|
||||
min-width: 0;
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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" />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
text-wrap: auto;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
|
||||
.listContainer {
|
||||
flex: 1;
|
||||
height: calc(100vh - 312px) !important;
|
||||
height: calc(100vh - 330px) !important;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user