Compare commits

..

2 Commits

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 00:05:22 +05:30
252 changed files with 4533 additions and 12398 deletions

View File

@@ -39,8 +39,6 @@ jobs:
matrix:
suite:
- alerts
- alertmanager
- alertmanagerrotation
- basepath
- callbackauthn
- cloudintegrations
@@ -55,7 +53,6 @@ jobs:
- queriermetrics
- querierscalar
- queriercommon
- querierai
- rawexportdata
- promqlconformance
- querierauthz

View File

@@ -6902,7 +6902,6 @@ components:
Querybuildertypesv5QueryEnvelope:
discriminator:
mapping:
builder_ai_query: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI'
builder_formula: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
builder_query: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
builder_trace_operator: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
@@ -6911,7 +6910,6 @@ components:
propertyName: type
oneOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopePromQL'
@@ -6926,15 +6924,6 @@ components:
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeBuilderAI:
properties:
spec:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeClickHouseSQL:
properties:
spec:
@@ -7048,7 +7037,6 @@ components:
Querybuildertypesv5QueryType:
enum:
- builder_query
- builder_ai_query
- builder_formula
- builder_trace_operator
- clickhouse_sql
@@ -15489,72 +15477,6 @@ paths:
summary: Lock dashboard (v2)
tags:
- dashboard
/api/v2/dashboards/{id}/migrate:
post:
deprecated: false
description: 'This endpoint retries the v1→v2 (Perses) migration on a dashboard
still stored in the v1 schema and returns the v2-shape result. It is idempotent:
a dashboard already in the v2 schema is returned unchanged.'
operationId: MigrateDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Migrate dashboard to v2
tags:
- dashboard
/api/v2/factor_password/forgot:
post:
deprecated: false
@@ -24778,6 +24700,149 @@ paths:
summary: Replace variables
tags:
- querier
/prometheus/api/v1/query:
get:
deprecated: false
description: Evaluate a PromQL expression at a single instant. Request and
response follow the Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/);
the /prometheus prefix distinguishes these PromQL-only endpoints from the
SigNoz query APIs. Also accepts POST with form-encoded parameters.
operationId: PrometheusInstantQuery
parameters:
- description: PromQL expression to evaluate
in: query
name: query
required: true
schema:
type: string
- description: 'Evaluation timestamp: float unix seconds or RFC3339. Defaults
to the server''s current time.'
in: query
name: time
schema:
type: string
- description: 'Evaluation timeout: float seconds or a Prometheus duration
string (e.g. 30s).'
in: query
name: timeout
schema:
type: string
- description: Set to any value to include query statistics in the response.
in: query
name: stats
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
properties:
result: {}
resultType:
enum:
- matrix
- vector
- scalar
- string
type: string
stats: {}
type: object
status:
enum:
- success
type: string
type: object
description: Query evaluated successfully
"400":
description: Unparsable expression or parameters (errorType bad_data)
"422":
description: Expression failed to evaluate (errorType execution)
"503":
description: Query timed out or was canceled
summary: Prometheus instant query
tags:
- prometheus
/prometheus/api/v1/query_range:
get:
deprecated: false
description: Evaluate a PromQL expression over a range of time on a fixed
step grid. Request and response follow the Prometheus HTTP API
(https://prometheus.io/docs/prometheus/latest/querying/api/); the
/prometheus prefix distinguishes these PromQL-only endpoints from the
SigNoz query APIs. Grids are capped at 11,000 points per series. Also
accepts POST with form-encoded parameters.
operationId: PrometheusRangeQuery
parameters:
- description: PromQL expression to evaluate
in: query
name: query
required: true
schema:
type: string
- description: 'Start timestamp: float unix seconds or RFC3339.'
in: query
name: start
required: true
schema:
type: string
- description: 'End timestamp: float unix seconds or RFC3339.'
in: query
name: end
required: true
schema:
type: string
- description: 'Grid step: float seconds or a Prometheus duration string
(e.g. 30s). Must be positive.'
in: query
name: step
required: true
schema:
type: string
- description: 'Evaluation timeout: float seconds or a Prometheus duration
string (e.g. 30s).'
in: query
name: timeout
schema:
type: string
- description: Set to any value to include query statistics in the response.
in: query
name: stats
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
properties:
result: {}
resultType:
enum:
- matrix
type: string
stats: {}
type: object
status:
enum:
- success
type: string
type: object
description: Query evaluated successfully
"400":
description: Unparsable expression or parameters, or a grid past the 11,000-point
cap (errorType bad_data)
"422":
description: Expression failed to evaluate (errorType execution)
"503":
description: Query timed out or was canceled
summary: Prometheus range query
tags:
- prometheus
servers:
- description: The fully qualified URL to the SigNoz APIServer.
url: https://{host}:{port}{base_path}

View File

@@ -1,123 +1,377 @@
# 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 fetch
reduction 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.
This document gives the context for `pkg/prometheus/clickhouseprometheusv2`.
This package is the second-generation ClickHouse-backed Prometheus provider.
The document tells you why the package exists. It tells you the correctness
rules that shaped it. It shows how we prove that each construct does not
change results. Keep these invariants when you change the provider. If your
change breaks an invariant, flag it and discuss it first.
---
## 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.
The v1 provider (`pkg/prometheus/clickhouseprometheus`) serves the promql
engine through the remote-read protobuf adapter. It fetches every raw sample
of a query's union window. It serializes all of them and gives them to the
engine. The cost follows the ingested data, not the question. This is how a
dashboard of PromQL panels can take an instance down.
In v2 the stock promql engine evaluates over a native `storage.Querier`: no
translation layer, per-selector fetch windows, and fetch reductions that are
provably invisible to the engine.
In v2, each query runs in one of two ways. The classifier decides per query:
**The core constraint: every reduction either preserves engine semantics exactly
or is not performed.** A PromQL result that differs from upstream Prometheus is
a lost user. The conformance suite
- **Transpiled**: ClickHouse evaluates the query. Only final (or near-final)
per-group grid arrays come back. The statements use the
`timeSeries*ToGrid` aggregate functions. The supported ClickHouse floor is
25.6 or later, so these functions are assumed available.
- **Engine**: the stock promql engine evaluates over this package's native
`storage.Querier`. Every shape that does not transpile takes this path.
**The core rule: a PromQL result that differs from upstream Prometheus is a
lost user. A construct that cannot reproduce engine semantics exactly falls
back. It does not approximate.** The conformance suite
(`tests/integration/tests/promqlconformance/`) replays Prometheus' own test
corpus against both providers and is the arbiter.
corpus against both providers. It is the arbiter. The classification golden
(`testdata/classification_golden.json`) freezes the route of each corpus
expression. The rest of this document is the PromQL-to-SQL story. 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 each grid point
`t_i = start + i*step`, for `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]`. If that latest sample is a stale
marker, the selector resolves to nothing. Older real samples in the window
do not change this.
- A range selector `[r]` collects every sample in `(t_i - r, t_i]`. Stale
markers are excluded.
- `offset d` shifts both windows to `(t_i - d - w, t_i - d]`.
The transpilation invariant follows from this model. Each transpiled
construct produces one array per output series. The array has exactly one
slot per grid point. Slot `i` holds the value at `t_i`. NULL means absent.
This makes composition correct, not only convenient. The engine evaluates
these operators independently per `t_i`. A representation that gets every
slot right gets the whole query right. Spatial aggregation over arrays is
sound because it combines values that belong to the same `t_i` by
construction. Scan time maps slot `i` back to `t_i = start + i*step`
(`toMatrix`). The sections below fill those slots with exactly the numbers
the engine computes. We validated each equivalence against the vendored
engine on live data before its shape entered the allowlist. An unproven
shape stays on the engine path.
## Classification: finding what a statement can answer
`classify` walks the parsed AST and looks for "core units". A core unit is a
maximal subtree of this shape:
[agg by/without (...)] [fn(] selector[range] [offset d] [)] [op scalar]...
`classifyCore` peels that chain from the outside in. It takes an optional
sum/min/max/avg/count aggregation. It then takes one allowlisted function or
a bare instant selector. It then takes the selector with its offset. On the
way out, it collects 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. This is an
allowlist. An overlooked construct becomes a fallback, not a wrong number.
Three unit kinds come out. Each kind has its own SQL form:
- `unitRange`: rate, irate, increase, delta, idelta over a range selector.
- `unitInstant`: instant vector selection, bare or comparison-filtered.
- `unitOverTime`: avg/min/max/sum/count/last `_over_time`.
If the whole tree is one unit, the plan is "full". The statement's rows are
the query result. Otherwise, `rewrite` cuts out each maximal unit and puts a
synthetic selector `__signoz_transpiled_N__` in its place. The engine then
runs the rewritten expression over the units' materialized results. This is
a "hybrid" plan. `histogram_quantile`, `topk`, `or`/`and`/`unless`, and
vector matching keep exact engine semantics. Their expensive inputs were
aggregated server-side.
Classification refuses a shape when it cannot guarantee exact semantics
server-side:
- The `@` modifier, anywhere.
- Default-resolution subqueries. Their resolution is a server runtime
setting that the transpiler cannot see.
- Duration expressions (`offset step()`, `[range()]`, ...), anywhere. The
engine resolves them into the selector's static fields only at evaluation
time. At classification time those fields hold zero values. A transpile
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 `__name__`, or matching on it, in hybrid plans. The synthetic
name would leak into results.
- Name-keeping units in hybrid plans. Bare and comparison-filtered instant
selectors and `last_over_time` keep their real `__name__` (`keepsName`).
Substitution would replace that name. These units transpile only as full
plans.
- Every function outside the allowlist: changes, resets,
quantile_over_time, absent, native-histogram functions, and more.
Units inside a fixed-resolution subquery evaluate on the subquery's own
grid, not the query grid. That grid is the set of epoch-aligned multiples of
the resolution strictly after `outerStart - offset - range`, ending at
`outer end - offset`. This is the exact derivation the engine uses. A grid
shifted by one step changes which samples every window sees.
## From one unit to one statement
`buildUnitSQL` renders each unit as one statement. For
`sum by (pod) (rate(m{job="api"}[5m]))` the skeleton is:
SELECT g0, sumForEach(grid) AS grid FROM (
SELECT any(series.g0) AS g0,
timeSeriesRateToGrid(<start>, <end>, <step>, <range>)(fromUnixTimestamp64Milli(unix_milli), value) AS grid
FROM signoz_metrics.distributed_samples_v4 AS points
INNER JOIN (
SELECT fingerprint, JSONExtractString(labels, 'pod') AS g0
FROM signoz_metrics.time_series_v4
WHERE <series predicates>
GROUP BY fingerprint, g0
) AS series ON points.fingerprint = series.fingerprint
WHERE metric_name = ? AND temporality IN ['Cumulative', 'Unspecified']
AND unix_milli > <start - range> AND unix_milli <= <end>
AND bitAnd(flags, 1) = 0
GROUP BY points.fingerprint
) GROUP BY g0
SETTINGS allow_experimental_ts_to_grid_aggregate_function = 1
Read it from the inside out.
**The time window** is the selector's semantics, verbatim. Strict `>` on the
lower bound and `<=` on the upper bound is the left-open `(t - w, t]` rule.
The offset shifts the whole window. `bitAnd(flags, 1) = 0` drops stale
markers. PromQL excludes them from range vectors.
**The inner GROUP BY** computes one grid array per series.
`timeSeriesRateToGrid(start, end, step, range)` is a parametric aggregate.
It takes (timestamp, value) pairs and produces `Array(Nullable(Float64))`
with one slot per grid point. It is correct because it implements the
engine's `extrapolatedRate`, decision for decision: counter resets, the
zero-point clamp, the extrapolation thresholds, the two-samples rule, and
the left-open window. We verified this: we fed identical samples to both and
compared slot for slot. The only observed difference is the last bit.
ClickHouse's C++ and Go round the same formula differently. That 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. We emit
`arrayMap(x -> x * <range seconds>, <rate expr>)`. This is exact by
definition: `extrapolatedRate` computes the same extrapolated delta for both
and divides by the range only when `isRate`. The multiplication reverses it
exactly. The grid parameters render as literals, not bound args. They are
aggregate-function parameters. The experimental gate rides as a SETTINGS
clause on the statement itself, so telemetrystore hooks cannot remove it.
The group key is functionally dependent on the fingerprint: one fingerprint
is the hash of one labelset. So the inner query groups by the fingerprint
alone and reads the key columns with `any()`. This is exact, and it makes
the per-row hash key smaller.
**The join** gives each series 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. The
projection is a known short list, and the label names live in Go. To build,
sort, and stringify every label pair per row would be waste. This is correct
because column-tuple equality is label-set equality on the projection. An
extracted `''` means the label is absent. That is Prometheus semantics for
`by()` over missing labels. The empties are skipped when the columns turn
back into labels. `without` and no-aggregation project a label set that
varies per series. They get the canonical key: `toJSONString` of the sorted
[label, value] pairs that the unit projects. `without` excludes the listed
labels plus `__name__`. No-aggregation keeps everything; the name comes off
in Go, per the engine's name-dropping rules. Here the sort is load-bearing.
Stored JSON key order is not canonical across fingerprints. Two orderings of
the same labels must land in one group. Empty values are filtered for the
same absent-label reason. 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: slot `i` of every input
array refers to the same `t_i`. The combinators skip NULLs. That is the
engine aggregating only the series present at `t_i`. An index where every
series is absent stays NULL. Two edges need explicit handling. First,
`countForEach` wraps in a map of 0 back to NULL. A count over an all-absent
index is an absent point, not 0. Second, a unit without aggregation still
passes through `maxForEach`. That is the identity for the common
one-fingerprint group. It is a deterministic NULL-skipping merge when a
regex `__name__` selector collapses distinct metrics onto one projected
label set. One caveat is inherent: the summation order over series differs
from the engine's. Spatial aggregates can differ in the last ULP. Float
addition is not associative. No ordering reproduces the engine's result
bit-exactly from inside a GROUP BY.
## Instant selectors: staleness needs two aggregates
`unitInstant` uses window = lookback. It must reproduce the shadowing rule:
the point is absent when the latest in-window sample is a stale marker.
`timeSeriesLastToGrid` alone cannot express that. To skip stale rows in
WHERE would resurrect the older real sample that the marker buried. So stale
rows stay in the scan for this kind only. 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
This is correct by cases on a slot's window. No samples at all: both
timestamp aggregates are NULL, so the slot is NULL. That is 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. That is
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 the window
holds only markers. They disagree. The slot is NULL. The marker shadows,
exactly as the engine's rule says. Timestamps are unique per series (ingest
dedups). So timestamp equality identifies "the same sample" without
ambiguity. We probed the `-If` combinator against these experimental
aggregates before we trusted it.
## Windowed *_over_time: whole buckets instead of a grid function
avg/min/max/sum/count `_over_time` aggregate every raw sample in the window.
No `timeSeries*ToGrid` function computes them. (`last_over_time` is the
exception. The last sample of a range vector is exactly
`timeSeriesLastToGrid`. PromQL excludes stale markers from range vectors; we
exclude them in WHERE.) These shapes transpile only when the range is a
whole multiple of the step. Then the window needs no per-sample fan-out.
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:
intDiv(unix_milli - <start> + <range> - 1, <step>)
This is `ceil((ts - start)/step)` shifted by W-1, so the earliest in-window
sample sits at 0. Slot k's window is buckets in `[k, k+W-1]`. The
alternative fans each sample into all W windows that cover it. That
multiplies rows by W. For a long range over a short step, that is a row
explosion measured in billions. The bucketed form's row count is
series × buckets: the size of the output, for any W.
Each series aggregates in one group. The `-Resample` combinator
(`sumResample`, `countResample`) holds the dense per-bucket partials inside
one group state: a bucket count, plus the function's value aggregate (sum
for sum/avg, min, max). An earlier form grouped by (series, bucket) and
assembled with `groupArrayInsertAt`. At scale that made 37M hash groups, and
per-thread partials scaled memory with the thread count. The slide then
combines each slot's at-most-W bucket partials by direct aggregation
(`arraySum(arraySlice(...))`). Window sums are added the way the engine adds
them. There is no prefix-sum differencing: its large-minus-large
cancellation would drift past the shadow tolerance on counter-sized values.
This is 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; see the float caveat above). A slot with
zero window count is absent. min/max filter their slices on the bucket
counts. An empty bucket's default can never look like a value: a real sample
can legitimately be +Inf.
Two shapes fall back to the engine path, which is exact: a range that does
not divide the step, and a window wider than `maxWindowBuckets` buckets (the
slide costs W combines per slot). A range narrower than the step needs
neither gate: the windows are pairwise disjoint, one bucket per slot, no
slide. That form is exact only together with the window-sliver predicate
below.
## Scalar ops, full plans, hybrid plans
The scalar-op pipeline runs in Go on the returned arrays
(`applyScalarOps`), slot by slot. Arithmetic operators compute. Comparisons
filter: the slot keeps the vector-side value or becomes NULL. Under `bool`
they return 0/1. This is trivially correct. It is the same float64 operation
the engine applies, to the same slot value, in the same operator order the
AST dictates. 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. The engine 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. The name cannot matter. Plans that group by or match on
`__name__` were refused at classification. Name-keeping units are never
substituted. One subtlety makes it exact: we write stale markers at absent
grid points. Without them, the engine's lookback would resurrect a point
from up to `lookback` earlier. The marker encodes "absent here" the way the
engine itself encodes it. Units evaluate concurrently. Each unit is one
series lookup plus one grid statement. A step of 0 is an instant query: a
single evaluation at `end`.
A note on the window sliver: when the window is narrower than the step, the
grid windows cover only `window/step` of the timeline. A sample in a gap
belongs to no window. It cannot move any grid point, but the grid aggregate
would buffer it. A WHERE predicate keeps only the in-window rows:
`positiveModulo(selStart - unix_milli, step) < window`, with the scan capped
at the last grid point. The lattice anchors at the selector start, because
the end can sit off-lattice on unaligned grids. This cut a 36k-series
one-week rate from 74s/28GiB to 16s/4.3GiB on fleet data. Over slivered
rows, `timeSeriesLastToGrid`'s window widening is harmless, so instant
selectors and `last_over_time` transpile at window < step too.
## Series lookup
Matchers resolve to series once per selector (`selectSeries`) against the series
tables, which hold one row per (fingerprint, bucket) at 1h/6h/1d/1w
granularities. Table selection and window rounding delegate to the shared
metrics schema package (`pkg/telemetryschema/metricstelemetryschema`); the
window start rounds down to the bucket boundary so a window beginning mid-bucket
still matches the bucket's row.
Both paths resolve matchers the same way, once per selector
(`selectSeries`). The series tables hold one row per (fingerprint, bucket)
at 1h/6h/1d/1w granularities. The shared schema package
(`pkg/telemetryschema/metricstelemetryschema`) picks the table whose bucket
fits the window. It rounds the window start down to the bucket boundary, so
a window that begins mid-bucket still matches the bucket's row. 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.
How matchers become SQL is documented at `applySeriesConditions`. The rules that
carry semantics:
## The engine path
- `__name__` matchers (all four types) translate to the `metric_name` column.
- Every other matcher becomes a `JSONExtractString` condition on the labels
column. An equality matcher against `""` matches series *without* the label,
mirroring PromQL, because `JSONExtractString` returns `""` for missing keys.
- Regexes are anchored (`^(?:...)$`) before they reach `match()`: PromQL
matchers match the whole value, ClickHouse `match()` searches for a
substring.
- The series-lookup upper bound is inclusive (`unix_milli <= end`) because the
exporter floors registration rows to the bucket start: a series first
registered in the bucket beginning exactly at `end` would otherwise be
invisible while its samples are in range.
Empty-valued labels come off at this boundary: an empty value means "label
absent" in Prometheus, but stored attribute JSON can carry them.
---
## Sample fetch
Samples are fetched per selector using the engine's per-selector hints, not the
query-wide union window — `foo / foo offset 1d` reads two narrow windows
instead of the widest one twice.
**Last-sample-per-step reduction.** Instant selectors of subquery-free queries
fetch only the last sample per step bucket. The engine resolves an instant
selector at each grid timestamp `t` to the latest sample in the left-open
lookback window `(t lookback, t]`. 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. 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; call sites that do not attach traits get the conservative raw
fetch.
**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. Duplicate timestamps pass through as stored: uniqueness is
ingest's job, and v1 feeds them to the engine as-is over the same data.
**The fingerprint filter is a shard-local semi-join.** The samples query
restricts to the matched series by re-running the series predicates as an
`IN (SELECT fingerprint FROM <local series table> ...)` subquery, not a GLOBAL
broadcast of the matched set. ClickHouse materializes the subquery's set per
shard before the scan, so it still engages the fingerprint primary-key column.
Because the subquery re-executes the predicates after the lookup ran, it can
match series registered in between; sample rows whose fingerprint the lookup
never saw are skipped — the lookup is the read snapshot.
---
Queries that do not transpile run in the stock engine over this package's
`storage.Querier`. This is still not the v1 path. Samples are fetched per
selector with the engine's per-selector hints, not the query-wide union
window. So `foo / foo offset 1d` reads two narrow windows, not 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. The code recovers it from the hints
as `hints.Start + lookback - 1ms`, the inverse of how the engine derives
`hints.Start`. Bucket boundaries then coincide with evaluation timestamps.
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. The subquery-free
proof travels in the context as `prometheus.QueryTraits`. 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. It 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 semi-join above exploits
that: each shard filters by its own series rows, which are exactly the series
of that shard's samples.
The temporality filter on every samples statement
(`temporality IN ['Cumulative', 'Unspecified']`) 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 exactly as they are in v1:
the rollout gate is parity with v1, and a Delta stream fed to `rate()`
`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
exploits that. The distributed samples table at the top-level FROM makes
ClickHouse rewrite the whole inner query per shard. 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. This is the 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. It engages the leading samples primary-key column.
Delta-temporality series stay invisible to PromQL here, exactly as in v1.
The rollout gate is parity with v1. To make 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, so this provider's work is attributable in `system.query_log`.
`code.namespace=clickhouse-prometheus-v2` and `code.function.name` naming
the call site (`selectSeries`, `selectSamples`, `transpiledUnit`,
`LabelValues`, `LabelNames`). This provider's work is attributable in
`system.query_log` without guessing from query text.

View File

@@ -276,10 +276,6 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.MigrateV2(ctx, orgID, id)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
}

View File

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

View File

@@ -24,8 +24,6 @@
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",

View File

@@ -24,8 +24,6 @@
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",

View File

@@ -52,8 +52,6 @@ import type {
ListDashboardsV2200,
ListDashboardsV2Params,
LockDashboardV2PathParameters,
MigrateDashboardV2200,
MigrateDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
PinDashboardV2PathParameters,
@@ -1806,85 +1804,6 @@ export const useLockDashboardV2 = <
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};
/**
* This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.
* @summary Migrate dashboard to v2
*/
export const migrateDashboardV2 = (
{ id }: MigrateDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<MigrateDashboardV2200>({
url: `/api/v2/dashboards/${id}/migrate`,
method: 'POST',
signal,
});
};
export const getMigrateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['migrateDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof migrateDashboardV2>>,
{ pathParams: MigrateDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return migrateDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type MigrateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof migrateDashboardV2>>
>;
export type MigrateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Migrate dashboard to v2
*/
export const useMigrateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
> => {
return useMutation(getMigrateDashboardV2MutationOptions(options));
};
/**
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
* @summary Get public dashboard data (v2)

View File

@@ -4301,18 +4301,6 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderDTO {
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType;
}
export enum Querybuildertypesv5QueryEnvelopeBuilderAIDTOType {
builder_ai_query = 'builder_ai_query',
}
export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO;
/**
* @type string
* @enum builder_ai_query
*/
type: Querybuildertypesv5QueryEnvelopeBuilderAIDTOType;
}
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
/**
* @type boolean
@@ -4496,7 +4484,6 @@ export interface Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO {
export type Querybuildertypesv5QueryEnvelopeDTO =
| Querybuildertypesv5QueryEnvelopeBuilderDTO
| Querybuildertypesv5QueryEnvelopeBuilderAIDTO
| Querybuildertypesv5QueryEnvelopeFormulaDTO
| Querybuildertypesv5QueryEnvelopeTraceOperatorDTO
| Querybuildertypesv5QueryEnvelopePromQLDTO
@@ -8300,7 +8287,6 @@ export interface Querybuildertypesv5QueryRangeResponseDTO {
export enum Querybuildertypesv5QueryTypeDTO {
builder_query = 'builder_query',
builder_ai_query = 'builder_ai_query',
builder_formula = 'builder_formula',
builder_trace_operator = 'builder_trace_operator',
clickhouse_sql = 'clickhouse_sql',
@@ -11178,17 +11164,6 @@ export type UnlockDashboardV2PathParameters = {
export type LockDashboardV2PathParameters = {
id: string;
};
export type MigrateDashboardV2PathParameters = {
id: string;
};
export type MigrateDashboardV2200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type GetFeatures200 = {
/**
* @type array

View File

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

View File

@@ -9,6 +9,8 @@ export enum FeatureKeys {
ANOMALY_DETECTION = 'anomaly_detection',
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
USE_INFRA_MONITORING_V2 = 'use_infra_monitoring_v2',
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',
}

View File

@@ -1,8 +1,5 @@
// ** Helpers
import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
@@ -392,17 +389,11 @@ const METRIC_TYPE_TO_ATTRIBUTE_TYPE: Record<
export function toAttributeType(
metricType: MetrictypesTypeDTO | undefined,
isMonotonic?: boolean,
temporality?: MetrictypesTemporalityDTO,
): ATTRIBUTE_TYPES | '' {
if (!metricType) {
return '';
}
// Only non-monotonic cumulative sums are treated as gauges; delta sums stay Sum
if (
metricType === MetrictypesTypeDTO.sum &&
isMonotonic === false &&
temporality === MetrictypesTemporalityDTO.cumulative
) {
if (metricType === MetrictypesTypeDTO.sum && isMonotonic === false) {
return ATTRIBUTE_TYPES.GAUGE;
}
return METRIC_TYPE_TO_ATTRIBUTE_TYPE[metricType] || '';

View File

@@ -1,28 +1,18 @@
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import { GoogleChatInitialConfig } from 'container/CreateAlertChannels/defaults';
import {
googleChatDescriptionDefaultValue,
googleChatTitleDefaultValue,
opsGenieDescriptionDefaultValue,
opsGenieMessageDefaultValue,
opsGeniePriorityDefaultValue,
pagerDutyAdditionalDetailsDefaultValue,
pagerDutyDescriptionDefaultValue,
pagerDutyDescriptionDefaultVaule,
pagerDutySeverityTextDefaultValue,
slackDescriptionDefaultValue,
slackTitleDefaultValue,
} from 'mocks-server/__mockdata__/alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
act,
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { testLabelInputAndHelpValue } from './testUtils';
@@ -235,7 +225,7 @@ describe('Create Alert Channel', () => {
);
expect(descriptionTextArea).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
pagerDutyDescriptionDefaultVaule,
);
});
it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => {
@@ -429,150 +419,5 @@ describe('Create Alert Channel', () => {
expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue);
});
});
describe('Google Chat', () => {
const validWebhookUrl =
'https://chat.googleapis.com/v1/spaces/AAAA/messages?key=dummy_key&token=dummy_token';
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.GoogleChat} />);
});
it('Should check if the selected item in the type dropdown has text "Google Chat"', () => {
expect(screen.getByText('Google Chat')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Title contains the google chat template', () => {
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
googleChatTitleDefaultValue,
);
});
it('Should check if Description contains the google chat template', () => {
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
googleChatDescriptionDefaultValue,
);
});
it('Should check if saving with a webhook url outside chat.googleapis.com displays error notification', async () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'gchat-channel',
);
await user.type(
screen.getByTestId('webhook-url-textbox'),
'https://example.com/webhook',
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'google_chat_webhook_url_invalid',
}),
);
});
it('Should check if saving sends a googlechat_configs payload', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'gchat-channel',
);
await user.type(screen.getByTestId('webhook-url-textbox'), validWebhookUrl);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'gchat-channel',
googlechat_configs: [
{
webhook_url: validWebhookUrl,
title: GoogleChatInitialConfig.title,
text: GoogleChatInitialConfig.text,
send_resolved: true,
},
],
});
});
});
describe('Changing the channel type', () => {
async function selectType(
user: ReturnType<typeof userEvent.setup>,
optionText: string,
): Promise<void> {
// the type dropdown opens on the inner search input of the antd select
await user.click(screen.getByRole('combobox'));
await user.click(await screen.findByTitle(optionText));
}
it('Should check if switching to Google Chat and back swaps the prefilled templates', async () => {
const user = userEvent.setup();
render(<CreateAlertChannels preType={ChannelType.Slack} />);
await selectType(user, 'Google Chat');
await waitFor(() =>
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
googleChatTitleDefaultValue,
),
);
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
googleChatDescriptionDefaultValue,
);
await selectType(user, 'Slack');
await waitFor(() =>
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
slackTitleDefaultValue,
),
);
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
slackDescriptionDefaultValue,
);
});
it('Should check if switching to Pagerduty prefills the pagerduty description and not the opsgenie one', async () => {
const user = userEvent.setup();
render(<CreateAlertChannels preType={ChannelType.Opsgenie} />);
await selectType(user, 'Pagerduty');
await waitFor(() =>
expect(screen.getByTestId('pager-description-textarea')).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
),
);
});
});
});
});

View File

@@ -5,7 +5,7 @@ import {
opsGenieMessageDefaultValue,
opsGeniePriorityDefaultValue,
pagerDutyAdditionalDetailsDefaultValue,
pagerDutyDescriptionDefaultValue,
pagerDutyDescriptionDefaultVaule,
pagerDutySeverityTextDefaultValue,
slackDescriptionDefaultValue,
slackTitleDefaultValue,
@@ -150,7 +150,7 @@ describe('Create Alert Channel (Normal User)', () => {
);
expect(descriptionTextArea).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
pagerDutyDescriptionDefaultVaule,
);
});
it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => {

View File

@@ -104,7 +104,6 @@ export enum ChannelType {
Pagerduty = 'pagerduty',
Opsgenie = 'opsgenie',
MsTeams = 'msteams',
GoogleChat = 'googlechat',
}
// LabelFilterStatement will be used for preparing filter conditions / matchers
@@ -126,11 +125,3 @@ export interface MsTeamsChannel extends Channel {
title?: string;
text?: string;
}
export interface GoogleChatChannel extends Channel {
// incoming webhook url of the google chat space, must be an
// https url on chat.googleapis.com
webhook_url?: string;
title?: string;
text?: string;
}

View File

@@ -1,51 +1,4 @@
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
WebhookChannel,
} from './config';
// shared by slack and ms teams, both render the same title / description boxes
export const SlackInitialConfig: Partial<SlackChannel> = {
text: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
*Description:* {{ .Annotations.description }}
*RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}
*RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}
*Details:*
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
{{- range $index, $label := .SortedPairs -}}
{{ if $index }}, {{ end }}
{{- $label.Name }}="{{ $label.Value -}}"
{{- end }}
{{- end -}}
)
{{- end }}`,
};
// mirrors DefaultGoogleChatReceiverConfig in pkg/types/alertmanagertypes/googlechat.go,
// which the backend applies when title / text are left empty
export const GoogleChatInitialConfig: Partial<GoogleChatChannel> = {
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
text: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }}
**Summary:** {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }}
**Description:** {{ .Annotations.description }}{{ end }}
{{ end }}`,
};
import { EmailChannel, OpsgenieChannel, PagerChannel } from './config';
export const PagerInitialConfig: Partial<PagerChannel> = {
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
@@ -493,26 +446,3 @@ export const EmailInitialConfig: Partial<EmailChannel> = {
</body>
</html>`,
};
// prefilled values of every channel type, keyed by type so the form can apply
// exactly one set of defaults and swap it when the type changes
export const ChannelInitialConfig: Record<
ChannelType,
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
>
> = {
[ChannelType.Slack]: SlackInitialConfig,
[ChannelType.MsTeams]: SlackInitialConfig,
[ChannelType.GoogleChat]: GoogleChatInitialConfig,
[ChannelType.Pagerduty]: PagerInitialConfig,
[ChannelType.Opsgenie]: OpsgenieInitialConfig,
[ChannelType.Email]: EmailInitialConfig,
[ChannelType.Webhook]: {},
};

