Compare commits

...

28 Commits

Author SHA1 Message Date
srikanthccv
fd30597641 feat(telemetrymetadata): bool values, start filter, and context-aware static keys
- fields/values returns boolValues for bool fields: when the caller asks for
  the bool type, when the name is a static bool field of the signal, or when
  the tag table holds bool rows for the key
- span and log values honor startUnixMilli against the tag tables
- static (intrinsic and calculated) keys honor fieldContext and fieldDataType
  and match case-insensitively; span and log contexts are served by the
  static fields instead of returning attribute keys or nothing
- isRoot/isEntryPoint are typed bool and meter keys carry a string data type
- skip the column evolution lookup when there are no keys to enrich

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01NNQrizqt5XQ1N2NNAcuAi8
2026-09-02 13:45:54 +05:30
Nikhil Mantri
70335dc707 feat(alert-channel-integrations): jira + jsm ops channel frontend (#12488)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

Adds **Jira** and **JSM Ops** as alert channel types in the existing
channel flow. No new pages or endpoints — both reuse the same create /
edit / list / test actions as every other channel.

**Jira**

- Required fields: Jira Cloud site URL, Atlassian email + API token,
project, and issue type. **Summary** and **Description** are prefilled,
editable templates (the rich issue body is built server-side).
- The form recommends using an Atlassian **service account**, with a
link to the docs.
- Advanced Options: priority, labels (chip input), resolve/reopen
transition-name overrides, and the reopen window.
- Client-side validation mirrors the backend (must be an
`https://….atlassian.net` URL; reopen window ≥ 1m) purely for a
friendlier error — the backend enforces the same rules.

**JSM Ops**

- The JSM **integration API key** is the only required field — there is
no site or region to configure.
- **Message**, **Description**, and **Priority** are prefilled, editable
templates; **Tags** is a chip input (defaults to `signoz`).

**Shared behaviour**

- `Send resolved alerts` is on by default; on resolve, Jira transitions
+ comments the issue and JSM Ops closes the alert.
- Optional fields left empty are omitted from the payload, so the
backend applies its own defaults.
- Jest tests cover both forms: rendering, prefilled defaults, validation
errors, and the exact save payloads (`jira_configs` / `jsmops_configs`).
- Generated API client types are regenerated to include
`jsmops_configs`; locale strings added for all new fields.

#### Issues closed by this PR

Stacked on top of #12478 · Discussion: SigNoz/pulse-pod#169 · Closes
SigNoz/pulse-pod#170

#### Screenshots / Screen Recordings

Jira Form : 

<img width="1512" height="823" alt="Screenshot 2026-08-18 at 1 16 49 PM"
src="https://github.com/user-attachments/assets/1a3a3419-de1b-4bf1-8a71-50d8341e5d47"
/>

Expanded Jira advanced options : 

<img width="1444" height="406" alt="Screenshot 2026-08-18 at 1 17 08 PM"
src="https://github.com/user-attachments/assets/d1298009-b0f3-4f3d-b960-b805b12f5026"
/>

JSM Ops Form: 

<img width="1479" height="631" alt="Screenshot 2026-08-18 at 1 19 11 PM"
src="https://github.com/user-attachments/assets/678b130b-8b1f-41c8-a78a-b6bd58ae2904"
/>

JSM Ops Advanced Options: 

<img width="1455" height="239" alt="Screenshot 2026-08-18 at 1 19 22 PM"
src="https://github.com/user-attachments/assets/a4425b39-b7e1-4f10-bd0a-83dbc678693a"
/>

#### Additional Information

Notes for reviewers:

- **This branch is stacked on #12478**, so the diff shows the backend
commits too — only the `frontend/` files are new here.
- Follows the pattern of the Google Chat channel frontend.
- **JSM Ops seeds `send_resolved: true` in its prefilled config on
purpose** — the backend cannot default it to on, so the UI carries the
default and sends it explicitly.
- **Tags is a chip input in the UI, but the backend takes a
comma-separated string** — joined on save, split back into chips on
edit-prefill.
- Jira's reopen window is sent as a duration string (`"72h"`) even
though the generated DTO types it as a number, so it's cast at that one
boundary. `custom_fields` stays API-only and is not surfaced in the
form.

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-09-02 03:52:24 +00:00
Ashwin Bhatkal
eb2d094b1f feat(infra-monitoring): Kubernetes Containers section (#12636)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

Adds the **Containers** section to the Kubernetes tab of Infrastructure
Monitoring, breaking each pod down into the app and sidecar containers
running inside it — which the Pods view rolls up into a single row — so
you can tell which container in a pod is throttling, leaking memory or
crash-looping.

- Backed by `POST /api/v2/infra_monitoring/kube_containers` through the
generated client. Filtering, grouping, time range, pagination, column
customization and the instrumentation checks callout all come from the
shared k8s entity framework, so this is mostly configuration rather than
new machinery.
- **List columns:** container name, pod, image:tag, kubectl-style
status, readiness, restarts, CPU and memory usage plus request/limit
utilization. Namespace, node, cluster and deployment sit behind the
column selector. Grouped rows show per-status and per-readiness counts.
- **Detail drawer:** ten `/v5/query_range` charts scoped to the single
container, plus the logs, traces and events tabs. Events are scoped to
the container's *pod*, since Kubernetes emits events per pod rather than
per container.
- A container's identity is the `(k8s.pod.uid, k8s.container.name)` pair
— a container name alone repeats across replicas, and a container ID
changes on every restart. Every other k8s entity is addressable by a
single name, so the first commit widens `SelectedItemParams` with an
optional container name, alongside the cluster and namespace slots that
already serve that purpose.

Columns and charts follow the descriptions in
https://github.com/SigNoz/signoz.io/pull/3644.

#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/5547

#### Additional Information

- Reviewed best commit by commit: identity foundation, then shared
constants/helpers, then the entity itself. Each stands on its own.
2026-09-01 14:07:03 +00:00
Nityananda Gohain
7c50fe3763 fix: quick filters old migration cleanup (#12746)
#### Description
This PR makes sure that the old migrations of quick filters are
decoupled from types as they should and not imported.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
Part of https://github.com/SigNoz/engineering-pod/issues/5947
2026-09-01 13:54:36 +00:00
Nikhil Mantri
160a1b018c feat(alert-channel-integrations): jira + jsm ops channel backend (#12478)
#### Description

Adds two Atlassian alert channels. Backend only — frontend is #12488;
channels are created via the API.

**Jira issues — `jira_configs`**

- A firing alert creates a Jira Cloud issue; when the alert resolves,
the issue is transitioned to done. A re-fire within 3 days reopens the
same issue instead of creating a new one. 3 days is default but can be
edited via frontend form.
- The issue body is rich **Atlassian Document Format (ADF)**: a status
panel, the rendered alert description, and deep-links back to SigNoz.
- Re-fires keep the issue in sync (summary and description are
refreshed), and every notification after the first — re-fire, resolve,
reopen — also posts a **comment** carrying the same rich ADF snapshot,
so the issue holds a full lifecycle timeline.
- Per-rule custom notification templates (title/body) are honored, same
as every other channel; multi-alert custom bodies render as
divider-separated sections.
- Auth is Atlassian email + API token; Atlassian **service accounts**
also work (routed via the `api.atlassian.com` gateway automatically —
the cloud id is resolved server-side and client-supplied values are
ignored). Jira Cloud only.

**JSM Ops alerts — `jsmops_configs`**

- A firing alert opens a JSM Operations alert (the ex-Opsgenie alert
product); resolve **closes** it. A fire after close opens a fresh alert
— there is no reopen window.
- Re-fires dedupe into the same alert and increment its count. The alert
description keeps the first-fire snapshot; the value-over-time story
lives in the notes.
- Every fire and the resolve appends a **note** to the alert. JSM Ops
notes support **plain text only** (they render neither HTML nor
markdown), so notes use a new plain-text renderer with links flattened
to `text (url)`.
- The alert description supports JSM's **HTML subset**, rendered from
the same markdown templates.
- Auth is the JSM integration API key. No region/site config needed.

**Also in this PR**

- Unit tests for both config types, both notifiers, and the new ADF +
plain-text renderers.
- OpenAPI spec regenerated (adds `jsmops_configs`).

#### Issues closed by this PR

Closes SigNoz/pulse-pod#168 · Discussion: SigNoz/pulse-pod#169

#### Screenshots / Screen Recordings

Jira alert issue:

<img width="1171" height="739" alt="Screenshot 2026-08-18 at 12 49
31 PM"
src="https://github.com/user-attachments/assets/1ec74757-5d4d-4534-a5ca-13bed07cce72"
/>

Jira issue comments as a timeline:

<img width="1034" height="746" alt="Screenshot 2026-08-18 at 12 50
32 PM"
src="https://github.com/user-attachments/assets/09afb687-b62b-4eb3-86ab-39489e38513e"
/>

JSM Ops alerts page look: 

<img width="1317" height="460" alt="Screenshot 2026-08-18 at 12 52
29 PM"
src="https://github.com/user-attachments/assets/dd5a60b5-ea6c-49b7-afde-f3b2c72b178a"
/>

JSM Ops alerts main body + comment timeline ( comments only support
plain text today ) :

<img width="1323" height="784" alt="Screenshot 2026-08-18 at 12 53
10 PM"
src="https://github.com/user-attachments/assets/871fb91c-307a-4c67-b2c4-bc9548506cbc"
/>

#### Additional Information

Notes for reviewers:

- Jira shadows upstream Alertmanager's `jira_configs` so our notifier
handles it instead of upstream's; this needs a small dedupe in
`PostableChannel.JSONSchema()` and leaves every other channel type
untouched.
- JSM Ops reuses the existing Opsgenie notifier; all new behaviour sits
behind a single `advancedFeatures` flag, so plain Opsgenie is unchanged
when it's off.
- `send_resolved` defaults off for both channels, so resolve-time
behaviour (Jira transition, JSM close + resolved note) needs it set on
the channel; the frontend will send it on by default.
- Notes are best-effort: a permanently-failed note (e.g. the first-fire
note racing JSM's asynchronous alert create) is dropped with a warning
instead of failing the whole notification. Nothing is lost — that first
datapoint is already in the alert body; retryable failures (429) still
retry.

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-09-01 13:03:48 +00:00
Naman Verma
27acdbd970 fix: ensure that alerts created as disabled don't actually fire (#12743)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Patch and Edit Rule APIs would call syncRuleStateWithTask instead of
adding the task blindly, which is what the create API was doing. This PR
fixes the incorrect call in the create API

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/312
2026-09-01 11:26:43 +00:00
Nityananda Gohain
7eb610287e feat: system dashboards (#12620)
#### Description
Adding support for system dashboards.
* as of now updates are only through new versions in the file.
* user cannot update the dashboard
* For now kept the dashboard content empty and will raise it separately.


Closes https://github.com/SigNoz/engineering-pod/issues/4501
2026-09-01 10:17:10 +00:00
Tushar Vats
74c946fe79 fix(querier): decode JSON columns in table and graph queries (#12555)
#### Description

**What was broken:** on a stack using the new JSON log body, any query
asking for a **table** (`scalar`) or a **graph** (`time_series`) failed
with HTTP 500 if the result contained a JSON column. The logs list view
worked fine, which is why this went unnoticed. The smallest way to hit
it is a raw ClickHouse panel running `select * from
signoz_logs.logs_v2`.

**Why it happened:** we ask ClickHouse to send JSON columns as plain
text, but the driver reports that such a column needs a different Go
type — so the reader prepared the wrong kind of container and the read
failed. The driver only reports the correct type *after* the first row
has been read, which is too late for code that sets up its containers up
front. The original JSON work patched around this inside the logs-list
reader only; the connection setting that causes it is global, so the
other two readers stayed broken.

**The fix:** correct the reported type once, at the connection that sets
that option, so every reader gets a container that works and receives a
normal map. Concretely:

- `pkg/querier` no longer needs its own workaround — the three readers
are back to ordinary code.
- The older v3/v4 read paths had the same bug and are fixed without any
changes of their own.
- A JSON path value such as `body_v2.level` now comes back as `"error"`
or `7` instead of a driver wrapper object.
- Grouping a graph by the whole JSON body used to collapse every group
into a single unlabelled line; each document now labels its own series.

#### Issues closed by this PR

Fixes https://github.com/SigNoz/engineering-pod/issues/5911

#### Additional Information

Verified end to end against a local stack with 1,000,000 log rows and
200,002 distinct `trace_id`s:

| query | before | after |
| --- | --- | --- |
| table query over a JSON column | 500 | 200, body returned as an object
|
| graph grouped by the JSON body | 500 | 200, 22 series, one per
document |
| graph grouped by `trace_id` (200k groups) | 200 | 200, unchanged |

A follow-up PR stacked on this one reworks how the graph reader
classifies columns — fixing boolean and small-integer columns in raw SQL
panels and cutting the reader's allocations.

Known gaps, unchanged from `main` and out of scope here:

- Waterfall and flamegraph read rows into structs, which this fix does
not cover. Moving span attributes to JSON will need the same type on
those fields, and one helper there fails silently rather than erroring.
- Dashboard variable queries no longer crash on a JSON column but still
reject it as an unsupported value type.
- A `Map(String, JSON)` column **panics inside the driver**, which can
take the process down. Confirmed still unfixed on `clickhouse-go` main,
and not yet reported upstream.
2026-09-01 10:07:56 +00:00
Gaurav Tewari
23dd98bee7 chore: update sample json in test ai-o11y (#12741)
#### Description

- Update the sample span JSON in the LLM Observability attribute-mapping
Test tab to use OpenInference-style attribute keys (`llm.model_name`,
`llm.provider`, `llm.token_count.*`, `input.value`, `output.value`)
instead of the earlier mix of `gen_ai.*` and placeholder `my_company.*`
keys.
- The sample is what users see first when trying out attribute mapping,
so it should reflect the attribute shape they'll actually be mapping
from.

#### Issues closed by this PR

Closes -
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=238442542&issue=SigNoz%7Cengineering-pod%7C5997

#### Screenshots / Screen Recordings

<img width="911" height="572" alt="image"
src="https://github.com/user-attachments/assets/88df20e9-0131-415c-9770-67ea8196075a"
/>


#### Additional Information

- Constant-only change (`SAMPLE_SPAN_JSON` in `spanInputStorage.ts`); no
parsing or mapping logic touched.
- Worth a sanity check that the new keys line up with the attribute
names the mapping UI is expected to suggest.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-01 09:30:30 +00:00
Swapnil Nakade
aca35a1309 chore: bumping cloud integration agent version to v0.0.14 (#12737)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Bumping cloud integration agent version from v0.0.13 to v0.0.14

#### Contributes to
https://github.com/SigNoz/platform-pod/issues/3038
2026-09-01 09:26:50 +00:00
Srikanth Chekuri
c65f6fa77d fix(instrumentation): stop recording promql engine spans (#12732)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Description

- The promql engine traces every evaluation through the global tracer
provider under an unnamed scope: `promqlExec`, `promqlPrepare`,
`promqlExecQueue`, and one `promqlInnerEval eval *promql.<Node>` span
per AST node per query. These didn't find much useful as the bottleneck
is usually the CH so we remove them.
2026-08-31 15:38:11 +00:00
Srikanth Chekuri
4d015a927e perf(clickhouseprometheusv2): skip the series lookup for statically named transpiled units (#12728)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Description

<img width="1420" height="516" alt="image"
src="https://github.com/user-attachments/assets/cc536314-71d2-4750-b394-d53c41ac7d91"
/>

the un-needed query contributed to this data read to query service,
which had a purpose in earlier dev cycle but not longer needed.
2026-08-31 14:13:25 +00:00
Srikanth Chekuri
abebea532b feat(prometheus): add the Prometheus query API under a /prometheus prefix (#12093)
#### Description

- Adds `GET|POST /prometheus/api/v1/query_range` and
`/prometheus/api/v1/query` (`pkg/prometheus/promapi`), following the
Prometheus HTTP API contract: float-unix or RFC3339 times, float-seconds
or duration-string durations, the `{status, data, errorType, error,
warnings, infos}` envelope with Prometheus' status codes, and the
11,000-point cap.
- The `/prometheus` prefix works as a drop-in Prometheus base URL:
Grafana's Prometheus data source, promtool, and the PromQL compliance
tester append `/api/v1/*` to a base URL, so they can point at SigNoz
unmodified. Same layout as Mimir/Cortex.
- Wired through `signoz.Handlers` (`prometheus.Handler` interface,
constructed in `NewHandlers`) like the other domain handlers.
- Range queries serve through the `RangeExecutor` capability when the
provider has it, so a clickhousev2-serving deployment transpiles through
these endpoints too.
- New `promapiconformance` integration suite: the frozen promqltest
corpus replayed against these endpoints with `prometheus::provider:
clickhousev2` — the two paths nothing else exercises (v2 as serving
provider, and this API surface). Instant cases go through `/query` with
a real `time` parameter. The `instant-coarse` corpus variants are
skipped — they exist only to encode instant evals as coarse ranges for
the v5 API, and their transpiled coarse-step serving is already covered
and ledgered by promqlconformance's clickhousev2 leg — so this suite
asserts zero divergences with no ledger of its own.
- Purely additive: the existing `GET /api/v1/query_range` and `GET
/api/v1/query` handlers are untouched. `openapi.yml` is generated and
these mux-registered routes are outside the generator, so their
documentation is the upstream Prometheus API contract they follow.

#### Additional Information

Final slice of the clickhouseprometheusv2 stack (#12323, #12324, #12325
— merged). Legacy endpoint removal, if ever, is a separate change after
usage drains.
2026-08-31 12:10:05 +00:00
Nikhil Soni
764fe8ec69 feat(logs): add pinned attributes preference support (#12687)
#### Description

Registers a new per-user preference `log_details_pinned_attributes` in
`pkg/types/preferencetypes`, following the same shape as the existing
`span_details_pinned_attributes` (trace-details pin feature, #11092).
2026-08-31 10:31:59 +00:00
Srikanth Chekuri
da9b4644df fix(prometheus): set NoStepSubqueryIntervalFn to stop promql subquery segfault (#12720)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
#### Description

- A PromQL subquery without a step, for example
`max_over_time(metric[5m:])`, segfaulted the whole query-service. The
engine calls `NoStepSubqueryIntervalFn` for such subqueries, and we
build the engine without it, so the call hits a nil function.
- The bug is present on every PromQL surface, because all of them share
the one engine constructor in `pkg/prometheus/engine.go`: v3 and v5
`query_range`, `/api/v1/query`, the clickhousev2 transpiler, and promql
alert rules. A saved rule with such a subquery crash-loops the instance
on its own schedule.
- The fix sets the callback to 1m. This matches the Prometheus default
global `evaluation_interval`, which upstream wires into this field. One
place fixes every path.
- This is the root cause of the SigNoz/platform-pod#3068 incident. The
instance-hardening request from that incident is tracked in
SigNoz/pulse-pod#308.

#### Issues closed by this PR

Closes SigNoz/platform-pod#3068

#### Additional Information

We audited `EngineOpts` for more bugs of the same class.
`NoStepSubqueryIntervalFn` is the only field the engine calls without a
nil guard; `promql.NewEngine` defaults the other nil-able fields
(`Parser`, `FeatureRegistry`). The remaining gaps against upstream
wiring are not crashes, and we filed them separately:
SigNoz/pulse-pod#305 (`@` modifier and negative offset disabled),
SigNoz/pulse-pod#306 (engine self-metrics not registered),
SigNoz/pulse-pod#307 (active query tracker startup panic risk),
SigNoz/pulse-pod#309 (step guard in the v3 cache), SigNoz/pulse-pod#310
(upstream proposal to fail fast on the nil callback).

Tests for the bug:

- `pkg/prometheus/engine_test.go` — fails with the exact segfault when
the fix is removed.
- `tests/integration/tests/promqlconformance/04_no_step_subquery.py` — a
step-less subquery through `/api/v5/query_range` returns correct values
on both providers, and the service stays up.
- `tests/integration/tests/alerts/04_promql_subquery_no_step.py` — a
promql alert rule with a step-less subquery evaluates and fires.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-29 09:26:57 +00:00
Aditya Singh
095821264e fix(explorer): guard saved-view URL params against non-JSON values (#12706)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Opening an explorer with a `viewName`/`viewKey` in the URL that isn't
valid JSON crashed the whole page.
- The hook ran `JSON.parse` on the raw param during render, so a bare
saved-view name threw error and broke page.
- Fix: wrap `JSON.parse` in try/catch and fall back to the raw string.
- Renamed the hook to `useGetSavedViewParams` and moved it under
`hooks/saveViews`; it only reads `viewName`/`viewKey` so the old
query-builder name/location was misleading. Now returns `{ viewName,
viewKey }` directly.
- Behavior preserved for all consumers; added tests for the non-JSON
case.


Closes https://github.com/SigNoz/engineering-pod/issues/5638

Screenshots/Recording

Before:

Test url: Just remove quotes from viewKey or viewName:
[url](https://app.us.staging.signoz.cloud/logs/logs-explorer?relativeTime=1month&compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522dataSource%2522%253A%2522logs%2522%252C%2522queryName%2522%253A%2522A%2522%252C%2522aggregateOperator%2522%253A%2522count%2522%252C%2522aggregateAttribute%2522%253A%257B%2522id%2522%253A%2522----%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522key%2522%253A%2522%2522%252C%2522type%2522%253A%2522%2522%257D%252C%2522timeAggregation%2522%253A%2522rate%2522%252C%2522spaceAggregation%2522%253A%2522sum%2522%252C%2522filter%2522%253A%257B%2522expression%2522%253A%2522%2522%257D%252C%2522aggregations%2522%253A%255B%257B%2522expression%2522%253A%2522count%28%29%2522%257D%255D%252C%2522functions%2522%253Anull%252C%2522filters%2522%253A%257B%2522items%2522%253A%255B%257B%2522id%2522%253A%2522228b8a2f-d6ba-4704-9104-936e91a2c119%2522%252C%2522key%2522%253A%257B%2522id%2522%253A%2522code.function--string--tag%2522%252C%2522dataType%2522%253A%2522string%2522%252C%2522key%2522%253A%2522code.function%2522%252C%2522type%2522%253A%2522tag%2522%257D%252C%2522op%2522%253A%2522%253D%2522%252C%2522value%2522%253A%2522render_test%2522%257D%255D%252C%2522op%2522%253A%2522AND%2522%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522stepInterval%2522%253A0%252C%2522having%2522%253A%257B%2522expression%2522%253A%2522%2522%257D%252C%2522limit%2522%253Anull%252C%2522orderBy%2522%253A%255B%255D%252C%2522groupBy%2522%253A%255B%255D%252C%2522legend%2522%253A%2522%2522%252C%2522reduceTo%2522%253A%2522avg%2522%252C%2522source%2522%253A%2522%2522%252C%2522name%2522%253A%2522A%2522%252C%2522signal%2522%253A%2522logs%2522%252C%2522order%2522%253Anull%252C%2522selectFields%2522%253Anull%252C%2522secondaryAggregations%2522%253Anull%257D%255D%252C%2522queryFormulas%2522%253A%255B%255D%252C%2522queryTraceOperator%2522%253A%255B%255D%257D%252C%2522promql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522query%2522%253A%2522%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%257D%255D%252C%2522clickhouse_sql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%252C%2522query%2522%253A%2522%2522%257D%255D%252C%2522id%2522%253A%2522bfb926e4-7b98-4cf4-bd4d-adcbd18a1da2%2522%252C%2522unit%2522%253A%2522%2522%257D&options=%7B%22selectColumns%22%3A%5B%7B%22name%22%3A%22timestamp%22%2C%22signal%22%3A%22logs%22%2C%22fieldContext%22%3A%22log%22%2C%22fieldDataType%22%3A%22%22%7D%2C%7B%22name%22%3A%22lkadsjfl%22%2C%22signal%22%3A%22%22%2C%22fieldContext%22%3A%22%22%2C%22fieldDataType%22%3A%22%22%7D%2C%7B%22name%22%3A%22body%22%2C%22signal%22%3A%22logs%22%2C%22fieldContext%22%3A%22log%22%2C%22fieldDataType%22%3A%22%22%7D%2C%7B%22name%22%3A%22test%22%2C%22signal%22%3A%22%22%2C%22fieldContext%22%3A%22%22%2C%22fieldDataType%22%3A%22%22%7D%2C%7B%22name%22%3A%22severity_text%22%2C%22description%22%3A%22Log+level.+Learn+more+%5Bhere%5D%28https%3A%2F%2Fopentelemetry.io%2Fdocs%2Fspecs%2Fotel%2Flogs%2Fdata-model%2F%23field-severitytext%29%22%2C%22signal%22%3A%22logs%22%2C%22fieldContext%22%3A%22log%22%2C%22fieldDataType%22%3A%22string%22%7D%5D%2C%22format%22%3A%22list%22%2C%22maxLines%22%3A1%2C%22fontSize%22%3A%22small%22%7D&panelTypes=%22list%22&viewName=%22test+manul+key%22&viewKey=068e4a96-5225-4abe-8f9b-a5009f26d4ce)

Breaks page

<img width="3456" height="1720" alt="image"
src="https://github.com/user-attachments/assets/4d7546a3-b5cb-4be3-bc9f-e0782446f7f1"
/>



#### Additional Information

Sentry: https://signoz-io.sentry.io/issues/7520808361
2026-08-28 06:40:25 +00:00
Vikrant Gupta
5069bf80b0 feat(authz): enable FGA for deployment hosts (#12699)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
#### Description

- Deployment host routes (`GET`/`PUT /api/v2/zeus/hosts`) now use
`CheckResources` + `ResourceDef` instead of the coarse
`ViewAccess`/`AdminAccess` gates — per-resource FGA checks on
enterprise, role gate on community.
- New `deployment-host` metaresource kind with `list`/`update` verbs —
the GET returns the deployment's host collection and the PUT upserts the
single editable host. Admins get `list`+`update`, editors and viewers
get `list`, preserving current behavior.
- Migration `118_add_deployment_host_tuples` backfills the tuples for
existing organizations and re-syncs the stored managed-role transaction
groups; new organizations get both from the registry at bootstrap.
- Regenerated OpenAPI spec and transaction-groups schema: the operations
advertise `deployment-host:list`/`deployment-host:update` scopes instead
of `VIEWER`/`ADMIN`.
- Added `deploymenthost/01_authz.py` covering managed-role gating,
custom-role `list`/`update` grants, and rejection of verbs the resource
does not support.

#### Issues closed by this PR

Closes SigNoz/platform-pod#2652
2026-08-27 13:22:27 +00:00
Aditya Singh
1086914a3b fix(query-builder): prevent qb crash on partial queries (#12705)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- traces explorer was crashing on some shared/legacy links...the query
builder reads `builder.queryFormulas` and `queryTraceOperator` directly
and these can be missing from a partial compositeQuery (old saved view,
shared link, older release)
- fixed it where the query enters state from the url param, defaulting
the arrays to `[]`...this way every consumer gets `[]` and not
undefined, not just the component that crashed
- also default them in `prepareQueryBuilderData` so any query loaded
into the provider is normalized
- optional chained the reads in `QueryBuilderV2` as a render level
safety net
- added a test that a partial query (missing these arrays) renders
without crashing

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/engineering-pod/issues/5949
Closes https://github.com/SigNoz/engineering-pod/issues/4320
Closes https://github.com/SigNoz/engineering-pod/issues/3335
Closes https://github.com/SigNoz/engineering-pod/issues/4314

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

[Test URL
Link](https://app.us.staging.signoz.cloud/traces-explorer?relativeTime=1h&compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522dataSource%2522%253A%2522logs%2522%252C%2522queryName%2522%253A%2522A%2522%252C%2522aggregateOperator%2522%253A%2522noop%2522%252C%2522aggregateAttribute%2522%253A%257B%2522id%2522%253A%2522----%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522key%2522%253A%2522%2522%252C%2522type%2522%253A%2522%2522%257D%252C%2522timeAggregation%2522%253A%2522rate%2522%252C%2522spaceAggregation%2522%253A%2522sum%2522%252C%2522filter%2522%253A%257B%2522expression%2522%253A%2522service.name%2520%253D%2520%27midtier-api-production%27%2520AND%2520severity_text%2520%253D%2520%27ERROR%27%2522%257D%252C%2522aggregations%2522%253A%255B%257B%2522expression%2522%253A%2522count%28%29%2522%257D%255D%252C%2522functions%2522%253A%255B%255D%252C%2522filters%2522%253A%257B%2522items%2522%253A%255B%255D%252C%2522op%2522%253A%2522AND%2522%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522stepInterval%2522%253Anull%252C%2522having%2522%253A%257B%2522expression%2522%253A%2522%2522%257D%252C%2522limit%2522%253Anull%252C%2522orderBy%2522%253A%255B%255D%252C%2522groupBy%2522%253A%255B%255D%252C%2522legend%2522%253A%2522%2522%252C%2522reduceTo%2522%253A%2522avg%2522%252C%2522source%2522%253A%2522%2522%257D%255D%257D%252C%2522promql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522query%2522%253A%2522%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%257D%255D%252C%2522clickhouse_sql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%252C%2522query%2522%253A%2522%2522%257D%255D%252C%2522id%2522%253A%25225d398425-eb7e-41e4-990a-de9375baf74a%2522%252C%2522unit%2522%253A%2522%2522%257D&options=%7B%22selectColumns%22%3A%5B%7B%22name%22%3A%22timestamp%22%2C%22signal%22%3A%22logs%22%2C%22fieldContext%22%3A%22log%22%2C%22fieldDataType%22%3A%22%22%2C%22isIndexed%22%3Afalse%7D%2C%7B%22name%22%3A%22body%22%2C%22signal%22%3A%22logs%22%2C%22fieldContext%22%3A%22log%22%2C%22fieldDataType%22%3A%22%22%2C%22isIndexed%22%3Afalse%7D%5D%2C%22maxLines%22%3A1%2C%22format%22%3A%22raw%22%2C%22fontSize%22%3A%22small%22%7D)

Before:
<img width="3452" height="1780" alt="image"
src="https://github.com/user-attachments/assets/4f1e871f-6063-4aeb-93b2-a902d57abdb9"
/>


After:
<img width="3002" height="1648" alt="image"
src="https://github.com/user-attachments/assets/645411d8-3462-4701-9694-1ea743ac2e17"
/>


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
Pager: https://signoz-1.pagerduty.com/incidents/Q3YCF0KG3OYSYX

Sentry:https://signoz-io.sentry.io/issues/7498496470/?referrer=pagerduty_integration&notification_uuid=5cd4dc50-634b-416f-ab84-9540706c43aa

<!--Please delete paragraphs that you did not use before submitting.-->
2026-08-27 12:43:34 +00:00
Vikrant Gupta
3e2daaea0e test(service-account): fix flaky ServiceAccountDrawer authz test (#12708)
#### Description

- The `shows PermissionDeniedCallout in Keys tab when list-keys
permission is denied` test intermittently failed in CI: the
`fireEvent.click` on the Keys tab races with the nuqs testing adapter,
which can abort the queued `tab=keys` URL update mid-flight, leaving the
drawer stuck on the Overview tab.
- Since the tab is URL state, the test now lands directly on the Keys
tab via initial search params (`{ account: 'sa-1', tab: 'keys' }`),
avoiding the userEvent/click interaction altogether. The click-to-Keys
flow remains covered in `ServiceAccountDrawer.test.tsx`.

#### Issues closed by this PR

closes SigNoz/platform-pod#3053
2026-08-27 12:34:32 +00:00
Vikrant Gupta
1b8155fea9 chore(featureflag): make features endpoint open access (#12704)
#### Description

- Changes `GET /api/v1/features` from `ViewAccess` to `OpenAccess` in
both editions so every authenticated user, including those on custom
roles, can read feature flags.
- Feature flags describe the org's plan, not the caller's privileges,
and the frontend needs them to boot. With #12700 making the active
license readable by every authenticated user, the flags must be readable
too — otherwise custom-role users load the license but hang on the flags
fetch.
- Applies the same change to the flagger endpoint `GET /api/v2/features`
so the v2 client behaves identically when the frontend migrates to it.

#### Issues closed by this PR

Closes: https://github.com/SigNoz/platform-pod/issues/2653
2026-08-27 09:58:46 +00:00
Nikhil Soni
dac406eb93 feat: add support for quering scope fields in traces (#10920)
### 📄 Summary


Add support for instrumentation scope for traces. PR on collector -
https://github.com/SigNoz/signoz-otel-collector/pull/811


#### Screenshots / Screen Recordings (if applicable)

<img width="468" height="298" alt="image"
src="https://github.com/user-attachments/assets/03335ca3-a1c5-428b-9bfa-cd1796f735df"
/>

#### Issues closed by this PR

https://github.com/SigNoz/signoz/issues/5319
2026-08-27 09:55:43 +00:00
Gaurav Tewari
f69bce998a feat: add useBarChart instead of graph (#12517)
#### Description

- The logs frequency chart now draws with the uPlotV2 `BarChart` instead
of the Chart.js `Graph`, putting it on the same chart stack as the rest
of the product. Covers both Logs Explorer and Live Logs, which share the
component.
- Chart setup moves into a `useLogsExplorerChartConfig` hook built on
the shared `buildBaseConfig`. Severity colours, labels, drag-to-zoom and
timezone handling all behave as before; stacking goes through
`stack`/`StackMode`.
- Timestamps now convert ns → s, since uPlot's x scale is in seconds
where Chart.js wanted ms.
- Removes the `.ant-card-body` rules from three stylesheets. They
stopped matching anything when #8904 dropped the antd Card wrapper.
- Unblocked by #12627, which removed the last-minute trim from the time
scale. My earlier attempt at that (#12528) is closed.

#### Issues closed by this PR

Closes -
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=128310734&issue=SigNoz%7Csignoz%7C9059

#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/b27f75ae-085d-410b-a5b8-491bac590fd0


#### Additional Information

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-27 07:09:12 +00:00
Srikanth Chekuri
ae9f58ba44 test: add integration tests for semconv family resolution (#12444)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Adds integration tests that pin the semconv family matrix on a live
stack, in a dedicated package that runs SigNoz with
`resolve_semconv_families` on, plus a second instance with the flag at
its default. Part of #6143.
- The fleet has one identity per state: OLD (old spelling only), NEW
(current only), BOTH (current `staging` and old `production` — the
conflict row), NEITHER (keyless).
- 36 filter cells (nine operators × both spellings × both contexts): the
result sets do not depend on the requested spelling, the current
spelling wins on the conflict row, and negative operators keep keyless
rows exactly like a single key.
- Singles: group-by merges the fleet and echoes the requested spelling
in the group column; a bare name with the family under two contexts
warns and keeps the resource side; logs stay literal with the flag on;
everything stays literal with the flag off.
- The suite is registered in the `integrationci` matrix. Verified: 42
passed against the live stack, on postgres and on sqlite.

#### Additional Information

- Stack: #12441 (merged) → #12442 (merged) → #12443 (merged) →
**#12444**. Rebased on main after the #12443 merge.
- The earlier `13_semconv_evolution.py` in this branch is replaced: its
`!=` expectations omitted keyless rows, which contradicts the contract
pinned by `queriercommon/06_keyless_semantics.py`.
2026-08-27 02:40:49 +00:00
Nityananda Gohain
a930da0901 feat: support for ts/scalar for llm spans (#12121)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Pull Request

---

### 📄 Summary

follow-up for #12027. Span-list trace-aggregate filtering ships in
#12122.

Adds `scalar` and `time_series` request types to `builder_ai_query`. The
`trace.` prefix selects the aggregation domain: trace aggregates use a
native CTE pipeline, while span aggregates delegate to the standard
traces builder with the qualification gate applied.

Trace-level filters qualify entire traces across both domains using the
standard filter pipeline. Grouping, `HAVING`, ordering, and limits match
the traces builder, including whole-window ranking for grouped time
series and top-N limits for scalar queries.

`count(trace.trace_id)` counts every AI trace, matching the trace list;
token aggregates average over traces that have token data (standard
`NULL` semantics, same as span-attribute aggregations elsewhere).

Includes SQL golden tests, rewrite unit tests, and integration coverage
for both domains, qualification, grouping, limits, bucketing, variables,
and targeted `400` errors.


#### Issues closed by this PR
Fixes https://github.com/SigNoz/engineering-pod/issues/5602
Fixes https://github.com/SigNoz/engineering-pod/issues/5603

---

###  Change Type
_Select all that apply_

- [x]  Feature
- [ ] 🐛 Bug fix
- [ ] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [ ] 🧪 Test-only

---

### 🧪 Testing Strategy
> How was this change validated?

- Tests added/updated:  
- Manual verification:
- Edge cases covered:

---

### ⚠️ Risk & Impact Assessment
> What could break? How do we recover?

- Blast radius: None
- Potential regressions:
- Rollback plan:



---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered

---

## 👀 Notes for Reviewers

Still in testing phase
---
2026-08-26 13:55:57 +00:00
Gaurav Tewari
5314cb7828 feat(ai-observability): AI o11y explorer (#12682)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- forks the Traces Explorer into the AI Observability Explorer tab,
replacing the "Explorer coming soon" placeholder. All four views land:
list, trace, timeseries and table.
- the copied code is kept identical to the traces original on purpose —
same variable names, same `LOCALSTORAGE` keys, same analytics events,
same `DataSource.TRACES`. Only the folder layout differs. Divergence
(GenAI columns, AI query surface) comes in follow-ups, so this stays a
clean base to diff against.
- shared modules are imported, not duplicated:
`TracesExplorer/TracesTable`, `TracesExplorer/Controls`,
`TracesExplorer/explorerUtils`, `TracesExplorer/ListView/utils` and
`pages/TracesExplorer/aiActions`.
- both table views therefore use the shared TanStack table, so the AI
explorer starts out with resizable/reorderable columns rather than the
old antd `ResizeTable`.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes -
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=223107878&issue=SigNoz%7Cengineering-pod%7C5843

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/6f3bb336-f555-4345-aeca-ba061401f5af





<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
- **Stacked on #12672.** The first three commits are that PR
cherry-picked, since the trace view fork depends on its `FieldCell`
trace_id handling and the optional `columnStorageKey` /
`respectColumnOrder` props. Review only the last commit here; rebase
drops the rest once #12672 lands.
- `LLMObservability.test.tsx` now stubs `Explorer` the same way it
already stubs `DashboardContainer` — the real toolbar calls
`useNavigationType`, which needs a data router that integration test
does not mount.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-26 13:45:26 +00:00
Nityananda Gohain
02c5555a48 fix: add ai_observability to saved views (#12675)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description
* adds `ai_observability` to saved view for ai explorer

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/engineering-pod/issues/5955
2026-08-26 11:28:54 +00:00
Naman Verma
0bdc7bf6a1 fix: resolve aggregate column for exp histograms before samples table (#12640)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Column for exponential histograms is not decided by samples tables so it
should not run for exp histogrms

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/295
2026-08-26 10:15:09 +00:00
Vinicius Lourenço
a8c04cb563 test(alerts): add e2e for alerts (#12349)
## Pull Request

---

### 📄 Summary
> Why does this change exist?  
> What problem does it solve, and why is this the right approach?

This adds a bunch of E2E tests for alerts, to test v1/v2 create and
edit, and also tests for alert history.

This started as tests only for history but decided to just add tests for
everything, while creating them, I found two bugs inside alerts, so they
already helping us before even landing :)

The changes in the UI are only to add testIds, no change in logic (and
no fix for the incidents)

| Scope | Before (`main`) | After (this branch) | Delta |
|---|---:|---:|---:|
| Alerts E2E tests | 2 | 191 | **+189** |
| Alerts E2E spec files | 1 | 31 | +30 |
| Whole E2E suite | 141 | 330 | **+189** |


#### Alerts page shell (7)

| File | Test | Status |
|---|---|---|
| `page.spec.ts` | AL-01 all four top-level tabs render |  |
| `page.spec.ts` | AL-02 default tab is Alert Rules |  |
| `page.spec.ts` | AL-03 tab switch writes ?tab= and clears subTab |  |
| `page.spec.ts` | AL-04 Configuration deep-link |  |
| `page.spec.ts` | AL-05 Triggered Alerts tab smoke |  |
| `page.spec.ts` | AL-06 Notification Channels tab smoke |  |
| `page.spec.ts` | AL-07 tab state survives reload |  |

#### Alert rules list (19)

| File | Test | Status |
|---|---|---|
| `list/columns.spec.ts` | LR-01 renders all default columns (Status,
Alert Name, Severity, Labels, Actions) | |
| `list/columns.spec.ts` | LR-02 shows empty state when no rules exist |
skipped |
| `list/columns.spec.ts` | LR-10 column selector hides and shows a
column | |
| `list/navigation.spec.ts` | LR-11 row click opens the overview page |
|
| `list/navigation.spec.ts` | LR-12 ctrl/cmd-click opens the overview in
a new tab | |
| `list/navigation.spec.ts` | LR-13 actions menu Edit and Edit in New
Tab navigate correctly | |
| `list/navigation.spec.ts` | LR-17 New Alert button navigates to alert
creation | |
| `list/navigation.spec.ts` | LR-18 shows ErrorEmptyState when list
fails to load | skipped |
| `list/pagination-sort.spec.ts` | LR-07 navigates between pages |  |
| `list/pagination-sort.spec.ts` | LR-08 changes page size |  |
| `list/pagination-sort.spec.ts` | LR-09 sorts by column header click |
|
| `list/row-actions.spec.ts` | LR-14 Disable then Enable toggles the
rule state | |
| `list/row-actions.spec.ts` | LR-15 Clone creates a copy and shows
success toast | |
| `list/row-actions.spec.ts` | LR-16 Delete removes the rule and shows
success toast | |
| `list/search.spec.ts` | LR-03 filters by name |  |
| `list/search.spec.ts` | LR-04 filters by severity and by label |  |
| `list/search.spec.ts` | LR-05 shows no-results state with clear button
| |
| `list/search.spec.ts` | LR-06 resets pagination when searching |  |
| `list/search.spec.ts` | LR-19 state and severity filters intersect,
they do not union | |

#### Create alert (52)

| File | Test | Status |
|---|---|---|
| `create/edge.spec.ts` | CE-04 a server-side rejection opens the error
modal and keeps the draft | |
| `create/edge.spec.ts` | CE-07 none of the four builder mounts logs a
console error | |
| `create/edge.spec.ts` | CE-09 the v2 Discard button is clickable |
skipped |
| `create/prefill.spec.ts` | CD-01 a compositeQuery alone selects the
alert type | |
| `create/prefill.spec.ts` | CD-02 thresholds prefill from JSON, and a
malformed value falls back | |
| `create/prefill.spec.ts` | CD-03 matchType and compareOp aliases
normalise to the enum | |
| `create/prefill.spec.ts` | CD-04 ruleName and yAxisUnit apply once and
never stomp an edit | |
| `create/prefill.spec.ts` | CD-05 evaluationWindowPreset=meter switches
to the cumulative daily window | |
| `create/prefill.spec.ts` | CD-06 URL prefill is ignored in edit mode |
|
| `create/shell.spec.ts` | CS-01 bare /alerts/new lists exactly the
expected alert-type cards | |
| `create/shell.spec.ts` | CS-02 picking a card writes both params and
mounts the v2 builder | |
| `create/shell.spec.ts` | CS-03 the anomaly card rewrites the rule
type, not the alert type | conditional |
| `create/shell.spec.ts` | CS-04 modifier-clicking a card opens the
builder in a new tab | |
| `create/shell.spec.ts` | CS-05 breadcrumb gains a third crumb after a
type is picked | |
| `create/shell.spec.ts` | CS-06 create renders inside the Alert Rules
tab and leaving drops subTab/search | |
| `create/shell.spec.ts` | CS-07 showClassicCreateAlertsPage=true
renders the v1 form instead | |
| `create/shell.spec.ts` | CS-08 Switch to Classic Experience replaces
history, so Back does not return to v2 | |
| `create/v1.spec.ts` | CV1-01 the classic form renders its steps and
the create-mode labels | |
| `create/v1.spec.ts` | CV1-02 the rendered severity is the default from
the rule, not the select | |
| `create/v1.spec.ts` | CV1-03 one keystroke in the name field is enough
to enable Save | |
| `create/v1.spec.ts` | CV1-04 Save stays disabled until the channel
configuration resolves | |
| `create/v1.spec.ts` | CV1-05 broadcast-to-all saves the rule with the
broadcast flag | skipped |
| `create/v1.spec.ts` | CV1-06 a cleared threshold is coerced to 0, so
the required-threshold branch is dead | |
| `create/v1.spec.ts` | CV1-07 cancelling the confirm dialog does not
save | |
| `create/v1.spec.ts` | CV1-08 the happy path posts the v1 body shape to
the shared endpoint | |
| `create/v1.spec.ts` | CV1-09 CV1-10 description, labels and severity
all land in the payload | |
| `create/v1.spec.ts` | CV1-11 test notification skips the dialog and
reports no matching data | |
| `create/v1.spec.ts` | CV1-12 with no channels the form is a dead end |
|
| `create/v1.spec.ts` | CV1-13 Cancel leaves the form without saving | |
| `create/v1.spec.ts` | CE-05 an empty PromQL expression is rejected
behind the dialog | |
| `create/v1.spec.ts` | CE-06 an empty ClickHouse query is rejected
behind the dialog | |
| `create/v1.spec.ts` | CV1-14 the condition sentence keeps its
selections | |
| `create/v2.spec.ts` | CV2-01 initial state: one critical threshold,
both actions gated | |
| `create/v2.spec.ts` | CV2-02 the save tooltip walks from the name gate
to the channel gate | |
| `create/v2.spec.ts` | CV2-03 clearing a threshold label re-gates the
save | |
| `create/v2.spec.ts` | CV2-04 a label added in the header survives the
save round-trip | |
| `create/v2.spec.ts` | CV2-05 a rejected label key surfaces as a
notification, not an inline message | |
| `create/v2.spec.ts` | CV2-06 CV2-07 the operator and match-type
selects offer the documented options | |
| `create/v2.spec.ts` | CV2-08 the operator is rule-wide: one change
reaches every threshold | |
| `create/v2.spec.ts` | CV2-09 CV2-10 added thresholds take preset
tiers, and the first cannot be removed | |
| `create/v2.spec.ts` | CV2-11 a channel on one threshold is not enough
— the validator loops all of them | |
| `create/v2.spec.ts` | CV2-12 the unit select is disabled while the
query has no y-axis unit | |
| `create/v2.spec.ts` | CV2-13 the recovery threshold control is never
rendered | |
| `create/v2.spec.ts` | CV2-14 CV2-15 the evaluation window and cadence
reach the payload | |
| `create/v2.spec.ts` | CV2-18 with no channels the dropdown offers only
a way to create one | |
| `create/v2.spec.ts` | CV2-19 routing policies unlock the save with
zero channels | |
| `create/v2.spec.ts` | CV2-16 the group-by select is disabled until the
query groups by something | |
| `create/v2.spec.ts` | CV2-17 repeat notifications enable their inputs
and reach the payload | |
| `create/v2.spec.ts` | CV2-20 happy-path save posts the v2 shape and
lands on the list | |
| `create/v2.spec.ts` | CV2-21 test notification reports that a
non-firing rule matched nothing | |
| `create/v2.spec.ts` | CV2-22 discard leaves without posting and resets
the form | |
| `create/v2.spec.ts` | CV2-23 every footer button is disabled while the
save is in flight | |

#### Edit alert (22)

| File | Test | Status |
|---|---|---|
| `edit/edge.spec.ts` | CE-03 an unknown ruleId shows AlertNotFound on
both entry URLs | |
| `edit/edge.spec.ts` | CE-03b /alerts/edit with no ruleId also lands on
AlertNotFound | |
| `edit/v1.spec.ts` | EV1-01 the classic form renders in edit mode
inside the details shell | |
| `edit/v1.spec.ts` | EV1-02 every seeded field prefills the form |  |
| `edit/v1.spec.ts` | EV1-03 preferredChannels decide which channel
control is prefilled | |
| `edit/v1.spec.ts` | EV1-04 the happy-path update PUTs the v1 body and
keeps unrelated params | |
| `edit/v1.spec.ts` | EV1-05 Discard leaves without a PUT and without
changing the rule | |
| `edit/v1.spec.ts` | EV1-06 the header title and the form name field
agree | |
| `edit/v1.spec.ts` | EV1-07 /alerts/edit redirects for a v1 rule
exactly as it does for v2 | |
| `edit/v1.spec.ts` | EV1-08 editing a v1 rule never migrates it to the
v2 schema | |
| `edit/v2.spec.ts` | EV2-01 the v2 editor renders inside the details
shell | |
| `edit/v2.spec.ts` | EV2-02 name and labels prefill from the rule |  |
| `edit/v2.spec.ts` | EV2-03 both thresholds prefill, and the sentence
reads spec[0] | |
| `edit/v2.spec.ts` | EV2-04 the recovery threshold control never
renders | |
| `edit/v2.spec.ts` | EV2-05 the evaluation window prefills, and a
non-preset value collapses to custom | |
| `edit/v2.spec.ts` | EV2-06 repeat notifications prefill from the
seeded renotify block | |
| `edit/v2.spec.ts` | EV2-07 alertOnAbsent prefills the advanced options
| |
| `edit/v2.spec.ts` | EV2-08 the evaluation cadence always reads back in
default mode | |
| `edit/v2.spec.ts` | EV2-09 changing a threshold PUTs the rule and the
change survives a reload | |
| `edit/v2.spec.ts` | EV2-10 the footer save is what persists a rename
made on the Overview tab | |
| `edit/v2.spec.ts` | EV2-11 Discard leaves without a PUT and without
touching the rule | |
| `edit/v2.spec.ts` | EV2-12 /alerts/edit is a legacy alias that
redirects into the details shell | |

#### Alert details (15)

| File | Test | Status |
|---|---|---|
| `details/actions.spec.ts` | AD-06 enable/disable toggle changes the
rule state | |
| `details/actions.spec.ts` | AD-07 Duplicate creates a copy and
navigates to overview | |
| `details/actions.spec.ts` | AD-08 Delete removes the rule and returns
to the list | |
| `details/chrome.spec.ts` | AD-09 copy-link button copies the current
URL to clipboard | conditional |
| `details/chrome.spec.ts` | AD-10 breadcrumb navigates back to the
alert list | |
| `details/chrome.spec.ts` | AD-13 document title updates to show the
rule name | |
| `details/header.spec.ts` | AD-01 v2 header shows editable name input
without Rename menu item | |
| `details/header.spec.ts` | AD-02 v1 header shows static title with
state, severity and labels | |
| `details/not-found.spec.ts` | AD-11 invalid ruleId shows AlertNotFound
page | |
| `details/not-found.spec.ts` | AD-12 missing ruleId on overview shows
AlertNotFound page | |
| `details/rename.spec.ts` | AD-03 v1 rename via modal updates the rule
name | |
| `details/rename.spec.ts` | AD-04 v2 inline rename saves via Overview
footer button | |
| `details/tabs.spec.ts` | AD-05 Overview/History tabs preserve ruleId
and relativeTime | |
| `details/tabs.spec.ts` | AD-05b switching to History tab discards
other history params | |
| `details/threshold-persistence.spec.ts` | TC-02 edit page displays the
saved threshold value | |

#### Alert history (75)

| File | Test | Status |
|---|---|---|
| `history/cross-cutting.spec.ts` | AX-01 full deep-link with all params
is honoured in one load | |
| `history/cross-cutting.spec.ts` | AX-02 page reload preserves all
history params | |
| `history/cross-cutting.spec.ts` | AX-03 browser back/forward restores
correct table state | |
| `history/cross-cutting.spec.ts` | AX-04 no unhandled console errors
across full history session | |
| `history/cross-cutting.spec.ts` | AX-05 no request storm on mount
(exactly one call per endpoint) | |
| `history/cross-cutting.spec.ts` | AX-06 v1 and v2 schema rules both
render history correctly | |
| `history/cross-cutting.spec.ts` | AX-07 no legacy v1 history API calls
during full session | |
| `history/cross-cutting.spec.ts` | AX-08 history API endpoints carry
expected params | |
| `history/empty-and-errors.spec.ts` | AE-01 invalid filter expression
shows syntax error and recovers on fix | |
| `history/empty-and-errors.spec.ts` | AE-02 empty filter_keys response
still mounts editor (no suggestions) | |
| `history/empty-and-errors.spec.ts` | AE-02b bogus ruleId never reaches
history APIs (shows AlertNotFound) | |
| `history/empty-and-errors.spec.ts` | AE-03 rule with no history
renders empty state (not error) | |
| `history/empty-and-errors.spec.ts` | AE-04 time range with no data
renders empty state | |
| `history/empty-and-errors.spec.ts` | AE-05 time-range change resets
pagination to first page | |
| `history/empty-and-errors.spec.ts` | AE-06 absurd time range (90d)
still renders | |
| `history/empty-and-errors.spec.ts` | AE-07 disabled rule history is
still readable | |
| `history/empty-and-errors.spec.ts` | AE-08 deleted rule shows
AlertNotFound on revisit | |
| `history/expression-filter.spec.ts` | AF-06 key suggestions load on
page load | |
| `history/expression-filter.spec.ts` | AF-07 value suggestions fetch
from filter_values endpoint | |
| `history/expression-filter.spec.ts` | AF-08 value suggestions filter
client-side as user types | |
| `history/expression-filter.spec.ts` | AF-09 running equality
expression filters the table | |
| `history/expression-filter.spec.ts` | AF-10 running expression resets
pagination to first page | |
| `history/expression-filter.spec.ts` | AF-11 Run button re-fetches
unchanged expression | |
| `history/expression-filter.spec.ts` | AF-12 in-flight query can be
cancelled | |
| `history/expression-filter.spec.ts` | AF-13 threshold.name and
severity keys filter correctly | |
| `history/expression-filter.spec.ts` | AF-14 unknown key returns 200
with zero rows (not 500) | |
| `history/expression-filter.spec.ts` | AF-15 expression is lost on
Overview→History round-trip (known bug) | |
| `history/expression-filter.spec.ts` | AF-16 expression and state
filter compose in request | |
| `history/expression-filter.spec.ts` | AF-17 clearing expression
restores full unfiltered list | |
| `history/state-filter.spec.ts` | AF-01 All filter sends no state param
in request | |
| `history/state-filter.spec.ts` | AF-02 Fired filter sends state=firing
in request | |
| `history/state-filter.spec.ts` | AF-03 Resolved filter shows empty for
rule with no resolutions | |
| `history/state-filter.spec.ts` | AF-03b Resolved filter shows rows for
rule with resolutions | |
| `history/state-filter.spec.ts` | AF-04 deep-link ?timelineFilter=FIRED
starts on Fired tab | |
| `history/state-filter.spec.ts` | AF-05 changing state filter resets
pagination to first page | |
| `history/statistics.spec.ts` | AS-01 Total Triggered card shows the
firing count | |
| `history/statistics.spec.ts` | AS-02 Avg. Resolution Time card shows
"No Resolutions." when none exist | |
| `history/statistics.spec.ts` | AS-03 empty stats card never renders a
sparkline | |
| `history/statistics.spec.ts` | AS-03b sparkline present with a
multi-point series | skipped |
| `history/statistics.spec.ts` | AS-04 change-vs-past indicator shows
"no previous data" when unavailable | |
| `history/statistics.spec.ts` | AS-09 stats update when time range
changes | |
| `history/statistics.spec.ts` | AS-11 Avg. Resolution Time shows
formatted duration when resolutions exist | |
| `history/statistics.spec.ts` | AS-12 Total Triggered counts only
firing rows (not resolved) | |
| `history/timeline-graph.spec.ts` | AT-03 renders canvas with two
segments (inactive→firing) | |
| `history/timeline-graph.spec.ts` | AT-03b renders canvas with three
segments (inactive→firing→inactive) | |
| `history/timeline-graph.spec.ts` | AT-19 handles nodata state without
console errors | |
| `history/timeline-pagination.spec.ts` | AT-06 next page sends cursor
and shows different rows | |
| `history/timeline-pagination.spec.ts` | AT-07 prev page drops the
cursor from request | |
| `history/timeline-pagination.spec.ts` | AT-08 pagination buttons
disable at first and last page | |
| `history/timeline-pagination.spec.ts` | AT-09 browser back after
paging returns to previous page | |
| `history/timeline-pagination.spec.ts` | AT-10 deep-link ?page=2 loads
second page directly | |
| `history/timeline-pagination.spec.ts` | AT-11 default sort order is
ascending | |
| `history/timeline-pagination.spec.ts` | AT-12 sorting toggles order
and resets to first page | |
| `history/timeline-pagination.spec.ts` | AT-13 single page disables
both pagination buttons | |
| `history/timeline-pagination.spec.ts` | AT-21 all pages together cover
the complete row set | |
| `history/timeline-table.spec.ts` | AT-01 timeline section renders all
chrome elements | |
| `history/timeline-table.spec.ts` | AT-02 Top 5 Contributors tab is
disabled with Coming Soon indicator | |
| `history/timeline-table.spec.ts` | AT-04 table rows display state,
labels and formatted timestamp | |
| `history/timeline-table.spec.ts` | AT-05 footer shows correct row
range | |
| `history/timeline-table.spec.ts` | AT-14 row click does not navigate
away | |
| `history/timeline-table.spec.ts` | AT-15 row actions link navigates to
logs explorer | |
| `history/timeline-table.spec.ts` | AT-15b row actions link navigates
to traces explorer | |
| `history/timeline-table.spec.ts` | AT-16 metrics rule rows show
disabled action (no related links) | |
| `history/timeline-table.spec.ts` | AT-17 CREATED AT column respects
app timezone setting | |
| `history/timeline-table.spec.ts` | AT-18 state cell renders Firing,
Resolved, and No Data correctly | |
| `history/timeline-table.spec.ts` | AT-18b pending/recovering states
render blank (coverage gap) | skipped |
| `history/timeline-table.spec.ts` | AT-18c disabled state renders as
"Muted" (coverage gap) | skipped |
| `history/timeline-table.spec.ts` | AT-20 time-range boundaries
inclusive/exclusive (coverage gap) | skipped |
| `history/top-contributors.spec.ts` | AS-05 card displays max 3 rows
with count ratios | |
| `history/top-contributors.spec.ts` | AS-13 contributor bar width is
the count as a percentage of the total | |
| `history/top-contributors.spec.ts` | AS-06 "View all" button only
appears when more than 3 contributors | |
| `history/top-contributors.spec.ts` | AS-07 View-all drawer shows
paginated list of all contributors | |
| `history/top-contributors.spec.ts` | AS-07b drawer opens from deep
link with ?viewAllTopContributors=true | |
| `history/top-contributors.spec.ts` | AS-08 View-all click adds
?viewAllTopContributors=true to URL | |
| `history/top-contributors.spec.ts` | AS-10 contributor rows show
related-logs link for logs-based rules | |

#### Notification channels (1)

| File | Test | Status |
|---|---|---|
| `channels/edit.spec.ts` | NC-01 an edited recipient persists after
reload | |

#### Skipped tests

| Test | File | Kind | Reason |
|---|---|---|---|
| the v2 Discard button is clickable | `create/edge.spec.ts` | hard
`test.skip(` | Real bug: the button is not clickable. Test written, left
ready to flip. |
| broadcast-to-all saves the rule with the broadcast flag |
`create/v1.spec.ts` | hard `test.skip(` | Real bug: the broadcast flag
is not persisted. |
| sparkline present with a multi-point series |
`history/statistics.spec.ts` | `test.skip(true)` | Flaky by
construction: the sparkline only renders with more than one data point,
and whether the seeded ~2-minute window lands in one stats bucket or two
depends on where it falls relative to the bucket boundary. |
| pending/recovering states render blank |
`history/timeline-table.spec.ts` | `test.skip(true)` | Unreachable:
`pending` and `recovering` are transient states, and no fixture can
reliably catch a rule mid-transition. |
| disabled state renders as "Muted" | `history/timeline-table.spec.ts` |
`test.skip(true)` | Unreachable: a `disabled` history row is
policy-driven, and disabling a rule appends no row (verified). |
| time-range boundaries inclusive/exclusive |
`history/timeline-table.spec.ts` | `test.skip(true)` | Unreachable:
asserting a row exactly at `start` and one at `start-1ms` means
controlling row timestamps, but evaluation times are whatever the ruler
chose. |
| the anomaly card rewrites the rule type, not the alert type |
`create/shell.spec.ts` | conditional | Runs only where the
`ANOMALY_DETECTION` feature flag is active; it is off on this stack. |
| copy-link button copies the current URL to clipboard |
`details/chrome.spec.ts` | conditional | Runs on Chromium only —
Playwright grants `clipboard-read` nowhere else. |


#### Issues closed by this PR
> Reference issues using `Closes #issue-number` to enable automatic
closure on merge.

Closes https://github.com/SigNoz/engineering-pod/issues/4917

---

###  Change Type
_Select all that apply_

- [ ]  Feature
- [ ] 🐛 Bug fix
- [ ] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [x] 🧪 Test-only

---

### ⚠️ Risk & Impact Assessment
> What could break? How do we recover?

- Blast radius: Alerts
- Potential regressions: None, only test ids
- Rollback plan: Find and fix the issue specifically

---

### 📝 Changelog
> Fill only if this affects users, APIs, UI, or documented behavior  
> Use **N/A** for internal or non-user-facing changes

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Maintenance |
| Description | We added more E2E tests for Alerts page. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [ ] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-26 06:37:32 +00:00
319 changed files with 25293 additions and 1655 deletions

View File

@@ -20,6 +20,16 @@ You are the Playwright Test Generator for the SigNoz frontend. You take a plan w
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
});
```
- **Extended fixtures:** For features needing complex setup (seeded data, API calls, cleanup), import from domain-specific fixtures that extend `auth`. See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the full pattern.
- `fixtures/alerts/alert-rules` — worker-scoped rule list + test-scoped rule factory
- `fixtures/alerts/alert-history` — extends alert-rules, adds history fixtures (waits on ruler evaluation)
```ts
// Alert list tests - need rules, no history
import { test, expect } from '../../../fixtures/alerts/alert-rules';
// Alert history tests - need evaluated history rows
import { test, expect } from '../../../fixtures/alerts/alert-history';
```
- **Test titles:** `TC-NN <short description>` — matches the planner's IDs.
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two cleanup shapes are valid; pick based on the spec size:
- **Per-test `try / finally`** — small specs (~ <10 scenarios) where each test owns its data.

View File

@@ -49,6 +49,7 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
- **The list pages render zero-state when the workspace is empty.** Many locators (search input, sort button, `new-dashboard-cta` testid, "All Dashboards" header) are absent in zero-state. A 30s timeout on those usually means the workspace was empty — seed first via `createDashboardViaApi`.
- **The "Enter dashboard name…" inline field is a `RequestDashboardBtn` (template-request feedback form), not a create flow.** Tests that try to use it to create a named dashboard will silently no-op. The only UI create paths are the "New dashboard" dropdown → "Create dashboard" (default name "Sample Title", see `DEFAULT_DASHBOARD_TITLE`) or "Import JSON".
- **Auth.** `tests/e2e/fixtures/auth.ts` logs in once per worker and caches `storageState` (cookies + localStorage with `AUTH_TOKEN`). For API-driven seeding/cleanup, use `authToken(page)` from `helpers/dashboards.ts` and pass `Authorization: Bearer <token>`. Never re-implement login.
- **Extended fixtures.** Domain-specific fixtures extend `auth` and add seeded data. Alerts uses `fixtures/alerts/alert-rules` (worker-scoped rule list, test-scoped factory) and `fixtures/alerts/alert-history` (extends alert-rules, waits on ruler evaluation). See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the pattern. When a test fails on missing data, check if it imports the wrong fixture level.
- **Ant Design popovers** (sort menu, action menu) are click-toggle. The trigger element is often an inline `<svg>` with a `data-testid` — clicking it opens the popover; clicking it again closes. After selecting an option, the popover auto-closes. If a test interacts with the popover twice, wait for the menu items to be visible explicitly between toggles.
- **Artifacts.** Every failed test writes to `tests/e2e/artifacts/results/<test-slug>/` — the `error-context.md` accessibility snapshot is the fastest way to see what the page actually looked like when it failed.
- **Type-check.** After edits, run `npx tsc --noEmit -p tests/e2e/tsconfig.json` if it succeeds, or rely on `npx playwright test --list` to validate the spec parses.

View File

@@ -58,10 +58,12 @@ jobs:
- querierai
- rawexportdata
- promqlconformance
- promapiconformance
- querierauthz
- role
- rootuser
- savedview
- semconvfamilies
- serviceaccount
- spanmapper
- querier_json_body

View File

@@ -46,6 +46,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
@@ -103,8 +104,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, authtypes.NewRegistry()), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule, systemDashboardRegistry)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()

View File

@@ -63,6 +63,7 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
@@ -136,8 +137,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, authtypes.NewRegistry()), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing, tagModule, systemDashboardRegistry)
},
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)

View File

@@ -109,6 +109,57 @@ components:
webhook_url:
$ref: '#/components/schemas/ConfigSecretURL'
type: object
AlertmanagertypesJSMOpsReceiverConfig:
properties:
api_key:
type: string
description:
type: string
http_config:
$ref: '#/components/schemas/ConfigHTTPClientConfig'
message:
type: string
priority:
type: string
send_resolved:
type: boolean
tags:
type: string
type: object
AlertmanagertypesJiraReceiverConfig:
properties:
custom_fields:
additionalProperties: {}
type: object
description:
type: string
http_config:
$ref: '#/components/schemas/ConfigHTTPClientConfig'
issue_type:
type: string
labels:
items:
type: string
type: array
priority:
type: string
project:
type: string
reopen_duration:
$ref: '#/components/schemas/ModelDuration'
reopen_transition:
type: string
resolve_transition:
type: string
send_resolved:
type: boolean
site:
type: string
summary:
type: string
wont_fix_resolution:
type: string
type: object
AlertmanagertypesMaintenanceKind:
enum:
- fixed
@@ -162,6 +213,10 @@ components:
oneOf:
- required:
- googlechat_configs
- required:
- jira_configs
- required:
- jsmops_configs
- required:
- discord_configs
- required:
@@ -192,8 +247,6 @@ components:
- msteams_configs
- required:
- msteamsv2_configs
- required:
- jira_configs
- required:
- rocketchat_configs
- required:
@@ -217,7 +270,11 @@ components:
type: array
jira_configs:
items:
$ref: '#/components/schemas/ConfigJiraConfig'
$ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig'
type: array
jsmops_configs:
items:
$ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig'
type: array
mattermost_configs:
items:
@@ -344,7 +401,11 @@ components:
type: array
jira_configs:
items:
$ref: '#/components/schemas/ConfigJiraConfig'
$ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig'
type: array
jsmops_configs:
items:
$ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig'
type: array
mattermost_configs:
items:
@@ -2559,6 +2620,7 @@ components:
- factor-api-key
- license
- subscription
- deployment-host
- logs
- traces
- metrics
@@ -2943,6 +3005,46 @@ components:
publicDashboard:
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
type: object
DashboardtypesGettableSystemDashboard:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
image:
type: string
locked:
type: boolean
name:
type: string
orgId:
type: string
schemaVersion:
type: string
source:
$ref: '#/components/schemas/DashboardtypesSource'
spec:
$ref: '#/components/schemas/DashboardtypesDashboardSpec'
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
nullable: true
type: array
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- orgId
- locked
- source
- schemaVersion
- name
- tags
- spec
type: object
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -6459,6 +6561,148 @@ components:
type: object
PreferencetypesValue:
type: object
PrometheusErrorResponseSchema:
properties:
error:
type: string
errorType:
enum:
- bad_data
- execution
- canceled
- timeout
- internal
type: string
status:
enum:
- error
type: string
required:
- status
- errorType
- error
type: object
PrometheusMatrixDataSchema:
properties:
result:
items:
$ref: '#/components/schemas/PrometheusMatrixSeriesSchema'
nullable: true
type: array
resultType:
enum:
- matrix
type: string
required:
- resultType
- result
type: object
PrometheusMatrixSeriesSchema:
properties:
metric:
additionalProperties:
type: string
nullable: true
type: object
values:
items:
$ref: '#/components/schemas/PrometheusSamplePairSchema'
nullable: true
type: array
required:
- metric
- values
type: object
PrometheusQueryDataSchema:
oneOf:
- $ref: '#/components/schemas/PrometheusMatrixDataSchema'
- $ref: '#/components/schemas/PrometheusVectorDataSchema'
- $ref: '#/components/schemas/PrometheusScalarDataSchema'
- $ref: '#/components/schemas/PrometheusStringDataSchema'
type: object
PrometheusSamplePairSchema:
description: 'A [timestamp, value] pair: float unix seconds, then the string-encoded
sample value ("NaN", "+Inf", "-Inf" included).'
items:
oneOf:
- type: number
- type: string
maxItems: 2
minItems: 2
nullable: true
type: array
PrometheusScalarDataSchema:
properties:
result:
$ref: '#/components/schemas/PrometheusSamplePairSchema'
resultType:
enum:
- scalar
type: string
required:
- resultType
- result
type: object
PrometheusStringDataSchema:
properties:
result:
$ref: '#/components/schemas/PrometheusSamplePairSchema'
resultType:
enum:
- string
type: string
required:
- resultType
- result
type: object
PrometheusSuccessResponseSchema:
properties:
data:
$ref: '#/components/schemas/PrometheusQueryDataSchema'
infos:
items:
type: string
type: array
status:
enum:
- success
type: string
warnings:
items:
type: string
type: array
required:
- status
- data
type: object
PrometheusVectorDataSchema:
properties:
result:
items:
$ref: '#/components/schemas/PrometheusVectorSampleSchema'
nullable: true
type: array
resultType:
enum:
- vector
type: string
required:
- resultType
- result
type: object
PrometheusVectorSampleSchema:
properties:
metric:
additionalProperties:
type: string
nullable: true
type: object
value:
$ref: '#/components/schemas/PrometheusSamplePairSchema'
required:
- metric
- value
type: object
PromotetypesPromotePath:
properties:
indexes:
@@ -8010,6 +8254,7 @@ components:
- logs
- metrics
- meter
- ai_observability
type: string
SavedviewtypesUpdatableSavedView:
properties:
@@ -8800,6 +9045,7 @@ components:
- span
- trace
- resource
- scope
- attribute
- body
- ""
@@ -15356,6 +15602,73 @@ paths:
summary: Migrate dashboard to v2
tags:
- dashboard
/api/v2/dashboards/system/{name}:
get:
deprecated: false
description: Returns a dashboard SigNoz ships and owns, addressed by its stable
definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards
are read-only and upgraded through releases. The dashboard's own `name` field
carries a reserved prefix that the path segment must not include.
operationId: GetSystemDashboard
parameters:
- in: path
name: name
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableSystemDashboard'
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:
- dashboard:read
- tokenizer:
- dashboard:read
summary: Get system dashboard
tags:
- dashboard
/api/v2/factor_password/forgot:
post:
deprecated: false
@@ -15467,10 +15780,8 @@ paths:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
- api_key: []
- tokenizer: []
summary: Get features
tags:
- features
@@ -23942,9 +24253,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- deployment-host:list
- tokenizer:
- VIEWER
- deployment-host:list
summary: Get host info from Zeus.
tags:
- zeus
@@ -23998,9 +24309,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- deployment-host:update
- tokenizer:
- ADMIN
- deployment-host:update
summary: Put host in Zeus for a deployment.
tags:
- zeus
@@ -24810,6 +25121,374 @@ paths:
summary: Replace variables
tags:
- querier
/prometheus/api/v1/query:
get:
description: 'Prometheus-compatible endpoint: the request and response contract
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
Parameters are accepted as URL query parameters or a form-encoded body, on
GET and POST alike.'
operationId: PrometheusQuery
parameters:
- description: PromQL expression.
in: query
name: query
required: true
schema:
description: PromQL expression.
type: string
- description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
to the server''s current time.'
in: query
name: time
schema:
description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
to the server''s current time.'
type: string
- description: 'Evaluation timeout: duration string or float seconds.'
in: query
name: timeout
schema:
description: 'Evaluation timeout: duration string or float seconds.'
type: string
- description: Any non-empty value includes query statistics in the response.
in: query
name: stats
schema:
description: Any non-empty value includes query statistics in the response.
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
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
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Unprocessable Entity
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Internal Server Error
"503":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Service Unavailable
security:
- api_key:
- metrics:read
- tokenizer:
- metrics:read
summary: Prometheus instant query
tags:
- prometheus
post:
description: 'Prometheus-compatible endpoint: the request and response contract
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
Parameters are accepted as URL query parameters or a form-encoded body, on
GET and POST alike.'
operationId: PrometheusQueryPost
parameters:
- description: PromQL expression.
in: query
name: query
required: true
schema:
description: PromQL expression.
type: string
- description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
to the server''s current time.'
in: query
name: time
schema:
description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
to the server''s current time.'
type: string
- description: 'Evaluation timeout: duration string or float seconds.'
in: query
name: timeout
schema:
description: 'Evaluation timeout: duration string or float seconds.'
type: string
- description: Any non-empty value includes query statistics in the response.
in: query
name: stats
schema:
description: Any non-empty value includes query statistics in the response.
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
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
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Unprocessable Entity
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Internal Server Error
"503":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Service Unavailable
security:
- api_key:
- metrics:read
- tokenizer:
- metrics:read
summary: Prometheus instant query
tags:
- prometheus
/prometheus/api/v1/query_range:
get:
description: 'Prometheus-compatible endpoint: the request and response contract
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
Parameters are accepted as URL query parameters or a form-encoded body, on
GET and POST alike.'
operationId: PrometheusQueryRange
parameters:
- description: PromQL expression.
in: query
name: query
required: true
schema:
description: PromQL expression.
type: string
- description: 'Range start: RFC3339 or float unix seconds.'
in: query
name: start
required: true
schema:
description: 'Range start: RFC3339 or float unix seconds.'
type: string
- description: 'Range end: RFC3339 or float unix seconds.'
in: query
name: end
required: true
schema:
description: 'Range end: RFC3339 or float unix seconds.'
type: string
- description: 'Resolution step: duration string or float seconds.'
in: query
name: step
required: true
schema:
description: 'Resolution step: duration string or float seconds.'
type: string
- description: 'Evaluation timeout: duration string or float seconds.'
in: query
name: timeout
schema:
description: 'Evaluation timeout: duration string or float seconds.'
type: string
- description: Any non-empty value includes query statistics in the response.
in: query
name: stats
schema:
description: Any non-empty value includes query statistics in the response.
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
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
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Unprocessable Entity
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Internal Server Error
"503":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Service Unavailable
security:
- api_key:
- metrics:read
- tokenizer:
- metrics:read
summary: Prometheus range query
tags:
- prometheus
post:
description: 'Prometheus-compatible endpoint: the request and response contract
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
Parameters are accepted as URL query parameters or a form-encoded body, on
GET and POST alike.'
operationId: PrometheusQueryRangePost
parameters:
- description: PromQL expression.
in: query
name: query
required: true
schema:
description: PromQL expression.
type: string
- description: 'Range start: RFC3339 or float unix seconds.'
in: query
name: start
required: true
schema:
description: 'Range start: RFC3339 or float unix seconds.'
type: string
- description: 'Range end: RFC3339 or float unix seconds.'
in: query
name: end
required: true
schema:
description: 'Range end: RFC3339 or float unix seconds.'
type: string
- description: 'Resolution step: duration string or float seconds.'
in: query
name: step
required: true
schema:
description: 'Resolution step: duration string or float seconds.'
type: string
- description: 'Evaluation timeout: duration string or float seconds.'
in: query
name: timeout
schema:
description: 'Evaluation timeout: duration string or float seconds.'
type: string
- description: Any non-empty value includes query statistics in the response.
in: query
name: stats
schema:
description: Any non-empty value includes query statistics in the response.
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
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
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Unprocessable Entity
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Internal Server Error
"503":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Service Unavailable
security:
- api_key:
- metrics:read
- tokenizer:
- metrics:read
summary: Prometheus range query
tags:
- prometheus
servers:
- description: The fully qualified URL to the SigNoz APIServer.
url: https://{host}:{port}{base_path}

View File

@@ -299,8 +299,11 @@ 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`.
grid statement: the group-key join resolves the matchers, and the samples
primary key takes the metric name straight from the selector. Only a
selector without a static `__name__` runs the series lookup first, to learn
the concrete metric names. 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
@@ -315,8 +318,9 @@ selectors and `last_over_time` transpile at window < step too.
## Series lookup
Both paths resolve matchers the same way, once per selector
(`selectSeries`). The series tables hold one row per (fingerprint, bucket)
The engine path resolves matchers once per selector (`selectSeries`); the
transpiled path builds the same conditions into its group-key join. Both
read the same tables. 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

View File

@@ -112,6 +112,41 @@ These two folders look similar but mean different things:
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
### Extended fixtures
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.
**Fixture scopes:**
- **test scope** — fresh data per test. Use for mutations (edit, delete, rename).
- **worker scope** — shared across tests in one worker. Use for read-only data. Worker scope pays the setup cost once per worker instead of once per test.
**The alerts pattern** (`fixtures/alerts/`) demonstrates extending fixtures:
```
fixtures/alerts/
├── alert-rules.ts # extends auth — worker-scoped rule list + test-scoped factory
└── alert-history.ts # extends alert-rules — adds history fixtures (waits on ruler)
```
Specs import from the fixture they need:
```ts
// List tests — just need rules, no history
import { test, expect } from '../../../fixtures/alerts/alert-rules';
// History tests — need history rows from ruler evaluation
import { test, expect } from '../../../fixtures/alerts/alert-history';
```
**When creating new fixtures:**
1. **Identify scope** — Will tests mutate the data? If yes, test-scoped. If read-only, worker-scoped.
2. **Group by domain** — Put fixtures in `fixtures/<domain>/`. Helpers in `helpers/<domain>/`.
3. **Extend existing fixtures** — Chain from `auth` or another fixture to inherit its setup.
4. **Handle timeouts** — Worker-scoped fixtures that wait on backend processing need explicit timeouts.
5. **Clean up** — Always delete seeded data in the fixture teardown (after `use()`).
6. **Extract logic into functions** — Keep the `test.extend()` block lean; move setup/teardown logic to named functions so the extend block reads as a manifest of "what fixtures exist."
Each spec follows these principles:
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
@@ -232,11 +267,14 @@ cd tests/e2e
# Single feature dir
npx playwright test tests/alerts/ --project=chromium
# Single sub-area
npx playwright test tests/alerts/history/ --project=chromium
# Single file
npx playwright test tests/alerts/alerts.spec.ts --project=chromium
npx playwright test tests/alerts/page.spec.ts --project=chromium
# Single test by title grep
npx playwright test --project=chromium -g "TC-01"
npx playwright test --project=chromium -g "AL-01"
```
### Iterative modes
@@ -270,7 +308,14 @@ yarn test:staging
| `SIGNOZ_E2E_PASSWORD` | Admin password. Bootstrap writes the integration-test default. |
| `SIGNOZ_E2E_SEEDER_URL` | Seeder HTTP base URL — hit by specs that need per-test telemetry. |
Loading order in `playwright.config.ts`: `.env` first (user-provided, staging), then `.env.local` with `override: true` (bootstrap-generated, local mode). Anything already set in `process.env` at yarn-test time wins because dotenv doesn't touch vars that are already present.
Precedence in `playwright.config.ts`, lowest to highest: `.env` (user-provided, staging) `.env.local` (bootstrap-generated, local mode) → whatever is already in `process.env`. The config parses both files itself and only fills in keys the environment does not already define, so exporting a variable always wins:
```bash
# runs against a locally served frontend, not whatever .env.local points at
SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test tests/alerts
```
This is deliberately not `dotenv.config({ override: true })`. That flag makes the *file* beat `process.env`, which silently discarded exported values — including the `SIGNOZ_E2E_BASE_URL` in `pnpm test:staging`, whenever a `.env.local` happened to exist.
### Playwright options

View File

@@ -32,9 +32,9 @@ type module struct {
tagModule tag.Module
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, tagModule)
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, tagModule, systemDashboardRegistry)
return &module{
pkgDashboardModule: pkgDashboardModule,
@@ -361,6 +361,14 @@ func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valu
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error {
return module.pkgDashboardModule.ReconcileSystemDashboards(ctx, orgID)
}
func (module *module) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.GetSystemDashboard(ctx, orgID, name)
}
func (module *module) delete(ctx context.Context, orgID, id valuer.UUID) error {
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {

View File

@@ -67,7 +67,7 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// note: add ee override methods first
// routes available only in ee version
router.HandleFunc("/api/v1/features", am.ViewAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/features", am.OpenAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
// base overrides
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)

View File

@@ -26,6 +26,55 @@
"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_jira_site": "Site URL",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
"jira_service_account_tip_link": "Learn how",
"field_jira_email": "Email",
"help_jira_email": "The Atlassian account email used for authentication.",
"field_jira_api_token": "API token",
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
"field_jira_project": "Project key",
"field_jira_issue_type": "Issue type",
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
"field_jira_summary": "Summary (issue title)",
"help_jira_summary": "Template for the Jira issue title.",
"field_jira_description": "Description",
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
"jira_advanced_section": "Advanced Options",
"field_jira_priority": "Priority",
"placeholder_jira_priority": "Leave empty to use the project default",
"help_jira_priority": "Must match a priority in the project's scheme, e.g. High.",
"field_jira_labels": "Labels",
"placeholder_jira_labels": "Type a label and press Enter",
"help_jira_labels": "signoz and a deduplication label are added automatically.",
"field_jira_resolve_transition": "Resolve transition",
"field_jira_reopen_transition": "Reopen transition",
"help_jira_resolve_transition": "When the alert resolves, SigNoz moves the Jira issue to a \"Done\" status via a workflow transition. This is auto-detected — leave it empty unless your project has more than one \"Done\" transition (e.g. Done vs. Won't Do) and you want to force a specific one by name.",
"help_jira_reopen_transition": "When a resolved alert fires again (within the reopen window), SigNoz moves the issue back out of \"Done\" to an active status via a workflow transition. This is auto-detected — leave it empty unless you want to force a specific one by name (e.g. To Do or Reopen).",
"placeholder_jira_resolve_transition": "Auto-detected, e.g. Done",
"placeholder_jira_reopen_transition": "Auto-detected, e.g. To Do",
"field_jira_reopen_duration": "Reopen window",
"placeholder_jira_reopen_duration": "e.g. 72h",
"help_jira_reopen_duration": "If a resolved alert fires again within this window, the same ticket is reopened; after the window, a re-fire opens a new ticket instead. Default: 3d.",
"tooltip_jira_reopen_duration": "Accepted units: m (minutes), h (hours), d (days), w (weeks), y (years) — e.g. 30m, 72h or 3d. Minimum 1m.",
"jira_reopen_duration_invalid": "Reopen window must be a duration like 30m, 72h or 3d (minimum 1m)",
"jsmops_tip": "Create an API integration on your JSM team's Operations page and paste its key below.",
"jsmops_tip_link": "Learn how",
"field_jsmops_api_key": "API key",
"help_jsmops_api_key": "The JSM Ops integration API key, from your team's Operations → Integrations → API. Make sure the integration is turned on.",
"field_jsmops_message": "Message (alert title)",
"help_jsmops_message": "Template for the alert title. Truncated to 130 characters.",
"field_jsmops_description": "Description",
"help_jsmops_description": "Template for the alert description. Rendered as rich text; kept under 15,000 characters.",
"jsmops_advanced_section": "Advanced Options",
"field_jsmops_priority": "Priority",
"help_jsmops_priority": "Template resolving to one of P1P5. Leave as-is to map from alert severity.",
"field_jsmops_tags": "Tags",
"placeholder_jsmops_tags": "Type a tag and press Enter",
"help_jsmops_tags": "Tags added to every alert.",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",

View File

@@ -26,6 +26,55 @@
"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_jira_site": "Site URL",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
"jira_service_account_tip_link": "Learn how",
"field_jira_email": "Email",
"help_jira_email": "The Atlassian account email used for authentication.",
"field_jira_api_token": "API token",
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
"field_jira_project": "Project key",
"field_jira_issue_type": "Issue type",
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
"field_jira_summary": "Summary (issue title)",
"help_jira_summary": "Template for the Jira issue title.",
"field_jira_description": "Description",
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
"jira_advanced_section": "Advanced Options",
"field_jira_priority": "Priority",
"placeholder_jira_priority": "Leave empty to use the project default",
"help_jira_priority": "Must match a priority in the project's scheme, e.g. High.",
"field_jira_labels": "Labels",
"placeholder_jira_labels": "Type a label and press Enter",
"help_jira_labels": "signoz and a deduplication label are added automatically.",
"field_jira_resolve_transition": "Resolve transition",
"field_jira_reopen_transition": "Reopen transition",
"help_jira_resolve_transition": "When the alert resolves, SigNoz moves the Jira issue to a \"Done\" status via a workflow transition. This is auto-detected — leave it empty unless your project has more than one \"Done\" transition (e.g. Done vs. Won't Do) and you want to force a specific one by name.",
"help_jira_reopen_transition": "When a resolved alert fires again (within the reopen window), SigNoz moves the issue back out of \"Done\" to an active status via a workflow transition. This is auto-detected — leave it empty unless you want to force a specific one by name (e.g. To Do or Reopen).",
"placeholder_jira_resolve_transition": "Auto-detected, e.g. Done",
"placeholder_jira_reopen_transition": "Auto-detected, e.g. To Do",
"field_jira_reopen_duration": "Reopen window",
"placeholder_jira_reopen_duration": "e.g. 72h",
"help_jira_reopen_duration": "If a resolved alert fires again within this window, the same ticket is reopened; after the window, a re-fire opens a new ticket instead. Default: 3d.",
"tooltip_jira_reopen_duration": "Accepted units: m (minutes), h (hours), d (days), w (weeks), y (years) — e.g. 30m, 72h or 3d. Minimum 1m.",
"jira_reopen_duration_invalid": "Reopen window must be a duration like 30m, 72h or 3d (minimum 1m)",
"jsmops_tip": "Create an API integration on your JSM team's Operations page and paste its key below.",
"jsmops_tip_link": "Learn how",
"field_jsmops_api_key": "API key",
"help_jsmops_api_key": "The JSM Ops integration API key, from your team's Operations → Integrations → API. Make sure the integration is turned on.",
"field_jsmops_message": "Message (alert title)",
"help_jsmops_message": "Template for the alert title. Truncated to 130 characters.",
"field_jsmops_description": "Description",
"help_jsmops_description": "Template for the alert description. Rendered as rich text; kept under 15,000 characters.",
"jsmops_advanced_section": "Advanced Options",
"field_jsmops_priority": "Priority",
"help_jsmops_priority": "Template resolving to one of P1P5. Leave as-is to map from alert severity.",
"field_jsmops_tags": "Tags",
"placeholder_jsmops_tags": "Type a tag and press Enter",
"help_jsmops_tags": "Tags added to every alert.",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",

View File

@@ -53,7 +53,7 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
};
}
// anything else
console.error('any');
console.error('ErrorResponseHandler: unclassified error');
return {
statusCode: 500,
payload: null,

View File

@@ -46,6 +46,8 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
GetSystemDashboard200,
GetSystemDashboardPathParameters,
ListDashboardViews200,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
@@ -1885,6 +1887,108 @@ export const useMigrateDashboardV2 = <
> => {
return useMutation(getMigrateDashboardV2MutationOptions(options));
};
/**
* Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.
* @summary Get system dashboard
*/
export const getSystemDashboard = (
{ name }: GetSystemDashboardPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetSystemDashboard200>({
url: `/api/v2/dashboards/system/${name}`,
method: 'GET',
signal,
});
};
export const getGetSystemDashboardQueryKey = ({
name,
}: GetSystemDashboardPathParameters) => {
return [`/api/v2/dashboards/system/${name}`] as const;
};
export const getGetSystemDashboardQueryOptions = <
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ name }: GetSystemDashboardPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetSystemDashboardQueryKey({ name });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getSystemDashboard>>
> = ({ signal }) => getSystemDashboard({ name }, signal);
return {
queryKey,
queryFn,
enabled: !!name,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetSystemDashboardQueryResult = NonNullable<
Awaited<ReturnType<typeof getSystemDashboard>>
>;
export type GetSystemDashboardQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get system dashboard
*/
export function useGetSystemDashboard<
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ name }: GetSystemDashboardPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetSystemDashboardQueryOptions({ name }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get system dashboard
*/
export const invalidateGetSystemDashboard = async (
queryClient: QueryClient,
{ name }: GetSystemDashboardPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetSystemDashboardQueryKey({ name }) },
options,
);
return queryClient;
};
/**
* 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

@@ -0,0 +1,396 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
PrometheusErrorResponseSchemaDTO,
PrometheusQueryParams,
PrometheusQueryPostParams,
PrometheusQueryRangeParams,
PrometheusQueryRangePostParams,
PrometheusSuccessResponseSchemaDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
/**
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
* @summary Prometheus instant query
*/
export const prometheusQuery = (
params: PrometheusQueryParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
url: `/prometheus/api/v1/query`,
method: 'GET',
params,
signal,
});
};
export const getPrometheusQueryQueryKey = (params?: PrometheusQueryParams) => {
return [`/prometheus/api/v1/query`, ...(params ? [params] : [])] as const;
};
export const getPrometheusQueryQueryOptions = <
TData = Awaited<ReturnType<typeof prometheusQuery>>,
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
>(
params: PrometheusQueryParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof prometheusQuery>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getPrometheusQueryQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof prometheusQuery>>> = ({
signal,
}) => prometheusQuery(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof prometheusQuery>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type PrometheusQueryQueryResult = NonNullable<
Awaited<ReturnType<typeof prometheusQuery>>
>;
export type PrometheusQueryQueryError = ErrorType<
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
>;
/**
* @summary Prometheus instant query
*/
export function usePrometheusQuery<
TData = Awaited<ReturnType<typeof prometheusQuery>>,
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
>(
params: PrometheusQueryParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof prometheusQuery>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getPrometheusQueryQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Prometheus instant query
*/
export const invalidatePrometheusQuery = async (
queryClient: QueryClient,
params: PrometheusQueryParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getPrometheusQueryQueryKey(params) },
options,
);
return queryClient;
};
/**
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
* @summary Prometheus instant query
*/
export const prometheusQueryPost = (
params: PrometheusQueryPostParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
url: `/prometheus/api/v1/query`,
method: 'POST',
params,
signal,
});
};
export const getPrometheusQueryPostMutationOptions = <
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof prometheusQueryPost>>,
TError,
{ params: PrometheusQueryPostParams },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof prometheusQueryPost>>,
TError,
{ params: PrometheusQueryPostParams },
TContext
> => {
const mutationKey = ['prometheusQueryPost'];
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 prometheusQueryPost>>,
{ params: PrometheusQueryPostParams }
> = (props) => {
const { params } = props ?? {};
return prometheusQueryPost(params);
};
return { mutationFn, ...mutationOptions };
};
export type PrometheusQueryPostMutationResult = NonNullable<
Awaited<ReturnType<typeof prometheusQueryPost>>
>;
export type PrometheusQueryPostMutationError = ErrorType<
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
>;
/**
* @summary Prometheus instant query
*/
export const usePrometheusQueryPost = <
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof prometheusQueryPost>>,
TError,
{ params: PrometheusQueryPostParams },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof prometheusQueryPost>>,
TError,
{ params: PrometheusQueryPostParams },
TContext
> => {
return useMutation(getPrometheusQueryPostMutationOptions(options));
};
/**
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
* @summary Prometheus range query
*/
export const prometheusQueryRange = (
params: PrometheusQueryRangeParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
url: `/prometheus/api/v1/query_range`,
method: 'GET',
params,
signal,
});
};
export const getPrometheusQueryRangeQueryKey = (
params?: PrometheusQueryRangeParams,
) => {
return [
`/prometheus/api/v1/query_range`,
...(params ? [params] : []),
] as const;
};
export const getPrometheusQueryRangeQueryOptions = <
TData = Awaited<ReturnType<typeof prometheusQueryRange>>,
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
>(
params: PrometheusQueryRangeParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof prometheusQueryRange>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getPrometheusQueryRangeQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof prometheusQueryRange>>
> = ({ signal }) => prometheusQueryRange(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof prometheusQueryRange>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type PrometheusQueryRangeQueryResult = NonNullable<
Awaited<ReturnType<typeof prometheusQueryRange>>
>;
export type PrometheusQueryRangeQueryError = ErrorType<
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
>;
/**
* @summary Prometheus range query
*/
export function usePrometheusQueryRange<
TData = Awaited<ReturnType<typeof prometheusQueryRange>>,
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
>(
params: PrometheusQueryRangeParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof prometheusQueryRange>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getPrometheusQueryRangeQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Prometheus range query
*/
export const invalidatePrometheusQueryRange = async (
queryClient: QueryClient,
params: PrometheusQueryRangeParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getPrometheusQueryRangeQueryKey(params) },
options,
);
return queryClient;
};
/**
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
* @summary Prometheus range query
*/
export const prometheusQueryRangePost = (
params: PrometheusQueryRangePostParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
url: `/prometheus/api/v1/query_range`,
method: 'POST',
params,
signal,
});
};
export const getPrometheusQueryRangePostMutationOptions = <
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
TError,
{ params: PrometheusQueryRangePostParams },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
TError,
{ params: PrometheusQueryRangePostParams },
TContext
> => {
const mutationKey = ['prometheusQueryRangePost'];
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 prometheusQueryRangePost>>,
{ params: PrometheusQueryRangePostParams }
> = (props) => {
const { params } = props ?? {};
return prometheusQueryRangePost(params);
};
return { mutationFn, ...mutationOptions };
};
export type PrometheusQueryRangePostMutationResult = NonNullable<
Awaited<ReturnType<typeof prometheusQueryRangePost>>
>;
export type PrometheusQueryRangePostMutationError = ErrorType<
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
>;
/**
* @summary Prometheus range query
*/
export const usePrometheusQueryRangePost = <
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
TError,
{ params: PrometheusQueryRangePostParams },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
TError,
{ params: PrometheusQueryRangePostParams },
TContext
> => {
return useMutation(getPrometheusQueryRangePostMutationOptions(options));
};

View File

@@ -385,6 +385,93 @@ export interface AlertmanagertypesGoogleChatReceiverConfigDTO {
webhook_url?: ConfigSecretURLDTO;
}
export interface AlertmanagertypesJSMOpsReceiverConfigDTO {
/**
* @type string
*/
api_key?: string;
/**
* @type string
*/
description?: string;
http_config?: ConfigHTTPClientConfigDTO;
/**
* @type string
*/
message?: string;
/**
* @type string
*/
priority?: string;
/**
* @type boolean
*/
send_resolved?: boolean;
/**
* @type string
*/
tags?: string;
}
export type AlertmanagertypesJiraReceiverConfigDTOCustomFields = {
[key: string]: unknown;
};
export type ModelDurationDTO = number;
export interface AlertmanagertypesJiraReceiverConfigDTO {
/**
* @type object
*/
custom_fields?: AlertmanagertypesJiraReceiverConfigDTOCustomFields;
/**
* @type string
*/
description?: string;
http_config?: ConfigHTTPClientConfigDTO;
/**
* @type string
*/
issue_type?: string;
/**
* @type array
*/
labels?: string[];
/**
* @type string
*/
priority?: string;
/**
* @type string
*/
project?: string;
reopen_duration?: ModelDurationDTO;
/**
* @type string
*/
reopen_transition?: string;
/**
* @type string
*/
resolve_transition?: string;
/**
* @type boolean
*/
send_resolved?: boolean;
/**
* @type string
*/
site?: string;
/**
* @type string
*/
summary?: string;
/**
* @type string
*/
wont_fix_resolution?: string;
}
export enum AlertmanagertypesMaintenanceKindDTO {
fixed = 'fixed',
recurring = 'recurring',
@@ -631,69 +718,6 @@ export interface ConfigIncidentioConfigDTO {
url_file?: string;
}
export interface ConfigJiraFieldConfigDTO {
/**
* @type boolean,null
*/
enable_update?: boolean | null;
/**
* @type string
*/
template?: string;
}
export type ModelDurationDTO = number;
export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown };
export interface ConfigJiraConfigDTO {
/**
* @type string
*/
api_type?: string;
api_url?: ConfigURLType2DTO;
/**
* @type object
*/
custom_fields?: ConfigJiraConfigDTOCustomFields;
description?: ConfigJiraFieldConfigDTO;
http_config?: ConfigHTTPClientConfigDTO;
/**
* @type string
*/
issue_type?: string;
/**
* @type array
*/
labels?: string[];
/**
* @type string
*/
priority?: string;
/**
* @type string
*/
project?: string;
reopen_duration?: ModelDurationDTO;
/**
* @type string
*/
reopen_transition?: string;
/**
* @type string
*/
resolve_transition?: string;
/**
* @type boolean
*/
send_resolved?: boolean;
summary?: ConfigJiraFieldConfigDTO;
/**
* @type string
*/
wont_fix_resolution?: string;
}
export interface ConfigMattermostFieldDTO {
/**
* @type boolean,null
@@ -1652,7 +1676,11 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
/**
* @type array
*/
jira_configs?: ConfigJiraConfigDTO[];
jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[];
/**
* @type array
*/
jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[];
/**
* @type array
*/
@@ -1779,7 +1807,11 @@ export interface AlertmanagertypesReceiverDTO {
/**
* @type array
*/
jira_configs?: ConfigJiraConfigDTO[];
jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[];
/**
* @type array
*/
jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[];
/**
* @type array
*/
@@ -2175,6 +2207,7 @@ export enum CoretypesKindDTO {
'factor-api-key' = 'factor-api-key',
license = 'license',
subscription = 'subscription',
'deployment-host' = 'deployment-host',
logs = 'logs',
traces = 'traces',
metrics = 'metrics',
@@ -3267,6 +3300,67 @@ export interface CommonJSONRefDTO {
$ref?: string;
}
export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown };
export interface ConfigJiraFieldConfigDTO {
/**
* @type boolean,null
*/
enable_update?: boolean | null;
/**
* @type string
*/
template?: string;
}
export interface ConfigJiraConfigDTO {
/**
* @type string
*/
api_type?: string;
api_url?: ConfigURLType2DTO;
/**
* @type object
*/
custom_fields?: ConfigJiraConfigDTOCustomFields;
description?: ConfigJiraFieldConfigDTO;
http_config?: ConfigHTTPClientConfigDTO;
/**
* @type string
*/
issue_type?: string;
/**
* @type array
*/
labels?: string[];
/**
* @type string
*/
priority?: string;
/**
* @type string
*/
project?: string;
reopen_duration?: ModelDurationDTO;
/**
* @type string
*/
reopen_transition?: string;
/**
* @type string
*/
resolve_transition?: string;
/**
* @type boolean
*/
send_resolved?: boolean;
summary?: ConfigJiraFieldConfigDTO;
/**
* @type string
*/
wont_fix_resolution?: string;
}
export interface DashboardGridItemDTO {
content?: CommonJSONRefDTO;
/**
@@ -3492,6 +3586,7 @@ export enum TelemetrytypesFieldContextDTO {
span = 'span',
trace = 'trace',
resource = 'resource',
scope = 'scope',
attribute = 'attribute',
body = 'body',
'' = '',
@@ -4958,6 +5053,53 @@ export interface DashboardtypesGettablePublicDashboardDataV2DTO {
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
}
export interface DashboardtypesGettableSystemDashboardDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
image?: string;
/**
* @type boolean
*/
locked: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
*/
schemaVersion: string;
source: DashboardtypesSourceDTO;
spec: DashboardtypesDashboardSpecDTO;
/**
* @type array,null
*/
tags: TagtypesGettableTagDTO[] | null;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export enum DashboardtypesPatchOpDTO {
add = 'add',
remove = 'remove',
@@ -7972,6 +8114,164 @@ export interface PreferencetypesUpdatablePreferenceDTO {
value?: unknown;
}
export enum PrometheusErrorResponseSchemaDTOErrorType {
bad_data = 'bad_data',
execution = 'execution',
canceled = 'canceled',
timeout = 'timeout',
internal = 'internal',
}
export enum PrometheusErrorResponseSchemaDTOStatus {
error = 'error',
}
export interface PrometheusErrorResponseSchemaDTO {
/**
* @type string
*/
error: string;
/**
* @enum bad_data,execution,canceled,timeout,internal
* @type string
*/
errorType: PrometheusErrorResponseSchemaDTOErrorType;
/**
* @enum error
* @type string
*/
status: PrometheusErrorResponseSchemaDTOStatus;
}
export enum PrometheusMatrixDataSchemaDTOResultType {
matrix = 'matrix',
}
export type PrometheusSamplePairSchemaDTOItem = number | string;
/**
* A [timestamp, value] pair: float unix seconds, then the string-encoded sample value ("NaN", "+Inf", "-Inf" included).
* @minItems 2
* @maxItems 2
* @nullable
*/
export type PrometheusSamplePairSchemaDTO =
| PrometheusSamplePairSchemaDTOItem[]
| null;
export type PrometheusMatrixSeriesSchemaDTOMetricAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type PrometheusMatrixSeriesSchemaDTOMetric =
PrometheusMatrixSeriesSchemaDTOMetricAnyOf | null;
export interface PrometheusMatrixSeriesSchemaDTO {
/**
* @type object,null
*/
metric: PrometheusMatrixSeriesSchemaDTOMetric;
/**
* @type array,null
*/
values: (PrometheusSamplePairSchemaDTO | null)[] | null;
}
export interface PrometheusMatrixDataSchemaDTO {
/**
* @type array,null
*/
result: PrometheusMatrixSeriesSchemaDTO[] | null;
/**
* @enum matrix
* @type string
*/
resultType: PrometheusMatrixDataSchemaDTOResultType;
}
export type PrometheusVectorSampleSchemaDTOMetricAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type PrometheusVectorSampleSchemaDTOMetric =
PrometheusVectorSampleSchemaDTOMetricAnyOf | null;
export interface PrometheusVectorSampleSchemaDTO {
/**
* @type object,null
*/
metric: PrometheusVectorSampleSchemaDTOMetric;
value: PrometheusSamplePairSchemaDTO | null;
}
export enum PrometheusVectorDataSchemaDTOResultType {
vector = 'vector',
}
export interface PrometheusVectorDataSchemaDTO {
/**
* @type array,null
*/
result: PrometheusVectorSampleSchemaDTO[] | null;
/**
* @enum vector
* @type string
*/
resultType: PrometheusVectorDataSchemaDTOResultType;
}
export enum PrometheusScalarDataSchemaDTOResultType {
scalar = 'scalar',
}
export interface PrometheusScalarDataSchemaDTO {
result: PrometheusSamplePairSchemaDTO | null;
/**
* @enum scalar
* @type string
*/
resultType: PrometheusScalarDataSchemaDTOResultType;
}
export enum PrometheusStringDataSchemaDTOResultType {
string = 'string',
}
export interface PrometheusStringDataSchemaDTO {
result: PrometheusSamplePairSchemaDTO | null;
/**
* @enum string
* @type string
*/
resultType: PrometheusStringDataSchemaDTOResultType;
}
export type PrometheusQueryDataSchemaDTO =
| PrometheusMatrixDataSchemaDTO
| PrometheusVectorDataSchemaDTO
| PrometheusScalarDataSchemaDTO
| PrometheusStringDataSchemaDTO;
export enum PrometheusSuccessResponseSchemaDTOStatus {
success = 'success',
}
export interface PrometheusSuccessResponseSchemaDTO {
data: PrometheusQueryDataSchemaDTO;
/**
* @type array
*/
infos?: string[];
/**
* @enum success
* @type string
*/
status: PrometheusSuccessResponseSchemaDTOStatus;
/**
* @type array
*/
warnings?: string[];
}
export interface PromotetypesWrappedIndexDTO {
fieldDataType?: TelemetrytypesFieldDataTypeDTO;
/**
@@ -9021,6 +9321,7 @@ export enum SavedviewtypesSourceDTO {
logs = 'logs',
metrics = 'metrics',
meter = 'meter',
ai_observability = 'ai_observability',
}
export interface SavedviewtypesSavedViewSpecDTO {
display?: SavedviewtypesDisplayDTO;
@@ -11310,6 +11611,17 @@ export type MigrateDashboardV2200 = {
status: string;
};
export type GetSystemDashboardPathParameters = {
name: string;
};
export type GetSystemDashboard200 = {
data: DashboardtypesGettableSystemDashboardDTO;
/**
* @type string
*/
status: string;
};
export type GetFeatures200 = {
/**
* @type array
@@ -12468,3 +12780,115 @@ export type ReplaceVariables200 = {
*/
status: string;
};
export type PrometheusQueryParams = {
/**
* @type string
* @description PromQL expression.
*/
query: string;
/**
* @type string
* @description Evaluation timestamp: RFC3339 or float unix seconds. Defaults to the server's current time.
*/
time?: string;
/**
* @type string
* @description Evaluation timeout: duration string or float seconds.
*/
timeout?: string;
/**
* @type string
* @description Any non-empty value includes query statistics in the response.
*/
stats?: string;
};
export type PrometheusQueryPostParams = {
/**
* @type string
* @description PromQL expression.
*/
query: string;
/**
* @type string
* @description Evaluation timestamp: RFC3339 or float unix seconds. Defaults to the server's current time.
*/
time?: string;
/**
* @type string
* @description Evaluation timeout: duration string or float seconds.
*/
timeout?: string;
/**
* @type string
* @description Any non-empty value includes query statistics in the response.
*/
stats?: string;
};
export type PrometheusQueryRangeParams = {
/**
* @type string
* @description PromQL expression.
*/
query: string;
/**
* @type string
* @description Range start: RFC3339 or float unix seconds.
*/
start: string;
/**
* @type string
* @description Range end: RFC3339 or float unix seconds.
*/
end: string;
/**
* @type string
* @description Resolution step: duration string or float seconds.
*/
step: string;
/**
* @type string
* @description Evaluation timeout: duration string or float seconds.
*/
timeout?: string;
/**
* @type string
* @description Any non-empty value includes query statistics in the response.
*/
stats?: string;
};
export type PrometheusQueryRangePostParams = {
/**
* @type string
* @description PromQL expression.
*/
query: string;
/**
* @type string
* @description Range start: RFC3339 or float unix seconds.
*/
start: string;
/**
* @type string
* @description Range end: RFC3339 or float unix seconds.
*/
end: string;
/**
* @type string
* @description Resolution step: duration string or float seconds.
*/
step: string;
/**
* @type string
* @description Evaluation timeout: duration string or float seconds.
*/
timeout?: string;
/**
* @type string
* @description Any non-empty value includes query statistics in the response.
*/
stats?: string;
};

View File

@@ -8,12 +8,14 @@ export interface AlertBreadcrumbProps {
items: BreadcrumbItemConfig[];
className?: string;
showDivider?: boolean;
testId?: string;
}
function AlertBreadcrumb({
items,
className,
showDivider = true,
testId,
}: AlertBreadcrumbProps): JSX.Element {
const breadcrumbItems = items.map((item) => ({
title: <BreadcrumbItem {...item} />,
@@ -24,6 +26,7 @@ function AlertBreadcrumb({
<Breadcrumb
className={`${styles.breadcrumb} ${className || ''}`}
items={breadcrumbItems}
data-testid={testId}
/>
{showDivider && <Divider className={styles.divider} />}
</>

View File

@@ -7,9 +7,8 @@ import axios from 'axios';
import TextToolTip from 'components/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { useOptionsMenu } from 'container/OptionsMenu';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useDeleteView } from 'hooks/saveViews/useDeleteView';
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
@@ -69,9 +68,7 @@ function ExplorerCard({
setIsOpen(newOpen);
};
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || '';
const { viewName, viewKey } = useGetSavedViewParams();
const { options } = useOptionsMenu({
storageKey:

View File

@@ -1,5 +1,3 @@
import { QueryParams } from 'constants/query';
export const ExploreHeaderToolTip = {
url: 'https://signoz.io/docs/querying/overview/?utm_source=product&utm_medium=new-query-builder',
text: 'More details on how to use query builder',
@@ -9,5 +7,3 @@ export const SaveButtonText = {
SAVE_AS_NEW_VIEW: 'Save as new view',
SAVE_VIEW: 'Save view',
};
export type QuerySearchParamNames = QueryParams.viewName | QueryParams.viewKey;

View File

@@ -241,28 +241,29 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
))
)}
{!showOnlyWhereClause && currentQuery.builder.queryFormulas.length > 0 && (
<div className="qb-formulas-container">
{currentQuery.builder.queryFormulas.map((formula, index) => {
const query =
currentQuery.builder.queryData[index] ||
currentQuery.builder.queryData[0];
{!showOnlyWhereClause &&
currentQuery.builder.queryFormulas?.length > 0 && (
<div className="qb-formulas-container">
{currentQuery.builder.queryFormulas.map((formula, index) => {
const query =
currentQuery.builder.queryData[index] ||
currentQuery.builder.queryData[0];
return (
<div key={formula.queryName} className="qb-formula">
<Formula
filterConfigs={filterConfigs}
query={query}
formula={formula}
index={index}
isAdditionalFilterEnable={false}
isQBV2
/>
</div>
);
})}
</div>
)}
return (
<div key={formula.queryName} className="qb-formula">
<Formula
filterConfigs={filterConfigs}
query={query}
formula={formula}
index={index}
isAdditionalFilterEnable={false}
isQBV2
/>
</div>
);
})}
</div>
)}
{shouldShowFooter && (
<QueryFooter
@@ -290,7 +291,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
</div>
))}
{currentQuery.builder.queryFormulas.map((formula) => (
{currentQuery.builder.queryFormulas?.map((formula) => (
<div key={formula.queryName} className="formula-name">
{formula.queryName}
</div>

View File

@@ -212,6 +212,32 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
expect(handleRunQueryMock).toHaveBeenCalled();
});
it('does not crash when builder.queryFormulas/queryTraceOperator are missing (partial/legacy query)', () => {
const currentQueryBase = baseQBContext.currentQuery as Query;
mockedUseQueryBuilder.mockReturnValue({
...baseQBContext,
currentQuery: {
...currentQueryBase,
builder: {
queryData: currentQueryBase.builder.queryData,
queryFormulas: undefined as unknown as [],
queryTraceOperator: undefined as unknown as [],
},
},
});
expect(() =>
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />),
).not.toThrow();
// query list still renders from queryData, formulas block is skipped
expect(document.querySelector('.query-names-section')).toBeInTheDocument();
expect(
document.querySelector('.qb-formulas-container'),
).not.toBeInTheDocument();
});
it('fx button is disabled when functions already exist', () => {
const currentQueryBase = baseQBContext.currentQuery as Query;
const supersetQueryBase = baseQBContext.supersetQuery as Query;

View File

@@ -4,7 +4,7 @@ import {
} from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
import {
setupAuthzAdmin,
setupAuthzDeny,
@@ -110,10 +110,7 @@ describe('ServiceAccountDrawer — permissions', () => {
it('shows PermissionDeniedCallout in Keys tab when list-keys permission is denied', async () => {
server.use(setupAuthzDeny(APIKeyListPermission));
renderDrawer();
await screen.findByDisplayValue('CI Bot');
fireEvent.click(screen.getByRole('radio', { name: /keys/i }));
renderDrawer({ account: 'sa-1', tab: 'keys' });
await waitFor(() => {
expect(screen.getByText(/list:factor-api-key/)).toBeInTheDocument();

View File

@@ -29,6 +29,7 @@ export enum InfraMonitoringEvents {
MetricsView = 'metrics',
Total = 'total',
Cluster = 'cluster',
Container = 'container',
DaemonSet = 'daemonSet',
Deployment = 'deployment',
Job = 'job',

View File

@@ -29,6 +29,7 @@ function PopoverContent({
<Link
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
className="contributor-row-popover-buttons__button"
data-testid="alert-popover-view-logs"
>
<div className="icon">
<LogsIcon />
@@ -40,6 +41,7 @@ function PopoverContent({
<Link
to={`${ROUTES.TRACES_EXPLORER}?${relatedTracesLink}`}
className="contributor-row-popover-buttons__button"
data-testid="alert-popover-view-traces"
>
<div className="icon">
<DraftingCompass

View File

@@ -26,7 +26,10 @@ function ChangePercentage({
}: ChangePercentageProps): JSX.Element {
if (direction > 0) {
return (
<div className="change-percentage change-percentage--success">
<div
className="change-percentage change-percentage--success"
data-testid="stats-card-change"
>
<div className="change-percentage__icon">
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
</div>
@@ -38,7 +41,10 @@ function ChangePercentage({
}
if (direction < 0) {
return (
<div className="change-percentage change-percentage--error">
<div
className="change-percentage change-percentage--error"
data-testid="stats-card-change"
>
<div className="change-percentage__icon">
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
</div>
@@ -50,7 +56,10 @@ function ChangePercentage({
}
return (
<div className="change-percentage change-percentage--no-previous-data">
<div
className="change-percentage change-percentage--no-previous-data"
data-testid="stats-card-change"
>
<div className="change-percentage__label">no previous data</div>
</div>
);
@@ -103,7 +112,12 @@ function StatsCard({
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
return (
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
<div
className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}
data-testid="stats-card"
data-stats-title={title}
data-empty={isEmpty ? 'true' : 'false'}
>
<div className="stats-card__title-wrapper">
<div className="title">{title}</div>
<div className="duration-indicator">
@@ -123,7 +137,7 @@ function StatsCard({
</div>
<div className="stats-card__stats">
<div className="count-label">
<div className="count-label" data-testid="stats-card-value">
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
</div>

View File

@@ -81,7 +81,11 @@ function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
);
return (
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
<div
style={{ height: '100%', width: '100%' }}
ref={graphRef}
data-testid="stats-card-sparkline"
>
<Uplot data={[xData, yData]} options={options} />
</div>
);

View File

@@ -48,11 +48,16 @@ function TopContributorsCard({
return (
<>
<div className="top-contributors-card">
<div className="top-contributors-card" data-testid="top-contributors-card">
<div className="top-contributors-card__header">
<div className="title">top contributors</div>
{topContributorsData.length > 3 && (
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
<Button
type="text"
className="view-all"
onClick={toggleViewAllDrawer}
data-testid="top-contributors-view-all"
>
<div className="label">View all</div>
<div className="icon">
<ArrowRight

View File

@@ -68,7 +68,10 @@ function TopContributorsRows({
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div className="total-contribution">
<div
className="total-contribution"
data-testid="top-contributors-row-count"
>
{count}/{totalCurrentTriggers}
</div>
</ConditionalAlertPopover>
@@ -78,7 +81,10 @@ function TopContributorsRows({
const handleRowClick = (
record: AlertRuleTopContributors,
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
'data-testid': string;
} => ({
'data-testid': 'top-contributors-row',
onClick: (): void => {
logEvent('Alert history: Top contributors row: Clicked', {
labels: record.labels,

View File

@@ -31,7 +31,10 @@ function ViewAllDrawer({
}}
title="Viewing All Contributors"
>
<div className="top-contributors-card--view-all">
<div
className="top-contributors-card--view-all"
data-testid="top-contributors-drawer"
>
<div className="top-contributors-card__content">
<TopContributorsRows
topContributors={topContributorsData}

View File

@@ -32,8 +32,8 @@ function GraphWrapper({
}, [data?.data]);
return (
<div className="timeline-graph">
<div className="timeline-graph__title">
<div className="timeline-graph" data-testid="timeline-graph">
<div className="timeline-graph__title" data-testid="timeline-graph-title">
{totalCurrentTriggers} triggers in {relativeTime}
</div>
<div className="timeline-graph__chart">

View File

@@ -118,7 +118,10 @@ function TimelineTableContent(): JSX.Element {
const handleRowClick = (
record: AlertRuleTimelineTableResponse,
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
'data-testid': string;
} => ({
'data-testid': 'timeline-row',
onClick: (): void => {
void logEvent('Alert history: Timeline table row: Clicked', {
ruleId: record.ruleID,
@@ -128,12 +131,15 @@ function TimelineTableContent(): JSX.Element {
});
return (
<div className="timeline-table">
<div className="timeline-table" data-testid="timeline-table">
{/* If we don't wait to have the keys, the QuerySearch will not render them at first usage */}
{!isLoadingKeys && hardcodedAttributeKeys ? (
<div className="timeline-table__filter">
<div className="timeline-table__filter-row">
<div className="timeline-table__filter-search">
<div
className="timeline-table__filter-search"
data-testid="timeline-filter-search"
>
<QuerySearch
onChange={querySearchOnChange}
queryData={queryData}
@@ -155,6 +161,7 @@ function TimelineTableContent(): JSX.Element {
<Skeleton.Input
className="timeline-table__filter--loading-skeleton"
active
data-testid="timeline-filter-skeleton"
/>
</div>
)}
@@ -172,14 +179,17 @@ function TimelineTableContent(): JSX.Element {
locale={{
emptyText:
isError && apiError ? (
<div className="timeline-table__error">
<div className="timeline-table__error" data-testid="timeline-error">
<ErrorContent error={apiError} />
</div>
) : undefined,
}}
footer={(): JSX.Element => (
<div className="timeline-table__pagination">
<div className="timeline-table__pagination-info">
<div
className="timeline-table__pagination-info"
data-testid="timeline-footer-range"
>
{paginationConfig.showTotal?.(totalItems, [
totalItems === 0
? 0

View File

@@ -21,18 +21,14 @@ export const timelineTableColumns = ({
sorter: true,
width: 140,
render: (value): JSX.Element => (
<div className="alert-rule-state">
<AlertState state={value} showLabel />
</div>
<AlertState state={value} showLabel testId="timeline-row-state" />
),
},
{
title: 'LABELS',
dataIndex: 'labels',
render: (labels): JSX.Element => (
<div className="alert-rule-labels">
<AlertLabels labels={labels} />
</div>
<AlertLabels labels={labels} testId="timeline-row-labels" />
),
},
{
@@ -40,7 +36,10 @@ export const timelineTableColumns = ({
dataIndex: 'unixMilli',
width: 200,
render: (value): JSX.Element => (
<div className="alert-rule__created-at">
<div
className="alert-rule__created-at"
data-testid="timeline-row-created-at"
>
{formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.DASH_DATETIME)}
</div>
),
@@ -53,7 +52,7 @@ export const timelineTableColumns = ({
if (!record.relatedTracesLink && !record.relatedLogsLink) {
return (
<Tooltip title="No links available for this item">
<Button type="text" ghost disabled>
<Button type="text" ghost disabled data-testid="timeline-row-actions">
<Ellipsis className="dropdown-icon" size="md" />
</Button>
</Tooltip>
@@ -65,7 +64,7 @@ export const timelineTableColumns = ({
relatedTracesLink={record.relatedTracesLink ?? ''}
relatedLogsLink={record.relatedLogsLink ?? ''}
>
<Button type="text" ghost>
<Button type="text" ghost data-testid="timeline-row-actions">
<Ellipsis className="dropdown-icon" size="md" />
</Button>
</ConditionalAlertPopover>

View File

@@ -10,6 +10,7 @@ const fieldContextToSuggestionMap: Record<
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
// no maps for the following values on suggestion context
[TelemetrytypesFieldContextDTO.trace]: undefined,
[TelemetrytypesFieldContextDTO.scope]: undefined,
[TelemetrytypesFieldContextDTO.body]: undefined,
[TelemetrytypesFieldContextDTO.metric]: undefined,
[TelemetrytypesFieldContextDTO.log]: undefined,

View File

@@ -23,6 +23,7 @@ function TimelineTabs(): JSX.Element {
{
value: TimelineTab.OVERALL_STATUS,
label: 'Overall Status',
testId: 'timeline-tab-overall-status',
},
{
value: TimelineTab.TOP_5_CONTRIBUTORS,
@@ -33,6 +34,7 @@ function TimelineTabs(): JSX.Element {
</div>
),
disabled: true,
testId: 'timeline-tab-top-contributors',
},
];
@@ -57,14 +59,17 @@ function TimelineFilters(): JSX.Element {
{
value: TimelineFilter.ALL,
label: 'All',
testId: 'timeline-filter-all',
},
{
value: TimelineFilter.FIRED,
label: 'Fired',
testId: 'timeline-filter-fired',
},
{
value: TimelineFilter.RESOLVED,
label: 'Resolved',
testId: 'timeline-filter-resolved',
},
];

View File

@@ -1,6 +1,10 @@
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import { GoogleChatInitialConfig } from 'container/CreateAlertChannels/defaults';
import {
GoogleChatInitialConfig,
JiraInitialConfig,
JsmOpsInitialConfig,
} from 'container/CreateAlertChannels/defaults';
import {
googleChatDescriptionDefaultValue,
googleChatTitleDefaultValue,
@@ -526,6 +530,213 @@ describe('Create Alert Channel', () => {
});
});
});
describe('Jira', () => {
const validSite = 'https://acme.atlassian.net';
const fillRequired = async (
user: ReturnType<typeof userEvent.setup>,
site: string,
): Promise<void> => {
await user.type(screen.getByTestId('channel-name-textbox'), 'jira-channel');
await user.type(screen.getByTestId('jira-site-textbox'), site);
await user.type(screen.getByTestId('jira-email-textbox'), 'me@acme.com');
await user.type(screen.getByTestId('jira-api-token-textbox'), 'tok123');
await user.type(screen.getByTestId('jira-project-textbox'), 'KAN');
};
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Jira} />);
});
it('Should check if the selected item in the type dropdown has text "Jira"', () => {
expect(screen.getByText('Jira')).toBeInTheDocument();
});
it('Should check if the Site URL field is displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_jira_site',
testId: 'jira-site-textbox',
});
});
it('Should prefill the issue type with Task', () => {
expect(screen.getByTestId('jira-issue-type-textbox')).toHaveValue('Task');
});
it('Should show the service-account recommendation tip linking to the docs', () => {
expect(screen.getByTestId('jira-service-account-tip')).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'jira_service_account_tip_link' }),
).toHaveAttribute(
'href',
'https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended',
);
});
it('Should display an error when the site is not an atlassian.net URL', async () => {
const user = userEvent.setup({ delay: null });
await fillRequired(user, 'https://example.com');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'jira_site_invalid',
}),
);
});
it('Should send a jira_configs payload with basic auth', 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({ delay: null });
await fillRequired(user, validSite);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'jira-channel',
jira_configs: [
{
site: validSite,
project: 'KAN',
issue_type: 'Task',
summary: JiraInitialConfig.summary,
description: JiraInitialConfig.description,
send_resolved: true,
http_config: {
basic_auth: { username: 'me@acme.com', password: 'tok123' },
},
},
],
});
}, 15000);
it('Should block save when the reopen window is below the 1m minimum', async () => {
const user = userEvent.setup({ delay: null });
await fillRequired(user, validSite);
await user.click(screen.getByText('jira_advanced_section'));
await user.type(screen.getByTestId('jira-reopen-duration-textbox'), '30s');
// the rule surfaces an inline message, not just a red border
await expect(
screen.findByText('jira_reopen_duration_invalid'),
).resolves.toBeInTheDocument();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'jira_reopen_duration_invalid',
}),
);
}, 15000);
});
describe('JSM Ops', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.JsmOps} />);
});
it('Should show "Jira Service Management Ops" as the selected type', () => {
expect(screen.getByText('Jira Service Management Ops')).toBeInTheDocument();
});
it('Should display the API key field properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_jsmops_api_key',
testId: 'jsmops-api-key-textbox',
});
});
it('Should show the tip linking to the JSM Ops docs', () => {
expect(screen.getByTestId('jsmops-tip')).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'jsmops_tip_link' }),
).toHaveAttribute(
'href',
'https://signoz.io/docs/alerts-management/notification-channel/jsm-ops/',
);
});
it('Should block save when the API key is missing', async () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'jsmops-channel',
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'api_key_required',
}),
);
});
it('Should send a jsmops_configs payload with prefilled defaults', 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'),
'jsmops-channel',
);
await user.type(screen.getByTestId('jsmops-api-key-textbox'), 'key-abc');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'jsmops-channel',
jsmops_configs: [
{
api_key: 'key-abc',
send_resolved: true,
message: JsmOpsInitialConfig.message,
description: JsmOpsInitialConfig.description,
priority: JsmOpsInitialConfig.priority,
tags: JsmOpsInitialConfig.tags?.join(','),
},
],
});
});
});
describe('Changing the channel type', () => {
async function selectType(
user: ReturnType<typeof userEvent.setup>,

View File

@@ -58,6 +58,38 @@ describe('EditAlertChannels save', () => {
expect(edit.calls[0].id).toBe('3');
});
it('blocks jira save when the reopen window is below the 1m minimum', async () => {
const edit = mockEditChannel();
const jiraInitialValue = {
type: 'jira',
name: 'jira-channel',
site: 'https://acme.atlassian.net',
username: 'user@acme.io',
password: 'token',
project: 'OPS',
issue_type: 'Task',
send_resolved: true,
reopen_duration: '30s',
};
const { unmount } = render(
<EditAlertChannels channelId="3" initialValue={jiraInitialValue} />,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
expect(edit.calls).toHaveLength(0);
unmount();
render(
<EditAlertChannels
channelId="3"
initialValue={{ ...jiraInitialValue, reopen_duration: '72h' }}
/>,
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
});
it('persists send_resolved toggle in the edit request', async () => {
const edit = mockEditChannel();
render(

View File

@@ -105,6 +105,8 @@ export enum ChannelType {
Opsgenie = 'opsgenie',
MsTeams = 'msteams',
GoogleChat = 'googlechat',
Jira = 'jira',
JsmOps = 'jsmops',
}
// LabelFilterStatement will be used for preparing filter conditions / matchers
@@ -134,3 +136,39 @@ export interface GoogleChatChannel extends Channel {
title?: string;
text?: string;
}
// JiraChannel configures the Jira Cloud alert channel. Auth is basic auth
// (Atlassian account email + API token) carried in username / password.
export interface JiraChannel extends Channel {
// Jira Cloud base URL, e.g. https://acme.atlassian.net
site: string;
project: string;
issue_type: string;
// issue title template
summary?: string;
// issue body template, rendered to rich text server-side
description?: string;
// basic auth: username is the Atlassian account email, password is the API token
username: string;
password: string;
priority?: string;
labels?: string[];
resolve_transition?: string;
reopen_transition?: string;
// duration string, e.g. 72h or 3d
reopen_duration?: string;
}
// JsmOpsChannel configures the Jira Service Management Ops alert channel
// (ex-Opsgenie alert API). Auth is the JSM integration API key.
export interface JsmOpsChannel extends Channel {
api_key: string;
// alert title template
message?: string;
// alert body template (markdown, rendered to HTML server-side)
description?: string;
// priority template, resolves to P1-P5
priority?: string;
// tags, joined to a comma-separated string for the backend
tags?: string[];
}

View File

@@ -2,6 +2,8 @@ import {
ChannelType,
EmailChannel,
GoogleChatChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
@@ -47,6 +49,23 @@ export const GoogleChatInitialConfig: Partial<GoogleChatChannel> = {
{{ end }}`,
};
// mirrors DefaultJiraSummaryTemplate / DefaultJiraDescriptionTemplate in
// pkg/types/alertmanagertypes/jira.go, which the backend applies when the
// summary / description are left empty. The description is markdown here and is
// wrapped in the ADF status panel + deep-links server-side.
export const JiraInitialConfig: Partial<JiraChannel> = {
issue_type: 'Task',
summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ 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 PagerInitialConfig: Partial<PagerChannel> = {
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
@@ -98,6 +117,33 @@ export const OpsgenieInitialConfig: Partial<OpsgenieChannel> = {
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
};
// mirrors DefaultJSMOpsMessageTemplate / DefaultJSMOpsDescriptionTemplate in
// pkg/types/alertmanagertypes/jsmops.go, applied by the backend when message /
// description are left empty. send_resolved is seeded on so JSM alerts close on
// resolve (the backend cannot default it, see jsmops.go). priority mirrors the
// Opsgenie template mapping severity to P1-P5.
export const JsmOpsInitialConfig: Partial<JsmOpsChannel> = {
send_resolved: true,
message: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ 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 }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }})
{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }})
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
{{ end }}{{ end }}`,
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
tags: ['signoz-alert'],
};
export const EmailInitialConfig: Partial<EmailChannel> = {
send_resolved: true,
html: `<!--
@@ -505,12 +551,16 @@ export const ChannelInitialConfig: Record<
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
GoogleChatChannel &
JiraChannel &
JsmOpsChannel
>
> = {
[ChannelType.Slack]: SlackInitialConfig,
[ChannelType.MsTeams]: SlackInitialConfig,
[ChannelType.GoogleChat]: GoogleChatInitialConfig,
[ChannelType.Jira]: JiraInitialConfig,
[ChannelType.JsmOps]: JsmOpsInitialConfig,
[ChannelType.Pagerduty]: PagerInitialConfig,
[ChannelType.Opsgenie]: OpsgenieInitialConfig,
[ChannelType.Email]: EmailInitialConfig,

View File

@@ -32,6 +32,8 @@ import {
ChannelType,
EmailChannel,
GoogleChatChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
@@ -43,7 +45,11 @@ import { ChannelInitialConfig } from './defaults';
import {
isChannelType,
isValidGoogleChatWebhookURL,
isValidJiraReopenDuration,
isValidJiraSiteURL,
prepareGoogleChatRequest,
prepareJiraRequest,
prepareJsmOpsRequest,
} from './utils';
import './CreateAlertChannels.styles.scss';
@@ -69,7 +75,9 @@ function CreateAlertChannels({
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
GoogleChatChannel &
JiraChannel &
JsmOpsChannel
>
>(() => ({
send_resolved: true,
@@ -434,6 +442,114 @@ function CreateAlertChannels({
showErrorModal,
]);
const validateJiraConfig = useCallback((): boolean => {
if (
!selectedConfig.site ||
!selectedConfig.username ||
!selectedConfig.password ||
!selectedConfig.project ||
!selectedConfig.issue_type
) {
notifications.error({
message: 'Error',
description: t('jira_required_fields'),
});
return false;
}
if (!isValidJiraSiteURL(selectedConfig.site)) {
notifications.error({
message: 'Error',
description: t('jira_site_invalid'),
});
return false;
}
if (
selectedConfig.reopen_duration &&
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
) {
notifications.error({
message: 'Error',
description: t('jira_reopen_duration_invalid'),
});
return false;
}
return true;
}, [selectedConfig, notifications, t]);
const onJiraHandler = useCallback(async () => {
if (!validateJiraConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareJiraRequest(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);
}
}, [
validateJiraConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const validateJsmOpsConfig = useCallback((): boolean => {
if (!selectedConfig.api_key) {
notifications.error({
message: 'Error',
description: t('api_key_required'),
});
return false;
}
return true;
}, [selectedConfig.api_key, notifications, t]);
const onJsmOpsHandler = useCallback(async () => {
if (!validateJsmOpsConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareJsmOpsRequest(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);
}
}, [
validateJsmOpsConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
if (!selectedConfig.name) {
@@ -452,6 +568,8 @@ function CreateAlertChannels({
[ChannelType.MsTeams]: onMsTeamsHandler,
[ChannelType.Email]: onEmailHandler,
[ChannelType.GoogleChat]: onGoogleChatHandler,
[ChannelType.Jira]: onJiraHandler,
[ChannelType.JsmOps]: onJsmOpsHandler,
};
if (isChannelType(value)) {
@@ -484,6 +602,8 @@ function CreateAlertChannels({
onMsTeamsHandler,
onEmailHandler,
onGoogleChatHandler,
onJiraHandler,
onJsmOpsHandler,
notifications,
t,
],
@@ -528,6 +648,20 @@ function CreateAlertChannels({
}
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
case ChannelType.Jira:
if (!validateJiraConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareJiraRequest(selectedConfig) });
break;
case ChannelType.JsmOps:
if (!validateJsmOpsConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
break;
default:
notifications.error({
message: 'Error',
@@ -576,6 +710,8 @@ function CreateAlertChannels({
prepareMsTeamsRequest,
prepareEmailRequest,
validateGoogleChatConfig,
validateJiraConfig,
validateJsmOpsConfig,
testChannel,
notifications,
],

View File

@@ -1,9 +1,17 @@
import {
AlertmanagertypesJiraReceiverConfigDTO,
AlertmanagertypesJSMOpsReceiverConfigDTO,
AlertmanagertypesPostableChannelDTO,
ConfigSecretURLDTO,
ModelDurationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ChannelType, GoogleChatChannel } from './config';
import {
ChannelType,
GoogleChatChannel,
JiraChannel,
JsmOpsChannel,
} from './config';
export const isChannelType = (type: string): type is ChannelType =>
Object.values(ChannelType).includes(type as ChannelType);
@@ -37,3 +45,126 @@ export const prepareGoogleChatRequest = (
},
],
});
const JIRA_CLOUD_HOST_SUFFIX = '.atlassian.net';
// the backend enforces the same rule, this is only for a nicer error experience
export const isValidJiraSiteURL = (url: string): boolean => {
try {
const { protocol, hostname } = new URL(url);
return (
protocol === 'https:' &&
hostname.toLowerCase().endsWith(JIRA_CLOUD_HOST_SUFFIX)
);
} catch {
return false;
}
};
// mirrors go's prometheus model.Duration units
const JIRA_DURATION_UNIT_MS: Record<string, number> = {
ms: 1,
s: 1_000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
y: 31_536_000_000,
};
const JIRA_DURATION_RE = /^(\d+(ms|s|m|h|d|w|y))+$/;
const JIRA_DURATION_TOKEN_RE = /(\d+)(ms|s|m|h|d|w|y)/g;
const JIRA_MIN_REOPEN_MS = 60_000;
// backend requires the same format and a >= 1m minimum, this is only for a
// nicer error experience. Empty and "0" defer to the backend default.
export const isValidJiraReopenDuration = (value: string): boolean => {
if (!value || value === '0') {
return true;
}
if (!JIRA_DURATION_RE.test(value)) {
return false;
}
let totalMs = 0;
for (const [, amount, unit] of value.matchAll(JIRA_DURATION_TOKEN_RE)) {
totalMs += Number(amount) * JIRA_DURATION_UNIT_MS[unit];
}
return totalMs >= JIRA_MIN_REOPEN_MS;
};
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareJiraRequest = (
config: Partial<JiraChannel>,
): AlertmanagertypesPostableChannelDTO => {
const jira: AlertmanagertypesJiraReceiverConfigDTO = {
site: config.site || '',
project: config.project || '',
issue_type: config.issue_type || '',
send_resolved: config.send_resolved || false,
http_config: {
basic_auth: {
username: config.username || '',
password: config.password || '',
},
},
};
if (config.summary) {
jira.summary = config.summary;
}
if (config.description) {
jira.description = config.description;
}
if (config.priority) {
jira.priority = config.priority;
}
if (config.labels?.length) {
jira.labels = config.labels;
}
if (config.resolve_transition) {
jira.resolve_transition = config.resolve_transition;
}
if (config.reopen_transition) {
jira.reopen_transition = config.reopen_transition;
}
if (config.reopen_duration) {
// the generated type models go's model.Duration as a number, the api takes a
// duration string like "72h"
jira.reopen_duration = config.reopen_duration as unknown as ModelDurationDTO;
}
return {
name: config.name || '',
jira_configs: [jira],
};
};
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareJsmOpsRequest = (
config: Partial<JsmOpsChannel>,
): AlertmanagertypesPostableChannelDTO => {
const jsmops: AlertmanagertypesJSMOpsReceiverConfigDTO = {
api_key: config.api_key || '',
send_resolved: config.send_resolved || false,
};
if (config.message) {
jsmops.message = config.message;
}
if (config.description) {
jsmops.description = config.description;
}
if (config.priority) {
jsmops.priority = config.priority;
}
if (config.tags?.length) {
// the backend takes a comma-separated string and splits it back
jsmops.tags = config.tags.join(',');
}
return {
name: config.name || '',
jsmops_configs: [jsmops],
};
};

View File

@@ -34,6 +34,7 @@ function AdvancedOptions(): JSX.Element {
})
}
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
testId="send-notification-if-data-is-missing-input"
/>
<Typography.Text>Minutes</Typography.Text>
</div>
@@ -66,6 +67,7 @@ function AdvancedOptions(): JSX.Element {
})
}
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
testId="enforce-minimum-datapoints-input"
/>
<Typography.Text>Datapoints</Typography.Text>
</div>

View File

@@ -66,6 +66,7 @@ function EvaluationWindowPopover({
tabIndex={0}
data-value={option.value}
data-section-id={sectionId}
data-testid={`${sectionId}-option-${option.value}`}
onClick={(): void => onChange(option.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {

View File

@@ -186,6 +186,7 @@ function Footer(): JSX.Element {
color="primary"
onClick={handleSaveAlert}
disabled={disableButtons || Boolean(alertValidationMessage)}
testId="save-alert-rule-button"
>
{isCreatingAlertRule || isUpdatingAlertRule ? (
<Loader data-testid="save-alert-rule-loader-icon" size={14} />
@@ -218,6 +219,7 @@ function Footer(): JSX.Element {
color="secondary"
onClick={handleTestNotification}
disabled={disableButtons || Boolean(alertValidationMessage)}
testId="test-notification-button"
>
{isTestingAlertRule ? (
<Loader data-testid="test-notification-loader-icon" size={14} />
@@ -249,6 +251,7 @@ function Footer(): JSX.Element {
color="secondary"
onClick={handleDiscard}
disabled={disableButtons}
testId="discard-alert-rule-button"
>
<X size={14} /> Discard
</Button>

View File

@@ -25,6 +25,8 @@ import {
ChannelType,
EmailChannel,
GoogleChatChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
@@ -34,7 +36,11 @@ import {
} from 'container/CreateAlertChannels/config';
import {
isValidGoogleChatWebhookURL,
isValidJiraReopenDuration,
isValidJiraSiteURL,
prepareGoogleChatRequest,
prepareJiraRequest,
prepareJsmOpsRequest,
} from 'container/CreateAlertChannels/utils';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
@@ -58,7 +64,9 @@ function EditAlertChannels({
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
GoogleChatChannel &
JiraChannel &
JsmOpsChannel
>
>({
...initialValue,
@@ -452,6 +460,124 @@ function EditAlertChannels({
t,
]);
const validateJiraConfig = useCallback((): string => {
if (
!selectedConfig.site ||
!selectedConfig.username ||
!selectedConfig.password ||
!selectedConfig.project ||
!selectedConfig.issue_type
) {
return t('jira_required_fields');
}
if (!isValidJiraSiteURL(selectedConfig.site)) {
return t('jira_site_invalid');
}
if (
selectedConfig.reopen_duration &&
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
) {
return t('jira_reopen_duration_invalid');
}
return '';
}, [selectedConfig, t]);
const onJiraEditHandler = useCallback(async () => {
const validationError = validateJiraConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareJiraRequest(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);
}
}, [
validateJiraConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const validateJsmOpsConfig = useCallback((): string => {
if (!selectedConfig.api_key) {
return t('api_key_required');
}
return '';
}, [selectedConfig, t]);
const onJsmOpsEditHandler = useCallback(async () => {
const validationError = validateJsmOpsConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareJsmOpsRequest(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);
}
}, [
validateJsmOpsConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
let result;
@@ -469,6 +595,10 @@ function EditAlertChannels({
result = await onEmailEditHandler();
} else if (value === ChannelType.GoogleChat) {
result = await onGoogleChatEditHandler();
} else if (value === ChannelType.Jira) {
result = await onJiraEditHandler();
} else if (value === ChannelType.JsmOps) {
result = await onJsmOpsEditHandler();
}
logEvent('Alert Channel: Save channel', {
type: value,
@@ -488,10 +618,13 @@ function EditAlertChannels({
onOpsgenieEditHandler,
onEmailEditHandler,
onGoogleChatEditHandler,
onJiraEditHandler,
onJsmOpsEditHandler,
],
);
const performChannelTest = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
async (channelType: ChannelType) => {
setTestingState(true);
try {
@@ -542,6 +675,32 @@ function EditAlertChannels({
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
}
case ChannelType.Jira: {
const validationError = validateJiraConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareJiraRequest(selectedConfig) });
break;
}
case ChannelType.JsmOps: {
const validationError = validateJsmOpsConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
break;
}
default:
notifications.error({
message: 'Error',
@@ -579,6 +738,8 @@ function EditAlertChannels({
t,
notifyError,
validateGoogleChatConfig,
validateJiraConfig,
validateJsmOpsConfig,
testChannel,
prepareWebhookRequest,
preparePagerRequest,

View File

@@ -54,7 +54,7 @@ import {
} from 'container/OptionsMenu/constants';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
import { useSaveView } from 'hooks/saveViews/useSaveView';
@@ -287,8 +287,7 @@ function ExplorerOptions({
const compositeQuery = mapCompositeQueryFromQuery(currentQuery, panelType);
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || '';
const { viewName, viewKey } = useGetSavedViewParams();
const extraData = viewsData?.data?.data?.find(
(view) => view.id === viewKey,

View File

@@ -0,0 +1,242 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapse, Form, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { JiraChannel } from '../../CreateAlertChannels/config';
import {
isValidJiraReopenDuration,
isValidJiraSiteURL,
} from '../../CreateAlertChannels/utils';
function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
const { t } = useTranslation('channels');
const update = (patch: Partial<JiraChannel>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const advanced = (
<>
<Form.Item
name="priority"
label={t('field_jira_priority')}
help={t('help_jira_priority')}
>
<Input
placeholder={t('placeholder_jira_priority')}
onChange={(event): void => update({ priority: event.target.value })}
data-testid="jira-priority-textbox"
/>
</Form.Item>
<Form.Item
name="labels"
label={t('field_jira_labels')}
help={t('help_jira_labels')}
>
<Select
mode="tags"
open={false}
placeholder={t('placeholder_jira_labels')}
onChange={(value): void => update({ labels: value as string[] })}
data-testid="jira-labels-select"
/>
</Form.Item>
<Form.Item
name="resolve_transition"
label={t('field_jira_resolve_transition')}
help={t('help_jira_resolve_transition')}
>
<Input
placeholder={t('placeholder_jira_resolve_transition')}
onChange={(event): void =>
update({ resolve_transition: event.target.value })
}
data-testid="jira-resolve-transition-textbox"
/>
</Form.Item>
<Form.Item
name="reopen_transition"
label={t('field_jira_reopen_transition')}
help={t('help_jira_reopen_transition')}
>
<Input
placeholder={t('placeholder_jira_reopen_transition')}
onChange={(event): void =>
update({ reopen_transition: event.target.value })
}
data-testid="jira-reopen-transition-textbox"
/>
</Form.Item>
<Form.Item
name="reopen_duration"
label={t('field_jira_reopen_duration')}
extra={t('help_jira_reopen_duration')}
rules={[
{
validator: (_, value: string): Promise<void> =>
isValidJiraReopenDuration(value)
? Promise.resolve()
: Promise.reject(new Error(t('jira_reopen_duration_invalid'))),
},
]}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_jira_reopen_duration')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
placeholder={t('placeholder_jira_reopen_duration')}
onChange={(event): void => update({ reopen_duration: event.target.value })}
data-testid="jira-reopen-duration-textbox"
/>
</Form.Item>
</>
);
return (
<>
<Typography.Text
color="muted"
size="sm"
testId="jira-service-account-tip"
style={{ display: 'block', marginBottom: 16 }}
>
{t('jira_service_account_tip')}{' '}
<Typography.Link
href="https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended"
target="_blank"
rel="noopener noreferrer"
>
{t('jira_service_account_tip_link')}
</Typography.Link>
</Typography.Text>
<Form.Item
name="site"
label={t('field_jira_site')}
required
rules={[
{
validator: (_, value: string): Promise<void> =>
!value || isValidJiraSiteURL(value)
? Promise.resolve()
: Promise.reject(new Error(t('jira_site_invalid'))),
},
]}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_jira_site')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
placeholder="https://your-domain.atlassian.net"
onChange={(event): void => update({ site: event.target.value })}
data-testid="jira-site-textbox"
/>
</Form.Item>
<Form.Item
name="username"
label={t('field_jira_email')}
help={t('help_jira_email')}
required
>
<Input
onChange={(event): void => update({ username: event.target.value })}
data-testid="jira-email-textbox"
/>
</Form.Item>
<Form.Item
name="password"
label={t('field_jira_api_token')}
help={t('help_jira_api_token')}
required
>
<Input
type="password"
onChange={(event): void => update({ password: event.target.value })}
data-testid="jira-api-token-textbox"
/>
</Form.Item>
<Form.Item name="project" label={t('field_jira_project')} required>
<Input
placeholder="e.g. OPS"
onChange={(event): void => update({ project: event.target.value })}
data-testid="jira-project-textbox"
/>
</Form.Item>
<Form.Item
name="issue_type"
label={t('field_jira_issue_type')}
help={t('help_jira_issue_type')}
required
>
<Input
onChange={(event): void => update({ issue_type: event.target.value })}
data-testid="jira-issue-type-textbox"
/>
</Form.Item>
<Form.Item
name="summary"
label={t('field_jira_summary')}
help={t('help_jira_summary')}
>
<Input.TextArea
rows={2}
onChange={(event): void => update({ summary: event.target.value })}
data-testid="jira-summary-textarea"
/>
</Form.Item>
<Form.Item
name="description"
label={t('field_jira_description')}
help={t('help_jira_description')}
>
<Input.TextArea
rows={6}
onChange={(event): void => update({ description: event.target.value })}
data-testid="jira-description-textarea"
/>
</Form.Item>
<Collapse
ghost
items={[
{
key: 'advanced',
label: t('jira_advanced_section'),
children: advanced,
},
]}
/>
</>
);
}
interface JiraProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<JiraChannel>>>;
}
export default JiraSettings;

View File

@@ -0,0 +1,117 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapse, Form, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { JsmOpsChannel } from '../../CreateAlertChannels/config';
function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
const { t } = useTranslation('channels');
const update = (patch: Partial<JsmOpsChannel>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const advanced = (
<>
<Form.Item
name="priority"
label={t('field_jsmops_priority')}
help={t('help_jsmops_priority')}
>
<Input.TextArea
rows={2}
onChange={(event): void => update({ priority: event.target.value })}
data-testid="jsmops-priority-textarea"
/>
</Form.Item>
<Form.Item
name="tags"
label={t('field_jsmops_tags')}
help={t('help_jsmops_tags')}
>
<Select
mode="tags"
open={false}
placeholder={t('placeholder_jsmops_tags')}
onChange={(value): void => update({ tags: value as string[] })}
data-testid="jsmops-tags-select"
/>
</Form.Item>
</>
);
return (
<>
<Typography.Text
color="muted"
size="sm"
testId="jsmops-tip"
style={{ display: 'block', marginBottom: 16 }}
>
{t('jsmops_tip')}{' '}
<Typography.Link
href="https://signoz.io/docs/alerts-management/notification-channel/jsm-ops/"
target="_blank"
rel="noopener noreferrer"
>
{t('jsmops_tip_link')}
</Typography.Link>
</Typography.Text>
<Form.Item
name="api_key"
label={t('field_jsmops_api_key')}
help={t('help_jsmops_api_key')}
required
>
<Input
type="password"
onChange={(event): void => update({ api_key: event.target.value })}
data-testid="jsmops-api-key-textbox"
/>
</Form.Item>
<Form.Item
name="message"
label={t('field_jsmops_message')}
help={t('help_jsmops_message')}
>
<Input.TextArea
rows={2}
onChange={(event): void => update({ message: event.target.value })}
data-testid="jsmops-message-textarea"
/>
</Form.Item>
<Form.Item
name="description"
label={t('field_jsmops_description')}
help={t('help_jsmops_description')}
>
<Input.TextArea
rows={6}
onChange={(event): void => update({ description: event.target.value })}
data-testid="jsmops-description-textarea"
/>
</Form.Item>
<Collapse
ghost
items={[
{
key: 'advanced',
label: t('jsmops_advanced_section'),
children: advanced,
},
]}
/>
</>
);
}
interface JsmOpsProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<JsmOpsChannel>>>;
}
export default JsmOpsSettings;

View File

@@ -10,6 +10,8 @@ import {
ChannelType,
EmailChannel,
GoogleChatChannel,
JiraChannel,
JsmOpsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
@@ -19,6 +21,8 @@ import history from 'lib/history';
import EmailSettings from './Settings/Email';
import GoogleChatSettings from './Settings/GoogleChat';
import JiraSettings from './Settings/Jira';
import JsmOpsSettings from './Settings/JsmOps';
import MsTeamsSettings from './Settings/MsTeams';
import OpsgenieSettings from './Settings/Opsgenie';
import PagerSettings from './Settings/Pager';
@@ -53,6 +57,10 @@ function FormAlertChannels({
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.GoogleChat:
return <GoogleChatSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Jira:
return <JiraSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.JsmOps:
return <JsmOpsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Opsgenie:
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Email:
@@ -141,6 +149,14 @@ function FormAlertChannels({
>
Google Chat
</Select.Option>
<Select.Option value="jira" key="jira" data-testid="select-option">
Jira
</Select.Option>
<Select.Option value="jsmops" key="jsmops" data-testid="select-option">
Jira Service Management Ops
</Select.Option>
</Select>
</Form.Item>
@@ -189,7 +205,9 @@ interface FormAlertChannelsProps {
PagerChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
GoogleChatChannel &
JiraChannel &
JsmOpsChannel
>
>
>;

View File

@@ -119,6 +119,7 @@ function BasicInfo({
<SeveritySelect
getPopupContainer={popupContainer}
defaultValue="critical"
data-testid="alert-severity-select"
onChange={(value: unknown | string): void => {
const s = (value as string) || 'critical';
setAlertDef({
@@ -147,6 +148,7 @@ function BasicInfo({
]}
>
<InputSmall
data-testid="alert-name-input-v1"
onChange={(e): void => {
setAlertDef({
...alertDef,
@@ -161,6 +163,7 @@ function BasicInfo({
name={['annotations', 'description']}
>
<TextareaMedium
data-testid="alert-description-input"
onChange={(e): void => {
setAlertDef({
...alertDef,

View File

@@ -105,7 +105,7 @@ function QuerySection({
{
label: (
<Tooltip title="Query Builder">
<Button className="nav-btns">
<Button className="nav-btns" data-testid="query-builder-tab">
<Atom size={14} />
<Typography.Text>Query Builder</Typography.Text>
</Button>
@@ -122,7 +122,11 @@ function QuerySection({
: 'ClickHouse'
}
>
<Button className="nav-btns" disabled={isAnomalyDetection}>
<Button
className="nav-btns"
disabled={isAnomalyDetection}
data-testid="clickhouse-tab"
>
<Terminal size={14} />
<Typography.Text>ClickHouse Query</Typography.Text>
</Button>
@@ -162,7 +166,11 @@ function QuerySection({
: 'ClickHouse'
}
>
<Button className="nav-btns" disabled={isAnomalyDetection}>
<Button
className="nav-btns"
disabled={isAnomalyDetection}
data-testid="clickhouse-tab"
>
<Terminal size={14} />
<Typography.Text>ClickHouse Query</Typography.Text>
</Button>
@@ -180,7 +188,11 @@ function QuerySection({
: 'PromQL'
}
>
<Button className="nav-btns" disabled={isAnomalyDetection}>
<Button
className="nav-btns"
disabled={isAnomalyDetection}
data-testid="promql-tab"
>
<PromQLIcon
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
/>

View File

@@ -80,6 +80,7 @@ function RuleOptions({
defaultValue={defaultCompareOp}
value={alertDef.condition?.op}
style={{ minWidth: '120px' }}
data-testid="alert-threshold-op-select"
onChange={(value: string | unknown): void => {
const newOp = (value as string) || '';
@@ -116,6 +117,7 @@ function RuleOptions({
defaultValue={defaultMatchType}
style={{ minWidth: '130px' }}
value={alertDef.condition?.matchType}
data-testid="alert-threshold-match-type-select-v1"
onChange={(value: string | unknown): void => handleMatchOptChange(value)}
>
<Select.Option value="1">{t('option_atleastonce')}</Select.Option>
@@ -177,6 +179,7 @@ function RuleOptions({
style={{ minWidth: '120px' }}
value={alertDef.evalWindow}
onChange={onChangeEvalWindow}
data-testid="alert-eval-window-select"
>
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
@@ -194,6 +197,7 @@ function RuleOptions({
style={{ minWidth: '120px' }}
value={alertDef.evalWindow}
onChange={onChangeEvalWindow}
data-testid="alert-eval-window-select"
>
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
@@ -395,6 +399,7 @@ function RuleOptions({
value={alertDef?.condition?.target}
onChange={onChange}
type="number"
data-testid="alert-threshold-target-input"
onWheel={(e): void => e.currentTarget.blur()}
/>
</Form.Item>

View File

@@ -844,8 +844,6 @@ function FormAlertRules({
return (
<>
{Element}
<div
id="top"
className={`form-alert-rules-container ${
@@ -968,6 +966,7 @@ function FormAlertRules({
!isChannelConfigurationValid ||
queryStatus === 'error'
}
data-testid="alert-save-button"
>
{isNewRule ? t('button_createrule') : t('button_savechanges')}
</ActionButton>
@@ -981,6 +980,7 @@ function FormAlertRules({
}
type="default"
onClick={onTestRuleHandler}
data-testid="alert-test-button"
>
{' '}
{t('button_testrule')}
@@ -989,6 +989,7 @@ function FormAlertRules({
disabled={loading || false}
type="default"
onClick={onCancelHandler}
data-testid="alert-cancel-button"
>
{isNewRule && t('button_cancelchanges')}
{ruleId && !isEmpty(ruleId) && t('button_discard')}
@@ -998,6 +999,7 @@ function FormAlertRules({
</div>
<ConfirmDialog
testId="alert-save-confirm-dialog"
open={isConfirmSaveOpen}
onOpenChange={setIsConfirmSaveOpen}
title={t('confirm_save_title')}

View File

@@ -174,6 +174,7 @@ function LabelSelect({
<div style={{ display: 'flex', width: '100%' }}>
<Input
data-testid="alert-labels-input-v1"
placeholder={renderPlaceholder()}
onChange={handleLabelChange}
onKeyUp={(e): void => {

View File

@@ -53,17 +53,24 @@ export const getUpdatedStepInterval = (evalWindow?: string): number => {
};
export const getSelectedQueryOptions = (
queries: Array<
| IBuilderQuery
| IBuilderTraceOperator
| IBuilderFormula
| IClickHouseQuery
| IPromQLQuery
>,
): SelectProps['options'] =>
queries
queries:
| Array<
| IBuilderQuery
| IBuilderTraceOperator
| IBuilderFormula
| IClickHouseQuery
| IPromQLQuery
>
| undefined
| null,
): SelectProps['options'] => {
if (!queries) {
return [];
}
return queries
.filter((query) => !query.disabled)
.map((query) => ({
label: 'queryName' in query ? query.queryName : query.name,
value: 'queryName' in query ? query.queryName : query.name,
}));
};

View File

@@ -4,8 +4,9 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './ColumnHeader.module.scss';
import cx from 'classnames';
import { MouseEventHandler } from 'react';
import { DOCS_BASE_URL } from 'constants/app';
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
const DOCS_ROOT = `${DOCS_BASE_URL}/docs`;
interface ColumnHeaderProps {
children?: React.ReactNode;
@@ -43,7 +44,7 @@ function ColumnHeader({
<div onClick={stopPropagationHandler}>
{tooltipTitle}{' '}
<a
href={`${DOCS_BASE_URL}${docPath}`}
href={`${DOCS_ROOT}${docPath}`}
target="_blank"
rel="noopener"
onClick={stopPropagationHandler}

View File

@@ -2,7 +2,6 @@
display: flex;
align-items: center;
gap: var(--spacing-5);
padding-left: 4px;
}
.infoIcon {

View File

@@ -2,8 +2,9 @@ import { Group, Info } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './EntityGroupHeader.module.scss';
import { DOCS_BASE_URL } from 'constants/app';
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
const DOCS_ROOT = `${DOCS_BASE_URL}/docs`;
interface EntityGroupHeaderProps {
title: string;
@@ -28,7 +29,7 @@ function EntityGroupHeader({
<>
{tooltipTitle}{' '}
<a
href={`${DOCS_BASE_URL}${docPath}`}
href={`${DOCS_ROOT}${docPath}`}
target="_blank"
rel="noopener"
onClick={(e): void => e.stopPropagation()}

View File

@@ -1,12 +1,10 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { useCopyToClipboard } from 'react-use';
import { Copy, X } from '@signozhq/icons';
import { X } from '@signozhq/icons';
import { Divider } from '@signozhq/ui/divider';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
@@ -20,6 +18,7 @@ import {
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants';
import { useInfraMonitoringSelectedItemParams } from '../hooks';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import LoadingContainer from '../LoadingContainer';
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
@@ -95,12 +94,14 @@ export default function K8sBaseDetails<T>({
selectedItem,
selectedItemParams.clusterName,
selectedItemParams.namespaceName,
selectedItemParams.containerName,
),
[
queryKeyPrefix,
selectedItem,
selectedItemParams.clusterName,
selectedItemParams.namespaceName,
selectedItemParams.containerName,
selectedTime,
getAutoRefreshQueryKey,
],
@@ -170,14 +171,9 @@ export default function K8sBaseDetails<T>({
[handleClose],
);
const [, copyToClipboard] = useCopyToClipboard();
const handleCopyId = useCallback((): void => {
if (selectedItem) {
copyToClipboard(selectedItem);
toast.success('ID copied to clipboard', { position: 'bottom-left' });
}
}, [copyToClipboard, selectedItem]);
toast.success('ID copied to clipboard', { position: 'bottom-left' });
}, []);
const entityName = entity ? getEntityName(entity) : '';
@@ -211,17 +207,13 @@ export default function K8sBaseDetails<T>({
(isEntityLoading && 'Loading...') ||
'-'}
</Typography.Text>
<TooltipSimple title="Copy ID">
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleCopyId}
data-testid="copy-id-button"
>
<Copy size={14} />
</Button>
</TooltipSimple>
<CopyButton
value={selectedItem ?? ''}
ariaLabel="Copy ID"
className={styles.copyIdButton}
testId="copy-id-button"
onCopy={handleCopyId}
/>
</>
) as unknown as string;

View File

@@ -9,7 +9,6 @@ import {
import { Button } from '@signozhq/ui/button';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
@@ -42,6 +41,7 @@ import {
} from '../hooks';
import { EntityCountsSection } from './components/EntityCountsSection/EntityCountsSection';
import { EntityMetadataItem } from './components/EntityMetadataItem/EntityMetadataItem';
import { K8sBaseDetailsContentProps } from './types';
import { getDrawerDurationMs } from './useDrawerLifecycleStore';
@@ -239,41 +239,18 @@ export default function K8sBaseDetailsContent<T>({
<>
<div className={styles.entityDetailsEntity}>
<div className={styles.entityDetailsGrid}>
<div className={styles.labelsRow}>
{metadataConfig.map((config) => (
<Typography.Text
{metadataConfig.map((config) => {
const value = config.getValue(entity);
return (
<EntityMetadataItem
key={config.label}
color="muted"
size="small"
weight="medium"
className={styles.entityDetailsMetadataLabel}
>
{config.label}
</Typography.Text>
))}
</div>
<div className={styles.valuesRow}>
{metadataConfig.map((config) => {
const value = config.getValue(entity);
if (config.render) {
return config.render(value, entity);
}
const displayValue = String(value);
return (
<Typography.Text
key={config.label}
size="small"
weight="medium"
className={styles.entityDetailsMetadataValue}
>
{displayValue}
</Typography.Text>
);
})}
</div>
label={config.label}
value={String(value)}
renderedValue={config.render?.(value, entity)}
/>
);
})}
</div>
{countsConfig &&

View File

@@ -296,6 +296,7 @@ export function K8sBaseList<
params.selectedItem,
params.clusterName,
params.namespaceName,
params.containerName,
);
queryClient.setQueryData(detailQueryKey, { data: record });
}
@@ -348,6 +349,12 @@ export function K8sBaseList<
params.namespaceName,
);
}
if (params.containerName) {
url.searchParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME,
params.containerName,
);
}
} else {
url.searchParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,

View File

@@ -19,6 +19,12 @@
& [data-hide-expanded='true'] {
display: none;
}
// The icon slot is rendered even when the icon inside it is hidden, and the
// header's flex gap then indents the title past the values below it.
& [data-slot='icon']:has([data-hide-expanded='true']) {
display: none;
}
}
.expandedTableFooter {

View File

@@ -214,6 +214,7 @@ export function K8sExpandedRow<
params.selectedItem,
params.clusterName,
params.namespaceName,
params.containerName,
);
queryClient.setQueryData(detailQueryKey, { data: row });
}

View File

@@ -0,0 +1,44 @@
.metadataItem {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
min-width: 0;
}
.valueRow {
display: flex;
align-items: center;
gap: var(--spacing-1);
min-width: 0;
}
.label {
letter-spacing: 0.44px;
text-transform: uppercase;
}
// Single-line ellipsis rather than Typography's `truncate`, which line-clamps:
// clamping still wraps the text, so a value breaking at a hyphen ends its line
// early and leaves a gap between the ellipsis and the copy button.
//
// This has to be a block: overflow and text-overflow do not apply to inline
// boxes, and Typography.Text renders an inline span.
.value {
display: block;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.valueText {
font-family: var(--periscope-font-family-mono);
}
// Sized to the icon rather than the default 2rem tap target, so it sits flush
// against the value instead of floating in its own block of padding.
.copyButton {
--button-padding: 2px;
--button-height: auto;
--button-width: auto;
}

View File

@@ -0,0 +1,63 @@
import { ReactNode, useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import styles from './EntityMetadataItem.module.scss';
export interface EntityMetadataItemProps {
label: string;
value: string;
/** An entity-supplied renderer, which opts out of clamping, tooltip and copy. */
renderedValue?: ReactNode;
}
export function EntityMetadataItem({
label,
value,
renderedValue,
}: EntityMetadataItemProps): JSX.Element {
const handleCopy = useCallback((): void => {
toast.success(`${label} copied to clipboard`, { position: 'bottom-left' });
}, [label]);
return (
<div className={styles.metadataItem}>
<Typography.Text
color="muted"
size="small"
weight="medium"
className={styles.label}
>
{label}
</Typography.Text>
{renderedValue ?? (
<div className={styles.valueRow}>
<TooltipSimple title={value} arrow side="bottom" align="start">
<span className={styles.value}>
<Typography.Text
size="small"
weight="medium"
className={styles.valueText}
>
{value}
</Typography.Text>
</span>
</TooltipSimple>
{!!value && (
<CopyButton
value={value}
size={10}
ariaLabel={`Copy ${label}`}
className={styles.copyButton}
testId={`copy-metadata-${label.toLowerCase().replace(/\s+/g, '-')}`}
onCopy={handleCopy}
/>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { EntityMetadataItem } from '../EntityMetadataItem';
const mockCopyToClipboard = jest.fn();
jest.mock('react-use', () => ({
__esModule: true,
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
}));
const mockToastSuccess = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
},
}));
describe('EntityMetadataItem', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders the label and its value', () => {
render(<EntityMetadataItem label="Cluster Name" value="prod-cluster" />);
expect(screen.getByText('Cluster Name')).toBeInTheDocument();
expect(screen.getByText('prod-cluster')).toBeInTheDocument();
});
it('copies the full value and confirms which field was copied', async () => {
render(
<EntityMetadataItem
label="Image:Tag"
value="ghcr.io/open-telemetry/demo:1.12.0-loadgenerator"
/>,
);
await userEvent.click(screen.getByTestId('copy-metadata-image:tag'));
await waitFor(() => {
expect(mockCopyToClipboard).toHaveBeenCalledWith(
'ghcr.io/open-telemetry/demo:1.12.0-loadgenerator',
);
});
expect(mockToastSuccess).toHaveBeenCalledWith(
'Image:Tag copied to clipboard',
expect.anything(),
);
});
it('offers no copy control when the value is empty', () => {
render(<EntityMetadataItem label="Node" value="" />);
expect(screen.queryByTestId('copy-metadata-node')).not.toBeInTheDocument();
});
it('exposes the full value on hover', async () => {
render(
<EntityMetadataItem
label="Node"
value="gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t"
/>,
);
await userEvent.hover(
screen.getByText('gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t'),
);
await waitFor(() => {
expect(
screen.getAllByText('gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t').length,
).toBeGreaterThan(1);
});
});
it('never presents the value as clickable', () => {
render(<EntityMetadataItem label="Node" value="a-very-long-node-name" />);
const valueEl = screen.getByText('a-very-long-node-name');
expect(valueEl).not.toHaveAttribute('data-interactive');
expect(valueEl).not.toHaveAttribute('data-truncate');
});
it('triggers the tooltip from the wrapper, never from the text itself', () => {
render(<EntityMetadataItem label="Node" value="a-very-long-node-name" />);
// Radix merges its handlers onto the trigger, and Typography styles
// itself interactive off any merged onClick — so the trigger has to stay
// off the text.
const textEl = screen.getByText('a-very-long-node-name');
expect(textEl).not.toHaveAttribute('data-slot', 'tooltip-trigger');
expect(textEl.parentElement).toHaveAttribute('data-slot', 'tooltip-trigger');
});
it('leaves an entity-supplied renderer alone', () => {
render(
<EntityMetadataItem
label="Status"
value="running"
renderedValue={<span data-testid="custom">custom node</span>}
/>,
);
expect(screen.getByTestId('custom')).toBeInTheDocument();
expect(screen.queryByTestId('copy-metadata-status')).not.toBeInTheDocument();
});
});

View File

@@ -11,6 +11,7 @@ import { jobEntityConfig } from '../Jobs/entity.config';
import { daemonSetEntityConfig } from '../DaemonSets/entity.config';
import { statefulSetEntityConfig } from '../StatefulSets/entity.config';
import { volumeEntityConfig } from '../Volumes/entity.config';
import { containerEntityConfig } from '../Containers/entity.config';
type AnyEntityConfig = K8sEntityConfig<
K8sEntityData,
@@ -34,6 +35,7 @@ export const entityRegistry: Record<string, AnyEntityConfig> = {
[K8sCategories.DAEMONSETS]: registerConfig(daemonSetEntityConfig),
[K8sCategories.STATEFULSETS]: registerConfig(statefulSetEntityConfig),
[K8sCategories.VOLUMES]: registerConfig(volumeEntityConfig),
[K8sCategories.CONTAINERS]: registerConfig(containerEntityConfig),
};
export function getEntityConfig(category: string): AnyEntityConfig | undefined {

View File

@@ -0,0 +1,142 @@
import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas';
import {
k8sContainerGetSelectedItemExpression,
k8sContainerInitialEventsExpression,
k8sContainerInitialLogTracesExpression,
} from 'container/InfraMonitoringK8sV2/Containers/constants';
import { getContainerMetricsQueryPayload } from 'container/InfraMonitoringK8sV2/Containers/metrics';
import {
getK8sContainerItemKey,
getK8sContainerRowKey,
} from 'container/InfraMonitoringK8sV2/Containers/table.config';
import { getContainerImageWithTag } from 'container/InfraMonitoringK8sV2/Containers/utils';
function makeContainer(
overrides: Partial<InframonitoringtypesContainerRecordDTO> = {},
): InframonitoringtypesContainerRecordDTO {
return {
containerName: 'nginx',
podUID: 'pod-uid-1',
meta: {
'k8s.container.name': 'nginx',
'k8s.pod.uid': 'pod-uid-1',
'k8s.pod.name': 'web-0',
'k8s.namespace.name': 'production',
'k8s.cluster.name': 'prod-cluster',
'container.image.name': 'nginx',
'container.image.tag': '1.27',
},
...overrides,
} as InframonitoringtypesContainerRecordDTO;
}
describe('container identity', () => {
it('keys a row by the (pod UID, container name) pair', () => {
expect(getK8sContainerRowKey(makeContainer())).toBe('pod-uid-1/nginx');
});
it('carries the container name alongside the pod UID into the drawer params', () => {
expect(getK8sContainerItemKey(makeContainer())).toStrictEqual({
selectedItem: 'pod-uid-1',
containerName: 'nginx',
clusterName: null,
namespaceName: null,
});
});
it('falls back to meta when the record fields are empty', () => {
const container = makeContainer({ containerName: '', podUID: '' });
expect(getK8sContainerItemKey(container)).toStrictEqual({
selectedItem: 'pod-uid-1',
containerName: 'nginx',
clusterName: null,
namespaceName: null,
});
});
it('scopes the details fetch to both halves of the identity', () => {
expect(
k8sContainerGetSelectedItemExpression({
selectedItem: 'pod-uid-1',
containerName: 'nginx',
}),
).toBe("k8s.pod.uid = 'pod-uid-1' AND k8s.container.name = 'nginx'");
});
});
describe('getContainerImageWithTag', () => {
it('renders name and tag together', () => {
expect(getContainerImageWithTag(makeContainer())).toBe('nginx:1.27');
});
it('drops the tag when the image is not pinned', () => {
const container = makeContainer({
meta: { 'container.image.name': 'nginx' },
});
expect(getContainerImageWithTag(container)).toBe('nginx');
});
it('renders nothing when the image name is missing', () => {
expect(getContainerImageWithTag(makeContainer({ meta: {} }))).toBe('');
});
});
describe('container drawer expressions', () => {
it('scopes logs and traces to the container within its pod', () => {
expect(k8sContainerInitialLogTracesExpression(makeContainer())).toBe(
"k8s.pod.uid = 'pod-uid-1' AND k8s.cluster.name = 'prod-cluster' AND k8s.namespace.name = 'production' AND k8s.container.name = 'nginx'",
);
});
it('scopes events to the pod, since k8s emits events per pod', () => {
expect(k8sContainerInitialEventsExpression(makeContainer())).toBe(
"k8s.object.kind = 'Pod' AND k8s.object.name = 'web-0' AND k8s.cluster.name = 'prod-cluster' AND attribute.k8s.namespace.name = 'production'",
);
});
});
describe('getContainerMetricsQueryPayload', () => {
const payloads = getContainerMetricsQueryPayload(makeContainer(), 1000, 2000);
it('returns one payload per documented chart', () => {
expect(payloads).toHaveLength(10);
});
it('scopes every query to the selected container', () => {
payloads.forEach((payload) => {
payload.query.builder.queryData.forEach((query) => {
expect(query.filters?.items).toStrictEqual([
expect.objectContaining({
key: expect.objectContaining({ key: 'k8s.pod.uid' }),
op: '=',
value: 'pod-uid-1',
}),
expect.objectContaining({
key: expect.objectContaining({ key: 'k8s.container.name' }),
op: '=',
value: 'nginx',
}),
]);
});
});
});
it('derives cache memory from the working set and RSS queries', () => {
const memoryByState = payloads[4];
expect(
memoryByState.query.builder.queryData.map((query) => [
query.queryName,
query.aggregateAttribute?.key,
]),
).toStrictEqual([
['A', 'container.memory.rss'],
['B', 'container.memory.working_set'],
]);
expect(memoryByState.query.builder.queryFormulas).toStrictEqual([
expect.objectContaining({ expression: 'B - A', legend: 'Cache Memory' }),
]);
});
});

View File

@@ -0,0 +1,170 @@
import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import {
buildEventsExpression,
buildLogsTracesExpression,
} from '../Base/utils';
import { K8sDetailsMetadataConfig, K8sDetailsWidgetInfo } from '../Base/types';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
CONTAINERS_DOC_PATH,
getContainerImageWithTag,
getContainerName,
getContainerPodUID,
} from './utils';
/** A container row is identified by the (pod UID, container name) pair. */
export const k8sContainerGetSelectedItemExpression = (
params: SelectedItemParams,
): string =>
[
`${INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID} = ${formatValueForExpression(
params.selectedItem ?? '',
)}`,
`${INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME} = ${formatValueForExpression(
params.containerName ?? '',
)}`,
].join(' AND ');
export const k8sContainerGetEntityName = getContainerName;
export const k8sContainerDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesContainerRecordDTO>[] =
[
{
label: 'Pod',
getValue: (c): string =>
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
},
{
label: 'NAMESPACE',
getValue: (c): string =>
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '',
},
{
label: 'Node',
getValue: (c): string =>
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '',
},
{
label: 'Cluster Name',
getValue: (c): string =>
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '',
},
{
label: 'Image:Tag',
getValue: getContainerImageWithTag,
},
];
export const k8sContainerInitialLogTracesExpression = (
container: InframonitoringtypesContainerRecordDTO,
): string => {
const base = buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
mainAttributeValue: getContainerPodUID(container),
clusterName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName:
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
const containerName = getContainerName(container);
if (!containerName) {
return base;
}
const containerClause = `${
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME
} = ${formatValueForExpression(containerName)}`;
return base ? `${base} AND ${containerClause}` : containerClause;
};
/**
* Kubernetes emits events against the pod, not the container, so the events tab
* is scoped to the container's pod.
*/
export const k8sContainerInitialEventsExpression = (
container: InframonitoringtypesContainerRecordDTO,
): string =>
buildEventsExpression({
objectKind: 'Pod',
objectName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
clusterName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName:
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const containerWidgetInfo: K8sDetailsWidgetInfo[] = [
{
title: 'CPU Usage (cores)',
yAxisUnit: '',
docPath: `${CONTAINERS_DOC_PATH}#cpu-usage-cores-1`,
description:
'Avg, max and min CPU consumption of the container in cores, showing how bursty it is.',
},
{
title: 'CPU Request, Limit Utilization',
yAxisUnit: 'percentunit',
docPath: `${CONTAINERS_DOC_PATH}#cpu-request-limit-utilization`,
description:
'Container CPU usage as a fraction of its own CPU request and limit; limit lines near 100% mean throttling.',
},
{
title: 'Memory Usage (bytes)',
yAxisUnit: 'bytes',
docPath: `${CONTAINERS_DOC_PATH}#memory-usage-bytes`,
description:
'Total memory charged to the container, including reclaimable page cache, against the headroom left before its limit.',
},
{
title: 'Memory Request, Limit Utilization',
yAxisUnit: 'percentunit',
docPath: `${CONTAINERS_DOC_PATH}#memory-request-limit-utilization`,
description:
'Container memory usage as a fraction of its own memory request and limit; limit lines near 100% risk an OOMKill.',
},
{
title: 'Memory by State',
yAxisUnit: 'bytes',
docPath: `${CONTAINERS_DOC_PATH}#memory-by-state`,
description:
'RSS, working set and cache memory of the container, separating heap growth from file cache.',
},
{
title: 'Memory Major Page Faults',
yAxisUnit: '',
docPath: `${CONTAINERS_DOC_PATH}#memory-major-page-faults`,
description:
'Major page fault rate of the container; sustained values mean the working set is paging to disk.',
},
{
title: 'File System (bytes)',
yAxisUnit: 'bytes',
docPath: `${CONTAINERS_DOC_PATH}#file-system-bytes`,
description:
'Capacity, available and used bytes of the container filesystem.',
},
{
title: 'Container Uptime',
yAxisUnit: 's',
docPath: `${CONTAINERS_DOC_PATH}#container-uptime`,
description:
'Time since the container last started; a sawtooth of resets means it is restarting repeatedly.',
},
{
title: 'Node CPU Utilization by Container',
yAxisUnit: 'percentunit',
docPath: `${CONTAINERS_DOC_PATH}#node-cpu-utilization-by-container`,
description:
"The container's CPU usage as a fraction of the whole node's capacity, to spot noisy neighbours.",
},
{
title: 'Node Memory Utilization by Container',
yAxisUnit: 'percentunit',
docPath: `${CONTAINERS_DOC_PATH}#node-memory-utilization-by-container`,
description:
"The container's memory usage as a fraction of the whole node's capacity.",
},
];

View File

@@ -0,0 +1,140 @@
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listContainers } from 'api/generated/services/inframonitoring';
import {
InframonitoringtypesContainerRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { InfraMonitoringEvents } from 'constants/events';
import { K8sEntityConfig } from '../Base/entity.config.types';
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
containerWidgetInfo,
k8sContainerDetailsMetadataConfig,
k8sContainerGetEntityName,
k8sContainerGetSelectedItemExpression,
k8sContainerInitialEventsExpression,
k8sContainerInitialLogTracesExpression,
} from './constants';
import { getContainerMetricsQueryPayload } from './metrics';
import {
getK8sContainerItemKey,
getK8sContainerRowKey,
k8sContainerColumnsConfig,
} from './table.config';
async function fetchListData(
filters: K8sBaseFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<
InframonitoringtypesContainerRecordDTO,
SelectedItemParams
>['list']['fetchListData']
> {
try {
const response = await listContainers(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesContainerRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
async function fetchEntityData(
filters: K8sDetailsFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<InframonitoringtypesContainerRecordDTO>['details']['fetchEntityData']
> {
try {
const response = await listContainers(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
export const containerEntityConfig: K8sEntityConfig<
InframonitoringtypesContainerRecordDTO,
SelectedItemParams
> = {
list: {
entity: InfraMonitoringEntity.CONTAINERS,
eventCategory: InfraMonitoringEvents.Container,
tableColumns: k8sContainerColumnsConfig,
fetchListData,
getRowKey: getK8sContainerRowKey,
getItemKey: getK8sContainerItemKey,
detailsQueryKeyPrefix: 'container',
},
details: {
category: InfraMonitoringEntity.CONTAINERS,
eventCategory: InfraMonitoringEvents.Container,
queryKeyPrefix: 'container',
getSelectedItemExpression: k8sContainerGetSelectedItemExpression,
fetchEntityData,
getEntityName: k8sContainerGetEntityName,
getInitialLogTracesExpression: k8sContainerInitialLogTracesExpression,
getInitialEventsExpression: k8sContainerInitialEventsExpression,
metadataConfig: k8sContainerDetailsMetadataConfig,
entityWidgetInfo: containerWidgetInfo,
getEntityQueryPayload: getContainerMetricsQueryPayload,
},
};

View File

@@ -0,0 +1,275 @@
import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { getContainerName, getContainerPodUID } from './utils';
const QUERY_NAMES = ['A', 'B', 'C', 'D', 'E', 'F'];
const STEP_INTERVAL = 60;
type TimeAggregation = 'avg' | 'max' | 'min' | 'latest';
type SpaceAggregation = 'sum' | 'avg' | 'max';
interface SeriesSpec {
metricKey: string;
legend: string;
timeAggregation: TimeAggregation;
spaceAggregation: SpaceAggregation;
}
interface FormulaSpec {
expression: string;
legend: string;
}
/**
* Every panel is scoped to a single container by the (k8s.pod.uid,
* k8s.container.name) pair that identifies its row in the list.
*/
function buildScopeFilters(
container: InframonitoringtypesContainerRecordDTO,
): TagFilter {
return {
items: [
{
id: 'pod-uid',
key: {
dataType: DataTypes.String,
id: `k8s_pod_uid--string--tag--false`,
key: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
type: 'tag',
},
op: '=',
value: getContainerPodUID(container),
},
{
id: 'container-name',
key: {
dataType: DataTypes.String,
id: `k8s_container_name--string--tag--false`,
key: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
type: 'tag',
},
op: '=',
value: getContainerName(container),
},
],
op: 'AND',
};
}
function buildQuery(
container: InframonitoringtypesContainerRecordDTO,
start: number,
end: number,
series: SeriesSpec[],
formulas: FormulaSpec[] = [],
): GetQueryResultsProps {
const filters = buildScopeFilters(container);
return {
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: series.map((spec, index) => ({
aggregateAttribute: {
dataType: DataTypes.Float64,
id: `${spec.metricKey.replace(/\./g, '_')}--float64--Gauge--true`,
key: spec.metricKey,
type: 'Gauge',
},
aggregateOperator: spec.timeAggregation,
dataSource: DataSource.METRICS,
disabled: false,
expression: QUERY_NAMES[index],
filters,
functions: [],
groupBy: [],
having: [],
legend: spec.legend,
limit: null,
orderBy: [],
queryName: QUERY_NAMES[index],
reduceTo: ReduceOperators.AVG,
spaceAggregation: spec.spaceAggregation,
stepInterval: STEP_INTERVAL,
timeAggregation: spec.timeAggregation,
})),
queryFormulas: formulas.map((formula, index) => ({
disabled: false,
expression: formula.expression,
legend: formula.legend,
queryName: `F${index + 1}`,
})),
queryTraceOperator: [],
},
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
id: v4(),
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
};
}
/** Absolute usage metrics are summed across series, ratios are averaged. */
function usageSeries(metricKey: string, legendPrefix = ''): SeriesSpec[] {
const prefix = legendPrefix ? `${legendPrefix} - ` : '';
return [
{
metricKey,
legend: `${prefix}Avg`,
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
{
metricKey,
legend: `${prefix}Max`,
timeAggregation: 'max',
spaceAggregation: 'sum',
},
{
metricKey,
legend: `${prefix}Min`,
timeAggregation: 'min',
spaceAggregation: 'sum',
},
];
}
function utilizationSeries(
metricKey: string,
legendPrefix: string,
): SeriesSpec[] {
return (['avg', 'max', 'min'] as TimeAggregation[]).map((timeAggregation) => ({
metricKey,
legend: `${legendPrefix} - ${
timeAggregation.charAt(0).toUpperCase() + timeAggregation.slice(1)
}`,
timeAggregation,
spaceAggregation: 'avg' as const,
}));
}
export const getContainerMetricsQueryPayload = (
container: InframonitoringtypesContainerRecordDTO,
start: number,
end: number,
): GetQueryResultsProps[] => {
const query = (
series: SeriesSpec[],
formulas?: FormulaSpec[],
): GetQueryResultsProps => buildQuery(container, start, end, series, formulas);
return [
query(usageSeries(INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE)),
query([
...utilizationSeries(
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_REQUEST_UTILIZATION,
'Request util %',
),
...utilizationSeries(
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_LIMIT_UTILIZATION,
'Limit util %',
),
]),
query([
...usageSeries(INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_USAGE, 'Usage'),
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_AVAILABLE,
legend: 'Available',
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
]),
query([
...utilizationSeries(
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_REQUEST_UTILIZATION,
'Request util %',
),
...utilizationSeries(
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION,
'Limit util %',
),
]),
query(
[
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_RSS,
legend: 'RSS Memory',
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_WORKING_SET,
legend: 'Working Set Memory',
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
],
[{ expression: 'B - A', legend: 'Cache Memory' }],
),
query([
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_MAJOR_PAGE_FAULTS,
legend: 'Major Page Faults',
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
]),
query([
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_CAPACITY,
legend: 'Capacity',
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_AVAILABLE,
legend: 'Available',
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_USAGE,
legend: 'Usage',
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
]),
query([
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_UPTIME,
legend: 'Uptime',
timeAggregation: 'latest',
spaceAggregation: 'sum',
},
]),
query([
{
metricKey: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_NODE_UTILIZATION,
legend: 'Node CPU Utilization',
timeAggregation: 'avg',
spaceAggregation: 'avg',
},
]),
query([
{
metricKey: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_NODE_UTILIZATION,
legend: 'Node Memory Utilization',
timeAggregation: 'avg',
spaceAggregation: 'avg',
},
]),
];
};

View File

@@ -0,0 +1,470 @@
import { Container } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import {
InframonitoringtypesContainerReadyDTO,
InframonitoringtypesContainerRecordDTO,
InframonitoringtypesContainerStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
import { SelectedItemParams } from '../hooks';
import {
CONTAINER_READY_COLORS,
CONTAINER_READY_LABELS,
CONTAINER_STATUS_COLORS,
CONTAINER_STATUS_LABELS,
CONTAINERS_DOC_PATH,
getContainerImageWithTag,
getContainerReadyItems,
getContainerStatusItems,
} from './utils';
export function getK8sContainerRowKey(
container: InframonitoringtypesContainerRecordDTO,
): string {
return (
[container.podUID, container.containerName].filter(Boolean).join('/') ||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
''
);
}
export function getK8sContainerItemKey(
container: InframonitoringtypesContainerRecordDTO,
): SelectedItemParams {
return {
selectedItem:
container.podUID ||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID] ||
null,
containerName:
container.containerName ||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
null,
clusterName: null,
namespaceName: null,
};
}
export type ContainerTableColumnConfig =
TableColumnDef<InframonitoringtypesContainerRecordDTO>;
/**
* The grouped table and its nested rows are separate tables, so a column and the
* one that replaces it while grouped share a width to keep the two aligned.
*/
const NAME_COLUMN_WIDTH = 220;
const STATUS_COLUMN_WIDTH = 250;
export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
{
id: 'containerGroup',
header: (): React.ReactNode => <EntityGroupHeader title="Container Group" />,
accessorFn: (row): string => row.containerName || '',
width: { min: NAME_COLUMN_WIDTH },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => (
<ExpandButtonWrapper isExpanded={isExpanded} toggleExpanded={toggleExpanded}>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
),
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
header: (): React.ReactNode => (
<EntityGroupHeader
title="Container Name"
icon={<Container data-hide-expanded="true" size={14} />}
docPath={`${CONTAINERS_DOC_PATH}#container-name`}
/>
),
accessorFn: (row): string =>
row.containerName ||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
'',
width: { min: NAME_COLUMN_WIDTH },
enableSort: true,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
},
{
id: 'podName',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#pod-name`}>
Pod Name
</ColumnHeader>
),
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
width: { min: 260 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const podName = value as string;
if (!podName) {
return <TextNoData type="tanstack" />;
}
return <TanStackTable.Text>{podName}</TanStackTable.Text>;
},
},
{
id: 'namespace',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
Namespace
</ColumnHeader>
),
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '',
width: { min: 160 },
enableSort: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
},
{
id: 'image',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#imagetag`}>
Image:Tag
</ColumnHeader>
),
accessorFn: (row): string => getContainerImageWithTag(row),
width: { min: 240 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const image = value as string;
if (!image) {
return <TextNoData type="tanstack" />;
}
return <TanStackTable.Text>{image}</TanStackTable.Text>;
},
},
{
id: 'containerStatus',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#status`}>Status</ColumnHeader>
),
accessorFn: (row): string => row.status,
width: { min: STATUS_COLUMN_WIDTH },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row }): React.ReactNode => {
if (
!row.status ||
row.status === InframonitoringtypesContainerStatusDTO.no_data
) {
return <TextNoData type="tanstack" />;
}
return (
<Badge color={CONTAINER_STATUS_COLORS[row.status]} variant="outline">
{CONTAINER_STATUS_LABELS[row.status]}
</Badge>
);
},
},
{
id: 'containerCountsByStatus',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#status`}>Status</ColumnHeader>
),
accessorFn: (
row,
): InframonitoringtypesContainerRecordDTO['containerCountsByStatus'] =>
row.containerCountsByStatus,
width: { min: STATUS_COLUMN_WIDTH },
enableSort: false,
visibilityBehavior: 'hidden-on-collapse',
cell: ({ row, rowId }): React.ReactNode => {
if (!row.containerCountsByStatus) {
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
items={getContainerStatusItems(row.containerCountsByStatus)}
rowId={rowId}
/>
);
},
},
{
id: 'containerReady',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#ready`}>Ready</ColumnHeader>
),
accessorFn: (row): string => row.ready,
width: { min: 130 },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row }): React.ReactNode => {
if (
!row.ready ||
row.ready === InframonitoringtypesContainerReadyDTO.no_data
) {
return <TextNoData type="tanstack" />;
}
return (
<Badge color={CONTAINER_READY_COLORS[row.ready]} variant="outline">
{CONTAINER_READY_LABELS[row.ready]}
</Badge>
);
},
},
{
id: 'containerCountsByReady',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#ready`}>Ready</ColumnHeader>
),
accessorFn: (
row,
): InframonitoringtypesContainerRecordDTO['containerCountsByReady'] =>
row.containerCountsByReady,
width: { min: 130 },
enableSort: false,
visibilityBehavior: 'hidden-on-collapse',
cell: ({ row, rowId }): React.ReactNode => {
if (!row.containerCountsByReady) {
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
items={getContainerReadyItems(row.containerCountsByReady)}
rowId={rowId}
/>
);
},
},
{
id: 'containerRestarts',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#restarts`}>
Restarts
</ColumnHeader>
),
accessorFn: (row): number => row.restarts,
width: { min: 130 },
enableSort: false,
cell: ({ value, rowId }): React.ReactNode => (
<ValidateColumnValueWrapper
rowId={rowId}
value={value as number}
entity={InfraMonitoringEntity.CONTAINERS}
attribute="Restarts"
>
<TanStackTable.Text>{value as number}</TanStackTable.Text>
</ValidateColumnValueWrapper>
),
},
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath={`${CONTAINERS_DOC_PATH}#cpu-req-usage-`}
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
CPU Request Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.cpuRequestUtilization,
width: { min: 210 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => (
<ValidateColumnValueWrapper
rowId={rowId}
value={value as number}
entity={InfraMonitoringEntity.CONTAINERS}
attribute="CPU Request"
>
<EntityProgressBar value={value as number} type="cpu-request" />
</ValidateColumnValueWrapper>
),
},
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath={`${CONTAINERS_DOC_PATH}#cpu-limit-usage-`}
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
CPU Limit Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.cpuLimitUtilization,
width: { min: 220 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => (
<ValidateColumnValueWrapper
rowId={rowId}
value={value as number}
entity={InfraMonitoringEntity.CONTAINERS}
attribute="CPU Limit"
>
<EntityProgressBar value={value as number} type="cpu-limit" />
</ValidateColumnValueWrapper>
),
},
{
id: 'cpu',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#cpu-usage-cores`}>
CPU Usage (cores)
</ColumnHeader>
),
accessorFn: (row): number => row.cpu,
width: { min: 160 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => (
<ValidateColumnValueWrapper
rowId={rowId}
value={Number(value)}
entity={InfraMonitoringEntity.CONTAINERS}
attribute="CPU metric"
>
<TanStackTable.Text>{Number(value).toFixed(2)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
),
},
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath={`${CONTAINERS_DOC_PATH}#mem-req-usage-`}
tooltip={<EntityProgressThresholds type="memory-request" />}
>
Memory Request Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.memoryRequestUtilization,
width: { min: 210 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => (
<ValidateColumnValueWrapper
rowId={rowId}
value={value as number}
entity={InfraMonitoringEntity.CONTAINERS}
attribute="Memory Request"
>
<EntityProgressBar value={value as number} type="memory-request" />
</ValidateColumnValueWrapper>
),
},
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath={`${CONTAINERS_DOC_PATH}#mem-limit-usage-`}
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
Memory Limit Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.memoryLimitUtilization,
width: { min: 220 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => (
<ValidateColumnValueWrapper
rowId={rowId}
value={value as number}
entity={InfraMonitoringEntity.CONTAINERS}
attribute="Memory Limit"
>
<EntityProgressBar value={value as number} type="memory-limit" />
</ValidateColumnValueWrapper>
),
},
{
id: 'memory',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#mem-usage-wss`}>
Memory Usage (WSS)
</ColumnHeader>
),
accessorFn: (row): number => row.memory,
width: { min: 210, default: '100%' },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => (
<ValidateColumnValueWrapper
rowId={rowId}
value={value as number}
entity={InfraMonitoringEntity.CONTAINERS}
attribute="memory metric"
>
<TanStackTable.Text>{formatBytes(value as number)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
),
},
{
id: 'node',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
Node
</ColumnHeader>
),
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '',
width: { default: 100 },
enableSort: false,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
},
{
id: 'cluster',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
Cluster
</ColumnHeader>
),
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '',
width: { default: 100 },
enableSort: false,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
},
{
id: 'deployment',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
Deployment
</ColumnHeader>
),
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '',
width: { default: 100 },
enableSort: false,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
},
];

View File

@@ -0,0 +1,169 @@
import { Color } from '@signozhq/design-tokens';
import { BadgeColor } from '@signozhq/ui/badge';
import {
InframonitoringtypesContainerCountsByReadyDTO,
InframonitoringtypesContainerCountsByStatusDTO,
InframonitoringtypesContainerReadyDTO,
InframonitoringtypesContainerRecordDTO,
InframonitoringtypesContainerStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { StatusCountItem } from '../components/GroupedStatusCounts';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
export const CONTAINERS_DOC_PATH =
'/infrastructure-monitoring/kubernetes/containers';
/** Renders as `name:tag`; the tag is dropped when the image is not pinned. */
export function getContainerImageWithTag(
container: InframonitoringtypesContainerRecordDTO,
): string {
const name = container.meta?.[INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME];
const tag = container.meta?.[INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_TAG];
if (!name) {
return '';
}
return tag ? `${name}:${tag}` : name;
}
export function getContainerName(
container: InframonitoringtypesContainerRecordDTO,
): string {
return (
container.containerName ||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
''
);
}
export function getContainerPodUID(
container: InframonitoringtypesContainerRecordDTO,
): string {
return (
container.podUID ||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID] ||
''
);
}
export const CONTAINER_STATUS_COLORS: Record<
InframonitoringtypesContainerStatusDTO,
BadgeColor
> = {
[InframonitoringtypesContainerStatusDTO.running]: 'forest',
[InframonitoringtypesContainerStatusDTO.completed]: 'robin',
[InframonitoringtypesContainerStatusDTO.waiting]: 'amber',
[InframonitoringtypesContainerStatusDTO.containercreating]: 'amber',
[InframonitoringtypesContainerStatusDTO.terminated]: 'sienna',
[InframonitoringtypesContainerStatusDTO.unknown]: 'vanilla',
[InframonitoringtypesContainerStatusDTO.no_data]: 'vanilla',
[InframonitoringtypesContainerStatusDTO.crashloopbackoff]: 'cherry',
[InframonitoringtypesContainerStatusDTO.imagepullbackoff]: 'cherry',
[InframonitoringtypesContainerStatusDTO.errimagepull]: 'cherry',
[InframonitoringtypesContainerStatusDTO.createcontainerconfigerror]: 'cherry',
[InframonitoringtypesContainerStatusDTO.oomkilled]: 'cherry',
[InframonitoringtypesContainerStatusDTO.error]: 'cherry',
[InframonitoringtypesContainerStatusDTO.containercannotrun]: 'cherry',
};
/** kubectl prints these as single CamelCase words, so the enum value alone is not a usable label. */
export const CONTAINER_STATUS_LABELS: Record<
InframonitoringtypesContainerStatusDTO,
string
> = {
[InframonitoringtypesContainerStatusDTO.running]: 'Running',
[InframonitoringtypesContainerStatusDTO.completed]: 'Completed',
[InframonitoringtypesContainerStatusDTO.waiting]: 'Waiting',
[InframonitoringtypesContainerStatusDTO.containercreating]:
'ContainerCreating',
[InframonitoringtypesContainerStatusDTO.terminated]: 'Terminated',
[InframonitoringtypesContainerStatusDTO.unknown]: 'Unknown',
[InframonitoringtypesContainerStatusDTO.no_data]: 'No data',
[InframonitoringtypesContainerStatusDTO.crashloopbackoff]: 'CrashLoopBackOff',
[InframonitoringtypesContainerStatusDTO.imagepullbackoff]: 'ImagePullBackOff',
[InframonitoringtypesContainerStatusDTO.errimagepull]: 'ErrImagePull',
[InframonitoringtypesContainerStatusDTO.createcontainerconfigerror]:
'CreateContainerConfigError',
[InframonitoringtypesContainerStatusDTO.oomkilled]: 'OOMKilled',
[InframonitoringtypesContainerStatusDTO.error]: 'Error',
[InframonitoringtypesContainerStatusDTO.containercannotrun]:
'ContainerCannotRun',
};
const CONTAINER_ERROR_STATUS_LABELS: Partial<
Record<keyof InframonitoringtypesContainerCountsByStatusDTO, string>
> = {
crashLoopBackOff: 'CrashLoopBackOff',
imagePullBackOff: 'ImagePullBackOff',
errImagePull: 'ErrImagePull',
createContainerConfigError: 'CreateContainerConfigError',
oomKilled: 'OOMKilled',
error: 'Error',
containerCannotRun: 'ContainerCannotRun',
};
export function getContainerStatusItems(
counts: InframonitoringtypesContainerCountsByStatusDTO,
): StatusCountItem[] {
const errorKeys = Object.keys(CONTAINER_ERROR_STATUS_LABELS) as Array<
keyof typeof CONTAINER_ERROR_STATUS_LABELS
>;
return [
{ value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
{ value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
{
value: counts.waiting + counts.containerCreating,
label: 'Waiting',
color: Color.BG_AMBER_500,
breakdown: [
{ label: 'Waiting', value: counts.waiting },
{ label: 'ContainerCreating', value: counts.containerCreating },
],
},
{
value: counts.terminated,
label: 'Terminated',
color: Color.BG_SIENNA_500,
},
{ value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
{
value: errorKeys.reduce((sum, key) => sum + counts[key], 0),
label: 'Error Status',
color: Color.BG_CHERRY_500,
breakdown: errorKeys.map((key) => ({
label: CONTAINER_ERROR_STATUS_LABELS[key] as string,
value: counts[key],
})),
},
];
}
export const CONTAINER_READY_COLORS: Record<
InframonitoringtypesContainerReadyDTO,
BadgeColor
> = {
[InframonitoringtypesContainerReadyDTO.ready]: 'forest',
[InframonitoringtypesContainerReadyDTO.not_ready]: 'cherry',
[InframonitoringtypesContainerReadyDTO.no_data]: 'vanilla',
};
export const CONTAINER_READY_LABELS: Record<
InframonitoringtypesContainerReadyDTO,
string
> = {
[InframonitoringtypesContainerReadyDTO.ready]: 'Ready',
[InframonitoringtypesContainerReadyDTO.not_ready]: 'Not Ready',
[InframonitoringtypesContainerReadyDTO.no_data]: 'No data',
};
export function getContainerReadyItems(
counts: InframonitoringtypesContainerCountsByReadyDTO,
): StatusCountItem[] {
return [
{ value: counts.ready, label: 'Ready', color: Color.BG_FOREST_500 },
{ value: counts.notReady, label: 'Not Ready', color: Color.BG_CHERRY_500 },
];
}

View File

@@ -3,8 +3,9 @@ import { Compass, Info } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './ChartHeader.module.scss';
import { DOCS_BASE_URL } from 'constants/app';
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
const DOCS_ROOT = `${DOCS_BASE_URL}/docs`;
interface ChartHeaderProps {
title: string;
@@ -33,7 +34,7 @@ function ChartHeader({
<>
{tooltipTitle}{' '}
<a
href={`${DOCS_BASE_URL}${docPath}`}
href={`${DOCS_ROOT}${docPath}`}
target="_blank"
rel="noopener"
onClick={(e): void => e.stopPropagation()}

View File

@@ -37,7 +37,15 @@
.title {
font-family: var(--periscope-font-family-mono);
--typography-margin: 0px var(--spacing-4) 0px 0px;
--typography-margin: 0px var(--spacing-1) 0px 0px;
}
// Sized to the icon rather than the default tap target, so it sits beside the
// entity name instead of a gap away from it.
.copyIdButton {
--button-padding: 2px;
--button-height: auto;
--button-width: auto;
}
.entityDetailsEntity {
@@ -45,30 +53,12 @@
flex-direction: column;
}
// Tracks size to the entity's field count instead of a fixed four, so entities
// with more fields stay on one row rather than spilling a near-empty second one.
.entityDetailsGrid {
display: flex;
flex-direction: column;
}
.labelsRow,
.valuesRow {
display: grid;
grid-template-columns: 1.5fr 1.5fr 1.5fr 1.5fr;
gap: 30px;
align-items: center;
}
.labelsRow {
margin-bottom: var(--spacing-4);
}
.entityDetailsMetadataLabel {
letter-spacing: 0.44px;
text-transform: uppercase;
}
.entityDetailsMetadataValue {
font-family: var(--periscope-font-family-mono);
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-4) var(--spacing-6);
}
.viewsTabsContainer {

View File

@@ -16,6 +16,7 @@ import {
ArrowUpDown,
ArrowUpToLine,
Bolt,
Box,
Boxes,
Computer,
Container,
@@ -31,6 +32,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { K8sDynamicList } from './Base/K8sDynamicList';
import {
GetClustersQuickFiltersConfig,
GetContainersQuickFiltersConfig,
GetDaemonsetsQuickFiltersConfig,
GetDeploymentsQuickFiltersConfig,
GetJobsQuickFiltersConfig,
@@ -152,6 +154,12 @@ export default function InfraMonitoringK8s(): JSX.Element {
const categories = useMemo(
() => [
{
key: K8sCategories.CONTAINERS,
label: 'Containers',
icon: <Box size={14} />,
config: GetContainersQuickFiltersConfig(),
},
{
key: K8sCategories.PODS,
label: 'Pods',

View File

@@ -61,10 +61,26 @@ export const INFRA_MONITORING_ATTR_KEYS = {
K8S_CONTAINER_CPU_LIMIT: 'k8s.container.cpu_limit',
K8S_CONTAINER_MEMORY_REQUEST: 'k8s.container.memory_request',
K8S_CONTAINER_MEMORY_LIMIT: 'k8s.container.memory_limit',
K8S_CONTAINER_CPU_REQUEST_UTILIZATION: 'k8s.container.cpu_request_utilization',
K8S_CONTAINER_CPU_LIMIT_UTILIZATION: 'k8s.container.cpu_limit_utilization',
K8S_CONTAINER_MEMORY_REQUEST_UTILIZATION:
'k8s.container.memory_request_utilization',
K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION:
'k8s.container.memory_limit_utilization',
K8S_CONTAINER_CPU_NODE_UTILIZATION: 'k8s.container.cpu.node.utilization',
K8S_CONTAINER_MEMORY_NODE_UTILIZATION: 'k8s.container.memory.node.utilization',
CONTAINER_CPU_USAGE: 'container.cpu.usage',
CONTAINER_MEMORY_USAGE: 'container.memory.usage',
CONTAINER_MEMORY_AVAILABLE: 'container.memory.available',
CONTAINER_MEMORY_WORKING_SET: 'container.memory.working_set',
CONTAINER_MEMORY_RSS: 'container.memory.rss',
CONTAINER_MEMORY_MAJOR_PAGE_FAULTS: 'container.memory.major_page_faults',
CONTAINER_FILESYSTEM_AVAILABLE: 'container.filesystem.available',
CONTAINER_FILESYSTEM_CAPACITY: 'container.filesystem.capacity',
CONTAINER_FILESYSTEM_USAGE: 'container.filesystem.usage',
CONTAINER_UPTIME: 'container.uptime',
CONTAINER_IMAGE_NAME: 'container.image.name',
CONTAINER_IMAGE_TAG: 'container.image.tag',
// Deployment
K8S_DEPLOYMENT_NAME: 'k8s.deployment.name',
@@ -165,6 +181,9 @@ export const K8sCategories = {
VOLUMES: 'volumes',
};
/** The section the Kubernetes view opens on when a link names none. */
export const DEFAULT_K8S_CATEGORY = K8sCategories.CONTAINERS;
const dotMap = {
[InfraMonitoringEntity.HOSTS]:
INFRA_MONITORING_ATTR_KEYS.SYSTEM_CPU_LOAD_AVERAGE_15M,
@@ -181,7 +200,7 @@ const dotMap = {
[InfraMonitoringEntity.DAEMONSETS]:
INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
[InfraMonitoringEntity.CONTAINERS]:
INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
[InfraMonitoringEntity.JOBS]:
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_DESIRED_SUCCESSFUL_PODS,
[InfraMonitoringEntity.VOLUMES]:
@@ -321,6 +340,161 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
];
}
export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
return [
{
type: FiltersType.CHECKBOX,
title: 'Container',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Pod',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Namespace',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Node',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Cluster',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Image',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Deployment',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Statefulset',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'DaemonSet',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Job',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.DEPLOYMENT_ENVIRONMENT,
dataType: DataTypes.String,
type: 'resource',
},
defaultOpen: true,
},
];
}
export function GetNodesQuickFiltersConfig(): IQuickFiltersConfig[] {
return [
{
@@ -767,6 +941,7 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
SELECTED_ITEM: 'selectedItem',
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
SELECTED_ITEM_CONTAINER_NAME: 'selectedItemContainerName',
DETAIL_RELATIVE_TIME: 'detailRelativeTime',
DETAIL_START_TIME: 'detailStartTime',
DETAIL_END_TIME: 'detailEndTime',
@@ -783,7 +958,7 @@ export const METRIC_NAMESPACE_BY_ENTITY: Record<InfraMonitoringEntity, string> =
[InfraMonitoringEntity.DEPLOYMENTS]: 'k8s.',
[InfraMonitoringEntity.STATEFULSETS]: 'k8s.',
[InfraMonitoringEntity.DAEMONSETS]: 'k8s.',
[InfraMonitoringEntity.CONTAINERS]: 'k8s.pod.',
[InfraMonitoringEntity.CONTAINERS]: 'k8s.container.',
[InfraMonitoringEntity.JOBS]: 'k8s.',
[InfraMonitoringEntity.VOLUMES]: 'k8s.volume.',
};

View File

@@ -16,8 +16,8 @@ import {
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import {
DEFAULT_K8S_CATEGORY,
INFRA_MONITORING_K8S_PARAMS_KEYS,
K8sCategories,
VIEWS,
} from './constants';
import { orderBySchema, OrderBySchemaType } from './schemas';
@@ -130,19 +130,23 @@ export const useInfraMonitoringCategory = (): UseQueryStateReturn<
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY,
parseAsString.withDefault(K8sCategories.PODS).withOptions(defaultNuqsOptions),
parseAsString
.withDefault(DEFAULT_K8S_CATEGORY)
.withOptions({ ...defaultNuqsOptions, clearOnDefault: false }),
);
export interface SelectedItemParams {
selectedItem: string | null;
clusterName?: string | null;
namespaceName?: string | null;
containerName?: string | null;
}
const selectedItemParamsParsers = {
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: parseAsString,
};
export type UseSelectedItemParamsReturn = [
@@ -167,6 +171,9 @@ export const useInfraMonitoringSelectedItemParams =
namespaceName:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME] ??
null,
containerName:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME] ??
null,
}),
[rawParams],
);
@@ -178,6 +185,7 @@ export const useInfraMonitoringSelectedItemParams =
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: null,
});
return;
}
@@ -189,6 +197,8 @@ export const useInfraMonitoringSelectedItemParams =
newParams.clusterName ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]:
newParams.namespaceName ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]:
newParams.containerName ?? null,
});
},
[setRawParams],

View File

@@ -38,19 +38,20 @@ import {
TEST_ENDPOINT,
} from '../../__tests__/fixtures';
const SAMPLE_SPAN = JSON.parse(SAMPLE_SPAN_JSON) as {
attributes: Record<string, unknown>;
resource: Record<string, unknown>;
};
const MAPPED_ATTRIBUTE_KEY = 'gen_ai.content.prompt';
// Deriving from the sample keeps exactly one key added, so the single `populated` badge assertion below stays exact.
const RESULT_SPAN = {
attributes: {
'my_company.llm.input': 'What is quantum computing?',
'llm.input_messages': 'What is quantum computing?',
'gen_ai.request.model': 'gpt-4',
'gen_ai.usage.total_tokens': 1250,
'gen_ai.content.completion': 'Quantum computing leverages...',
'gen_ai.content.prompt': 'What is quantum computing?',
},
resource: {
'service.name': 'llm-gateway',
'deployment.environment': 'production',
...SAMPLE_SPAN.attributes,
[MAPPED_ATTRIBUTE_KEY]: SAMPLE_SPAN.attributes['input.value'],
},
resource: SAMPLE_SPAN.resource,
};
const EDITED_SPAN_JSON = `{
@@ -97,7 +98,7 @@ describe('TestTab — sample-span flow', () => {
).resolves.toBeInTheDocument();
expect(screen.getByTestId('test-result-0')).toBeInTheDocument();
expect(screen.getByTestId('test-result-0-attributes')).toHaveTextContent(
'gen_ai.content.prompt',
MAPPED_ATTRIBUTE_KEY,
);
expect(screen.getByText('populated')).toBeInTheDocument();
expect(screen.queryByTestId('test-error')).not.toBeInTheDocument();

View File

@@ -7,11 +7,14 @@ import { parseSpanInput } from './testPayload';
export const SAMPLE_SPAN_JSON = `{
"attributes": {
"my_company.llm.input": "What is quantum computing?",
"llm.input_messages": "What is quantum computing?",
"gen_ai.request.model": "gpt-4",
"gen_ai.usage.total_tokens": 1250,
"gen_ai.content.completion": "Quantum computing leverages..."
"llm.model_name": "gpt-4o",
"llm.provider": "openai",
"llm.token_count.prompt": 1024,
"llm.token_count.completion": 226,
"llm.token_count.prompt_details.cache_read": 512,
"input.value": "What is quantum computing?",
"output.value": "Quantum computing leverages superposition and entanglement...",
"session.id": "chat-8f2e41"
},
"resource": {
"service.name": "llm-gateway",

View File

@@ -1,11 +0,0 @@
.explorer {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-2) var(--spacing-0);
}
.placeholder {
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,106 @@
.trace-explorer-header {
.trace-explorer-run-query {
display: flex;
flex-direction: row-reverse;
align-items: center;
margin: 8px 16px;
gap: 8px;
}
.filter-outlined-btn {
border-radius: 0px 2px 2px 0px;
border-top: 1px solid var(--l1-border);
border-right: 1px solid var(--l1-border);
border-bottom: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
}
.trace-explorer-header.single-child {
justify-content: flex-end;
}
.traces-explorer-views {
padding: 8px;
padding-bottom: 60px;
margin-bottom: 24px;
.ant-tabs-tabpane {
padding: 0 8px;
}
}
.qb-search-view-container {
padding: 8px;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l2-background) !important;
height: 34px !important;
box-sizing: border-box !important;
}
}
.trace-explorer-list-view {
flex: 1;
}
.trace-explorer-traces-view {
flex: 1;
}
.trace-explorer-table-view {
flex: 1;
}
.trace-explorer-time-series-view {
flex: 1;
}
.trace-explorer-page {
display: flex;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.filter {
width: 260px;
height: 100%;
min-height: 100vh;
border-right: 0px;
border: 1px solid var(--l1-border);
background-color: var(--l1-background);
> .ant-card-body {
padding: 0;
width: 258px;
}
}
.trace-explorer {
width: 100%;
background: var(--l1-background);
> .ant-card-body {
padding: 0;
}
border-color: var(--l1-border);
}
.trace-explorer.filters-expanded {
width: calc(100% - 260px);
}
}

View File

@@ -1,13 +1,373 @@
import styles from './Explorer.module.scss';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import Toolbar from 'container/Toolbar/Toolbar';
import {
getExportQueryData,
getQueryByPanelType,
} from 'container/TracesExplorer/explorerUtils';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import {
ICurrentQueryData,
useHandleExplorerTabChange,
} from 'hooks/useHandleExplorerTabChange';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isEmpty } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import {
tracesAddFilterAction,
tracesChangeViewAction,
tracesRunQueryAction,
tracesSaveViewAction,
} from 'pages/TracesExplorer/aiActions';
import { Warning } from 'types/api';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
explorerViewToPanelType,
getExplorerViewFromUrl,
} from 'utils/explorerUtils';
import { v4 } from 'uuid';
import { TOOLBAR_VIEWS } from './constants';
import ListView from './ListView/ListView';
import { defaultSelectedColumns } from './ListView/configs';
import QuerySection from './QuerySection/QuerySection';
import TableView from './TableView/TableView';
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
import TracesView from './TracesView/TracesView';
import './Explorer.styles.scss';
// Shell for the AI Observability Explorer tab. Owns the
// /ai-observability/explorer route and is intentionally empty for now: the
// query builder + results surface land in a follow-up.
function Explorer(): JSX.Element {
const {
panelType,
updateAllQueriesOperators,
handleRunQuery,
stagedQuery,
handleSetConfig,
currentQuery,
handleSetQueryData,
redirectWithQueryBuilderData,
} = useQueryBuilder();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const { options } = useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const listQueryKeyRef = useRef<any>();
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
const [isCancelled, setIsCancelled] = useState(false);
useEffect(() => {
if (isLoadingQueries) {
setIsCancelled(false);
}
}, [isLoadingQueries]);
const handleCancelQuery = useCallback(() => {
if (listQueryKeyRef.current) {
queryClient.cancelQueries(listQueryKeyRef.current);
}
setIsCancelled(true);
// Reset loading state — the active view unmounts when cancelled, so no
// child will call setIsLoadingQueries(false) otherwise.
setIsLoadingQueries(false);
}, [queryClient]);
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
);
const [warning, setWarning] = useState<Warning | undefined>();
const [isOpen, setOpen] = useState<boolean>(true);
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
initialQueriesMap.traces,
PANEL_TYPES.LIST,
DataSource.TRACES,
),
[updateAllQueriesOperators],
);
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const handleChangeSelectedView = useCallback(
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
setSelectedView(view);
handleExplorerTabChange(
explorerViewToPanelType[view],
querySearchParameters,
);
},
[handleExplorerTabChange, handleSetConfig],
);
// ─── AI Assistant page actions (only when license feature is on) ───────────
const aiActions = useMemo(
() =>
isAIAssistantEnabled
? [
tracesRunQueryAction({
currentQuery,
handleSetQueryData,
redirectWithQueryBuilderData,
}),
tracesAddFilterAction({
currentQuery,
handleSetQueryData,
redirectWithQueryBuilderData,
}),
tracesChangeViewAction({
onChangeView: (view) => handleChangeSelectedView(view as ExplorerViews),
}),
tracesSaveViewAction({
// POC stub — logs a save request; wire to real API when available
onSaveView: async (name) => {
// eslint-disable-next-line no-console
console.info('[AI Assistant] Save view requested:', name);
},
}),
]
: [],
// eslint-disable-next-line react-hooks/exhaustive-deps
[
isAIAssistantEnabled,
currentQuery,
handleSetQueryData,
redirectWithQueryBuilderData,
handleChangeSelectedView,
],
);
usePageActions('traces-explorer', aiActions);
// ───────────────────────────────────────────────────────────────────────────
const exportDefaultQuery = useMemo(
() =>
getQueryByPanelType(
stagedQuery || initialQueriesMap.traces,
panelType || PANEL_TYPES.LIST,
),
[stagedQuery, panelType],
);
const handleExport = useCallback(
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
if (!dashboard || !panelType) {
return;
}
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
? panelType
: PANEL_TYPES.TIME_SERIES;
const widgetId = v4();
const query = getExportQueryData(
exportDefaultQuery,
panelTypeParam,
options,
);
logEvent('Traces Explorer: Add to dashboard successful', {
panelType,
isNewDashboard,
dashboardName: dashboard?.title,
});
const dashboardEditView = getExportToDashboardLink({
query,
panelType: panelTypeParam,
dashboardId: dashboard.id,
widgetId,
});
if (dashboardEditView) {
safeNavigate(dashboardEditView);
}
},
[
exportDefaultQuery,
panelType,
safeNavigate,
options,
getExportToDashboardLink,
],
);
useShareBuilderUrl({ defaultValue: defaultQuery });
const logEventCalledRef = useRef(false);
useEffect(() => {
if (!logEventCalledRef.current) {
logEvent('Traces Explorer: Page visited', {});
logEventCalledRef.current = true;
}
}, []);
const isFilterApplied = useMemo(() => {
// if any of the non-disabled queries has filters applied, return true
const result = stagedQuery?.builder?.queryData?.filter(
(item) => !isEmpty(item.filters?.items) && !item.disabled,
);
return !!result?.length;
}, [stagedQuery]);
return (
<div className={styles.explorer} data-testid="llm-observability-explorer">
<div className={styles.placeholder}>Explorer coming soon.</div>
</div>
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div
className="trace-explorer-page"
data-testid="llm-observability-explorer"
>
<Card className="filter" hidden={!isOpen}>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.TRACES_EXPLORER}
signal={SignalType.TRACES}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
/>
</Card>
<div
className={cx('trace-explorer', {
'filters-expanded': isOpen,
})}
>
<div className="trace-explorer-header">
<Toolbar
showAutoRefresh
leftActions={
<LeftToolbarActions
showFilter={isOpen}
handleFilterVisibilityChange={(): void => setOpen(!isOpen)}
items={TOOLBAR_VIEWS}
selectedView={selectedView}
onChangeSelectedView={handleChangeSelectedView}
/>
}
warningElement={
!isEmpty(warning) ? <WarningPopover warningData={warning} /> : <div />
}
rightActions={
<RightToolbarActions
onStageRunQuery={(): void => {
setIsCancelled(false);
handleRunQuery();
}}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
/>
}
/>
</div>
<ExplorerCard sourcepage={DataSource.TRACES}>
<div className="query-section-container">
<QuerySection />
</div>
</ExplorerCard>
<div className="traces-explorer-views">
{isCancelled && (
<QueryCancelledPlaceholder subText='Click "Run Query" to load traces.' />
)}
{!isCancelled && selectedView === ExplorerViews.LIST && (
<div className="trace-explorer-list-view">
<ListView
isFilterApplied={isFilterApplied}
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
</div>
)}
{!isCancelled && selectedView === ExplorerViews.TRACE && (
<div className="trace-explorer-traces-view">
<TracesView
isFilterApplied={isFilterApplied}
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
</div>
)}
{!isCancelled && selectedView === ExplorerViews.TIMESERIES && (
<div className="trace-explorer-time-series-view">
<TimeSeriesView
dataSource={DataSource.TRACES}
isFilterApplied={isFilterApplied}
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
</div>
)}
{!isCancelled && selectedView === ExplorerViews.TABLE && (
<div className="trace-explorer-table-view">
<TableView
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
</div>
)}
</div>
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}
sourcepage={DataSource.TRACES}
onExport={handleExport}
handleChangeSelectedView={handleChangeSelectedView}
/>
</div>
</div>
</Sentry.ErrorBoundary>
);
}

View File

@@ -0,0 +1,8 @@
.container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: calc(100vh - 240px);
min-height: 400px;
}

View File

@@ -0,0 +1,34 @@
.trace-explorer-controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
.order-by-container {
display: flex;
align-items: center;
gap: 8px;
.order-by-label {
color: var(--muted-foreground);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
display: flex;
align-items: center;
gap: 4px;
}
.order-by-select {
width: 100px;
.ant-select-selector {
border: none;
box-shadow: none;
background-color: transparent;
}
}
}
}

View File

@@ -0,0 +1,272 @@
import {
Dispatch,
memo,
MutableRefObject,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import {
getTraceLink,
transformSpanRows,
} from 'container/TracesExplorer/ListView/utils';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
defaultSelectedColumns,
PER_PAGE_OPTIONS,
TIMESTAMP_FIELD,
} from './configs';
import './ListView.styles.scss';
import styles from './ListView.module.scss';
interface ListViewProps {
isFilterApplied: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
}
function ListView({
isFilterApplied,
setWarning,
setIsLoadingQueries,
queryKeyRef,
}: ListViewProps): JSX.Element {
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const {
selectedTime: globalSelectedTime,
maxTime,
minTime,
loading: timeRangeUpdateLoading,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { options, config } = useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
const paginationConfig =
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
[stagedQuery, orderBy],
);
// Stable sorted-name signature for the queryKey.
// - Drag updates selectColumns; raw queryKey would churn on reorder.
// - Trace API fetches only listed columns → add/remove must refetch.
// - Sorted-name signature: stable on reorder, changes on add/remove.
const selectColumnsSignature = useMemo(
() =>
(options?.selectColumns ?? [])
.map((c) => c.name)
.sort()
.join(','),
[options?.selectColumns],
);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
orderBy,
],
[
stagedQuery,
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
orderBy,
],
);
if (queryKeyRef) {
queryKeyRef.current = queryKey;
}
const { data, isFetching, isLoading, isError, error } = useGetQueryRange(
{
query: requestQuery,
graphType: panelType,
selectedTime: 'GLOBAL_TIME' as const,
globalSelectedInterval: globalSelectedTime as CustomTimeType,
params: {
dataSource: 'traces',
},
tableParams: {
pagination: paginationConfig,
selectColumns: options?.selectColumns,
},
},
ENTITY_VERSION_V5,
{
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading &&
!!stagedQuery &&
panelType === PANEL_TYPES.LIST &&
!!options?.selectColumns?.length,
},
);
useEffect(() => {
if (data?.payload) {
setWarning(data?.warning);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
useEffect(() => {
if (isLoading || isFetching) {
setIsLoadingQueries(true);
} else {
setIsLoadingQueries(false);
}
}, [isLoading, isFetching, setIsLoadingQueries]);
const queryTableDataResult = data?.payload?.data?.newResult?.data?.result;
const queryTableData = useMemo(
() => queryTableDataResult || [],
[queryTableDataResult],
);
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
const fields = [
TIMESTAMP_FIELD,
...(options?.selectColumns ?? []).filter(
(field) => field.name !== TIMESTAMP_FIELD.name,
),
];
return fields.map((field) => getFieldColumn(field));
}, [options?.selectColumns]);
const rows = useMemo(
() => transformSpanRows(queryTableData),
[queryTableData],
);
const handleColumnOrderChange = useCallback(
(reordered: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(reordered.map((column) => column.id));
},
[config],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
void logEvent('Traces Explorer: Data present', {
panelType,
});
}
}, [isLoading, isFetching, isError, rows, panelType]);
return (
<div className={styles.container}>
<div className="trace-explorer-controls">
<div className="order-by-container">
<div className="order-by-label">
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
<ListViewOrderBy
value={orderBy}
onChange={handleOrderChange}
dataSource={DataSource.TRACES}
/>
</div>
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
selectedColumns={options?.selectColumns}
/>
<TraceExplorerControls
isLoading={isFetching}
totalCount={rows.length}
config={config}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
<TracesTable
data={rows}
columns={columns}
panelType="LIST"
getRowHref={getTraceLink}
isLoading={isLoading}
isFetching={isFetching}
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
/>
</div>
);
}
ListView.defaultProps = {
queryKeyRef: undefined,
};
export default memo(ListView);

View File

@@ -0,0 +1,19 @@
import type { TelemetryFieldKey } from 'api/v5/v5';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const defaultSelectedColumns: string[] = [
'service.name',
'name',
'duration_nano',
'http_method',
'response_status_code',
'timestamp',
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
// Pinned timestamp column
export const TIMESTAMP_FIELD = {
name: 'timestamp',
fieldContext: 'span',
} as TelemetryFieldKey;

View File

@@ -0,0 +1,61 @@
import { memo, useCallback, useMemo } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ExplorerOrderBy from 'container/ExplorerOrderBy';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const isList = panelTypes === PANEL_TYPES.LIST;
const config: QueryBuilderProps['filterConfigs'] = {
stepInterval: { isHidden: false, isDisabled: false },
limit: { isHidden: isList, isDisabled: true },
having: { isHidden: isList, isDisabled: true },
};
return config;
}, [panelTypes]);
const renderOrderBy = useCallback(
({ query, onChange }: OrderByFilterProps) => (
<ExplorerOrderBy query={query} onChange={onChange} />
),
[],
);
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
const shouldRenderCustomOrderBy =
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
return {
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
};
}, [panelTypes, renderOrderBy]);
const isListViewPanel = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
[panelTypes],
);
return (
<QueryBuilderV2
isListViewPanel={isListViewPanel}
showTraceOperator
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
queryComponents={queryComponents}
panelType={panelTypes}
filterConfigs={filterConfigs}
showOnlyWhereClause={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}
version="v3" // setting this to v3 as we this is rendered in logs explorer
/>
);
}
export default memo(QuerySection);

View File

@@ -0,0 +1,7 @@
.traces-table-view-header {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 12px;
flex-shrink: 0;
}

View File

@@ -0,0 +1,130 @@
import {
Dispatch,
memo,
MutableRefObject,
SetStateAction,
useEffect,
useMemo,
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Space } from 'antd';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ExportMenu from 'components/ExportMenu/ExportMenu';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { QueryTable } from 'container/QueryTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import './TableView.styles.scss';
function TableView({
setWarning,
setIsLoadingQueries,
queryKeyRef,
}: {
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<any>;
}): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const {
selectedTime: globalSelectedTime,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
],
[globalSelectedTime, maxTime, minTime, stagedQuery],
);
if (queryKeyRef) {
queryKeyRef.current = queryKey;
}
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
{
query: stagedQuery || initialQueriesMap.traces,
graphType: panelType || PANEL_TYPES.TABLE,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource: 'traces',
},
},
ENTITY_VERSION_V5,
{
queryKey,
enabled: !!stagedQuery && panelType === PANEL_TYPES.TABLE,
},
);
useEffect(() => {
if (isLoading || isFetching) {
setIsLoadingQueries(true);
} else {
setIsLoadingQueries(false);
}
}, [isLoading, isFetching, setIsLoadingQueries]);
const queryTableData = useMemo(
() =>
data?.payload?.data?.newResult?.data?.result ||
data?.payload.data.result ||
[],
[data],
);
useEffect(() => {
if (data?.payload) {
setWarning(data.warning);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
return (
<Space.Compact block direction="vertical">
{isError && error && <ErrorInPlace error={error as APIError} />}
{!isError && data && (
<div className="traces-table-view-header">
<ExportMenu
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="traces-table"
/>
</div>
)}
{!isError && (
<QueryTable
query={stagedQuery || initialQueriesMap.traces}
queryTableData={queryTableData as QueryDataV3[]}
loading={isLoading}
sticky
/>
)}
</Space.Compact>
);
}
TableView.defaultProps = {
queryKeyRef: undefined,
};
export default memo(TableView);

View File

@@ -0,0 +1,8 @@
.trace-explorer-time-series-view-container {
&-header {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 12px;
}
}

View File

@@ -0,0 +1,147 @@
import {
Dispatch,
MutableRefObject,
SetStateAction,
useEffect,
useMemo,
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import './TimeSeriesView.styles.scss';
function TimeSeriesViewContainer({
dataSource = DataSource.TRACES,
isFilterApplied,
setWarning,
setIsLoadingQueries,
queryKeyRef,
}: TimeSeriesViewProps): JSX.Element {
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
const isValidToConvertToMs = useMemo(() => {
const isValid: boolean[] = [];
currentQuery.builder.queryData.forEach(
({ aggregateAttribute, aggregateOperator }) => {
const isExistDurationNanoAttribute =
aggregateAttribute?.key === 'durationNano' ||
aggregateAttribute?.key === 'duration_nano';
const isCountOperator =
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
},
);
return isValid.every(Boolean);
}, [currentQuery]);
const defaultUnit = isValidToConvertToMs ? 'ms' : 'short';
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit(defaultUnit);
const {
selectedTime: globalSelectedTime,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
],
[globalSelectedTime, maxTime, minTime, stagedQuery],
);
if (queryKeyRef) {
queryKeyRef.current = queryKey;
}
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
{
query: stagedQuery || initialQueriesMap[dataSource],
graphType: panelType || PANEL_TYPES.TIME_SERIES,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource,
},
},
// ENTITY_VERSION_V4,
ENTITY_VERSION_V5,
{
queryKey,
enabled: !!stagedQuery && panelType === PANEL_TYPES.TIME_SERIES,
},
);
useEffect(() => {
if (data?.payload) {
setWarning(data?.warning);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
const responseData = useMemo(
() => (isValidToConvertToMs ? convertDataValueToMs(data) : data),
[data, isValidToConvertToMs],
);
useEffect(() => {
if (isLoading || isFetching) {
setIsLoadingQueries(true);
} else {
setIsLoadingQueries(false);
}
}, [isLoading, isFetching, setIsLoadingQueries]);
return (
<div className="trace-explorer-time-series-view-container">
<TimeSeriesView
isFilterApplied={isFilterApplied}
isError={isError}
error={error as APIError}
isLoading={isLoading || isFetching}
data={responseData}
yAxisUnit={yAxisUnit}
onYAxisUnitChange={onUnitChange}
dataSource={dataSource}
setWarning={setWarning}
allowExport
/>
</div>
);
}
interface TimeSeriesViewProps {
dataSource?: DataSource;
isFilterApplied: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<any>;
}
TimeSeriesViewContainer.defaultProps = {
dataSource: DataSource.TRACES,
queryKeyRef: undefined,
};
export default TimeSeriesViewContainer;

View File

@@ -0,0 +1,15 @@
.container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
// Page chain isn't a flex column, so anchor the virtualized table against the viewport.
height: calc(100vh - 240px);
min-height: 400px;
}
.actionsContainer {
display: flex;
justify-content: space-between;
align-items: center;
}

View File

@@ -0,0 +1,190 @@
import {
Dispatch,
memo,
MutableRefObject,
SetStateAction,
useEffect,
useMemo,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import { columns, PER_PAGE_OPTIONS } from './configs';
import styles from './TracesView.module.scss';
interface TracesViewProps {
isFilterApplied: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
}
function TracesView({
isFilterApplied,
setWarning,
setIsLoadingQueries,
queryKeyRef,
}: TracesViewProps): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const {
selectedTime: globalSelectedTime,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
const transformedQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
[stagedQuery],
);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
panelType,
paginationQueryData,
],
[
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
panelType,
paginationQueryData,
],
);
if (queryKeyRef) {
queryKeyRef.current = queryKey;
}
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
{
query: transformedQuery,
graphType: panelType || PANEL_TYPES.TRACE,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource: 'traces',
},
tableParams: {
pagination: paginationQueryData,
},
},
ENTITY_VERSION_V5,
{
queryKey,
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
},
);
useEffect(() => {
if (data?.payload) {
setWarning(data?.warning);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
const rows = useMemo<TracesTableRow[]>(
() =>
(responseData ?? []).map((item) => {
const row = item.data;
return { ...row, id: row.trace_id };
}) as TracesTableRow[],
[responseData],
);
useEffect(() => {
if (isLoading || isFetching) {
setIsLoadingQueries(true);
} else {
setIsLoadingQueries(false);
}
}, [isLoading, isFetching, setIsLoadingQueries]);
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
void logEvent('Traces Explorer: Data present', {
panelType: 'TRACE',
});
}
}, [isLoading, isFetching, isError, rows.length]);
return (
<div className={styles.container}>
<div className={styles.actionsContainer}>
<Typography>
This tab only shows Root Spans. More details
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
{' '}
here
</Typography.Link>
</Typography>
<div className="trace-explorer-controls">
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
/>
<TraceExplorerControls
isLoading={isLoading}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
</div>
<TracesTable
data={rows}
columns={columns}
respectColumnOrder
panelType="TRACE"
getRowHref={getTraceLink}
isLoading={isLoading}
isFetching={isFetching}
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
/>
</div>
);
}
TracesView.defaultProps = {
queryKeyRef: undefined,
};
export default memo(TracesView);

View File

@@ -0,0 +1,25 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
const TRACE_FIELDS = [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'name' },
{ name: 'duration_nano' },
{ name: 'span_count' },
{ name: 'trace_id' },
] as TelemetryFieldKey[];
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
(field) => ({
...getFieldColumn(field),
enableRemove: false,
canBeHidden: false,
}),
);

View File

@@ -0,0 +1,36 @@
export const TOOLBAR_VIEWS = {
list: {
name: 'list',
label: 'List',
show: true,
key: 'list',
},
timeseries: {
name: 'timeseries',
label: 'Timeseries',
disabled: false,
show: true,
key: 'timeseries',
},
trace: {
name: 'trace',
label: 'Trace',
disabled: false,
show: true,
key: 'trace',
},
table: {
name: 'table',
label: 'Table',
disabled: false,
show: true,
key: 'table',
},
clickhouse: {
name: 'clickhouse',
label: 'Clickhouse',
disabled: false,
show: false,
key: 'clickhouse',
},
};

View File

@@ -18,6 +18,12 @@ jest.mock('pages/DashboardPageV2/DashboardContainer', () => ({
default: (): JSX.Element => <div data-testid="llm-overview-dashboard" />,
}));
// Same data-router gap as the dashboard above: the Explorer toolbar calls useNavigationType.
jest.mock('container/LLMObservability/Explorer/Explorer', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="llm-observability-explorer" />,
}));
function setupList(items = mockRules): void {
server.use(
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>

View File

@@ -4,13 +4,6 @@
padding: 0px 8px;
.logs-frequency-chart {
.ant-card-body {
height: 140px;
min-height: 140px;
padding: 0 16px 22px 16px;
font-family: 'Geist Mono';
}
margin-bottom: 0px;
}
}

View File

@@ -15,9 +15,8 @@ import {
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
@@ -50,7 +49,7 @@ function BodyTitleRenderer({
const { featureFlags } = useAppContext();
const [, setCopy] = useCopyToClipboard();
const { notifications } = useNotifications();
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const { viewName } = useGetSavedViewParams();
const cleanedNodeKey = removeObjectFromString(nodeKey);
const isBodyJsonQueryEnabled =

View File

@@ -7,13 +7,12 @@ import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
import cx from 'classnames';
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { QueryParams } from 'constants/query';
import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import {
@@ -141,7 +140,7 @@ export default function TableViewActions(
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const { viewName } = useGetSavedViewParams();
const { dataType, logType: fieldType } = getFieldAttributes(record.field);
// there is no option for where clause in old logs explorer and live logs page or infra monitoring

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
@@ -88,7 +88,7 @@ jest.mock('react-router-dom', () => ({
}));
jest.mock('hooks/queryBuilder/useQueryBuilder');
jest.mock('hooks/queryBuilder/useGetSearchQueryParam');
jest.mock('hooks/saveViews/useGetSavedViewParams');
describe('TableViewActions', () => {
const TEST_VALUE = 'test value';
@@ -140,8 +140,10 @@ describe('TableViewActions', () => {
}),
} as any);
// Default mock for useGetSearchQueryParam
jest.mocked(useGetSearchQueryParam).mockReturnValue(null);
// Default mock for useGetSavedViewParams
jest
.mocked(useGetSavedViewParams)
.mockReturnValue({ viewName: '', viewKey: '' });
});
it('should render without crashing', () => {
@@ -249,7 +251,9 @@ describe('TableViewActions', () => {
updateQueriesData: mockUpdateQueriesData,
} as any);
jest.mocked(useGetSearchQueryParam).mockReturnValue(null);
jest
.mocked(useGetSavedViewParams)
.mockReturnValue({ viewName: '', viewKey: '' });
render(
<TableViewActions

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