View File

@@ -14,24 +14,16 @@ import testPagerApi from 'api/channels/testPager';
import testSlackApi from 'api/channels/testSlack';
import testWebhookApi from 'api/channels/testWebhook';
import logEvent from 'api/common/logEvent';
import {
useCreateChannel,
useTestChannel,
} from 'api/generated/services/channels';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
@@ -39,12 +31,12 @@ import {
ValidatePagerChannel,
WebhookChannel,
} from './config';
import { ChannelInitialConfig } from './defaults';
import {
isChannelType,
isValidGoogleChatWebhookURL,
prepareGoogleChatRequest,
} from './utils';
EmailInitialConfig,
OpsgenieInitialConfig,
PagerInitialConfig,
} from './defaults';
import { isChannelType } from './utils';
import './CreateAlertChannels.styles.scss';
@@ -68,38 +60,69 @@ function CreateAlertChannels({
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
EmailChannel
>
>(() => ({
>({
send_resolved: true,
...ChannelInitialConfig[preType],
}));
text: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
*Description:* {{ .Annotations.description }}
*RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}
*RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}
*Details:*
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
{{- range $index, $label := .SortedPairs -}}
{{ if $index }}, {{ end }}
{{- $label.Name }}="{{ $label.Value -}}"
{{- end }}
{{- end -}}
)
{{- end }}`,
});
const [savingState, setSavingState] = useState<boolean>(false);
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
const { mutateAsync: createChannel } = useCreateChannel();
const { mutateAsync: testChannel } = useTestChannel();
const [type, setType] = useState<ChannelType>(preType);
const onTypeChangeHandler = useCallback(
(value: string) => {
const nextType = value as ChannelType;
if (nextType === type) {
return;
const currentType = type;
setType(value as ChannelType);
if (value === ChannelType.Pagerduty && currentType !== value) {
// reset config to pager defaults
setSelectedConfig({
name: selectedConfig?.name,
send_resolved: selectedConfig.send_resolved,
...PagerInitialConfig,
});
}
setType(nextType);
if (value === ChannelType.Opsgenie && currentType !== value) {
setSelectedConfig((selectedConfig) => ({
...selectedConfig,
...OpsgenieInitialConfig,
}));
}
// the fields the types share (title, text, description) keep the value of
// the type that was selected before, so the new type's defaults have to be
// written to both the config and the form
const defaults = ChannelInitialConfig[nextType];
setSelectedConfig((selectedConfig) => ({ ...selectedConfig, ...defaults }));
formInstance.setFieldsValue(defaults);
// reset config to email defaults
if (value === ChannelType.Email && currentType !== value) {
setSelectedConfig((selectedConfig) => ({
...selectedConfig,
...EmailInitialConfig,
}));
}
},
[type, formInstance],
[type, selectedConfig],
);
const prepareSlackRequest = useCallback(
@@ -384,56 +407,6 @@ function CreateAlertChannels({
showErrorModal,
]);
const validateGoogleChatConfig = useCallback((): boolean => {
if (!selectedConfig.webhook_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return false;
}
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
notifications.error({
message: 'Error',
description: t('google_chat_webhook_url_invalid'),
});
return false;
}
return true;
}, [selectedConfig.webhook_url, notifications, t]);
const onGoogleChatHandler = useCallback(async () => {
if (!validateGoogleChatConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareGoogleChatRequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateGoogleChatConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
if (!selectedConfig.name) {
@@ -451,7 +424,6 @@ function CreateAlertChannels({
[ChannelType.Opsgenie]: onOpsgenieHandler,
[ChannelType.MsTeams]: onMsTeamsHandler,
[ChannelType.Email]: onEmailHandler,
[ChannelType.GoogleChat]: onGoogleChatHandler,
};
if (isChannelType(value)) {
@@ -483,7 +455,6 @@ function CreateAlertChannels({
onOpsgenieHandler,
onMsTeamsHandler,
onEmailHandler,
onGoogleChatHandler,
notifications,
t,
],
@@ -521,13 +492,6 @@ function CreateAlertChannels({
request = prepareEmailRequest();
await testEmail(request);
break;
case ChannelType.GoogleChat:
if (!validateGoogleChatConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
default:
notifications.error({
message: 'Error',
@@ -549,11 +513,7 @@ function CreateAlertChannels({
status: 'Test success',
});
} catch (error) {
showErrorModal(
error instanceof APIError
? error
: toAPIError(error as ErrorType<RenderErrorResponseDTO>),
);
showErrorModal(error as APIError);
logEvent('Alert Channel: Test notification', {
type: channelType,
@@ -575,8 +535,6 @@ function CreateAlertChannels({
prepareSlackRequest,
prepareMsTeamsRequest,
prepareEmailRequest,
validateGoogleChatConfig,
testChannel,
notifications,
],
);
@@ -604,6 +562,9 @@ function CreateAlertChannels({
initialValue: {
type,
...selectedConfig,
...PagerInitialConfig,
...OpsgenieInitialConfig,
...EmailInitialConfig,
},
}}
/>

View File

@@ -1,39 +1,4 @@
import {
AlertmanagertypesPostableChannelDTO,
ConfigSecretURLDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ChannelType, GoogleChatChannel } from './config';
import { ChannelType } from './config';
export const isChannelType = (type: string): type is ChannelType =>
Object.values(ChannelType).includes(type as ChannelType);
const GOOGLE_CHAT_WEBHOOK_HOST = 'chat.googleapis.com';
// the backend enforces the same two rules, this is only for a nicer error experience
export const isValidGoogleChatWebhookURL = (url: string): boolean => {
try {
const { protocol, hostname } = new URL(url);
return (
protocol === 'https:' && hostname.toLowerCase() === GOOGLE_CHAT_WEBHOOK_HOST
);
} catch {
return false;
}
};
// create, update and test all send the same body shape
export const prepareGoogleChatRequest = (
config: Partial<GoogleChatChannel>,
): AlertmanagertypesPostableChannelDTO => ({
name: config.name || '',
googlechat_configs: [
{
// the generated type models go's config.SecretURL as an object, the api takes a string
webhook_url: (config.webhook_url || '') as unknown as ConfigSecretURLDTO,
title: config.title || '',
text: config.text || '',
send_resolved: config.send_resolved || false,
},
],
});

View File

@@ -14,17 +14,10 @@ import testPagerApi from 'api/channels/testPager';
import testSlackApi from 'api/channels/testSlack';
import testWebhookApi from 'api/channels/testWebhook';
import logEvent from 'api/common/logEvent';
import {
useTestChannel,
useUpdateChannelByID,
} from 'api/generated/services/channels';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
@@ -32,15 +25,10 @@ import {
ValidatePagerChannel,
WebhookChannel,
} from 'container/CreateAlertChannels/config';
import {
isValidGoogleChatWebhookURL,
prepareGoogleChatRequest,
} from 'container/CreateAlertChannels/utils';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
function EditAlertChannels({
initialValue,
@@ -57,8 +45,7 @@ function EditAlertChannels({
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
EmailChannel
>
>({
...initialValue,
@@ -67,26 +54,6 @@ function EditAlertChannels({
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
const { mutateAsync: updateChannel } = useUpdateChannelByID();
const { mutateAsync: testChannel } = useTestChannel();
const notifyError = useCallback(
(error: unknown): APIError => {
const apiError =
error instanceof APIError
? error
: toAPIError(error as ErrorType<RenderErrorResponseDTO>);
notifications.error({
message: apiError.getErrorCode(),
description: apiError.getErrorMessage(),
});
return apiError;
},
[notifications],
);
const [type, setType] = useState<ChannelType>(
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
);
@@ -397,61 +364,6 @@ function EditAlertChannels({
}
}, [prepareMsTeamsRequest, t, notifications, selectedConfig]);
const validateGoogleChatConfig = useCallback((): string => {
if (!selectedConfig?.webhook_url) {
return t('webhook_url_required');
}
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
return t('google_chat_webhook_url_invalid');
}
return '';
}, [selectedConfig, t]);
const onGoogleChatEditHandler = useCallback(async () => {
const validationError = validateGoogleChatConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareGoogleChatRequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateGoogleChatConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
let result;
@@ -467,8 +379,6 @@ function EditAlertChannels({
result = await onOpsgenieEditHandler();
} else if (value === ChannelType.Email) {
result = await onEmailEditHandler();
} else if (value === ChannelType.GoogleChat) {
result = await onGoogleChatEditHandler();
}
logEvent('Alert Channel: Save channel', {
type: value,
@@ -487,7 +397,6 @@ function EditAlertChannels({
onMsTeamsEditHandler,
onOpsgenieEditHandler,
onEmailEditHandler,
onGoogleChatEditHandler,
],
);
@@ -529,19 +438,6 @@ function EditAlertChannels({
await testEmail(request);
}
break;
case ChannelType.GoogleChat: {
const validationError = validateGoogleChatConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
}
default:
notifications.error({
message: 'Error',
@@ -563,7 +459,10 @@ function EditAlertChannels({
status: 'Test success',
});
} catch (error) {
notifyError(error);
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,
@@ -577,9 +476,6 @@ function EditAlertChannels({
// eslint-disable-next-line react-hooks/exhaustive-deps
[
t,
notifyError,
validateGoogleChatConfig,
testChannel,
prepareWebhookRequest,
preparePagerRequest,
prepareSlackRequest,

View File

@@ -1,82 +0,0 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { GoogleChatChannel } from '../../CreateAlertChannels/config';
import { isValidGoogleChatWebhookURL } from '../../CreateAlertChannels/utils';
function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
const { t } = useTranslation('channels');
return (
<>
<Form.Item
name="webhook_url"
label={t('field_webhook_url')}
required
rules={[
{
validator: (_, value: string): Promise<void> =>
!value || isValidGoogleChatWebhookURL(value)
? Promise.resolve()
: Promise.reject(new Error(t('google_chat_webhook_url_invalid'))),
},
]}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_google_chat_url')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
webhook_url: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
/>
</Form.Item>
<Form.Item name="title" label={t('field_slack_title')}>
<Input.TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
title: event.target.value,
}))
}
data-testid="title-textarea"
/>
</Form.Item>
<Form.Item name="text" label={t('field_slack_description')}>
<Input.TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
text: event.target.value,
}))
}
data-testid="description-textarea"
placeholder={t('placeholder_slack_description')}
/>
</Form.Item>
</>
);
}
interface GoogleChatProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<GoogleChatChannel>>>;
}
export default GoogleChat;

View File

@@ -9,7 +9,6 @@ import ROUTES from 'constants/routes';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
@@ -18,7 +17,6 @@ import {
import history from 'lib/history';
import EmailSettings from './Settings/Email';
import GoogleChatSettings from './Settings/GoogleChat';
import MsTeamsSettings from './Settings/MsTeams';
import OpsgenieSettings from './Settings/Opsgenie';
import PagerSettings from './Settings/Pager';
@@ -51,8 +49,6 @@ function FormAlertChannels({
return <PagerSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.MsTeams:
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.GoogleChat:
return <GoogleChatSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Opsgenie:
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Email:
@@ -133,14 +129,6 @@ function FormAlertChannels({
<Select.Option value="msteams" key="msteams" data-testid="select-option">
Microsoft Teams
</Select.Option>
<Select.Option
value="googlechat"
key="googlechat"
data-testid="select-option"
>
Google Chat
</Select.Option>
</Select>
</Form.Item>
@@ -188,8 +176,7 @@ interface FormAlertChannelsProps {
WebhookChannel &
PagerChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
EmailChannel
>
>
>;

View File

@@ -17,7 +17,11 @@ import {
QuickFilterChangeEventData,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import {
InfraMonitoringEvents,
logInfraFilterCustomizedEvent,
logInfraMonitoringListViewedEvent,
} from 'constants/events';
import { initialQueriesMap } from 'constants/queryBuilder';
import K8sBaseDetails, {
K8sDetailsFilters,
@@ -53,10 +57,6 @@ import styles from './InfraMonitoringHosts.module.scss';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import {
logInfraFilterCustomizedEvent,
logInfraMonitoringListViewedEvent,
} from 'container/InfraMonitoringK8sV2/Base/events';
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);

View File

@@ -1,4 +1,5 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { logInfraFilterCustomizedEvent } from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
StatusFilterValue,
@@ -8,7 +9,6 @@ import {
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import styles from './StatusFilter.module.scss';
import { logInfraFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
const statusOptions: Array<{
label: string;

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Color } from '@signozhq/design-tokens';
import { Badge } from '@signozhq/ui/badge';
import { Progress } from '@signozhq/ui/progress';
import {
@@ -9,7 +10,6 @@ import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { TextNoData } from 'container/InfraMonitoringK8sV2/components';
import { getStrokeColorForPercent } from 'container/InfraMonitoringK8sV2/components/EntityProgressBar.utils';
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
import {
getHostQueryPayload,
@@ -18,6 +18,26 @@ import {
import infraHostsStyles from './InfraMonitoringHosts.module.scss';
export function getProgressColor(percent: number): string {
if (percent >= 90) {
return Color.BG_SAKURA_500;
}
if (percent >= 60) {
return Color.BG_AMBER_500;
}
return Color.BG_FOREST_500;
}
export function getMemoryProgressColor(percent: number): string {
if (percent >= 90) {
return Color.BG_CHERRY_500;
}
if (percent >= 60) {
return Color.BG_AMBER_500;
}
return Color.BG_FOREST_500;
}
export type HostDetailMetadataConfigType =
K8sDetailsMetadataConfig<InframonitoringtypesHostRecordDTO>;
export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
@@ -59,7 +79,7 @@ export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
render: (value): React.ReactNode => (
<Progress
percent={Number(Number(value).toFixed(1))}
strokeColor={getStrokeColorForPercent('cpu', Number(value))}
strokeColor={getProgressColor(Number(value))}
showInfo
/>
),
@@ -70,7 +90,7 @@ export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
render: (value): React.ReactNode => (
<Progress
percent={Number(Number(value).toFixed(1))}
strokeColor={getStrokeColorForPercent('memory', Number(value))}
strokeColor={getMemoryProgressColor(Number(value))}
showInfo
/>
),

View File

@@ -9,7 +9,6 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { getGroupByEl } from 'container/InfraMonitoringK8sV2/Base/utils';
import {
EntityProgressBar,
EntityProgressThresholds,
ExpandButtonWrapper,
GroupedStatusCounts,
ValidateColumnValueWrapper,
@@ -99,7 +98,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
),
},
{
id: INFRA_MONITORING_ATTR_KEYS.HOST_NAME,
id: 'hostName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Hostname"
@@ -109,7 +108,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
),
accessorFn: (row): string => row.hostName ?? '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -169,10 +168,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
{
id: 'cpu',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/host-monitoring#cpu-usage"
tooltip={<EntityProgressThresholds type="cpu" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#cpu-usage">
CPU Usage
</ColumnHeader>
),
@@ -199,9 +195,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
id: 'memory',
header: (): React.ReactNode => (
<ColumnHeader
tooltip={
<EntityProgressThresholds type="memory" note="Excluding cache memory." />
}
tooltip="Excluding cache memory."
docPath="/infrastructure-monitoring/host-monitoring#memory-usage"
>
Memory Usage (WSS)
@@ -227,12 +221,9 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
},
},
{
id: 'disk_usage',
id: 'diskUsage',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/host-monitoring#disk-usage"
tooltip={<EntityProgressThresholds type="disk" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#disk-usage">
Disk Usage
</ColumnHeader>
),

View File

@@ -3,14 +3,13 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './ColumnHeader.module.scss';
import cx from 'classnames';
import { MouseEventHandler } from 'react';
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
interface ColumnHeaderProps {
children?: React.ReactNode;
docPath?: string;
tooltip?: React.ReactNode;
tooltip?: string;
className?: string;
}
@@ -20,9 +19,6 @@ function ColumnHeader({
tooltip,
className,
}: ColumnHeaderProps): JSX.Element {
const stopPropagationHandler: MouseEventHandler = (e): void =>
e.stopPropagation();
const renderContent = (): React.ReactNode => {
if (children) {
return children;
@@ -34,25 +30,21 @@ function ColumnHeader({
const renderInfoIcon = (): React.ReactNode => {
if (docPath) {
const tooltipTitle = tooltip || 'Not sure what this means?';
const isJustStringTitle = typeof tooltipTitle === 'string';
return (
<TooltipSimple
arrow
title={
<div onClick={stopPropagationHandler}>
<>
{tooltipTitle}{' '}
<a
href={`${DOCS_BASE_URL}${docPath}`}
target="_blank"
rel="noopener"
onClick={stopPropagationHandler}
onClick={(e): void => e.stopPropagation()}
>
{isJustStringTitle
? 'Learn more.'
: 'Check the documentation to learn more.'}
Learn more.
</a>
</div>
</>
}
>
<div className={styles.infoIcon}>
@@ -64,9 +56,7 @@ function ColumnHeader({
if (tooltip) {
return (
<TooltipSimple
title={<div onClick={stopPropagationHandler}>{tooltip}</div>}
>
<TooltipSimple title={tooltip}>
<div className={styles.infoIcon}>
<Info size="md" />
</div>

View File

@@ -12,7 +12,11 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
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,
@@ -46,8 +50,6 @@ import { K8sBaseDetailsContentProps } from './types';
import { getDrawerDurationMs } from './useDrawerLifecycleStore';
import styles from '../EntityDetailsUtils/entityDetails.module.scss';
import { logInfraDrawerTabViewedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function K8sBaseDetailsContent<T>({

View File

@@ -9,7 +9,11 @@ import TanStackTable, {
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';
@@ -44,10 +48,6 @@ import { K8sInstrumentationChecksCallout } from './components/K8sInstrumentation
import styles from './K8sBaseList.module.scss';
import cx from 'classnames';
import {
logInfraColumnSortedEvent,
logInfraTimeRangeCustomizedEvent,
} from 'container/InfraMonitoringK8sV2/Base/events';
export type K8sBaseListEmptyStateContext = {
isError: boolean;
@@ -128,8 +128,6 @@ export function K8sBaseList<
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
rowHeight: 42,
headerHeight: 58,
paginationHeight: 52,
});
const {
@@ -438,17 +436,16 @@ export function K8sBaseList<
isFetching={isFetching}
cancelQuery={cancelQuery}
/>
<K8sInstrumentationChecksCallout entity={entity} />
<K8sTableToolbar
entity={entity}
eventCategory={eventCategory}
leftFilters={leftFilters}
onOpenOptionsDrawer={handleOpenOptionsDrawer}
/>
<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'}

View File

@@ -13,7 +13,7 @@
--tanstack-table-resize-handle-hover-bg: var(--l1-border);
--tanstack-table-row-height: 36px;
--tanstack-cell-padding-left-override: 26px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-padding-right-override: 15px;
& [data-hide-expanded='true'] {

View File

@@ -10,19 +10,19 @@ import TanStackTable, {
TableColumnDef,
TanStackTableStateProvider,
} from 'components/TanStackTableView';
import { QueryParams } from 'constants/query';
import { CornerDownRight } from '@signozhq/icons';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { v4 as uuid } from 'uuid';
import { useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../constants';
import { logInfraColumnSortedEvent } from 'constants/events';
import { InfraMonitoringEntity } from '../constants';
import {
SelectedItemParams,
useInfraMonitoringGroupBy,
@@ -36,9 +36,6 @@ import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferences
import styles from './K8sExpandedRow.module.scss';
import { buildExpressionFromGroupMeta } from './utils';
import { logInfraColumnSortedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
import { getUnstableCurrentSearchParams } from 'container/TopNav/DateTimeSelectionV2/utils/getUnstableCurrentSearchParams';
import { QueryParams } from 'constants/query';
const EXPANDED_ROW_LIMIT = 10;
@@ -95,6 +92,7 @@ export function K8sExpandedRow<
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const location = useLocation();
const queryClient = useQueryClient();
@@ -260,26 +258,13 @@ export function K8sExpandedRow<
},
};
const searchParams = getUnstableCurrentSearchParams();
searchParams.set(
const newUrlQuery = new URLSearchParams(urlQuery.toString());
newUrlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
searchParams.delete(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
searchParams.delete(INFRA_MONITORING_K8S_PARAMS_KEYS.EXPANDED);
searchParams.delete(orderByParamKey);
searchParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE, '1');
if (orderBy) {
searchParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY,
JSON.stringify(orderBy),
);
}
safeNavigate(`${location.pathname}?${searchParams.toString()}`);
safeNavigate(`${location.pathname}?${newUrlQuery.toString()}`);
};
const total = data?.total ?? 0;
@@ -291,7 +276,6 @@ export function K8sExpandedRow<
color="secondary"
variant="outlined"
className={styles.viewAllButton}
data-testid="expanded-row-view-all"
onClick={handleViewAllClick}
prefix={<CornerDownRight size={14} />}
>

View File

@@ -2,7 +2,10 @@ import React, { useCallback, useMemo, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import logEvent from 'api/common/logEvent';
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';
@@ -21,7 +24,6 @@ import {
import { useInfraMonitoringPageListing } from '../hooks';
import styles from './K8sHeader.module.scss';
import { logInfraFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
interface K8sHeaderProps {
controlListPrefix?: React.ReactNode;

View File

@@ -4,34 +4,19 @@ import { Select } from 'antd';
import { Download, SlidersVertical } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import { InfraMonitoringEvents } from 'constants/events';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
InfraMonitoringEvents,
logInfraGroupByCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEntity } from '../constants';
import {
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
} from '../hooks';
import { useInfraMonitoringGroupByData } from './useInfraMonitoringGroupByData';
import styles from './K8sTableToolbar.module.scss';
import { logInfraGroupByCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
const NAME_COLUMN_KEYS: Set<string> = new Set([
INFRA_MONITORING_ATTR_KEYS.HOST_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
]);
interface K8sTableToolbarProps {
entity: InfraMonitoringEntity;
@@ -52,17 +37,11 @@ function K8sTableToolbar({
useInfraMonitoringGroupByData(entity);
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const handleGroupByChange = useCallback(
(value: string[]) => {
void setCurrentPage(1);
if (orderBy && NAME_COLUMN_KEYS.has(orderBy.columnName)) {
void setOrderBy(null);
}
void setGroupBy(value);
void logEvent(InfraMonitoringEvents.GroupByChanged, {
@@ -73,16 +52,15 @@ function K8sTableToolbar({
logInfraGroupByCustomizedEvent(entity, value);
},
[entity, eventCategory, orderBy, setCurrentPage, setOrderBy, setGroupBy],
[entity, eventCategory, setCurrentPage, setGroupBy],
);
return (
<div className={styles.toolbar}>
<div className={styles.groupByContainer} data-testid="k8s-table-group-by">
<div className={styles.groupByContainer}>
<div className={styles.groupByLabel}>Group by</div>
<Select
className={styles.groupBySelect}
data-testid="k8s-table-group-by-select"
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}

View File

@@ -1370,127 +1370,4 @@ describe('K8sBaseList', () => {
).resolves.toBeInTheDocument();
});
});
describe('groupBy change clears orderBy', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [{ id: 'item-1' }],
total: 1,
error: null,
});
server.use(
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
res(ctx.json({ status: 'success', data: { ready: true } })),
),
rest.get('http://localhost/api/v1/fields/keys', (_, res, ctx) =>
res(
ctx.json({
status: 'success',
data: {
keys: {
resource: [{ name: 'k8s.namespace.name' }],
},
},
}),
),
),
);
});
it('should clear orderBy for name columns when groupBy is changed', async () => {
const user = userEvent.setup();
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
// k8s.pod.name is a name column - should be cleared
orderBy: JSON.stringify({ columnName: 'k8s.pod.name', order: 'desc' }),
},
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByTestId('k8s-table-group-by')).toBeInTheDocument();
});
// Open group by dropdown using testId
const groupByContainer = screen.getByTestId('k8s-table-group-by-select');
const groupBySelect = groupByContainer.querySelector(
'.ant-select-selector',
) as Element;
await user.click(groupBySelect);
// Wait for options to load and click on the namespace option
const namespaceOption = await screen.findByTitle('k8s.namespace.name');
await user.click(namespaceOption);
// Verify orderBy was cleared (set to null) for name column
await waitFor(() => {
const orderByCalls = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('orderBy'))
.filter((v) => v !== undefined);
const hasOrderByCleared = orderByCalls.some((v) => v === null);
expect(hasOrderByCleared).toBe(true);
});
});
it('should keep orderBy for non-name columns when groupBy is changed', async () => {
const user = userEvent.setup();
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
// cpu is NOT a name column - should be kept
orderBy: JSON.stringify({ columnName: 'cpu', order: 'desc' }),
},
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByTestId('k8s-table-group-by')).toBeInTheDocument();
});
// Open group by dropdown using testId
const groupByContainer = screen.getByTestId('k8s-table-group-by-select');
const groupBySelect = groupByContainer.querySelector(
'.ant-select-selector',
) as Element;
await user.click(groupBySelect);
// Wait for options to load and click on the namespace option
const namespaceOption = await screen.findByTitle('k8s.namespace.name');
await user.click(namespaceOption);
// Verify orderBy was NOT cleared for non-name column
await waitFor(() => {
const orderByCalls = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('orderBy'))
.filter((v) => v !== undefined);
// orderBy should never be set to null
const hasOrderByCleared = orderByCalls.some((v) => v === null);
expect(hasOrderByCleared).toBe(false);
});
});
});
});

View File

@@ -1,13 +1,13 @@
/* 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';
import { logInfraColumnCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
jest.mock('container/InfraMonitoringK8sV2/Base/events', () => ({
jest.mock('constants/events', () => ({
logInfraColumnCustomizedEvent: jest.fn(),
}));

View File

@@ -3,6 +3,7 @@ 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';
@@ -16,7 +17,6 @@ import {
} from '../../../constants';
import { getDrawerDurationMs } from '../../useDrawerLifecycleStore';
import styles from './EntityCountsSection.module.scss';
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
export interface EntityCountConfig<T> {
label: string;

View File

@@ -1,113 +0,0 @@
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import logEvent from 'api/common/logEvent';
import { getNavigationReferrer } from 'lib/navigation';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isCustomTimeRange } from 'store/globalTime';
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 logInfraGroupByCustomizedEvent(
entityType: InfraMonitoringEntity,
groupByKeysList: string[],
): void {
void logEvent('infra_group_by_customized', {
entity_type: entityType,
group_by_keys_list: groupByKeysList,
});
}
export function logInfraExplorerNavigatedEvent(params: {
entityType: InfraMonitoringEntity;
destination:
| 'metrics_explorer'
| 'logs_explorer'
| 'traces_explorer'
| 'k8s_list';
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
tab: string;
sourceKey: string | null;
drawerDurationMsAtNavigation: number | null;
}): void {
void logEvent('infra_explorer_navigated', {
entity_type: params.entityType,
destination: params.destination,
source: params.source,
tab: params.tab,
source_key: params.sourceKey,
drawer_duration_ms_at_navigation: params.drawerDurationMsAtNavigation,
});
}

View File

@@ -4,6 +4,7 @@ import {
useColumnOrder,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import { logInfraColumnCustomizedEvent } from 'constants/events';
import { InfraMonitoringEntity } from '../constants';
@@ -12,7 +13,6 @@ import {
useInfraMonitoringLineClamp,
} from './useInfraMonitoringTablePreferencesStore';
import { sortByColumnOrder } from './utils';
import { logInfraColumnCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
interface UseEmitColumnCustomizedParams<TData> {
entity: InfraMonitoringEntity;

View File

@@ -60,7 +60,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
id: 'clusterName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Cluster Name"
@@ -70,7 +70,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
),
accessorFn: (row): string => row.clusterName || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',

View File

@@ -10,7 +10,6 @@ import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
@@ -70,7 +69,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
id: 'daemonsetName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="DaemonSet Name"
@@ -81,7 +80,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -175,10 +174,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-req-usage-"
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-req-usage-">
CPU Request Usage (%)
</ColumnHeader>
),
@@ -196,7 +192,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="cpu-request" />
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -204,10 +200,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-limit-usage-"
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-limit-usage-">
CPU Limit Usage (%)
</ColumnHeader>
),
@@ -224,7 +217,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
@@ -258,10 +251,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-req-usage-"
tooltip={<EntityProgressThresholds type="memory-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-req-usage-">
Memory Request Usage (%)
</ColumnHeader>
),
@@ -279,7 +269,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="memory-request" />
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -287,10 +277,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-limit-usage-"
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-limit-usage-">
Memory Limit Usage (%)
</ColumnHeader>
),
@@ -307,7 +294,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="memory-limit" />
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},

View File

@@ -10,7 +10,6 @@ import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
@@ -71,7 +70,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
id: 'deploymentName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Deployment Name"
@@ -82,7 +81,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -163,10 +162,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-req-usage-"
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-req-usage-">
CPU Request Usage (%)
</ColumnHeader>
),
@@ -184,7 +180,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="cpu-request" />
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -192,10 +188,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-limit-usage-"
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-limit-usage-">
CPU Limit Usage (%)
</ColumnHeader>
),
@@ -212,7 +205,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
@@ -245,10 +238,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/deployments#mem-req-usage-"
tooltip={<EntityProgressThresholds type="memory-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-req-usage-">
Memory Request Usage (%)
</ColumnHeader>
),
@@ -266,7 +256,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="memory-request" />
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -274,10 +264,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/deployments#mem-limit-usage-"
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-limit-usage-">
Memory Limit Usage (%)
</ColumnHeader>
),
@@ -294,7 +281,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="memory-limit" />
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},

View File

@@ -3,7 +3,10 @@ import { Undo } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import { InfraMonitoringEvents } from 'constants/events';
import {
InfraMonitoringEvents,
logInfraDrawerTimeRangeCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
@@ -14,7 +17,6 @@ import {
import { useEntityDetailsTime } from './useEntityDetailsTime';
import styles from './EntityDateTimeSelector.module.scss';
import { logInfraDrawerTimeRangeCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
interface EntityDateTimeSelectorProps {
eventEntity: string;

View File

@@ -16,7 +16,10 @@ import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import {
InfraMonitoringEvents,
logInfraDrawerFilterCustomizedEvent,
} from 'constants/events';
import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
@@ -38,7 +41,6 @@ import { getEntityEventsQueryPayload, isEventsKeyNotFoundError } from './utils';
import styles from './EntityEvents.module.scss';
import { useTimezone } from 'providers/Timezone';
import { logInfraDrawerFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
interface EventDataType {
key: string;

View File

@@ -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';
@@ -50,7 +53,6 @@ import { isModifierKeyPressed } from 'utils/app';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { logInfraDrawerFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
interface Props {
eventEntity: string;

View File

@@ -2,7 +2,10 @@ 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';
@@ -32,7 +35,6 @@ import { isKeyNotFoundError } from '../utils';
import styles from './EntityMetrics.module.scss';
import { MetricsTable } from './MetricsTable';
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
interface EntityMetricsProps<T> {
entity: T;

View File

@@ -16,7 +16,10 @@ import {
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { ResizeTable } from 'components/ResizeTable';
import { InfraMonitoringEvents } from 'constants/events';
import {
InfraMonitoringEvents,
logInfraDrawerFilterCustomizedEvent,
} from 'constants/events';
import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
@@ -38,7 +41,6 @@ import { getEntityTracesQueryPayload } from './utils';
import styles from './EntityTraces.module.scss';
import { useTimezone } from 'providers/Timezone';
import { logInfraDrawerFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
interface Props {
eventEntity: string;

View File

@@ -1,48 +0,0 @@
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import logEvent from 'api/common/logEvent';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isCustomTimeRange } from 'store/globalTime';
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 logInfraDrawerTabViewedEvent(
entityType: InfraMonitoringEntity,
tab: string,
isDefaultTab: boolean,
): void {
void logEvent('infra_drawer_tab_viewed', {
entity_type: entityType,
tab,
is_default_tab: isDefaultTab,
});
}

View File

@@ -51,14 +51,14 @@ import {
} from './hooks';
import styles from './InfraMonitoringK8s.module.scss';
import { InfraMonitoringEvents } from 'constants/events';
import logEvent from 'api/common/logEvent';
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import {
logInfraFilterCustomizedEvent,
logInfraMonitoringListViewedEvent,
} from 'container/InfraMonitoringK8sV2/Base/events';
InfraMonitoringEvents,
} from 'constants/events';
import logEvent from 'api/common/logEvent';
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
export default function InfraMonitoringK8s(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);

View File

@@ -10,7 +10,6 @@ import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
@@ -64,7 +63,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
id: 'jobName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Job Name"
@@ -75,7 +74,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME] || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -159,10 +158,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-req-usage-"
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-req-usage-">
CPU Request Usage (%)
</ColumnHeader>
),
@@ -180,7 +176,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
attribute="CPU Request"
rowId={rowId}
>
<EntityProgressBar value={cpuRequest} type="cpu-request" />
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -188,10 +184,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-limit-usage-"
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-limit-usage-">
CPU Limit Usage (%)
</ColumnHeader>
),
@@ -208,7 +201,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
attribute="CPU Limit"
rowId={rowId}
>
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
@@ -241,10 +234,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/jobs#mem-req-usage-"
tooltip={<EntityProgressThresholds type="memory-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#mem-req-usage-">
Memory Request Usage (%)
</ColumnHeader>
),
@@ -262,7 +252,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
attribute="Memory Request"
rowId={rowId}
>
<EntityProgressBar value={memoryRequest} type="memory-request" />
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -270,10 +260,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/jobs#mem-limit-usage-"
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#mem-limit-usage-">
Memory Limit Usage (%)
</ColumnHeader>
),
@@ -290,7 +277,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
attribute="Memory Limit"
rowId={rowId}
>
<EntityProgressBar value={memoryLimit} type="memory-limit" />
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},

View File

@@ -111,8 +111,7 @@ export const namespaceWidgetInfo = [
{
title: 'CPU Usage (cores)',
yAxisUnit: '',
docPath:
'/infrastructure-monitoring/kubernetes/namespaces/#cpu-usage-cores-1',
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#cpu-usage-cores',
},
{
title: 'Memory Usage (bytes)',

View File

@@ -66,7 +66,7 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
id: 'namespaceName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Namespace Name"
@@ -76,7 +76,7 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
),
accessorFn: (row): string => row.namespaceName || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',

View File

@@ -57,7 +57,7 @@ export const nodeWidgetInfo = [
{
title: 'CPU Usage (cores)',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/nodes/#cpu-usage-cores-1',
docPath: '/infrastructure-monitoring/kubernetes/nodes/#cpu-usage-cores',
},
{
title: 'Memory Usage (bytes)',

View File

@@ -68,7 +68,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
id: 'nodeName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Node Name"
@@ -78,7 +78,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
),
accessorFn: (row): string => row.nodeName || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',

View File

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

View File

@@ -17,7 +17,6 @@ import {
} from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
@@ -69,7 +68,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
id: 'podName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Pod Name"
@@ -80,7 +79,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -97,7 +96,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
</ColumnHeader>
),
accessorFn: (row): string => row.podStatus,
width: { min: 250 },
width: { min: 160 },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row }): React.ReactNode => {
@@ -176,7 +175,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
),
accessorFn: (row): number => row.podRestarts,
width: { min: 140 },
enableSort: false,
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => {
const restarts = value as number;
return (
@@ -194,10 +193,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/pods#cpu-req-usage-"
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#cpu-req-usage-">
CPU Request Usage (%)
</ColumnHeader>
),
@@ -214,7 +210,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
entity={InfraMonitoringEntity.PODS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="cpu-request" />
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -222,10 +218,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/pods#cpu-limit-usage-"
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#cpu-limit-usage-">
CPU Limit Usage (%)
</ColumnHeader>
),
@@ -241,7 +234,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
entity={InfraMonitoringEntity.PODS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
@@ -273,10 +266,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/pods#mem-req-usage-"
tooltip={<EntityProgressThresholds type="memory-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#mem-req-usage-">
Memory Request Usage (%)
</ColumnHeader>
),
@@ -293,7 +283,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
entity={InfraMonitoringEntity.PODS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="memory-request" />
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -301,10 +291,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/pods#mem-limit-usage-"
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#mem-limit-usage-">
Memory Limit Usage (%)
</ColumnHeader>
),
@@ -320,7 +307,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
entity={InfraMonitoringEntity.PODS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="memory-limit" />
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},

View File

@@ -10,7 +10,6 @@ import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
@@ -71,7 +70,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
id: 'statefulsetName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="StatefulSet Name"
@@ -82,7 +81,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -166,10 +165,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-req-usage-"
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-req-usage-">
CPU Request Usage (%)
</ColumnHeader>
),
@@ -187,7 +183,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="cpu-request" />
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -195,10 +191,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-limit-usage-"
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-limit-usage-">
CPU Limit Usage (%)
</ColumnHeader>
),
@@ -215,7 +208,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
@@ -249,10 +242,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-req-usage-"
tooltip={<EntityProgressThresholds type="memory-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-req-usage-">
Memory Request Usage (%)
</ColumnHeader>
),
@@ -270,7 +260,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="memory-request" />
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -278,10 +268,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-limit-usage-"
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-limit-usage-">
Memory Limit Usage (%)
</ColumnHeader>
),
@@ -298,7 +285,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="memory-limit" />
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},

View File

@@ -64,7 +64,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
id: 'pvcName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="PVC Name"
@@ -74,7 +74,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
),
accessorFn: (row): string => row.persistentVolumeClaimName || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -195,7 +195,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
},
},
{
id: 'inodes_used',
id: 'inodesUsed',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/volumes#volume-inodes-used">
Inodes Used
@@ -219,7 +219,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
},
},
{
id: 'inodes_free',
id: 'inodesFree',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/volumes#volume-inodes-free">
Inodes Free

View File

@@ -26,6 +26,48 @@ export function formatBytes(bytes: number, decimals = 2): string {
return `${parseFloat((bytes / k ** i).toFixed(decimals))} ${sizes[i]}`;
}
/**
* Returns stroke color for request utilization parameters according to current value
*/
export function getStrokeColorForRequestUtilization(value: number): string {
const percent = Number((value * 100).toFixed(1));
// Orange
if (percent <= 50) {
return Color.BG_AMBER_500;
}
// Green
if (percent > 50 && percent <= 100) {
return Color.BG_FOREST_500;
}
// Regular Red
if (percent > 100 && percent <= 150) {
return Color.BG_SAKURA_500;
}
// Dark Red
return Color.BG_CHERRY_600;
}
/**
* Returns stroke color for limit utilization parameters according to current value
*/
export function getStrokeColorForLimitUtilization(value: number): string {
const percent = Number((value * 100).toFixed(1));
// Green
if (percent <= 60) {
return Color.BG_FOREST_500;
}
// Yellow
if (percent > 60 && percent <= 80) {
return Color.BG_AMBER_200;
}
// Orange
if (percent > 80 && percent <= 95) {
return Color.BG_AMBER_500;
}
// Red
return Color.BG_SAKURA_500;
}
export const POD_STATUS_COLORS: Record<
InframonitoringtypesPodStatusDTO,
BadgeColor

View File

@@ -1,11 +1,35 @@
import { Progress } from '@signozhq/ui/progress';
import TanStackTable from 'components/TanStackTableView';
import {
getMemoryProgressColor,
getProgressColor,
} from 'container/InfraMonitoringHostsV2/constants';
import {
getStrokeColorForLimitUtilization,
getStrokeColorForRequestUtilization,
} from '../commonUtils';
import styles from './EntityProgressBar.module.scss';
import {
EntityProgressBarType,
getStrokeColor,
} from './EntityProgressBar.utils';
type EntityProgressBarType = 'request' | 'limit' | 'cpu' | 'memory' | 'disk';
function getStrokeColor(type: EntityProgressBarType, value: number): string {
switch (type) {
case 'limit':
return getStrokeColorForLimitUtilization(value);
case 'request':
return getStrokeColorForRequestUtilization(value);
case 'cpu':
return getProgressColor(Number((value * 100).toFixed(1)));
case 'memory':
return getMemoryProgressColor(Number((value * 100).toFixed(1)));
case 'disk':
return getProgressColor(Number((value * 100).toFixed(1)));
default:
return getStrokeColorForRequestUtilization(value);
}
}
export function EntityProgressBar({
value,

View File

@@ -1,254 +0,0 @@
import { Color } from '@signozhq/design-tokens';
export type EntityProgressBarType =
| 'cpu-request'
| 'cpu-limit'
| 'memory-request'
| 'memory-limit'
| 'cpu'
| 'memory'
| 'disk';
export interface EntityProgressThreshold {
matches: (percent: number) => boolean;
color: string;
range: string;
label: string;
description: string;
}
const CPU_REQUEST_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent <= 50,
color: Color.BG_AMBER_500,
range: '≤ 50%',
label: 'Over-requested',
description:
'CPU usage is at most half of the request. The rest of the request stays reserved on the node.',
},
{
matches: (percent): boolean => percent <= 100,
color: Color.BG_FOREST_500,
range: '> 50% - 100%',
label: 'Right-sized',
description: 'CPU usage is close to the request and stays within it.',
},
{
matches: (percent): boolean => percent <= 150,
color: Color.BG_SAKURA_500,
range: '> 100% - 150%',
label: 'Over request',
description:
'CPU usage is above the request. The extra CPU is not guaranteed and depends on spare node capacity.',
},
{
matches: (): boolean => true,
color: Color.BG_CHERRY_600,
range: '> 150%',
label: 'Request badly undersized',
description:
'CPU usage is more than 1.5x the request, so most of the CPU in use is not guaranteed.',
},
];
const CPU_LIMIT_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent <= 60,
color: Color.BG_FOREST_500,
range: '≤ 60%',
label: 'Healthy',
description: 'CPU usage is well below the limit.',
},
{
matches: (percent): boolean => percent <= 80,
color: Color.BG_AMBER_200,
range: '> 60% - 80%',
label: 'Watch',
description: 'CPU usage is approaching the limit.',
},
{
matches: (percent): boolean => percent <= 95,
color: Color.BG_AMBER_500,
range: '> 80% - 95%',
label: 'Near limit',
description:
'CPU usage is close to the limit. Usage above the limit is throttled.',
},
{
matches: (): boolean => true,
color: Color.BG_SAKURA_500,
range: '> 95%',
label: 'At limit',
description:
'CPU usage is at the limit, so the container is likely being throttled.',
},
];
const MEMORY_REQUEST_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent <= 50,
color: Color.BG_AMBER_500,
range: '≤ 50%',
label: 'Over-requested',
description:
'Memory usage is at most half of the request. The rest of the request stays reserved on the node.',
},
{
matches: (percent): boolean => percent <= 100,
color: Color.BG_FOREST_500,
range: '> 50% - 100%',
label: 'Right-sized',
description: 'Memory usage is close to the request and stays within it.',
},
{
matches: (percent): boolean => percent <= 150,
color: Color.BG_SAKURA_500,
range: '> 100% - 150%',
label: 'Over request',
description:
'Memory usage is above the request. The extra memory is not guaranteed and is reclaimed first under node memory pressure.',
},
{
matches: (): boolean => true,
color: Color.BG_CHERRY_600,
range: '> 150%',
label: 'Request badly undersized',
description:
'Memory usage is more than 1.5x the request, so most of the memory in use is not guaranteed.',
},
];
const MEMORY_LIMIT_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent <= 60,
color: Color.BG_FOREST_500,
range: '≤ 60%',
label: 'Healthy',
description: 'Memory usage is well below the limit.',
},
{
matches: (percent): boolean => percent <= 80,
color: Color.BG_AMBER_200,
range: '> 60% - 80%',
label: 'Watch',
description: 'Memory usage is approaching the limit.',
},
{
matches: (percent): boolean => percent <= 95,
color: Color.BG_AMBER_500,
range: '> 80% - 95%',
label: 'Near limit',
description:
'Memory usage is close to the limit. Unlike CPU, memory is not throttled: reaching the limit ends in an OOM kill.',
},
{
matches: (): boolean => true,
color: Color.BG_SAKURA_500,
range: '> 95%',
label: 'At limit',
description:
'Memory usage is at the limit, so an OOM kill and container restart are likely.',
},
];
const CPU_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent < 60,
color: Color.BG_FOREST_500,
range: '< 60%',
label: 'Healthy',
description: 'CPU usage is well below the available capacity.',
},
{
matches: (percent): boolean => percent < 90,
color: Color.BG_AMBER_500,
range: '60% - 89.9%',
label: 'Elevated',
description: 'CPU usage is high relative to the available capacity.',
},
{
matches: (): boolean => true,
color: Color.BG_SAKURA_500,
range: '≥ 90%',
label: 'Critical',
description: 'CPU usage is close to the available capacity.',
},
];
const MEMORY_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent < 60,
color: Color.BG_FOREST_500,
range: '< 60%',
label: 'Healthy',
description: 'Memory usage is well below the available capacity.',
},
{
matches: (percent): boolean => percent < 90,
color: Color.BG_AMBER_500,
range: '60% - 89.9%',
label: 'Elevated',
description: 'Memory usage is high relative to the available capacity.',
},
{
matches: (): boolean => true,
color: Color.BG_CHERRY_500,
range: '≥ 90%',
label: 'Critical',
description:
'Memory usage is close to the available capacity. Unlike CPU, memory is not throttled: running out ends in an OOM kill.',
},
];
const DISK_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent < 60,
color: Color.BG_FOREST_500,
range: '< 60%',
label: 'Healthy',
description: 'Most of the volume is still free.',
},
{
matches: (percent): boolean => percent < 90,
color: Color.BG_AMBER_500,
range: '60% - 89.9%',
label: 'Elevated',
description: 'Used space is high relative to the volume capacity.',
},
{
matches: (): boolean => true,
color: Color.BG_SAKURA_500,
range: '≥ 90%',
label: 'Critical',
description: 'The volume is nearly full. Writes fail once no space is left.',
},
];
export const THRESHOLDS_BY_TYPE: Record<
EntityProgressBarType,
EntityProgressThreshold[]
> = {
'cpu-request': CPU_REQUEST_THRESHOLDS,
'cpu-limit': CPU_LIMIT_THRESHOLDS,
'memory-request': MEMORY_REQUEST_THRESHOLDS,
'memory-limit': MEMORY_LIMIT_THRESHOLDS,
cpu: CPU_THRESHOLDS,
memory: MEMORY_THRESHOLDS,
disk: DISK_THRESHOLDS,
};
export function getStrokeColorForPercent(
type: EntityProgressBarType,
percent: number,
): string {
const thresholds = THRESHOLDS_BY_TYPE[type];
const match = thresholds.find((threshold) => threshold.matches(percent));
return (match ?? thresholds[thresholds.length - 1]).color;
}
export function getStrokeColor(
type: EntityProgressBarType,
value: number,
): string {
return getStrokeColorForPercent(type, Number((value * 100).toFixed(1)));
}

View File

@@ -1,39 +0,0 @@
.container {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
max-width: 320px;
text-align: left;
text-wrap: wrap;
margin-bottom: var(--spacing-1);
}
.threshold {
display: flex;
align-items: stretch;
gap: var(--spacing-4);
}
.swatch {
width: 3px;
border-radius: 1px;
flex-shrink: 0;
background-color: var(--ept-color);
}
.thresholdBody {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.thresholdHeading {
display: flex;
align-items: baseline;
gap: var(--spacing-2);
}
.range {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}

View File

@@ -1,56 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import {
EntityProgressBarType,
THRESHOLDS_BY_TYPE,
} from './EntityProgressBar.utils';
import styles from './EntityProgressThresholds.module.scss';
interface EntityProgressThresholdsProps {
type: EntityProgressBarType;
note?: string;
}
export function EntityProgressThresholds({
type,
note,
}: EntityProgressThresholdsProps): JSX.Element {
return (
<div
className={styles.container}
data-testid={`entity-progress-thresholds-${type}`}
>
{note && (
<Typography.Text as="p" size="small">
{note}
</Typography.Text>
)}
{THRESHOLDS_BY_TYPE[type].map((threshold) => (
<div key={threshold.range} className={styles.threshold}>
<span
className={styles.swatch}
style={{ '--ept-color': threshold.color } as React.CSSProperties}
/>
<div className={styles.thresholdBody}>
<div className={styles.thresholdHeading}>
<Typography.Text as="span" size="small" weight="medium">
{threshold.label}
</Typography.Text>
<Typography.Text
as="span"
size="small"
color="muted"
className={styles.range}
>
{threshold.range}
</Typography.Text>
</div>
<Typography.Text as="p" size="small" color="muted">
{threshold.description}
</Typography.Text>
</div>
</div>
))}
</div>
);
}

View File

@@ -1,28 +0,0 @@
import { render, screen } from '@testing-library/react';
import { THRESHOLDS_BY_TYPE } from '../EntityProgressBar.utils';
import { EntityProgressThresholds } from '../EntityProgressThresholds';
describe('EntityProgressThresholds', () => {
it('renders every threshold band for the given type', () => {
render(<EntityProgressThresholds type="cpu-limit" />);
expect(
screen.getByTestId('entity-progress-thresholds-cpu-limit'),
).toBeInTheDocument();
THRESHOLDS_BY_TYPE['cpu-limit'].forEach((threshold) => {
expect(screen.getByText(threshold.label)).toBeInTheDocument();
expect(screen.getByText(threshold.range)).toBeInTheDocument();
expect(screen.getByText(threshold.description)).toBeInTheDocument();
});
});
it('renders the note above the threshold bands when provided', () => {
render(
<EntityProgressThresholds type="memory" note="Excluding cache memory." />,
);
expect(screen.getByText('Excluding cache memory.')).toBeInTheDocument();
});
});

View File

@@ -1,5 +1,4 @@
export { EntityProgressBar } from './EntityProgressBar';
export { EntityProgressThresholds } from './EntityProgressThresholds';
export { ValidateColumnValueWrapper } from './ValidateColumnValueWrapper';
export { ExpandButtonWrapper } from './ExpandButtonWrapper';
export {

View File

@@ -33,7 +33,6 @@ function AllAttributes({
metricName,
metricType,
isMonotonic,
temporality,
minTime,
maxTime,
}: AllAttributesProps): JSX.Element {
@@ -72,7 +71,6 @@ function AllAttributes({
groupBy,
limit,
isMonotonic,
temporality,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -91,7 +89,7 @@ function AllAttributes({
[MetricsExplorerEventKeys.AttributeKey]: groupBy,
});
},
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
[metricName, metricType, isMonotonic, handleExplorerTabChange],
);
const goToMetricsExploreWithAppliedAttribute = useCallback(
@@ -103,7 +101,6 @@ function AllAttributes({
undefined,
undefined,
isMonotonic,
temporality,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -123,7 +120,7 @@ function AllAttributes({
[MetricsExplorerEventKeys.AttributeValue]: value,
});
},
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
[metricName, metricType, isMonotonic, handleExplorerTabChange],
);
const handleKeyMenuItemClick = useCallback(

View File

@@ -86,7 +86,6 @@ function MetricDetails({
undefined,
undefined,
metadata?.isMonotonic,
metadata?.temporality,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -109,7 +108,6 @@ function MetricDetails({
handleExplorerTabChange,
metadata?.type,
metadata?.isMonotonic,
metadata?.temporality,
]);
useEffect(() => {
@@ -198,7 +196,6 @@ function MetricDetails({
metricName={metricName}
metricType={metadata?.type}
isMonotonic={metadata?.isMonotonic}
temporality={metadata?.temporality}
minTime={minTime}
maxTime={maxTime}
/>

View File

@@ -147,44 +147,6 @@ describe('MetricDetails utils', () => {
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
});
it('treats a cumulative non-monotonic Sum as a Gauge', () => {
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetrictypesTypeDTO.sum,
undefined,
undefined,
undefined,
false,
MetrictypesTemporalityDTO.cumulative,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
ATTRIBUTE_TYPES.GAUGE,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('avg');
});
it('treats a delta non-monotonic Sum as a Sum', () => {
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetrictypesTypeDTO.sum,
undefined,
undefined,
undefined,
false,
MetrictypesTemporalityDTO.delta,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
ATTRIBUTE_TYPES.SUM,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
});
it('should create correct query for GAUGE metric type', () => {
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,

View File

@@ -35,7 +35,6 @@ export interface AllAttributesProps {
metricName: string;
metricType: MetrictypesTypeDTO | undefined;
isMonotonic?: boolean;
temporality?: MetrictypesTemporalityDTO;
minTime?: number;
maxTime?: number;
}

View File

@@ -89,16 +89,12 @@ export function getMetricDetailsQuery(
groupBy?: string,
limit?: number,
isMonotonic?: boolean,
temporality?: MetrictypesTemporalityDTO,
): Query {
let timeAggregation;
let spaceAggregation;
let aggregateOperator;
// Only non-monotonic cumulative sums are treated as gauges; delta sums stay Sum
const isNonMonotonicSum =
metricType === MetrictypesTypeDTO.sum &&
isMonotonic === false &&
temporality === MetrictypesTemporalityDTO.cumulative;
metricType === MetrictypesTypeDTO.sum && isMonotonic === false;
switch (metricType) {
case MetrictypesTypeDTO.sum:
@@ -135,7 +131,7 @@ export function getMetricDetailsQuery(
break;
}
const attributeType = toAttributeType(metricType, isMonotonic, temporality);
const attributeType = toAttributeType(metricType, isMonotonic);
return {
...initialQueriesMap[DataSource.METRICS],

View File

@@ -393,13 +393,12 @@ describe('selecting a metric type updates the aggregation options', () => {
]);
});
it('cumulative non-monotonic Sum metric is treated as Gauge', () => {
it('non-monotonic Sum metric is treated as Gauge', () => {
returnMetrics([
makeMetric({
metricName: 'active_connections',
type: MetrictypesTypeDTO.sum,
isMonotonic: false,
temporality: 'cumulative' as never,
}),
]);
@@ -428,36 +427,6 @@ describe('selecting a metric type updates the aggregation options', () => {
]);
});
it('delta non-monotonic Sum metric is treated as Sum', () => {
returnMetrics([
makeMetric({
metricName: 'queue_depth_delta',
type: MetrictypesTypeDTO.sum,
isMonotonic: false,
temporality: 'delta' as never,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'queue_depth_delta' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toStrictEqual([
'Rate',
'Increase',
]);
expect(getOptionLabels('space-agg-options')).toStrictEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('Histogram metric shows no time options and P50P99 space options', () => {
returnMetrics([
makeMetric({

View File

@@ -34,7 +34,7 @@ export type MetricNameSelectorProps = {
function getAttributeType(
metric: MetricsexplorertypesListMetricDTO,
): ATTRIBUTE_TYPES | '' {
return toAttributeType(metric.type, metric.isMonotonic, metric.temporality);
return toAttributeType(metric.type, metric.isMonotonic);
}
function createAutocompleteData(

View File

@@ -1,7 +1,8 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { FeatureKeys } from 'constants/features';
import { server } from 'mocks-server/server';
import { render, screen } from 'tests/test-utils';
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
import {
invalidLicense,
setupAuthzAdmin,
@@ -58,6 +59,23 @@ function renderEditPage(
describe('CreateEditRolePage - Feature Gate', () => {
describe('create mode - feature disabled', () => {
it('shows error when fine-grained authz flag is inactive', async () => {
renderCreatePage({
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
});
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows error when license is invalid', async () => {
renderCreatePage({ activeLicense: invalidLicense });
@@ -95,6 +113,23 @@ describe('CreateEditRolePage - Feature Gate', () => {
const ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
const ROLE_NAME = 'test-role';
it('shows error when fine-grained authz flag is inactive', async () => {
renderEditPage(ROLE_ID, ROLE_NAME, {
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
});
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows error when license is invalid', async () => {
renderEditPage(ROLE_ID, ROLE_NAME, { activeLicense: invalidLicense });

View File

@@ -1,6 +1,7 @@
import * as roleApi from 'api/generated/services/role';
import { FeatureKeys } from 'constants/features';
import { server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import { defaultFeatureFlags, render, screen, waitFor } from 'tests/test-utils';
import {
invalidLicense,
setupAuthzAdmin,
@@ -32,6 +33,26 @@ describe('ViewRolePage - Feature Gate', () => {
});
describe('feature disabled', () => {
it('shows error when fine-grained authz flag is inactive', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: {
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
},
});
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows error when license is invalid', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),

View File

@@ -4,7 +4,13 @@ import {
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent } from 'tests/test-utils';
import {
defaultFeatureFlags,
render,
screen,
userEvent,
} from 'tests/test-utils';
import { FeatureKeys } from 'constants/features';
import {
invalidLicense,
setupAuthzAdmin,
@@ -185,6 +191,30 @@ describe('RolesSettings', () => {
}
});
it('hides the create button and disables row clicks when fine-grained authz flag is inactive', async () => {
render(<RolesSettings />, undefined, {
appContextOverrides: {
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
},
});
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /custom role/i }),
).not.toBeInTheDocument();
const rows = document.querySelectorAll('.roles-table-row');
rows.forEach((row) => {
expect(row).not.toHaveClass('roles-table-row--clickable');
expect(row.getAttribute('role')).not.toBe('button');
});
});
it('hides the create button and disables row clicks when license is not valid', async () => {
render(<RolesSettings />, undefined, {
appContextOverrides: { activeLicense: invalidLicense },

View File

@@ -2,15 +2,10 @@
* This was introduced to fix a sync bug between Nuqs and react-router-dom
*
* We are using the wrong adapter for nuqs because the correct one only supports v6/v7,
* and we are at version v5. Nuqs writes params straight to the History API, which
* react-router v5 never observes, so `useLocation().search` (and `useUrlQuery()`) can
* be several nuqs updates behind the real URL.
* and we are at version v5. This causes the nuqs/react-router-dom to be out of sync.
*
* Use this whenever you need to build a navigation target on top of the current
* params, otherwise stale values get republished and nuqs adopts them back on its
* next flush (it snapshots `window.location.search`).
*
* We can revert this once we migrate react-router-dom to v6.
* We can revert this commit once we migrate react-router-dom to v6, or once we migrate
* to DateTimeSelectionV3
*/
/**

View File

@@ -0,0 +1,11 @@
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
export function useIsInfraMonitoringV2(): boolean {
const { featureFlags } = useAppContext();
return Boolean(
featureFlags?.find(
(flag) => flag.name === FeatureKeys.USE_INFRA_MONITORING_V2,
)?.active,
);
}

View File

@@ -1,3 +1,4 @@
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
@@ -5,12 +6,22 @@ export const useRolesFeatureGate = (): {
isRolesEnabled: boolean;
isLoading: boolean;
} => {
const { activeLicense, isFetchingActiveLicense } = useAppContext();
const {
activeLicense,
featureFlags,
isFetchingActiveLicense,
isFetchingFeatureFlags,
} = useAppContext();
const isValidLicense = activeLicense?.status === LicenseStatus.VALID;
const isFineGrainedAuthzEnabled =
featureFlags?.find((f) => f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ)
?.active ?? false;
return {
isRolesEnabled: isValidLicense,
isLoading: isFetchingActiveLicense && !activeLicense,
isRolesEnabled: isValidLicense && isFineGrainedAuthzEnabled,
isLoading:
(isFetchingActiveLicense && !activeLicense) ||
(isFetchingFeatureFlags && !featureFlags),
};
};

View File

@@ -7,7 +7,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import getMinAgo from './getStartAndEndTime/getMinAgo';
const validCustomTimeRegex = /^(\d+)(months?|[mhdw])$/;
const validCustomTimeRegex = /^(\d+)([mhdw])$/;
export const isValidShortHandDateTimeFormat = (time: string): boolean =>
validCustomTimeRegex.test(time);

View File

@@ -27,13 +27,9 @@ export const slackTitleDefaultValue = `[{{ .Status | toUpper }}{{ if eq .Status
export const slackDescriptionDefaultValue = `{{ range .Alerts -}} *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }} *Summary:* {{ .Annotations.summary }} *Description:* {{ .Annotations.description }} *RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}} *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}} *Details:* {{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }} {{ end }} {{ end }}`;
export const googleChatTitleDefaultValue = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`;
export const googleChatDescriptionDefaultValue = `{{ range .Alerts -}} **Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }} **Summary:** {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }} **Description:** {{ .Annotations.description }}{{ end }} {{ end }}`;
export const editSlackDescriptionDefaultValue = `{{ range .Alerts -}} *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }} dummy_summary *Summary:* {{ .Annotations.summary }} *Description:* {{ .Annotations.description }} *Details:* {{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }} {{ end }} {{ end }}`;
export const pagerDutyDescriptionDefaultValue = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }} {{- if gt (len .CommonLabels) (len .GroupLabels) -}} {{" "}}( {{- with .CommonLabels.Remove .GroupLabels.Names }} {{- range $index, $label := .SortedPairs -}} {{ if $index }}, {{ end }} {{- $label.Name }}="{{ $label.Value -}}" {{- end }} {{- end -}} ) {{- end }}`;
export const pagerDutyDescriptionDefaultVaule = `{{ if gt (len .Alerts.Firing) 0 -}} Alerts Firing: {{ range .Alerts.Firing }} - Message: {{ .Annotations.description }} Labels: {{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Annotations: {{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Source: {{ .GeneratorURL }} {{ end }} {{- end }} {{ if gt (len .Alerts.Resolved) 0 -}} Alerts Resolved: {{ range .Alerts.Resolved }} - Message: {{ .Annotations.description }} Labels: {{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Annotations: {{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Source: {{ .GeneratorURL }} {{ end }} {{- end }}`;
export const pagerDutyAdditionalDetailsDefaultValue = JSON.stringify({
firing: `{{ .Alerts.Firing | toJson }}`,

View File

@@ -10,7 +10,6 @@ import Spinner from 'components/Spinner';
import ROUTES from 'constants/routes';
import {
ChannelType,
GoogleChatChannel,
MsTeamsChannel,
PagerChannel,
SlackChannel,
@@ -60,20 +59,11 @@ function ChannelsEdit(): JSX.Element {
const prepChannelConfig = (): {
type: string;
channel: SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
GoogleChatChannel;
channel: SlackChannel & WebhookChannel & PagerChannel & MsTeamsChannel;
} => {
let channel: SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
GoogleChatChannel = {
let channel: SlackChannel & WebhookChannel & PagerChannel & MsTeamsChannel = {
name: '',
};
if (value && 'slack_configs' in value) {
const slackConfig = value.slack_configs[0];
channel = slackConfig;
@@ -91,16 +81,6 @@ function ChannelsEdit(): JSX.Element {
channel,
};
}
if (value && 'googlechat_configs' in value) {
const [googleChatConfig] = value.googlechat_configs;
channel = googleChatConfig;
return {
type: ChannelType.GoogleChat,
channel,
};
}
if (value && 'pagerduty_configs' in value) {
const pagerConfig = value.pagerduty_configs[0];
channel = pagerConfig;

View File

@@ -182,56 +182,4 @@ describe('ValueSelector', () => {
});
});
});
describe('opening and closing without touching the list', () => {
function renderWith(
selection: VariableSelection,
options: string[],
): jest.Mock {
const onChange = jest.fn();
render(
<TooltipProvider>
<ValueSelector
options={options}
variableType="dynamic"
multiSelect
showAllOption
selection={selection}
onChange={onChange}
emptyFallback={{ value: [], allSelected: false }}
testId="variable-select-env"
/>
</TooltipProvider>,
);
return onChange;
}
async function openThenClose(): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const control = screen.getByTestId('variable-select-env');
await user.click(control.querySelector('input') as HTMLInputElement);
await user.keyboard('{Escape}');
}
it('does not promote a pick that covers every available option to ALL', async () => {
// A narrow time range can leave only the selected value in the list. That is
// still an explicit pick, not "everything, always".
const onChange = renderWith(
{ value: ['checkout-service-prod'], allSelected: false },
['checkout-service-prod'],
);
await openThenClose();
expect(onChange).not.toHaveBeenCalled();
});
it('does not rewrite a dynamic ALL into concrete values', async () => {
const onChange = renderWith({ value: null, allSelected: true }, OPTIONS);
await openThenClose();
expect(onChange).not.toHaveBeenCalled();
});
});
});

View File

@@ -145,133 +145,6 @@ describe('reconcileWithOptions', () => {
),
).toBeNull();
});
describe('preserveSelection (options moved on their own — time range, reload)', () => {
const multi = model({
type: 'DYNAMIC',
multiSelect: true,
showAllOption: true,
dynamicAttribute: 'service.name',
});
it('keeps a multi-select pick the new option list no longer offers', () => {
expect(
reconcileWithOptions(multi, { value: ['frontend'], allSelected: false }, [
'backend',
'cart',
]),
).toStrictEqual({ value: null, allSelected: true });
expect(
reconcileWithOptions(
multi,
{ value: ['frontend'], allSelected: false },
['backend', 'cart'],
{ preserveSelection: true },
),
).toBeNull();
});
it('still materializes ALL, which must track the option list', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
{ value: ['a'], allSelected: true },
['a', 'b'],
{ preserveSelection: true },
),
).toStrictEqual({ value: ['a', 'b'], allSelected: true });
});
it('still fills the default when nothing is selected yet', () => {
expect(
reconcileWithOptions(multi, { value: [], allSelected: false }, ['a', 'b'], {
preserveSelection: true,
}),
).toStrictEqual({ value: null, allSelected: true });
});
});
// A typed value is in no option list, so no refetch can invalidate it.
describe('customValues (typed in, never offered by the data)', () => {
const multi = model({
type: 'DYNAMIC',
multiSelect: true,
showAllOption: true,
dynamicAttribute: 'service.name',
});
it('keeps them through a re-scope that drops a fetched value', () => {
expect(
reconcileWithOptions(
multi,
{
value: ['frontend', 'typed-in'],
allSelected: false,
customValues: ['typed-in'],
},
['backend', 'cart'],
),
).toStrictEqual({
value: ['typed-in'],
allSelected: false,
customValues: ['typed-in'],
});
});
it('never re-defaults a selection made only of them', () => {
expect(
reconcileWithOptions(
multi,
{ value: ['typed-in'], allSelected: false, customValues: ['typed-in'] },
['backend', 'cart'],
),
).toBeNull();
});
// An inert marker is not worth a store write + dependent refetch to prune.
it('leaves a stale marker alone when it drops nothing', () => {
expect(
reconcileWithOptions(
multi,
{
value: ['frontend', 'typed-in'],
allSelected: false,
customValues: ['typed-in', 'removed-earlier'],
},
['frontend'],
),
).toBeNull();
});
it('prunes markers for values it does drop', () => {
expect(
reconcileWithOptions(
multi,
{
value: ['stale', 'typed-in'],
allSelected: false,
customValues: ['typed-in'],
},
['frontend'],
),
).toStrictEqual({
value: ['typed-in'],
allSelected: false,
customValues: ['typed-in'],
});
});
it('still drops an unmarked value the list no longer offers', () => {
expect(
reconcileWithOptions(
multi,
{ value: ['frontend', 'stale'], allSelected: false },
['frontend'],
),
).toStrictEqual({ value: ['frontend'], allSelected: false });
});
});
});
describe('configuredDefaultValue', () => {

View File

@@ -1,91 +0,0 @@
import type { VariableSelection } from '../selectionTypes';
import { selectionFromCommittedValues } from '../utils/selectionUtils';
const OPTIONS = ['checkout', 'payments', 'cart'];
const FALLBACK: VariableSelection = { value: null, allSelected: true };
function commit(
values: string[],
overrides: Partial<Parameters<typeof selectionFromCommittedValues>[0]> = {},
): VariableSelection {
return selectionFromCommittedValues({
values,
options: OPTIONS,
showAllOption: true,
emptyFallback: FALLBACK,
...overrides,
});
}
// What a multi-select commit resolves to. The option list is known only here, so this
// is the one place a typed value can be recognised.
describe('selectionFromCommittedValues', () => {
it('marks values the option list did not offer as typed in', () => {
expect(commit(['checkout', 'typed-in'])).toStrictEqual({
value: ['checkout', 'typed-in'],
allSelected: false,
customValues: ['typed-in'],
});
});
it('marks a selection made only of typed-in values', () => {
expect(commit(['a', 'b'])).toStrictEqual({
value: ['a', 'b'],
allSelected: false,
customValues: ['a', 'b'],
});
});
it('records no marker when every pick came from the list', () => {
expect(commit(['checkout', 'cart'])).toStrictEqual({
value: ['checkout', 'cart'],
allSelected: false,
});
});
it('reads a set covering every option as ALL', () => {
expect(commit(OPTIONS)).toStrictEqual({
value: OPTIONS,
allSelected: true,
});
});
// ALL re-materializes to the option set, so recording this as ALL would drop the
// typed value on the next refetch.
it('does not read every option PLUS a typed value as ALL', () => {
expect(commit([...OPTIONS, 'typed-in'])).toStrictEqual({
value: [...OPTIONS, 'typed-in'],
allSelected: false,
customValues: ['typed-in'],
});
});
// Derived from the values + options at commit time, never from the old selection.
it('recomputes the marker: a typed value the data now offers is a normal pick', () => {
expect(
commit(['checkout', 'was-typed'], {
options: [...OPTIONS, 'was-typed'],
}),
).toStrictEqual({ value: ['checkout', 'was-typed'], allSelected: false });
});
it('does not read it as ALL when the variable offers no ALL', () => {
expect(commit(OPTIONS, { showAllOption: false })).toStrictEqual({
value: OPTIONS,
allSelected: false,
});
});
it('resolves an empty commit to the variable fallback', () => {
expect(commit([])).toBe(FALLBACK);
});
it('marks everything while the options have not arrived', () => {
// Nothing to judge against yet; erring this way keeps a value rather than dropping it.
expect(commit(['typed-in'], { options: [] })).toStrictEqual({
value: ['typed-in'],
allSelected: false,
customValues: ['typed-in'],
});
});
});

View File

@@ -4,8 +4,6 @@ import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import { VariableCycleReason } from '../../store/slices/variableFetchSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
import type { VariableSelection } from '../selectionTypes';
import { useAutoSelect } from '../hooks/useAutoSelect';
@@ -17,11 +15,7 @@ function run(
variable: VariableFormModel,
options: string[],
selection: VariableSelection,
cycleReason?: VariableCycleReason,
): VariableSelection | undefined {
useDashboardStore.setState({
variableCycleReasons: cycleReason ? { [variable.name]: cycleReason } : {},
});
const onAutoSelect = jest.fn();
renderHook(() => useAutoSelect(variable, options, selection, onAutoSelect));
return onAutoSelect.mock.calls[0]?.[0];
@@ -76,13 +70,11 @@ describe('useAutoSelect', () => {
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: true });
});
// Re-scoped options only — a time-range refetch must NOT re-default; see below.
it('re-scoped: falls back to ALL, not the first option, when every selected value is gone', () => {
it('falls back to ALL, not the first option, when every selected value is gone', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['x', 'y'],
{ value: ['a', 'b'], allSelected: false },
VariableCycleReason.ValueCascade,
);
expect(next).toStrictEqual({ value: ['x', 'y'], allSelected: true });
});
@@ -110,23 +102,20 @@ describe('useAutoSelect', () => {
expect(next).toStrictEqual({ value: ['b'], allSelected: false });
});
it('re-scoped: keeps the still-valid subset of a multi-select', () => {
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true }),
['a', 'b', 'd'],
{ value: ['a', 'b', 'c'], allSelected: false },
VariableCycleReason.ValueCascade,
);
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: false });
});
it('re-scoped: re-defaults a multi-select when none of the selected values remain', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true }),
['x', 'y'],
{ value: ['a', 'b'], allSelected: false },
VariableCycleReason.ValueCascade,
);
it('re-defaults a multi-select when none of the selected values remain', () => {
const next = run(model({ type: 'QUERY', multiSelect: true }), ['x', 'y'], {
value: ['a', 'b'],
allSelected: false,
});
expect(next).toStrictEqual({ value: ['x'], allSelected: false });
});
@@ -162,45 +151,4 @@ describe('useAutoSelect', () => {
});
expect(next).toBeUndefined();
});
describe('by cycle reason', () => {
const service = model({
name: 'service',
type: 'DYNAMIC',
multiSelect: true,
showAllOption: true,
dynamicAttribute: 'service.name',
});
const gone: VariableSelection = { value: ['frontend'], allSelected: false };
it('keeps the selection when a full cycle refetched the options', () => {
// The new window has no data for the selected service — no reason to widen to ALL.
const next = run(
service,
['backend', 'cart'],
gone,
VariableCycleReason.FullCycle,
);
expect(next).toBeUndefined();
});
it('re-scopes the selection when a value cascade refetched the options', () => {
const next = run(
service,
['backend', 'cart'],
gone,
VariableCycleReason.ValueCascade,
);
expect(next).toStrictEqual({ value: null, allSelected: true });
});
it('reconciles a variable with no cycle of its own (custom definition change)', () => {
const next = run(
model({ name: 'env', type: 'CUSTOM', multiSelect: true }),
['staging', 'prod'],
{ value: ['dev'], allSelected: false },
);
expect(next).toStrictEqual({ value: ['staging'], allSelected: false });
});
});
});

View File

@@ -13,11 +13,11 @@ jest.mock('nuqs', () => ({
useQueryState: (): unknown => [null, jest.fn()],
}));
const mockGlobalTime = { minTime: 1, maxTime: 2, selectedTime: '5m' };
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: mockGlobalTime }),
selector({
globalTime: { minTime: 1, maxTime: 2, selectedTime: '5m' },
}),
}));
jest.mock('../../DashboardSettings/Variables/variableAdapters', () => ({
@@ -150,57 +150,3 @@ describe('useVariableSelection — setSelection', () => {
expect(svcCycleId()).toBe(before + 1);
});
});
describe('useVariableSelection — what a time-range change enqueues', () => {
// Longer than FETCH_CYCLE_DEBOUNCE_MS, which the hook keeps private.
const PAST_DEBOUNCE = 400;
function reasons(): Record<string, string> {
return useDashboardStore.getState().variableCycleReasons;
}
beforeEach(() => {
jest.useFakeTimers();
mockGlobalTime.selectedTime = '5m';
useDashboardStore.setState({
variableValues: {},
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableCycleReasons: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
});
});
afterEach(() => {
jest.useRealTimers();
});
// The tag is what stops the reconcile re-defaulting a user's selection.
it('tags every variable as a full cycle, overriding an earlier cascade tag', () => {
const { result, rerender } = renderHook(() =>
useVariableSelection(dashboard),
);
act(() => {
jest.advanceTimersByTime(PAST_DEBOUNCE);
});
expect(reasons()).toStrictEqual({ env: 'full-cycle', svc: 'full-cycle' });
// A value change re-scopes the dependent's options: it may drop what no longer applies.
act(() => {
result.current.setSelection('env', { value: ['prod'], allSelected: false });
});
expect(reasons().svc).toBe('value-cascade');
mockGlobalTime.selectedTime = '30m';
rerender();
act(() => {
jest.advanceTimersByTime(PAST_DEBOUNCE);
});
expect(reasons()).toStrictEqual({ env: 'full-cycle', svc: 'full-cycle' });
});
});

View File

@@ -6,7 +6,6 @@ import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import type { VariableSelection } from '../../selectionTypes';
import { areSelectionsEqual } from '../../utils/resolveVariableSelection';
import { selectionFromCommittedValues } from '../../utils/selectionUtils';
import OverflowValuesTooltip from './OverflowValuesTooltip';
import styles from '../../VariablesBar.module.scss';
@@ -76,23 +75,13 @@ function ValueSelector({
options.every((option) => draft.includes(option));
const commit = (values: string[]): void => {
// A close that left the list as it opened commits nothing — else a pick covering
// every option this window offers would be promoted to a standing ALL.
if (
areSelectionsEqual(
{ value: values, allSelected: false },
{ value: committedValues, allSelected: false },
)
) {
return;
}
const next = selectionFromCommittedValues({
values,
options,
showAllOption,
emptyFallback,
});
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
showAllOption &&
options.length > 0 &&
options.every((option) => values.includes(option));
const next: VariableSelection =
values.length === 0 ? emptyFallback : { value: values, allSelected: isAll };
// Closing without actually changing the selection must not re-fire onChange —
// that would needlessly re-cascade to dependent variables/panels.

View File

@@ -1,11 +1,6 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import {
selectVariableCycleReason,
VariableCycleReason,
} from '../../store/slices/variableFetchSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
import { reconcileWithOptions } from '../utils/resolveVariableSelection';
import type { VariableSelection } from '../selectionTypes';
@@ -14,9 +9,6 @@ import type { VariableSelection } from '../selectionTypes';
* `onAutoSelect` only when the value must change. The reconcile rule lives in
* {@link reconcileWithOptions} (shared with seed + payload defaulting) so the bar
* and the panel query can never disagree about a variable's default.
*
* Only a value cascade may re-default the selection; a full cycle (time range,
* reload) leaves the user's pick alone. Types with no cycle of their own reconcile.
*/
export function useAutoSelect(
variable: VariableFormModel,
@@ -24,14 +16,8 @@ export function useAutoSelect(
selection: VariableSelection,
onAutoSelect: (selection: VariableSelection) => void,
): void {
const cycleReason = useDashboardStore(
selectVariableCycleReason(variable.name),
);
useEffect(() => {
const next = reconcileWithOptions(variable, selection, options, {
preserveSelection: cycleReason === VariableCycleReason.FullCycle,
});
const next = reconcileWithOptions(variable, selection, options);
if (next) {
onAutoSelect(next);
}

View File

@@ -10,11 +10,6 @@ export interface VariableSelection {
value: SelectedVariableValue;
/** True when every option is selected ("ALL"); for dynamic vars value may be null. */
allSelected: boolean;
/**
* Entries of `value` the user typed rather than picked. Never in any option list,
* so the reconcile keeps them instead of reading them as invalid.
*/
customValues?: string[];
}
/** Selected values for a dashboard's variables, keyed by variable name. */

View File

@@ -134,23 +134,12 @@ export function resolveDefaultSelection(
return { value: model.multiSelect ? [] : '', allSelected: false };
}
interface ReconcileOptions {
/**
* Set when no other variable caused this refetch (time-range change, reload): the
* selection then outranks the options and is kept as-is. Leave false for a
* dependency cascade, where a selection that no longer applies must give way.
*/
preserveSelection?: boolean;
}
/**
* Reconciles a variable's current selection against its freshly-fetched options.
* Returns the next selection, or null when nothing should change (a valid pick is
* left untouched — local-first). Behaviour, in order:
* - materialize ALL to the full option set (query/custom);
* - keep a multi-select selection outright when `preserveSelection` is set;
* - keep a still-valid multi-select subset, dropping only entries the list no longer
* offers and the user did not type in (`customValues`);
* - keep a still-valid multi-select subset, dropping only invalid entries;
* - otherwise auto-pick the default (or first option) so dependent variables and
* panels always resolve against a usable value.
*/
@@ -158,7 +147,6 @@ export function reconcileWithOptions(
model: VariableFormModel,
current: VariableSelection,
options: string[],
{ preserveSelection = false }: ReconcileOptions = {},
): VariableSelection | null {
if (options.length === 0) {
return null;
@@ -173,31 +161,13 @@ export function reconcileWithOptions(
Array.isArray(current.value) &&
current.value.length > 0
) {
// A pick this window has no data for is still the user's filter; re-defaulting it
// here is what widened a single pick to ALL on every time-range change.
if (preserveSelection) {
return null;
}
// A typed value is in no option list, so it is never "no longer offered".
const custom = new Set(current.customValues ?? []);
const valid = current.value
.map(String)
.filter((c) => options.includes(c) || custom.has(c));
const valid = current.value.map(String).filter((c) => options.includes(c));
if (valid.length === current.value.length) {
return null;
}
if (valid.length === 0) {
return fillDefault(model, options);
}
const customValues = valid.filter((v) => custom.has(v));
return {
value: valid,
allSelected: false,
...(customValues.length > 0 && { customValues }),
};
return valid.length > 0
? { value: valid, allSelected: false }
: fillDefault(model, options);
}
if (!model.multiSelect) {

View File

@@ -47,43 +47,6 @@ export function hasUsableValue(
return value !== '' && value !== null && value !== undefined;
}
interface CommittedValues {
values: string[];
options: string[];
showAllOption: boolean;
emptyFallback: VariableSelection;
}
/**
* The selection a multi-select commit resolves to. Options are known only here, so
* this is where a value the list never offered is recorded as typed in.
*/
export function selectionFromCommittedValues({
values,
options,
showAllOption,
emptyFallback,
}: CommittedValues): VariableSelection {
if (values.length === 0) {
return emptyFallback;
}
const customValues = values.filter((value) => !options.includes(value));
// ALL re-materializes to the option set, so a set carrying a typed value is not ALL
// — the next refetch would expand it back and drop what the user typed.
const allSelected =
showAllOption &&
options.length > 0 &&
customValues.length === 0 &&
options.every((option) => values.includes(option));
return {
value: values,
allSelected,
...(customValues.length > 0 && { customValues }),
};
}
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
export function selectionToPayload(
selection: VariableSelectionMap,

View File

@@ -34,7 +34,6 @@ function reset(names: string[], context: VariableFetchContext): void {
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableCycleReasons: {},
variableFetchContext: null,
});
store().initVariableFetch(names, context);
@@ -134,33 +133,6 @@ describe('variableFetchSlice', () => {
expect(states().q1).toBe('error');
expect(states().q2).toBe('idle');
});
// The reason is what tells the post-fetch reconcile whether it may re-default a
// selection: a full cycle must not, a value cascade must.
it('tags a full cycle, then re-tags only the cascaded variables', () => {
store().enqueueFetchAll();
expect(store().variableCycleReasons).toStrictEqual({
q1: 'full-cycle',
q2: 'full-cycle',
d1: 'full-cycle',
d2: 'full-cycle',
});
resolve('q1');
store().enqueueDescendants('q1');
expect(store().variableCycleReasons).toStrictEqual({
q1: 'full-cycle',
q2: 'value-cascade',
d1: 'full-cycle',
d2: 'full-cycle',
});
});
it('drops the reason for a variable that no longer exists', () => {
store().enqueueFetchAll();
store().initVariableFetch(['q1'], context);
expect(store().variableCycleReasons).toStrictEqual({ q1: 'full-cycle' });
});
});
describe('variableFetchSlice — query depends on a dynamic', () => {

View File

@@ -9,7 +9,6 @@ import {
type FetchMaps,
isVariableInActiveFetchState,
resolveFetchState,
VariableCycleReason,
VariableFetchState,
} from './variableFetchSlice.utils';
@@ -31,10 +30,7 @@ function queryParentsHaveValues(
);
}
export {
VariableCycleReason,
VariableFetchState,
} from './variableFetchSlice.utils';
export { VariableFetchState } from './variableFetchSlice.utils';
/**
* Runtime fetch orchestration for dashboard variables — native port of V1's
@@ -49,8 +45,6 @@ export interface VariableFetchSlice {
variableFetchStates: Record<string, VariableFetchState>;
variableLastUpdated: Record<string, number>;
variableCycleIds: Record<string, number>;
/** Why each variable's current cycle was enqueued, read by the post-fetch reconcile. */
variableCycleReasons: Record<string, VariableCycleReason>;
/**
* Whether a QUERY/DYNAMIC variable settled its fetch with zero options (so it
* will never get a value). Lets a dependent panel fall through to "no data"
@@ -112,7 +106,6 @@ export const createVariableFetchSlice: StateCreator<
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableCycleReasons: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
@@ -122,7 +115,6 @@ export const createVariableFetchSlice: StateCreator<
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableCycleReasons: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
@@ -140,7 +132,6 @@ export const createVariableFetchSlice: StateCreator<
initVariableFetch: (names, context): void => {
const maps = cloneMaps(get());
const resolvedEmpty = { ...get().variableResolvedEmpty };
const reasons = { ...get().variableCycleReasons };
names.forEach((name) => {
if (!maps.states[name]) {
maps.states[name] = VariableFetchState.Idle;
@@ -153,14 +144,12 @@ export const createVariableFetchSlice: StateCreator<
delete maps.lastUpdated[name];
delete maps.cycleIds[name];
delete resolvedEmpty[name];
delete reasons[name];
}
});
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
variableCycleReasons: reasons,
variableResolvedEmpty: resolvedEmpty,
variableFetchContext: context,
});
@@ -182,11 +171,6 @@ export const createVariableFetchSlice: StateCreator<
dynamicVariableOrder,
} = variableFetchContext;
const maps = cloneMaps(get());
const reasons = { ...get().variableCycleReasons };
const bump = (name: string): void => {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
reasons[name] = VariableCycleReason.FullCycle;
};
// Query variables wait only for their QUERY parents. A DYNAMIC parent does not
// gate: its option fetch feeds only its own dropdown, while its selected value
@@ -194,7 +178,7 @@ export const createVariableFetchSlice: StateCreator<
// dependent query substitutes it immediately and refetches via the cascade if
// it later changes. Text/custom parents resolve synchronously, so nothing waits.
queryVariableOrder.forEach((name) => {
bump(name);
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
const parents = dependencyData.parentGraph[name] || [];
const hasQueryParents = parents.some((p) => variableTypes[p] === 'QUERY');
maps.states[name] = hasQueryParents
@@ -208,7 +192,7 @@ export const createVariableFetchSlice: StateCreator<
const orderedQuery = new Set(queryVariableOrder);
Object.keys(variableTypes).forEach((name) => {
if (variableTypes[name] === 'QUERY' && !orderedQuery.has(name)) {
bump(name);
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
maps.states[name] = resolveFetchState(maps, name);
}
});
@@ -219,7 +203,7 @@ export const createVariableFetchSlice: StateCreator<
// populate fast even when query variables are slow; a sibling selection change
// later refetches them via `enqueueDescendantsBatch`.
dynamicVariableOrder.forEach((name) => {
bump(name);
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
maps.states[name] = resolveFetchState(maps, name);
});
@@ -227,7 +211,6 @@ export const createVariableFetchSlice: StateCreator<
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
variableCycleReasons: reasons,
lastFetchAllKey: key ?? get().lastFetchAllKey,
});
},
@@ -307,11 +290,6 @@ export const createVariableFetchSlice: StateCreator<
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
const maps = cloneMaps(get());
const reasons = { ...get().variableCycleReasons };
const bump = (name: string): void => {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
reasons[name] = VariableCycleReason.ValueCascade;
};
const changed = new Set(names);
// Callers commit values before this runs, so the gate sees the new parent values.
const selection = selectVariableValues(get().dashboardId)(get());
@@ -327,7 +305,7 @@ export const createVariableFetchSlice: StateCreator<
});
});
queryDescendants.forEach((desc) => {
bump(desc);
maps.cycleIds[desc] = (maps.cycleIds[desc] || 0) + 1;
maps.states[desc] = queryParentsHaveValues(
desc,
variableFetchContext,
@@ -344,7 +322,7 @@ export const createVariableFetchSlice: StateCreator<
dynamicVariableOrder
.filter((dynName) => !changed.has(dynName))
.forEach((dynName) => {
bump(dynName);
maps.cycleIds[dynName] = (maps.cycleIds[dynName] || 0) + 1;
maps.states[dynName] = resolveFetchState(maps, dynName);
});
}
@@ -353,7 +331,6 @@ export const createVariableFetchSlice: StateCreator<
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
variableCycleReasons: reasons,
});
},
});
@@ -370,12 +347,6 @@ export const selectVariableCycleId =
(state: DashboardStore): number =>
state.variableCycleIds[name] ?? 0;
/** Selector: why a variable's cycle was enqueued. Undefined for types that never fetch. */
export const selectVariableCycleReason =
(name: string) =>
(state: DashboardStore): VariableCycleReason | undefined =>
state.variableCycleReasons[name];
/** Selector: whether a variable has completed at least one fetch. */
export const selectVariableFetchedOnce =
(name: string) =>

View File

@@ -7,14 +7,6 @@ export enum VariableFetchState {
Error = 'error',
}
/** Why a cycle was started — only a cascade may re-default a user's selection. */
export enum VariableCycleReason {
/** `enqueueFetchAll`: load, time-range or variable-order change. */
FullCycle = 'full-cycle',
/** `enqueueDescendantsBatch`: a parent or sibling variable's value changed. */
ValueCascade = 'value-cascade',
}
/** Mutable clones a fetch action works over before committing back in one `set`. */
export interface FetchMaps {
states: Record<string, VariableFetchState>;

View File

@@ -52,12 +52,12 @@ const makeDashboard = (
...overrides,
}) as unknown as DashboardListItem;
const renderRow = (dashboard: DashboardListItem, canEdit = true): void => {
const renderRow = (dashboard: DashboardListItem): void => {
render(
<DashboardRow
dashboard={dashboard}
index={0}
canEdit={canEdit}
canEdit
showUpdatedAt={false}
showUpdatedBy={false}
/>,
@@ -105,19 +105,6 @@ describe('DashboardRow', () => {
expect(mockSafeNavigate).not.toHaveBeenCalled();
expect(screen.getByTestId('legacy-dashboard-id')).toBeInTheDocument();
expect(
screen.getByTestId('legacy-dashboard-retry-migration'),
).toBeInTheDocument();
});
it('withholds the retry action from a row the user cannot edit', async () => {
renderRow(makeDashboard({ legacy: true }), false);
await userEvent.click(screen.getByTestId('dashboard-title-0'));
expect(
screen.queryByTestId('legacy-dashboard-retry-migration'),
).not.toBeInTheDocument();
});
});
});

View File

@@ -252,7 +252,6 @@ function DashboardRow({
open={isLegacyDialogOpen}
dashboardId={id}
dashboardName={name}
canEdit={canEdit}
onClose={(): void => setIsLegacyDialogOpen(false)}
/>
)}

View File

@@ -49,7 +49,6 @@
.footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -18,30 +18,19 @@ jest.mock('container/Integrations/utils', () => ({
handleContactSupport: (isCloud: boolean): void => mockContactSupport(isCloud),
}));
const mockRetryMigration = jest.fn();
let isMigrating = false;
jest.mock('../../hooks/useRetryMigration', () => ({
useRetryMigration: (): {
retryMigration: jest.Mock;
isMigrating: boolean;
} => ({ retryMigration: mockRetryMigration, isMigrating }),
}));
const DASHBOARD_ID = '0f9a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c';
describe('LegacyDashboardDialog', () => {
beforeEach(() => {
jest.clearAllMocks();
isMigrating = false;
});
const setup = ({ open = true, canEdit = true } = {}): void => {
const setup = (open = true): void => {
render(
<LegacyDashboardDialog
open={open}
dashboardId={DASHBOARD_ID}
dashboardName="My Legacy Dashboard"
canEdit={canEdit}
onClose={jest.fn()}
/>,
);
@@ -68,31 +57,8 @@ describe('LegacyDashboardDialog', () => {
expect(mockContactSupport).toHaveBeenCalledTimes(1);
});
it('retries the migration for the dashboard', async () => {
setup();
await userEvent.click(screen.getByTestId('legacy-dashboard-retry-migration'));
expect(mockRetryMigration).toHaveBeenCalledWith(DASHBOARD_ID);
});
it('blocks retry and close while the migration is in flight', () => {
isMigrating = true;
setup();
expect(screen.getByTestId('legacy-dashboard-retry-migration')).toBeDisabled();
expect(screen.getByTestId('legacy-dashboard-close')).toBeDisabled();
});
it('offers only the support path without edit access', () => {
setup({ canEdit: false });
expect(
screen.queryByTestId('legacy-dashboard-retry-migration'),
).not.toBeInTheDocument();
expect(
screen.getByTestId('legacy-dashboard-contact-support'),
).toBeInTheDocument();
});
it('renders nothing when closed', () => {
setup({ open: false });
setup(false);
expect(screen.queryByTestId('legacy-dashboard-id')).not.toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
import { ArrowUpRight, Copy, RotateCw } from '@signozhq/icons';
import { ArrowUpRight, Copy } from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
@@ -9,34 +9,28 @@ import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
import { useRetryMigration } from '../../hooks/useRetryMigration';
import styles from './LegacyDashboardDialog.module.scss';
interface LegacyDashboardDialogProps {
open: boolean;
dashboardId: string;
dashboardName: string;
canEdit: boolean;
onClose: () => void;
}
/**
* Explains why a legacy (pre-v2) dashboard can't be opened in the new experience
* and offers to re-run the migration. Legacy rows are surfaced by the list API
* with `legacy: true` but have no v2 spec to render. Retrying needs edit access,
* so viewers only get the dashboard ID to share with support.
* and hands the user the dashboard ID to share with support. Legacy rows are
* surfaced by the list API with `legacy: true` but have no v2 spec to render.
*/
function LegacyDashboardDialog({
open,
dashboardId,
dashboardName,
canEdit,
onClose,
}: LegacyDashboardDialogProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const { isCloudUser } = useGetTenantLicense();
const { retryMigration, isMigrating } = useRetryMigration(onClose);
const onCopyId = (): void => {
copyToClipboard(dashboardId);
@@ -55,14 +49,6 @@ function LegacyDashboardDialog({
});
};
const onRetryMigration = (): void => {
retryMigration(dashboardId);
void logEvent(DashboardListEvents.LegacyDialogAction, {
action: 'retryMigration',
dashboardId,
});
};
return (
<DialogWrapper
title="This dashboard isn't available in the new experience"
@@ -79,15 +65,14 @@ function LegacyDashboardDialog({
variant="ghost"
color="secondary"
size="md"
disabled={isMigrating}
onClick={onClose}
testId="legacy-dashboard-close"
>
Close
</Button>
<Button
variant={canEdit ? 'outlined' : 'solid'}
color={canEdit ? 'secondary' : 'primary'}
variant="solid"
color="primary"
size="md"
suffix={<ArrowUpRight size={14} />}
onClick={onContactSupport}
@@ -95,20 +80,6 @@ function LegacyDashboardDialog({
>
Contact Support
</Button>
{canEdit && (
<Button
variant="solid"
color="primary"
size="md"
prefix={<RotateCw size={14} />}
disabled={isMigrating}
loading={isMigrating}
onClick={onRetryMigration}
testId="legacy-dashboard-retry-migration"
>
Retry migration
</Button>
)}
</div>
}
>
@@ -116,10 +87,8 @@ function LegacyDashboardDialog({
<Typography.Text className={styles.description}>
<strong>{dashboardName || 'This dashboard'}</strong> hasn&apos;t been
migrated to the new dashboard experience yet, so it can&apos;t be opened
here.{' '}
{canEdit
? "Retrying the migration often works once we've handled the case that blocked it. If it still fails, share the dashboard ID below with support."
: "Share the dashboard ID below with support and we'll help you move it over."}
here. Share the dashboard ID below with support and we&apos;ll help you
move it over.
</Typography.Text>
<div className={styles.idField}>

View File

@@ -1,109 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateListDashboardsForUserV2,
useMigrateDashboardV2,
} from 'api/generated/services/dashboard';
import { useRetryMigration } from '../useRetryMigration';
jest.mock('react-query', () => ({
useQueryClient: jest.fn(),
}));
jest.mock('api/generated/services/dashboard', () => ({
useMigrateDashboardV2: jest.fn(),
invalidateListDashboardsForUserV2: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const queryClient = { invalidateQueries: jest.fn() };
const mockMutate = jest.fn();
const onMigrated = jest.fn();
type MutationHandlers = {
onSuccess: () => Promise<void>;
onError: (error: unknown) => void;
};
let captured: MutationHandlers;
// Stands in for the generated mutation hook: records the handlers the hook wires
// up so each one can be driven directly, and reports the requested in-flight state.
function setup(isLoading = false): {
retryMigration: (id: string) => void;
isMigrating: boolean;
} {
(useMigrateDashboardV2 as jest.Mock).mockImplementation(
(options: { mutation: MutationHandlers }) => {
captured = options.mutation;
return { mutate: mockMutate, isLoading };
},
);
return renderHook(() => useRetryMigration(onMigrated)).result.current;
}
// A 501 from GET/POST on an un-migrated dashboard carries the render error envelope.
const envelopeError = {
response: {
status: 501,
data: {
error: { code: 'dashboard_invalid_data', message: 'not in v6 schema' },
},
},
message: 'Request failed with status code 501',
};
// A gateway failure responds without an envelope, so there is no backend reason to show.
const bodylessError = {
response: { status: 502, data: '<html>bad gateway</html>' },
message: 'Request failed with status code 502',
};
describe('useRetryMigration', () => {
beforeEach(() => {
jest.clearAllMocks();
(useQueryClient as jest.Mock).mockReturnValue(queryClient);
});
it('sends the dashboard id as a path parameter', () => {
setup().retryMigration('dash-1');
expect(mockMutate).toHaveBeenCalledWith({ pathParams: { id: 'dash-1' } });
});
it('refreshes the list, confirms with a toast and reports success', async () => {
setup();
await captured.onSuccess();
expect(invalidateListDashboardsForUserV2).toHaveBeenCalledWith(queryClient);
expect(toast.success).toHaveBeenCalledWith(
'Dashboard migrated to the new experience',
);
expect(onMigrated).toHaveBeenCalledTimes(1);
});
it('surfaces the backend reason and does not report success on failure', () => {
setup();
captured.onError(envelopeError);
expect(toast.error).toHaveBeenCalledWith('not in v6 schema');
expect(onMigrated).not.toHaveBeenCalled();
});
it('points the user at support when the failure carries no reason', () => {
setup();
captured.onError(bodylessError);
expect(toast.error).toHaveBeenCalledWith(
'Could not migrate this dashboard. Please contact support.',
);
});
it('reports the in-flight state from the mutation', () => {
expect(setup(true).isMigrating).toBe(true);
});
});

View File

@@ -1,48 +0,0 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateListDashboardsForUserV2,
useMigrateDashboardV2,
} from 'api/generated/services/dashboard';
import { toAPIError } from 'utils/errorUtils';
const FAILURE_MESSAGE =
'Could not migrate this dashboard. Please contact support.';
export interface UseRetryMigrationResult {
// Re-run the v1 to v2 migration for a dashboard.
retryMigration: (id: string) => void;
isMigrating: boolean;
}
// Wraps the retry-migration mutation for a legacy (pre-v2) dashboard: refreshes
// the personalized list so the row loses its legacy flag, and reports the
// backend's reason as a toast when the dashboard still can't be converted.
export function useRetryMigration(
onMigrated?: () => void,
): UseRetryMigrationResult {
const queryClient = useQueryClient();
const migrate = useMigrateDashboardV2({
mutation: {
onSuccess: async (): Promise<void> => {
await invalidateListDashboardsForUserV2(queryClient);
toast.success('Dashboard migrated to the new experience');
onMigrated?.();
},
onError: (error): void => {
toast.error(toAPIError(error, FAILURE_MESSAGE).getErrorMessage());
},
},
});
const retryMigration = useCallback(
(id: string): void => {
migrate.mutate({ pathParams: { id } });
},
[migrate],
);
return { retryMigration, isMigrating: migrate.isLoading };
}

View File

@@ -114,23 +114,6 @@ describe('getCaretContext — stage detection', () => {
expect(ctx.partial).toBe('');
});
it('never replaces past the caret when it sits before the operator', () => {
const ctx = getCaretContext("env = 'prod'", 4);
expect(ctx.stage).toBe('operator');
expect(ctx.partial).toBe('');
expect(ctx.replaceStart).toBe(4);
expect(ctx.replaceEnd).toBe(4);
});
it('never replaces past the caret when it sits before the value', () => {
const ctx = getCaretContext("env = 'prod'", 6);
expect(ctx.stage).toBe('value');
expect(ctx.operator).toBe('=');
expect(ctx.partial).toBe('');
expect(ctx.replaceStart).toBe(6);
expect(ctx.replaceEnd).toBe(6);
});
it('detects the stage of the term under a mid-string caret', () => {
const q = "env = AND team = 'core'";
// caret right after the first `env ` (index 4) is the operator stage
@@ -159,13 +142,6 @@ describe('spliceAtCaret', () => {
expect(next).toBe("env = 'prod'");
});
it('inserts (without duplicating text) at a caret parked before a token', () => {
const q = "env = 'prod'";
const ctx = getCaretContext(q, 4);
const { next } = spliceAtCaret(q, ctx, '!= ');
expect(next).toBe("env != = 'prod'");
});
it('preserves text after the caret', () => {
const q = "env AND team = 'core'";
const ctx = getCaretContext(q, 4); // operator gap after `env`

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