Compare commits

..

16 Commits

Author SHA1 Message Date
Vikrant Gupta
afb1eb4a41 feat(licensing): add v4 license endpoints with resource authz (#12731)
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 strongly typed `/api/v4/licenses` endpoints on the apiserver with
OpenAPI definitions: activate, list, get, refresh, delete, and `GET
/api/v4/licenses/active`.
- Wires resource authz (`license:create/list/read/update/delete`) via
`CheckResources`; `GET /active` is `OpenAccess` and never includes the
license key — the key is returned only by the FGA-gated get-by-id, so
orgs can grant `license:read` selectively. Migration 118 backfills
license tuples for existing orgs.
- Delete is allowed only for non-cloud licenses; licenses managed by
SigNoz Cloud are rejected.
- v4 responses are camelCase with lowercase enum values; v3 routes and
stored license data are unchanged.

#### Issues closed by this PR

Closes https://github.com/SigNoz/platform-pod/issues/3076
2026-09-02 18:49:35 +00:00
Aditya Singh
4f6414ef61 fix(logs): preserve active viewKey in log-details filter/group/replace (#12757)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Fixes the issue where applying filter for value / filter out (also
group by and replace filter) from the log details drawer was removing
the added columns from the table.
- As part of this fix, we rename `id` and `name` fields in
explorerTabChange input type. Now it reads `viewName` and `viewKey` and
avoids confusion
- Now consumers of explorerTabChange need to send viewName and viewKey
only if needed. As now this is an optional field

#### Screenshots/Recording

Before


https://github.com/user-attachments/assets/4c6145e3-4979-4386-9230-f74eb00721c8



After


https://github.com/user-attachments/assets/52bc3083-40a2-4345-aebd-63770882b06c



#### Issues closed by this PR

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

#### Additional Information

- same issue was present in the old drawer (group by, replace filter)
and the metrics explorer detail (passed the metric name)..both fixed
here. metrics never surfaced as a bug since it opens in time series with
no columns to collapse
- filter for/out in the old drawer was never affected, it uses a
different add to query path
2026-09-02 12:13:37 +00:00
Nikhil Mantri
70335dc707 feat(alert-channel-integrations): jira + jsm ops channel frontend (#12488)
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 **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
247 changed files with 14712 additions and 3592 deletions

View File

@@ -50,7 +50,6 @@ jobs:
- logspipelines
- passwordauthn
- preference
- quickfilter
- querierlogs
- queriertraces
- queriermetrics
@@ -59,6 +58,7 @@ jobs:
- querierai
- rawexportdata
- promqlconformance
- promapiconformance
- querierauthz
- role
- rootuser

View File

@@ -96,6 +96,7 @@ func runGenerateAuthz(_ context.Context) error {
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,

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)

File diff suppressed because it is too large Load Diff

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

@@ -22,89 +22,6 @@ func NewLicensingAPI(licensing licensing.Licensing) licensing.API {
return &licensingAPI{licensing: licensing}
}
func (api *licensingAPI) Activate(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
return
}
req := new(licensetypes.PostableLicense)
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
render.Error(rw, err)
return
}
err = api.licensing.Activate(r.Context(), orgID, req.Key)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusAccepted, nil)
}
func (api *licensingAPI) GetActive(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
return
}
license, err := api.licensing.GetActive(r.Context(), orgID)
if err != nil {
render.Error(rw, err)
return
}
gettableLicense := licensetypes.NewGettableLicense(license.Data, license.Key)
render.Success(rw, http.StatusOK, gettableLicense)
}
func (api *licensingAPI) Refresh(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
return
}
err = api.licensing.Refresh(r.Context(), orgID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (api *licensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()

View File

@@ -95,24 +95,65 @@ func (provider *provider) Validate(ctx context.Context) error {
return nil
}
func (provider *provider) Activate(ctx context.Context, organizationID valuer.UUID, key string) error {
data, err := provider.zeus.GetLicense(ctx, key)
func (provider *provider) Activate(ctx context.Context, organizationID valuer.UUID, key string) (*licensetypes.License, error) {
zeusLicense, err := provider.zeus.GetLicense(ctx, key)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to fetch license data with upstream server")
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to fetch license data with upstream server")
}
license, err := licensetypes.NewLicense(data, organizationID)
license, err := licensetypes.NewLicense(zeusLicense, organizationID)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create license entity")
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create license entity")
}
storableLicense := licensetypes.NewStorableLicenseFromLicense(license)
err = provider.store.Create(ctx, storableLicense)
if err != nil {
return nil, err
}
return license, nil
}
func (provider *provider) Get(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) (*licensetypes.License, error) {
storableLicense, err := provider.store.Get(ctx, organizationID, licenseID)
if err != nil {
return nil, err
}
return licensetypes.NewLicenseFromStorableLicense(storableLicense)
}
func (provider *provider) List(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.License, error) {
storableLicenses, err := provider.store.GetAll(ctx, organizationID)
if err != nil {
return nil, err
}
licenses := make([]*licensetypes.License, 0, len(storableLicenses))
for _, storableLicense := range storableLicenses {
license, err := licensetypes.NewLicenseFromStorableLicense(storableLicense)
if err != nil {
return nil, err
}
licenses = append(licenses, license)
}
return licenses, nil
}
func (provider *provider) Delete(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) error {
license, err := provider.Get(ctx, organizationID, licenseID)
if err != nil {
return err
}
return nil
if err := license.ErrIfCloud(); err != nil {
return errors.WithAdditionalf(err, "license %s cannot be deleted", licenseID.StringValue())
}
return provider.store.Delete(ctx, organizationID, licenseID)
}
func (provider *provider) GetActive(ctx context.Context, organizationID valuer.UUID) (*licensetypes.License, error) {
@@ -139,7 +180,7 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI
return err
}
data, err := provider.zeus.GetLicense(ctx, activeLicense.Key)
zeusLicense, err := provider.zeus.GetLicense(ctx, activeLicense.Key)
if err != nil {
if time.Since(activeLicense.LastValidatedAt) > time.Duration(provider.config.FailureThreshold)*provider.config.PollInterval {
activeLicense.UpdateFeatures(licensetypes.BasicPlan)
@@ -154,7 +195,7 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI
return err
}
err = activeLicense.Update(data)
err = activeLicense.Update(zeusLicense)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create license entity from license data")
}

View File

@@ -64,6 +64,22 @@ func (store *store) GetAll(ctx context.Context, organizationID valuer.UUID) ([]*
return storableLicenses, nil
}
func (store *store) Delete(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) error {
_, err := store.
sqlstore.
BunDB().
NewDelete().
Model(new(licensetypes.StorableLicense)).
Where("org_id = ?", organizationID).
Where("id = ?", licenseID).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to delete license with ID: %s", licenseID)
}
return nil
}
func (store *store) Update(ctx context.Context, organizationID valuer.UUID, storableLicense *licensetypes.StorableLicense) error {
_, err := store.
sqlstore.

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

@@ -76,11 +76,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost)
// v3
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Activate)).Methods(http.MethodPost)
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Refresh)).Methods(http.MethodPut)
router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(ah.LicensingAPI.GetActive)).Methods(http.MethodGet)
// v4
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)

View File

@@ -51,7 +51,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
}, nil
}
func (provider *Provider) GetLicense(ctx context.Context, key string) ([]byte, error) {
func (provider *Provider) GetLicense(ctx context.Context, key string) (*zeustypes.License, error) {
response, err := provider.do(
ctx,
provider.config.URL.JoinPath("/v2/licenses/me"),
@@ -63,7 +63,12 @@ func (provider *Provider) GetLicense(ctx context.Context, key string) ([]byte, e
return nil, err
}
return []byte(gjson.GetBytes(response, "data").String()), nil
license := new(zeustypes.License)
if err := json.Unmarshal([]byte(gjson.GetBytes(response, "data").String()), license); err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, zeus.ErrCodeResponseMalformed, "failed to unmarshal license data")
}
return license, nil
}
func (provider *Provider) GetCheckoutURL(ctx context.Context, key string, body []byte) ([]byte, error) {

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

@@ -103,30 +103,30 @@ function createMockLicense(
overrides: Partial<LicenseResModel> = {},
): LicenseResModel {
return {
key: 'test-key',
event_queue: {
created_at: '0',
id: 'test-license-id',
eventQueue: {
createdAt: '0',
event: LicenseEvent.NO_EVENT,
scheduled_at: '0',
scheduledAt: '0',
status: '',
updated_at: '0',
updatedAt: '0',
},
state: LicenseState.ACTIVATED,
status: LicenseStatus.VALID,
platform: LicensePlatform.CLOUD,
created_at: '0',
createdAt: '0',
plan: {
created_at: '0',
id: '0',
createdAt: '0',
description: '',
is_active: true,
isActive: true,
name: '',
updated_at: '0',
updatedAt: '0',
},
plan_id: '0',
free_until: '0',
updated_at: '0',
valid_from: 0,
valid_until: 0,
freeUntil: '0',
updatedAt: '0',
validFrom: 0,
validUntil: 0,
...overrides,
};
}
@@ -850,6 +850,22 @@ describe('PrivateRoute', () => {
assertStaysOnRoute(ROUTES.WORKSPACE_LOCKED);
});
it('should keep a custom role (ANONYMOUS) on workspace locked instead of bouncing to unauthorized', () => {
renderPrivateRoute({
initialRoute: ROUTES.WORKSPACE_LOCKED,
appContext: {
isLoggedIn: true,
isFetchingActiveLicense: false,
activeLicense: createMockLicense({ platform: LicensePlatform.CLOUD }),
trialInfo: createMockTrialInfo({ workSpaceBlock: true }),
user: createMockUser({ role: USER_ROLES.ANONYMOUS as ROLES }),
},
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.WORKSPACE_LOCKED);
});
it('should not redirect self-hosted users to workspace locked even when workSpaceBlock is true', () => {
renderPrivateRoute({
initialRoute: ROUTES.HOME,
@@ -1024,6 +1040,24 @@ describe('PrivateRoute', () => {
assertStaysOnRoute(ROUTES.WORKSPACE_SUSPENDED);
});
it('should keep a custom role (ANONYMOUS) on workspace suspended instead of bouncing to unauthorized', () => {
renderPrivateRoute({
initialRoute: ROUTES.WORKSPACE_SUSPENDED,
appContext: {
isLoggedIn: true,
isFetchingActiveLicense: false,
activeLicense: createMockLicense({
platform: LicensePlatform.CLOUD,
state: LicenseState.DEFAULTED,
}),
user: createMockUser({ role: USER_ROLES.ANONYMOUS as ROLES }),
},
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.WORKSPACE_SUSPENDED);
});
it('should not redirect self-hosted users to workspace suspended when license is defaulted', () => {
renderPrivateRoute({
initialRoute: ROUTES.HOME,
@@ -1580,6 +1614,18 @@ describe('PrivateRoute', () => {
path: ROUTES.SUPPORT,
deniedRoles: [USER_ROLES.AUTHOR as ROLES],
},
WORKSPACE_LOCKED: {
path: ROUTES.WORKSPACE_LOCKED,
deniedRoles: DENIED_ROLES,
},
WORKSPACE_SUSPENDED: {
path: ROUTES.WORKSPACE_SUSPENDED,
deniedRoles: DENIED_ROLES,
},
WORKSPACE_ACCESS_RESTRICTED: {
path: ROUTES.WORKSPACE_ACCESS_RESTRICTED,
deniedRoles: DENIED_ROLES,
},
};
const authzRouteRolePairs: [string, string, ROLES][] = Object.entries(

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,702 @@
/**
* ! 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 {
ActivateLicense201,
DeleteLicensePathParameters,
GetActiveLicense200,
GetLicense200,
GetLicensePathParameters,
LicensetypesPostableLicenseDTO,
ListLicenses200,
RefreshLicensePathParameters,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint validates the license key with the upstream server and activates the license for the organization.
* @deprecated
* @summary Activate a license.
*/
export const activateLicenseDeprecated = (
licensetypesPostableLicenseDTO?: BodyType<LicensetypesPostableLicenseDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v3/licenses`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: licensetypesPostableLicenseDTO,
signal,
});
};
export const getActivateLicenseDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof activateLicenseDeprecated>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof activateLicenseDeprecated>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
> => {
const mutationKey = ['activateLicenseDeprecated'];
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 activateLicenseDeprecated>>,
{ data?: BodyType<LicensetypesPostableLicenseDTO> }
> = (props) => {
const { data } = props ?? {};
return activateLicenseDeprecated(data);
};
return { mutationFn, ...mutationOptions };
};
export type ActivateLicenseDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof activateLicenseDeprecated>>
>;
export type ActivateLicenseDeprecatedMutationBody =
| BodyType<LicensetypesPostableLicenseDTO>
| undefined;
export type ActivateLicenseDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Activate a license.
*/
export const useActivateLicenseDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof activateLicenseDeprecated>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof activateLicenseDeprecated>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
> => {
return useMutation(getActivateLicenseDeprecatedMutationOptions(options));
};
/**
* This endpoint refreshes the active license of the organization from the upstream server.
* @deprecated
* @summary Refresh a license.
*/
export const refreshLicenseDeprecated = (signal?: AbortSignal) => {
return GeneratedAPIInstance<void>({
url: `/api/v3/licenses`,
method: 'PUT',
signal,
});
};
export const getRefreshLicenseDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof refreshLicenseDeprecated>>,
TError,
void,
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof refreshLicenseDeprecated>>,
TError,
void,
TContext
> => {
const mutationKey = ['refreshLicenseDeprecated'];
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 refreshLicenseDeprecated>>,
void
> = () => {
return refreshLicenseDeprecated();
};
return { mutationFn, ...mutationOptions };
};
export type RefreshLicenseDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof refreshLicenseDeprecated>>
>;
export type RefreshLicenseDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Refresh a license.
*/
export const useRefreshLicenseDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof refreshLicenseDeprecated>>,
TError,
void,
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof refreshLicenseDeprecated>>,
TError,
void,
TContext
> => {
return useMutation(getRefreshLicenseDeprecatedMutationOptions(options));
};
/**
* This endpoint lists all the licenses of the organization.
* @summary List licenses.
*/
export const listLicenses = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListLicenses200>({
url: `/api/v4/licenses`,
method: 'GET',
signal,
});
};
export const getListLicensesQueryKey = () => {
return [`/api/v4/licenses`] as const;
};
export const getListLicensesQueryOptions = <
TData = Awaited<ReturnType<typeof listLicenses>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listLicenses>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListLicensesQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listLicenses>>> = ({
signal,
}) => listLicenses(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listLicenses>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListLicensesQueryResult = NonNullable<
Awaited<ReturnType<typeof listLicenses>>
>;
export type ListLicensesQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List licenses.
*/
export function useListLicenses<
TData = Awaited<ReturnType<typeof listLicenses>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listLicenses>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListLicensesQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List licenses.
*/
export const invalidateListLicenses = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListLicensesQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoint validates the license key with the upstream server and activates the license for the organization.
* @summary Activate a license.
*/
export const activateLicense = (
licensetypesPostableLicenseDTO?: BodyType<LicensetypesPostableLicenseDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ActivateLicense201>({
url: `/api/v4/licenses`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: licensetypesPostableLicenseDTO,
signal,
});
};
export const getActivateLicenseMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
> => {
const mutationKey = ['activateLicense'];
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 activateLicense>>,
{ data?: BodyType<LicensetypesPostableLicenseDTO> }
> = (props) => {
const { data } = props ?? {};
return activateLicense(data);
};
return { mutationFn, ...mutationOptions };
};
export type ActivateLicenseMutationResult = NonNullable<
Awaited<ReturnType<typeof activateLicense>>
>;
export type ActivateLicenseMutationBody =
| BodyType<LicensetypesPostableLicenseDTO>
| undefined;
export type ActivateLicenseMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Activate a license.
*/
export const useActivateLicense = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
> => {
return useMutation(getActivateLicenseMutationOptions(options));
};
/**
* This endpoint deletes the license by id. Licenses managed by SigNoz Cloud cannot be deleted.
* @summary Delete a license.
*/
export const deleteLicense = (
{ id }: DeleteLicensePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v4/licenses/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteLicenseMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteLicense>>,
TError,
{ pathParams: DeleteLicensePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteLicense>>,
TError,
{ pathParams: DeleteLicensePathParameters },
TContext
> => {
const mutationKey = ['deleteLicense'];
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 deleteLicense>>,
{ pathParams: DeleteLicensePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteLicense(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteLicenseMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteLicense>>
>;
export type DeleteLicenseMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete a license.
*/
export const useDeleteLicense = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteLicense>>,
TError,
{ pathParams: DeleteLicensePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteLicense>>,
TError,
{ pathParams: DeleteLicensePathParameters },
TContext
> => {
return useMutation(getDeleteLicenseMutationOptions(options));
};
/**
* This endpoint gets the license by id.
* @summary Get a license.
*/
export const getLicense = (
{ id }: GetLicensePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetLicense200>({
url: `/api/v4/licenses/${id}`,
method: 'GET',
signal,
});
};
export const getGetLicenseQueryKey = ({ id }: GetLicensePathParameters) => {
return [`/api/v4/licenses/${id}`] as const;
};
export const getGetLicenseQueryOptions = <
TData = Awaited<ReturnType<typeof getLicense>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetLicensePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getLicense>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetLicenseQueryKey({ id });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getLicense>>> = ({
signal,
}) => getLicense({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getLicense>>, TError, TData> & {
queryKey: QueryKey;
};
};
export type GetLicenseQueryResult = NonNullable<
Awaited<ReturnType<typeof getLicense>>
>;
export type GetLicenseQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get a license.
*/
export function useGetLicense<
TData = Awaited<ReturnType<typeof getLicense>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetLicensePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getLicense>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetLicenseQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get a license.
*/
export const invalidateGetLicense = async (
queryClient: QueryClient,
{ id }: GetLicensePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetLicenseQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint refreshes the active license of the organization from the upstream server.
* @summary Refresh a license.
*/
export const refreshLicense = (
{ id }: RefreshLicensePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v4/licenses/${id}`,
method: 'PUT',
signal,
});
};
export const getRefreshLicenseMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
{ pathParams: RefreshLicensePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
{ pathParams: RefreshLicensePathParameters },
TContext
> => {
const mutationKey = ['refreshLicense'];
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 refreshLicense>>,
{ pathParams: RefreshLicensePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return refreshLicense(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type RefreshLicenseMutationResult = NonNullable<
Awaited<ReturnType<typeof refreshLicense>>
>;
export type RefreshLicenseMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Refresh a license.
*/
export const useRefreshLicense = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
{ pathParams: RefreshLicensePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
{ pathParams: RefreshLicensePathParameters },
TContext
> => {
return useMutation(getRefreshLicenseMutationOptions(options));
};
/**
* This endpoint gets the active license of the organization.
* @summary Get the active license.
*/
export const getActiveLicense = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetActiveLicense200>({
url: `/api/v4/licenses/active`,
method: 'GET',
signal,
});
};
export const getGetActiveLicenseQueryKey = () => {
return [`/api/v4/licenses/active`] as const;
};
export const getGetActiveLicenseQueryOptions = <
TData = Awaited<ReturnType<typeof getActiveLicense>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getActiveLicense>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetActiveLicenseQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getActiveLicense>>> = ({
signal,
}) => getActiveLicense(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getActiveLicense>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetActiveLicenseQueryResult = NonNullable<
Awaited<ReturnType<typeof getActiveLicense>>
>;
export type GetActiveLicenseQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get the active license.
*/
export function useGetActiveLicense<
TData = Awaited<ReturnType<typeof getActiveLicense>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getActiveLicense>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetActiveLicenseQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get the active license.
*/
export const invalidateGetActiveLicense = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetActiveLicenseQueryKey() },
options,
);
return queryClient;
};

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

@@ -1,298 +0,0 @@
/**
* ! 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 {
GetQuickFilters200,
GetQuickFiltersPathParameters,
ListQuickFilters200,
QuickfiltertypesUpdatableQuickFiltersDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Returns the org's quick filters for every signal, each filter as a telemetry field key.
* @summary List quick filters
*/
export const listQuickFilters = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListQuickFilters200>({
url: `/api/v2/quick_filters`,
method: 'GET',
signal,
});
};
export const getListQuickFiltersQueryKey = () => {
return [`/api/v2/quick_filters`] as const;
};
export const getListQuickFiltersQueryOptions = <
TData = Awaited<ReturnType<typeof listQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listQuickFilters>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListQuickFiltersQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listQuickFilters>>> = ({
signal,
}) => listQuickFilters(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listQuickFilters>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListQuickFiltersQueryResult = NonNullable<
Awaited<ReturnType<typeof listQuickFilters>>
>;
export type ListQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List quick filters
*/
export function useListQuickFilters<
TData = Awaited<ReturnType<typeof listQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listQuickFilters>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListQuickFiltersQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List quick filters
*/
export const invalidateListQuickFilters = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListQuickFiltersQueryKey() },
options,
);
return queryClient;
};
/**
* Replaces the org's quick filters for the signal named in the body.
* @summary Update quick filters
*/
export const updateQuickFilters = (
quickfiltertypesUpdatableQuickFiltersDTO?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/quick_filters`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: quickfiltertypesUpdatableQuickFiltersDTO,
signal,
});
};
export const getUpdateQuickFiltersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
TContext
> => {
const mutationKey = ['updateQuickFilters'];
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 updateQuickFilters>>,
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> }
> = (props) => {
const { data } = props ?? {};
return updateQuickFilters(data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateQuickFiltersMutationResult = NonNullable<
Awaited<ReturnType<typeof updateQuickFilters>>
>;
export type UpdateQuickFiltersMutationBody =
| BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>
| undefined;
export type UpdateQuickFiltersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update quick filters
*/
export const useUpdateQuickFilters = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
TContext
> => {
return useMutation(getUpdateQuickFiltersMutationOptions(options));
};
/**
* Returns the org's quick filters for one signal, each filter as a telemetry field key.
* @summary Get a signal's quick filters
*/
export const getQuickFilters = (
{ signalName }: GetQuickFiltersPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetQuickFilters200>({
url: `/api/v2/quick_filters/${signalName}`,
method: 'GET',
signal,
});
};
export const getGetQuickFiltersQueryKey = ({
signalName,
}: GetQuickFiltersPathParameters) => {
return [`/api/v2/quick_filters/${signalName}`] as const;
};
export const getGetQuickFiltersQueryOptions = <
TData = Awaited<ReturnType<typeof getQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ signalName }: GetQuickFiltersPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getQuickFilters>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetQuickFiltersQueryKey({ signalName });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getQuickFilters>>> = ({
signal,
}) => getQuickFilters({ signalName }, signal);
return {
queryKey,
queryFn,
enabled: !!signalName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getQuickFilters>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetQuickFiltersQueryResult = NonNullable<
Awaited<ReturnType<typeof getQuickFilters>>
>;
export type GetQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get a signal's quick filters
*/
export function useGetQuickFilters<
TData = Awaited<ReturnType<typeof getQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ signalName }: GetQuickFiltersPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getQuickFilters>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetQuickFiltersQueryOptions({ signalName }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get a signal's quick filters
*/
export const invalidateGetQuickFilters = async (
queryClient: QueryClient,
{ signalName }: GetQuickFiltersPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetQuickFiltersQueryKey({ signalName }) },
options,
);
return queryClient;
};

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
*/
@@ -3268,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;
/**
@@ -4960,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',
@@ -7173,6 +7313,249 @@ export interface InframonitoringtypesVolumesDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface LicensetypesFeatureDTO {
/**
* @type boolean
*/
active?: boolean;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
route?: string;
/**
* @type integer
* @format int64
*/
usage?: number;
/**
* @type integer
* @format int64
*/
usage_limit?: number;
}
export interface LicensetypesLicenseEventQueueDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
event: string;
/**
* @type string
* @format date-time
*/
scheduledAt: string;
/**
* @type string
*/
status: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface LicensetypesLicensePlanDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
description: string;
/**
* @type string
*/
id: string;
/**
* @type boolean
*/
isActive: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface LicensetypesGettableActiveLicenseDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
eventQueue: LicensetypesLicenseEventQueueDTO;
/**
* @type array
*/
features: LicensetypesFeatureDTO[];
/**
* @type string
* @format date-time
*/
freeUntil: string;
/**
* @type string
*/
id: string;
plan: LicensetypesLicensePlanDTO;
/**
* @type string
*/
platform: string;
/**
* @type string
*/
state: string;
/**
* @type string
*/
status: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
/**
* @type integer
* @format int64
*/
validFrom: number;
/**
* @type integer
* @format int64
*/
validUntil: number;
}
export interface LicensetypesGettableLicenseDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
eventQueue: LicensetypesLicenseEventQueueDTO;
/**
* @type array
*/
features: LicensetypesFeatureDTO[];
/**
* @type string
* @format date-time
*/
freeUntil: string;
/**
* @type string
*/
id: string;
plan: LicensetypesLicensePlanDTO;
/**
* @type string
*/
platform: string;
/**
* @type string
*/
state: string;
/**
* @type string
*/
status: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
/**
* @type integer
* @format int64
*/
validFrom: number;
/**
* @type integer
* @format int64
*/
validUntil: number;
}
export interface LicensetypesGettableLicenseWithKeyDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
eventQueue: LicensetypesLicenseEventQueueDTO;
/**
* @type array
*/
features: LicensetypesFeatureDTO[];
/**
* @type string
* @format date-time
*/
freeUntil: string;
/**
* @type string
*/
id: string;
/**
* @type string
* @format password
*/
key: string;
plan: LicensetypesLicensePlanDTO;
/**
* @type string
*/
platform: string;
/**
* @type string
*/
state: string;
/**
* @type string
*/
status: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
/**
* @type integer
* @format int64
*/
validFrom: number;
/**
* @type integer
* @format int64
*/
validUntil: number;
}
export interface LicensetypesPostableLicenseDTO {
/**
* @type string
* @format password
*/
key?: string;
}
/**
* @nullable
*/
@@ -7974,6 +8357,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;
/**
@@ -8437,28 +8978,6 @@ export enum Querybuildertypesv5QueryTypeDTO {
clickhouse_sql = 'clickhouse_sql',
promql = 'promql',
}
export interface QuickfiltertypesSignalFiltersDTO {
/**
* @type array,null
*/
filters?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
/**
* @type string
*/
signal?: string;
}
export interface QuickfiltertypesUpdatableQuickFiltersDTO {
/**
* @type array,null
*/
filters?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
/**
* @type string
*/
signal?: string;
}
export interface RenderErrorResponseDTO {
error: ErrorsJSONDTO;
/**
@@ -11335,6 +11854,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
@@ -11849,28 +12379,6 @@ export type GetPublicDashboardPanelQueryRangeV2200 = {
status: string;
};
export type ListQuickFilters200 = {
/**
* @type array,null
*/
data: QuickfiltertypesSignalFiltersDTO[] | null;
/**
* @type string
*/
status: string;
};
export type GetQuickFiltersPathParameters = {
signalName: string;
};
export type GetQuickFilters200 = {
data: QuickfiltertypesSignalFiltersDTO;
/**
* @type string
*/
status: string;
};
export type Readyz200 = {
data: FactoryResponseDTO;
/**
@@ -12473,6 +12981,50 @@ export type GetFlamegraph200 = {
status: string;
};
export type ListLicenses200 = {
/**
* @type array
*/
data: LicensetypesGettableLicenseDTO[];
/**
* @type string
*/
status: string;
};
export type ActivateLicense201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteLicensePathParameters = {
id: string;
};
export type GetLicensePathParameters = {
id: string;
};
export type GetLicense200 = {
data: LicensetypesGettableLicenseWithKeyDTO;
/**
* @type string
*/
status: string;
};
export type RefreshLicensePathParameters = {
id: string;
};
export type GetActiveLicense200 = {
data: LicensetypesGettableActiveLicenseDTO;
/**
* @type string
*/
status: string;
};
export type GetWaterfallV4PathParameters = {
traceID: string;
};
@@ -12515,3 +13067,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

@@ -0,0 +1,25 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/quickFilters/getCustomFilters';
const getCustomFilters = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
const { signal } = props;
try {
const response = await axios.get(`/orgs/me/filters/${signal}`);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getCustomFilters;

View File

@@ -0,0 +1,13 @@
import axios from 'api';
import { AxiosError } from 'axios';
import { SuccessResponse } from 'types/api';
import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters';
const updateCustomFiltersAPI = async (
props: UpdateCustomFiltersProps,
): Promise<SuccessResponse<void> | AxiosError> =>
axios.put(`/orgs/me/filters`, {
...props.data,
});
export default updateCustomFiltersAPI;

View File

@@ -1,25 +0,0 @@
import { ApiV3Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
LicenseEventQueueResModel,
PayloadProps,
} from 'types/api/licensesV3/getActive';
const getActive = async (): Promise<
SuccessResponseV2<LicenseEventQueueResModel>
> => {
try {
const response = await axios.get<PayloadProps>('/licenses/active');
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getActive;

View File

@@ -1,24 +0,0 @@
import { ApiV3Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/licenses/apply';
const apply = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/licenses', {
key: props.key,
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default apply;

View File

@@ -1,20 +0,0 @@
import { ApiV3Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps } from 'types/api/licenses/apply';
const apply = async (): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>('/licenses');
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default apply;

View File

@@ -57,8 +57,8 @@ function MenuItemGenerator({
handleExplorerTabChange(currentPanelType, {
query,
name,
id,
viewName: name,
viewKey: id,
});
},
[viewData, handleExplorerTabChange],

View File

@@ -101,6 +101,45 @@ export function deriveCheckboxState({
return filterState;
}
/**
* Returns a new query with every clause for this attribute key removed, both
* from the structured filter items and the raw filter expression.
*/
export function clearFilterFromQuery({
currentQuery,
filter,
activeQueryIndex,
}: {
currentQuery: Query;
filter: IQuickFiltersConfig;
activeQueryIndex: number;
}): Query {
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filter: {
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
filter.attributeKey.key,
]),
},
filters: {
...item.filters,
items:
idx === activeQueryIndex
? item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
) || []
: [...(item.filters?.items || [])],
op: item.filters?.op || 'AND',
},
})),
},
};
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function applyCheckboxToggle({
currentQuery,

View File

@@ -7,8 +7,10 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { applyCheckboxToggle } from './checkboxFilterQuery';
import { clearFilterFromQuery } from '../shared/filterQuery';
import {
applyCheckboxToggle,
clearFilterFromQuery,
} from './checkboxFilterQuery';
import { CheckedState } from '../../types';
import { SectionType } from './v2/itemRules';
@@ -92,13 +94,7 @@ function useCheckboxFilterActions({
};
const onClear = (): void => {
dispatch(
clearFilterFromQuery({
currentQuery,
filterKey: filter.attributeKey.key,
activeQueryIndex,
}),
);
dispatch(clearFilterFromQuery({ currentQuery, filter, activeQueryIndex }));
};
return { onChange, onClear };

View File

@@ -3,7 +3,6 @@ import { Input } from '@signozhq/ui/input';
import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LoaderCircle } from '@signozhq/icons';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import {
IQuickFiltersConfig,
QuickFilterChangeEventData,
@@ -45,14 +44,8 @@ export default function CheckboxFilterV2(
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
props;
const [searchText, setSearchText] = useState<string>('');
const [isSearchOpen, setIsSearchOpen] = useState<boolean>(false);
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const handleToggleSearch = (): void => {
setIsSearchOpen((prev) => !prev);
setSearchText('');
};
const { currentQuery } = useQueryBuilder();
const activeQueryIndex = useActiveQueryIndex(source);
@@ -81,10 +74,6 @@ export default function CheckboxFilterV2(
searchText,
existingQuery,
metricNamespace: useFieldApis.metricNamespace,
source:
source === QuickFiltersSource.METER_EXPLORER
? TelemetrytypesSourceDTO.meter
: undefined,
startUnixMilli: useFieldApis.startUnixMilli,
endUnixMilli: useFieldApis.endUnixMilli,
enabled: isOpen,
@@ -173,9 +162,12 @@ export default function CheckboxFilterV2(
<CheckboxFilterV2Header
title={filter.title}
isOpen={isOpen}
showClearAll={!!attributeValues.length}
onToggleOpen={onToggleOpen}
onToggleSearch={handleToggleSearch}
onClear={onClear}
isSomeFilterPresentForCurrentAttribute={
isSomeFilterPresentForCurrentAttribute
}
/>
{isOpen && isLoading && !hasLoadedOnce.current && (
<section>
@@ -184,26 +176,23 @@ export default function CheckboxFilterV2(
)}
{isOpen && (!isLoading || hasLoadedOnce.current) && (
<>
{isSearchOpen && (
<section className={styles.search}>
<Input
autoFocus
placeholder="Filter values"
onChange={(e): void => setSearchTextDebounced(e.target.value)}
disabled={isFilterDisabled}
data-testid="checkbox-filter-search"
suffix={
isFetching ? (
<LoaderCircle
size={14}
className={styles.searchSpinner}
data-testid="checkbox-filter-search-loading"
/>
) : null
}
/>
</section>
)}
<section className={styles.search}>
<Input
placeholder="Filter values"
onChange={(e): void => setSearchTextDebounced(e.target.value)}
disabled={isFilterDisabled}
data-testid="checkbox-filter-search"
suffix={
isFetching ? (
<LoaderCircle
size={14}
className={styles.searchSpinner}
data-testid="checkbox-filter-search-loading"
/>
) : null
}
/>
</section>
{totalCount > 0 && (
<section className={styles.values}>

View File

@@ -3,20 +3,12 @@
align-items: center;
justify-content: space-between;
cursor: pointer;
gap: var(--spacing-2);
}
.leftAction {
display: flex;
align-items: center;
gap: var(--spacing-3);
flex: 1 1 auto;
min-width: 0;
// The collapse chevron must keep its size; only the title absorbs the squeeze.
> svg {
flex-shrink: 0;
}
}
.title {
@@ -26,31 +18,16 @@
line-height: 18px;
letter-spacing: -0.07px;
text-transform: capitalize;
// Always ellipsize a long name; on hover the actions take width and it
// compresses further.
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rightAction {
display: flex;
align-items: center;
gap: var(--spacing-1);
flex-shrink: 0;
// Always laid out so the header height stays constant (no shift on hover);
// collapsed to zero width until hover, so the title uses the full width and
// reflows/ellipsizes when the actions appear.
width: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
min-width: 48px;
}
.header:hover .rightAction {
width: auto;
opacity: 1;
pointer-events: auto;
.clearAll {
font-size: 12px;
color: var(--accent-primary);
cursor: pointer;
}

View File

@@ -1,24 +1,24 @@
import { Typography } from '@signozhq/ui/typography';
import { ChevronDown, ChevronRight, Search, Undo2 } from '@signozhq/icons';
import { SectionActionButton } from '../../shared/SectionActionButton/SectionActionButton';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import styles from './CheckboxFilterV2Header.module.scss';
interface CheckboxFilterHeaderProps {
title: string;
isOpen: boolean;
showClearAll: boolean;
onToggleOpen: () => void;
onToggleSearch: () => void;
onClear: () => void;
isSomeFilterPresentForCurrentAttribute: boolean;
}
export function CheckboxFilterV2Header({
title,
isOpen,
showClearAll,
onToggleOpen,
onToggleSearch,
onClear,
isSomeFilterPresentForCurrentAttribute,
}: CheckboxFilterHeaderProps): JSX.Element {
return (
<section
@@ -42,22 +42,21 @@ export function CheckboxFilterV2Header({
)}
<Typography.Text className={styles.title}>{title}</Typography.Text>
</section>
{isOpen && (
<section className={styles.rightAction}>
<SectionActionButton
icon={<Search size={14} />}
tooltip="Search"
onClick={onToggleSearch}
testId="checkbox-filter-search-toggle"
/>
<SectionActionButton
icon={<Undo2 size={14} />}
tooltip="Reset"
onClick={onClear}
testId="checkbox-filter-clear-all"
/>
</section>
)}
<section className={styles.rightAction}>
{isOpen && showClearAll && isSomeFilterPresentForCurrentAttribute && (
<Typography.Text
className={styles.clearAll}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
onClear();
}}
data-testid="checkbox-filter-clear-all"
>
Clear
</Typography.Text>
)}
</section>
</section>
);
}

View File

@@ -59,7 +59,6 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-production');
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -145,7 +144,6 @@ describe('CheckboxFilterV2 - interactions', () => {
// Related values now appear in "Related" section (no badge, uses divider instead)
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -195,7 +193,6 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-prod');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -240,7 +237,6 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-prod');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'xyz-no-match');
@@ -348,7 +344,6 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-pod-a-v1');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'pod-a');
@@ -523,7 +518,7 @@ describe('CheckboxFilterV2 - interactions', () => {
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
it('shows the reset action when expanded even with no active filter', async () => {
it('hides clear button when no filter applied for attribute', async () => {
mockFieldsValuesAPI({
stringValues: ['production'],
});
@@ -538,9 +533,9 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-production');
// Reset is always available on an expanded section now (hover-gated via
// CSS), not conditional on an active filter.
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
});
it('calls onFilterChange when clear clicked', async () => {

View File

@@ -110,7 +110,6 @@ describe('CheckboxFilterV2 - states', () => {
await screen.findByTestId('checkbox-value-row-production');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');

View File

@@ -7,8 +7,9 @@ describe('CheckboxFilterV2Header', () => {
const defaultProps = {
title: 'Environment',
isOpen: false,
showClearAll: true,
isSomeFilterPresentForCurrentAttribute: true,
onToggleOpen: jest.fn(),
onToggleSearch: jest.fn(),
onClear: jest.fn(),
};
@@ -30,12 +31,11 @@ describe('CheckboxFilterV2Header', () => {
expect(header).toHaveAttribute('data-state', 'closed');
});
it('does not render the section actions when collapsed', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
it('does not show clear button when collapsed', () => {
render(
<CheckboxFilterV2Header {...defaultProps} isOpen={false} showClearAll />,
);
expect(
screen.queryByTestId('checkbox-filter-search-toggle'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
@@ -50,13 +50,36 @@ describe('CheckboxFilterV2Header', () => {
expect(header).toHaveAttribute('data-state', 'open');
});
it('renders both search and reset actions when expanded', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
it('shows clear button when expanded + showClearAll=true', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll />);
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
expect(screen.getByText('Clear')).toBeInTheDocument();
});
it('hides clear button when showClearAll=false', () => {
render(
<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll={false} />,
);
expect(
screen.getByTestId('checkbox-filter-search-toggle'),
).toBeInTheDocument();
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
});
it('hides clear button when no filter present for attribute', () => {
render(
<CheckboxFilterV2Header
{...defaultProps}
isOpen
showClearAll
isSomeFilterPresentForCurrentAttribute={false}
/>,
);
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
});
});
@@ -99,35 +122,28 @@ describe('CheckboxFilterV2Header', () => {
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
it('calls onToggleSearch on search click without toggling open', async () => {
const user = userEvent.setup();
const onToggleSearch = jest.fn();
const onToggleOpen = jest.fn();
render(
<CheckboxFilterV2Header
{...defaultProps}
isOpen
onToggleSearch={onToggleSearch}
onToggleOpen={onToggleOpen}
/>,
);
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
expect(onToggleSearch).toHaveBeenCalledTimes(1);
expect(onToggleOpen).not.toHaveBeenCalled();
});
it('calls onClear on reset click without toggling open', async () => {
it('calls onClear on clear button click', async () => {
const user = userEvent.setup();
const onClear = jest.fn();
render(
<CheckboxFilterV2Header {...defaultProps} isOpen onClear={onClear} />,
);
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
expect(onClear).toHaveBeenCalledTimes(1);
});
it('clear button click does not trigger onToggleOpen', async () => {
const user = userEvent.setup();
const onToggleOpen = jest.fn();
const onClear = jest.fn();
render(
<CheckboxFilterV2Header
{...defaultProps}
isOpen
onClear={onClear}
onToggleOpen={onToggleOpen}
onClear={onClear}
/>,
);

View File

@@ -1,11 +1,7 @@
import { useMemo } from 'react';
import { useGetFieldsValues } from 'api/generated/services/fields';
import {
TelemetrytypesSignalDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
@@ -14,7 +10,6 @@ interface UseFieldValuesProps {
searchText: string;
existingQuery?: string;
metricNamespace?: string;
source?: TelemetrytypesSourceDTO;
startUnixMilli?: number;
endUnixMilli?: number;
enabled: boolean;
@@ -38,7 +33,6 @@ export function useFieldValues({
searchText,
existingQuery,
metricNamespace,
source,
startUnixMilli,
endUnixMilli,
enabled,
@@ -52,7 +46,6 @@ export function useFieldValues({
searchText,
existingQuery,
metricNamespace,
source,
startUnixMilli,
// This field does not affect the backend but I wanted to keep it here
// in case we add the support in the future
@@ -82,12 +75,6 @@ export function useFieldValues({
}, [data]);
const allValues: string[] = useMemo(() => {
// Bool fields should always offer true/false.
// The values api returns nothing for them.
if (filter.attributeKey.dataType === DataTypes.bool) {
return ['true', 'false'];
}
const values = data?.data?.values;
if (!values) {
return [];
@@ -104,7 +91,7 @@ export function useFieldValues({
.map((value) => value.toString()) || [];
return [...stringValues, ...numberValues];
}, [data, filter.attributeKey.dataType]);
}, [data]);
return { relatedValues, allValues, isLoading, isFetching };
}

View File

@@ -11,16 +11,6 @@
padding-right: 9px !important;
}
.duration-reset {
opacity: 0;
pointer-events: none;
}
.ant-collapse-header:hover .duration-reset {
opacity: 1;
pointer-events: auto;
}
.ant-collapse-header-text {
color: var(--l2-foreground);
font-family: Inter;
@@ -115,6 +105,11 @@
.section-body-header {
display: flex;
> button {
position: absolute;
right: 4px;
padding-top: 13px;
}
.ant-collapse {
width: 100%;
}

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Collapse } from 'antd';
import { Undo2 } from '@signozhq/icons';
import { Button, Collapse } from 'antd';
import {
IQuickFiltersConfig,
QuickFiltersSource,
@@ -15,16 +14,12 @@ import {
AllTraceFilterKeys,
AllTraceFilterKeyValue,
HandleRunProps,
traceFilterKeys,
unionTagFilterItems,
} from 'pages/TracesExplorer/Filter/filterUtils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { clearFilterFromQuery } from '../shared/filterQuery';
import { SectionActionButton } from '../shared/SectionActionButton/SectionActionButton';
import './Duration.styles.scss';
export type FilterType = Record<
@@ -273,19 +268,12 @@ function Duration({
handleRun();
}, [selectedFilters]);
const onClearHandler = (): void => {
if (!selectedFilters?.durationNanoMin && !selectedFilters?.durationNanoMax) {
return;
}
const clearedQuery = clearFilterFromQuery({
currentQuery,
filterKey: traceFilterKeys.durationNano.key,
activeQueryIndex,
});
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(clearedQuery);
} else {
redirectWithQueryBuilderData(clearedQuery);
const onClearHandler = (e: React.MouseEvent): void => {
e.stopPropagation();
e.preventDefault();
if (selectedFilters?.durationNanoMin || selectedFilters?.durationNanoMax) {
handleRun({ clearByType: 'durationNano' });
}
};
@@ -306,19 +294,18 @@ function Duration({
/>
),
label: 'Duration',
extra: activeKeys.includes('durationNano') ? (
<div className="duration-reset">
<SectionActionButton
icon={<Undo2 size={14} />}
tooltip="Reset"
onClick={onClearHandler}
testId="collapse-duration-clearBtn"
/>
</div>
) : undefined,
},
]}
/>
{activeKeys.includes('durationNano') && (
<Button
type="link"
onClick={onClearHandler}
data-testid="collapse-duration-clearBtn"
>
Clear All
</Button>
)}
</div>
);
}

View File

@@ -1,8 +0,0 @@
.iconBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
min-width: 24px;
height: 24px;
}

View File

@@ -1,39 +0,0 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from 'antd';
import styles from './SectionActionButton.module.scss';
interface SectionActionButtonProps {
icon: ReactNode;
tooltip: string;
onClick: () => void;
testId: string;
}
export function SectionActionButton({
icon,
tooltip,
onClick,
testId,
}: SectionActionButtonProps): JSX.Element {
return (
<Tooltip title={tooltip}>
<Button
variant="link"
color="secondary"
size="sm"
className={styles.iconBtn}
onMouseDown={(e): void => e.preventDefault()}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
onClick();
}}
data-testid={testId}
>
{icon}
</Button>
</Tooltip>
);
}

View File

@@ -1,43 +0,0 @@
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { isKeyMatch } from '../Checkbox/utils';
/**
* Returns a new query with every clause for this attribute key removed, both
* from the structured filter items and the raw filter expression.
*/
export function clearFilterFromQuery({
currentQuery,
filterKey,
activeQueryIndex,
}: {
currentQuery: Query;
filterKey: string;
activeQueryIndex: number;
}): Query {
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filter: {
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
filterKey,
]),
},
filters: {
...item.filters,
items:
idx === activeQueryIndex
? item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filterKey),
) || []
: [...(item.filters?.items || [])],
op: item.filters?.op || 'AND',
},
})),
},
};
}

View File

@@ -17,7 +17,7 @@ import { CSS } from '@dnd-kit/utilities';
import { Button } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical } from '@signozhq/icons';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
function SortableFilter({
filter,
@@ -25,13 +25,13 @@ function SortableFilter({
allowDrag,
allowRemove,
}: {
filter: TelemetryFieldKey;
onRemove: (filter: TelemetryFieldKey) => void;
filter: FilterType;
onRemove: (filter: FilterType) => void;
allowDrag: boolean;
allowRemove: boolean;
}): JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: filter.key as string });
useSortable({ id: filter.key });
const style = {
transform: CSS.Transform.toString(transform),
@@ -46,14 +46,14 @@ function SortableFilter({
>
<div {...attributes} {...listeners} className="drag-handle">
{allowDrag && <GripVertical size={16} />}
{filter.name}
{filter.key}
</div>
{allowRemove && (
<Button
className="remove-filter-btn periscope-btn"
size="small"
onClick={(): void => {
onRemove(filter);
onRemove(filter as FilterType);
}}
>
Remove
@@ -69,8 +69,8 @@ function AddedFilters({
setAddedFilters,
}: {
inputValue: string;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
}): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
@@ -90,12 +90,12 @@ function AddedFilters({
const filteredAddedFilters = useMemo(
() =>
addedFilters.filter((filter) =>
filter.name.toLowerCase().includes(inputValue.toLowerCase()),
filter.key.toLowerCase().includes(inputValue.toLowerCase()),
),
[addedFilters, inputValue],
);
const handleRemoveFilter = (filter: TelemetryFieldKey): void => {
const handleRemoveFilter = (filter: FilterType): void => {
setAddedFilters((prev) => prev.filter((f) => f.key !== filter.key));
};
@@ -116,7 +116,7 @@ function AddedFilters({
<div className="no-values-found">No values found</div>
) : (
<SortableContext
items={addedFilters.map((f) => f.key as string)}
items={addedFilters.map((f) => f.key)}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>

View File

@@ -4,9 +4,14 @@ import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { FieldContext, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { DataSource } from 'types/common/queryBuilder';
function OtherFiltersSkeleton(): JSX.Element {
return (
@@ -32,49 +37,106 @@ function OtherFilters({
}: {
signal: SignalType | undefined;
inputValue: string;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
}): JSX.Element {
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
const { data, isFetching } = useGetQueryKeySuggestions(
{
searchText: inputValue,
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
signalSource: isMeterDataSource ? 'meter' : '',
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, signal, inputValue],
enabled: !!signal,
},
const isLogDataSource = useMemo(
() => SIGNAL_DATA_SOURCE_MAP[signal as SignalType] === DataSource.LOGS,
[signal],
);
const isMeterDataSource = useMemo(
() => signal && signal === SignalType.METER_EXPLORER,
[signal],
);
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data?.data?.keys || {}).flat();
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
// add, render) can trust it.
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
name: attr.name,
signal: attr.signal,
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType,
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
}));
const addedKeys = new Set(
addedFilters.map((filter) =>
buildCompositeKey(filter.name, filter.fieldContext, filter.fieldDataType),
),
const { data: suggestionsData, isFetching: isFetchingSuggestions } =
useGetAttributeSuggestions(
{
searchText: inputValue,
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
filters: {} as TagFilter,
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && isLogDataSource,
},
);
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
}, [data, addedFilters]);
const handleAddFilter = (filter: TelemetryFieldKey): void => {
setAddedFilters((prev) => [...prev, filter]);
const { data: aggregateKeysData, isFetching: isFetchingAggregateKeys } =
useGetAggregateKeys(
{
searchText: inputValue,
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
aggregateOperator: 'noop',
aggregateAttribute: '',
tagType: '',
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && !isLogDataSource && !isMeterDataSource,
},
);
const { data: fieldKeysData, isLoading: isLoadingFieldKeys } =
useGetQueryKeySuggestions(
{
searchText: inputValue,
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
signalSource: 'meter',
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && isMeterDataSource,
},
);
const otherFilters = useMemo(() => {
let filterAttributes;
if (isLogDataSource) {
filterAttributes = suggestionsData?.payload?.attributes || [];
} else if (isMeterDataSource) {
const fieldKeys: QueryKeyDataSuggestionsProps[] = Object.values(
fieldKeysData?.data?.data?.keys || {},
)?.flat();
filterAttributes = fieldKeys.map(
(attr) =>
({
key: attr.name,
dataType: attr.fieldDataType,
type: attr.fieldContext,
signal: attr.signal,
}) as BaseAutocompleteData,
);
} else {
filterAttributes = aggregateKeysData?.payload?.attributeKeys || [];
}
return filterAttributes?.filter(
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
);
}, [
suggestionsData,
aggregateKeysData,
addedFilters,
isLogDataSource,
fieldKeysData,
isMeterDataSource,
]);
const handleAddFilter = (filter: FilterType): void => {
setAddedFilters((prev) => [
...prev,
{
key: filter.key,
dataType: filter.dataType,
type: filter.type,
},
]);
};
const renderFilters = (): React.ReactNode => {
if (isFetching) {
const isLoading =
isFetchingSuggestions || isFetchingAggregateKeys || isLoadingFieldKeys;
if (isLoading) {
return <OtherFiltersSkeleton />;
}
if (!otherFilters?.length) {
@@ -83,11 +145,11 @@ function OtherFilters({
return otherFilters.map((filter) => (
<div key={filter.key} className="qf-filter-item other-filters-item">
<div className="qf-filter-key">{filter.name}</div>
<div className="qf-filter-key">{filter.key}</div>
<Button
className="add-filter-btn periscope-btn"
size="small"
onClick={(): void => handleAddFilter(filter)}
onClick={(): void => handleAddFilter(filter as FilterType)}
>
Add
</Button>

View File

@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { SignalType } from '../types';
import AddedFilters from './AddedFilters';
@@ -18,7 +19,7 @@ function QuickFiltersSettings({
}: {
signal: SignalType | undefined;
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: TelemetryFieldKey[];
customFilters: FilterType[];
refetchCustomFilters: () => void;
}): JSX.Element {
const {
@@ -27,7 +28,6 @@ function QuickFiltersSettings({
addedFilters,
setAddedFilters,
handleSaveChanges,
hasUnsavedChanges,
isUpdatingCustomFilters,
inputValue,
handleInputChange,
@@ -39,6 +39,18 @@ function QuickFiltersSettings({
signal,
});
const hasUnsavedChanges = useMemo(
() =>
// check if both arrays have the same length and same order of elements
!(
addedFilters.length === customFilters.length &&
addedFilters.every(
(filter, index) => filter.key === customFilters[index].key,
)
),
[addedFilters, customFilters],
);
return (
<>
<div className="qf-header">

View File

@@ -1,31 +1,27 @@
import { useCallback, useMemo, useState } from 'react';
import { useUpdateQuickFilters } from 'api/generated/services/quick-filter';
import { useCallback, useState } from 'react';
import { useMutation } from 'react-query';
import logEvent from 'api/common/logEvent';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import updateCustomFiltersAPI from 'api/quickFilters/updateCustomFilters';
import axios, { AxiosError } from 'axios';
import { SignalType } from 'components/QuickFilters/types';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
interface UseQuickFilterSettingsProps {
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: TelemetryFieldKey[];
customFilters: FilterType[];
refetchCustomFilters: () => void;
signal?: SignalType;
}
interface UseQuickFilterSettingsReturn {
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
handleSettingsClose: () => void;
handleDiscardChanges: () => void;
handleSaveChanges: () => void;
hasUnsavedChanges: boolean;
isUpdatingCustomFilters: boolean;
inputValue: string;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
@@ -41,43 +37,27 @@ const useQuickFilterSettings = ({
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
const [inputValue, setInputValue] = useState<string>('');
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
const normalizedCustomFilters = useMemo<TelemetryFieldKey[]>(
() =>
customFilters.map((filter) => ({
...filter,
key: buildCompositeKey(
filter.name,
filter.fieldContext,
filter.fieldDataType,
),
})),
[customFilters],
);
const [addedFilters, setAddedFilters] = useState<TelemetryFieldKey[]>(
normalizedCustomFilters,
);
const [addedFilters, setAddedFilters] = useState<FilterType[]>(customFilters);
const { notifications } = useNotifications();
const { mutate: updateCustomFilters, isLoading: isUpdatingCustomFilters } =
useUpdateQuickFilters({
mutation: {
onSuccess: () => {
setIsSettingsOpen(false);
refetchCustomFilters();
void logEvent('Quick Filters Settings: changes saved', {
addedFilters,
});
notifications.success({
message: 'Quick filters updated successfully',
placement: 'bottomRight',
});
},
onError: (error) => {
notifications.error({
message: error.message || SOMETHING_WENT_WRONG,
placement: 'bottomRight',
});
},
useMutation(updateCustomFiltersAPI, {
onSuccess: () => {
setIsSettingsOpen(false);
refetchCustomFilters();
logEvent('Quick Filters Settings: changes saved', {
addedFilters,
});
notifications.success({
message: 'Quick filters updated successfully',
placement: 'bottomRight',
});
},
onError: (error: AxiosError) => {
notifications.error({
message: axios.isAxiosError(error) ? error.message : SOMETHING_WENT_WRONG,
placement: 'bottomRight',
});
},
});
const debouncedUpdate = useDebouncedFn((value) => {
@@ -98,30 +78,17 @@ const useQuickFilterSettings = ({
}, [setIsSettingsOpen]);
const handleDiscardChanges = useCallback((): void => {
setAddedFilters(normalizedCustomFilters);
}, [normalizedCustomFilters, setAddedFilters]);
const hasUnsavedChanges = useMemo(
() =>
!(
addedFilters.length === normalizedCustomFilters.length &&
addedFilters.every(
(filter, index) => filter.key === normalizedCustomFilters[index].key,
)
),
[addedFilters, normalizedCustomFilters],
);
setAddedFilters(customFilters);
}, [customFilters, setAddedFilters]);
const handleSaveChanges = useCallback((): void => {
if (signal) {
updateCustomFilters({
data: {
// Send only the stored TelemetryFieldKey fields; the composite `key`
// is UI-only.
filters: addedFilters.map((filter) => ({
name: filter.name,
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
key: filter.key,
datatype: filter.dataType,
type: filter.type,
})),
signal,
},
@@ -135,7 +102,6 @@ const useQuickFilterSettings = ({
addedFilters,
setAddedFilters,
handleSaveChanges,
hasUnsavedChanges,
isUpdatingCustomFilters,
inputValue,
setInputValue,

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import { useGetQuickFilters } from 'api/generated/services/quick-filter';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { useQuery } from 'react-query';
import getCustomFilters from 'api/quickFilters/getCustomFilters';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { IQuickFiltersConfig, SignalType } from '../types';
import { getFilterConfig } from '../utils';
@@ -11,7 +13,7 @@ interface UseFilterConfigProps {
}
interface UseFilterConfigReturn {
filterConfig: IQuickFiltersConfig[];
customFilters: TelemetryFieldKey[];
customFilters: FilterType[];
isCustomFiltersLoading: boolean;
isDynamicFilters: boolean;
refetchCustomFilters: () => void;
@@ -23,16 +25,17 @@ const useFilterConfig = ({
}: UseFilterConfigProps): UseFilterConfigReturn => {
const {
isFetching: isCustomFiltersLoading,
data,
data: customFilters = [],
refetch,
} = useGetQuickFilters(
{ signalName: signal ?? '' },
{ query: { enabled: !!signal } },
);
const customFilters = useMemo<TelemetryFieldKey[]>(
() => (data?.data?.filters ?? []) as TelemetryFieldKey[],
[data],
} = useQuery<FilterType[], Error>(
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
async () => {
const res = await getCustomFilters({ signal: signal || '' });
return 'payload' in res && res.payload?.filters ? res.payload.filters : [];
},
{
enabled: !!signal,
},
);
const isDynamicFilters = useMemo(

View File

@@ -1,24 +0,0 @@
import { useMemo } from 'react';
import {
NANO_SECOND_MULTIPLIER,
useLastComputedMinMax,
} from 'store/globalTime';
import { QuickFilterCheckboxUseFieldApis } from '../types';
/**
* Builds the `useFieldApis` config for a signal quick-filter page.
* if existingQuery is sent null, related values are not fetched
*/
export function useSignalFieldApis(): QuickFilterCheckboxUseFieldApis {
const { minTime, maxTime } = useLastComputedMinMax();
return useMemo(
() => ({
startUnixMilli: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
endUnixMilli: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
existingQuery: null,
}),
[minTime, maxTime],
);
}

View File

@@ -11,7 +11,7 @@ import {
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import '@testing-library/jest-dom';
@@ -34,9 +34,9 @@ const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
const BASE_URL = ENVIRONMENT.baseURL;
const SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
@@ -338,63 +338,6 @@ describe('Quick Filters with custom filters', () => {
);
});
it('keeps same-name fields with different context as distinct entries', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(quickFiltersSuggestionsURL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: {
level: [
{
name: 'level',
fieldContext: 'attribute',
fieldDataType: 'string',
signal: 'logs',
},
{
name: 'level',
fieldContext: 'span',
fieldDataType: 'string',
signal: 'logs',
},
],
},
},
}),
),
),
);
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
// Both `level` variants are shown despite sharing a name.
await waitFor(() =>
expect(within(otherSection).getAllByText('level')).toHaveLength(2),
);
// Adding one variant removes only that one; the other stays.
const firstLevel = within(otherSection).getAllByText('level')[0];
const addButton = firstLevel.parentElement?.querySelector('button');
await user.click(addButton as HTMLButtonElement);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
await waitFor(() => {
expect(within(addedSection).getAllByText('level')).toHaveLength(1);
expect(within(otherSection).getAllByText('level')).toHaveLength(1);
});
});
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -515,7 +458,7 @@ describe('Quick Filters with custom filters', () => {
const requestBody = putHandler.mock.calls[0][0];
expect(requestBody.filters).toStrictEqual(
expect.arrayContaining([
expect.not.objectContaining({ name: FILTER_OS_DESCRIPTION }),
expect.not.objectContaining({ key: FILTER_OS_DESCRIPTION }),
]),
);
expect(requestBody.signal).toBe(SIGNAL);
@@ -669,9 +612,9 @@ describe('Quick Filters refetch behavior', () => {
filters: [
...(quickFiltersListResponse.data.filters ?? []),
{
name: 'new.custom.filter',
fieldDataType: 'string',
fieldContext: 'resource',
key: 'new.custom.filter',
dataType: 'string',
type: 'resource',
} as const,
],
},

View File

@@ -1,10 +1,5 @@
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
@@ -17,39 +12,6 @@ const FILTER_TYPE_MAP: Record<string, FiltersType> = {
duration_nano: FiltersType.DURATION,
};
// Both maps below (and mapFieldDataType/mapFieldContext) exist only for the old
// v3 attribute-values fetch in useCheckboxFilterValues, which is the sole reader
// of attributeKey.dataType/type. Query/list endpoints don't consume them (v5 and
// the API-monitoring/exceptions/infra paths all send a name-based expression).
// Once the values fetch moves to fields/values (by name) in Phase A, this whole
// mapping can be removed and attributeKey reduced to { id, key }.
// The new field data types are rendered down to the v3 spellings the
// attribute-values call expects, matching the backend's legacy conversion
// (number -> float64).
const FIELD_DATA_TYPE_TO_DATA_TYPE: Record<string, DataTypes> = {
[TelemetrytypesFieldDataTypeDTO.string]: DataTypes.String,
[TelemetrytypesFieldDataTypeDTO.bool]: DataTypes.bool,
[TelemetrytypesFieldDataTypeDTO.float64]: DataTypes.Float64,
[TelemetrytypesFieldDataTypeDTO.int64]: DataTypes.Int64,
[TelemetrytypesFieldDataTypeDTO.number]: DataTypes.Float64,
};
// Only tag and resource exist in the v3 attribute-type enum; other contexts
// render as empty so the still-live v3 values path never sees a spelling it
// can't use, matching the backend's legacy conversion.
const FIELD_CONTEXT_TO_ATTRIBUTE_TYPE: Record<string, string> = {
[TelemetrytypesFieldContextDTO.attribute]: 'tag',
[TelemetrytypesFieldContextDTO.resource]: 'resource',
};
const mapFieldDataType = (fieldDataType?: string): DataTypes =>
(fieldDataType && FIELD_DATA_TYPE_TO_DATA_TYPE[fieldDataType]) ||
DataTypes.EMPTY;
const mapFieldContext = (fieldContext?: string): string =>
(fieldContext && FIELD_CONTEXT_TO_ATTRIBUTE_TYPE[fieldContext]) || '';
const getFilterName = (str: string): string => {
if (FILTER_TITLE_MAP[str]) {
return FILTER_TITLE_MAP[str];
@@ -64,16 +26,16 @@ const getFilterName = (str: string): string => {
.join(' ');
};
const getFilterType = (att: TelemetryFieldKey): FiltersType => {
if (FILTER_TYPE_MAP[att.name]) {
return FILTER_TYPE_MAP[att.name];
const getFilterType = (att: FilterType): FiltersType => {
if (FILTER_TYPE_MAP[att.key]) {
return FILTER_TYPE_MAP[att.key];
}
return FiltersType.CHECKBOX;
};
export const getFilterConfig = (
signal?: SignalType,
customFilters?: TelemetryFieldKey[],
customFilters?: FilterType[],
config?: IQuickFiltersConfig[],
): IQuickFiltersConfig[] => {
if (!customFilters?.length || !signal) {
@@ -84,13 +46,13 @@ export const getFilterConfig = (
(att, index) =>
({
type: getFilterType(att),
title: getFilterName(att.name),
title: getFilterName(att.key),
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
attributeKey: {
id: att.name,
key: att.name,
dataType: mapFieldDataType(att.fieldDataType),
type: mapFieldContext(att.fieldContext),
id: att.key,
key: att.key,
dataType: att.dataType,
type: att.type,
},
defaultOpen: index < 2,
}) as IQuickFiltersConfig,

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import refreshPaymentStatus from 'api/v3/licenses/put';
import { refreshLicense } from 'api/generated/services/licenses';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { RefreshCcw } from '@signozhq/icons';
@@ -14,17 +14,21 @@ function RefreshPaymentStatus({
className?: string;
}): JSX.Element {
const { t } = useTranslation(['failedPayment']);
const { activeLicenseRefetch } = useAppContext();
const { activeLicense, activeLicenseRefetch } = useAppContext();
const [isLoading, setIsLoading] = useState(false);
const handleRefreshPaymentStatus = async (): Promise<void> => {
if (!activeLicense) {
return;
}
setIsLoading(true);
try {
await refreshPaymentStatus();
await refreshLicense({ id: activeLicense.id });
await Promise.all([activeLicenseRefetch()]);
activeLicenseRefetch();
} catch (e) {
console.error(e);
}

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

@@ -28,7 +28,6 @@ export const REACT_QUERY_KEY = {
DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE',
GET_HOST_LIST: 'GET_HOST_LIST',
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',

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

@@ -3,7 +3,6 @@ import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
@@ -12,8 +11,6 @@ import DomainList from './Domains/DomainList';
import './Explorer.styles.scss';
function Explorer(): JSX.Element {
const quickFilterFieldApis = useSignalFieldApis();
useEffect(() => {
logEvent('API Monitoring: Landing page visited', {});
}, []);
@@ -29,7 +26,6 @@ function Explorer(): JSX.Element {
showFilterCollapse={false}
showQueryName={false}
handleFilterVisibilityChange={(): void => {}}
useFieldApis={quickFilterFieldApis}
/>
</section>
<DomainList />

View File

@@ -453,7 +453,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
if (
!isFetchingActiveLicense &&
!isNull(activeLicense) &&
activeLicense?.event_queue?.event === LicenseEvent.DEFAULT
activeLicense?.eventQueue?.event === LicenseEvent.DEFAULT
) {
setShowPaymentFailedWarning(true);
}
@@ -820,7 +820,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
Your bill payment has failed. Your workspace will get suspended on{' '}
<span>
{getFormattedDateWithMinutes(
dayjs(activeLicense?.event_queue?.scheduled_at).unix() || Date.now(),
activeLicense?.eventQueue?.scheduledAt
? dayjs(activeLicense.eventQueue.scheduledAt).unix()
: dayjs().unix(),
)}
.
</span>

View File

@@ -15,6 +15,11 @@ import { getFormattedDate } from 'utils/timeUtils';
import BillingContainer from './BillingContainer';
jest.mock('hooks/useActiveLicenseKey/useActiveLicenseKey', () => ({
__esModule: true,
default: jest.fn(() => ({ licenseKey: 'test-key', isLoading: false })),
}));
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({

View File

@@ -30,6 +30,7 @@ import useAxiosError from 'hooks/useAxiosError';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { isEmpty, pick } from 'lodash-es';
import useActiveLicenseKey from 'hooks/useActiveLicenseKey/useActiveLicenseKey';
import { useAppContext } from 'providers/App/App';
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
@@ -145,6 +146,7 @@ export default function BillingContainer(): JSX.Element {
activeLicense,
activeLicenseFetchError,
} = useAppContext();
const { licenseKey } = useActiveLicenseKey();
const { notifications } = useNotifications();
const handleError = useAxiosError();
@@ -207,9 +209,9 @@ export default function BillingContainer(): JSX.Element {
isFetching: isFetchingBillingData,
data: billingData,
} = useQuery([REACT_QUERY_KEY.GET_BILLING_USAGE, user?.id], {
queryFn: () => getUsage(activeLicense?.key || ''),
queryFn: () => getUsage(licenseKey || ''),
onError: handleError,
enabled: activeLicense !== null,
enabled: !!licenseKey,
onSuccess: processUsageData,
});

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

@@ -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

@@ -452,15 +452,15 @@ function ExplorerOptions({
if (handleChangeSelectedView) {
handleChangeSelectedView(panelTypeToExplorerView[currentPanelType], {
query,
name,
id,
viewName: name,
viewKey: id,
});
} else {
// to remove this after traces cleanup
handleExplorerTabChange(currentPanelType, {
query,
name,
id,
viewName: name,
viewKey: id,
});
}
},

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

@@ -16,6 +16,8 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { StatusCodes } from 'http-status-codes';
import find from 'lodash-es/find';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import { buildLicenseReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/license.permissions';
import { useAppContext } from 'providers/App/App';
import {
ErrorResponse,
@@ -673,17 +675,21 @@ function GeneralSettings({
</span>
</div>
{(showCustomDomainSettings || activeLicense?.key) && (
{(showCustomDomainSettings || activeLicense) && (
<div className="custom-domain-card">
{showCustomDomainSettings && <CustomDomainSettings />}
{showCustomDomainSettings && activeLicense?.key && (
{showCustomDomainSettings && activeLicense && (
<div className="custom-domain-card-divider" />
)}
{activeLicense?.key && (
<>
<LicenseKeyRow />
<LicenseRowDismissibleCallout />
</>
{activeLicense && (
<AuthZGuardContent
checks={[buildLicenseReadPermission(activeLicense.id)]}
>
<>
<LicenseKeyRow />
<LicenseRowDismissibleCallout />
</>
</AuthZGuardContent>
)}
</div>
)}

View File

@@ -2,16 +2,16 @@ import { useCopyToClipboard } from 'react-use';
import { Copy, KeyRound } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
import { useAppContext } from 'providers/App/App';
import useActiveLicenseKey from 'hooks/useActiveLicenseKey/useActiveLicenseKey';
import { getMaskedKey } from 'utils/maskedKey';
import './LicenseKeyRow.styles.scss';
function LicenseKeyRow(): JSX.Element | null {
const { activeLicense } = useAppContext();
const { licenseKey } = useActiveLicenseKey();
const [, copyToClipboard] = useCopyToClipboard();
if (!activeLicense?.key) {
if (!licenseKey) {
return null;
}
@@ -27,16 +27,14 @@ function LicenseKeyRow(): JSX.Element | null {
<span className="license-key-row__label">SigNoz License Key</span>
</span>
<span className="license-key-row__value">
<code className="license-key-row__code">
{getMaskedKey(activeLicense.key)}
</code>
<code className="license-key-row__code">{getMaskedKey(licenseKey)}</code>
<Button
type="button"
size="sm"
aria-label="Copy license key"
data-testid="license-key-row-copy-btn"
className="license-key-row__copy-btn"
onClick={(): void => handleCopyLicenseKey(activeLicense.key)}
onClick={(): void => handleCopyLicenseKey(licenseKey)}
>
<Copy size={12} />
</Button>

View File

@@ -1,7 +1,13 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import useActiveLicenseKey from 'hooks/useActiveLicenseKey/useActiveLicenseKey';
import LicenseKeyRow from '../LicenseKeyRow';
jest.mock('hooks/useActiveLicenseKey/useActiveLicenseKey');
const mockUseActiveLicenseKey = useActiveLicenseKey as jest.MockedFunction<
typeof useActiveLicenseKey
>;
const mockCopyToClipboard = jest.fn();
jest.mock('react-use', () => ({
@@ -23,20 +29,22 @@ describe('LicenseKeyRow', () => {
jest.clearAllMocks();
});
it('renders nothing when activeLicense key is absent', () => {
const { container } = render(<LicenseKeyRow />, undefined, {
appContextOverrides: { activeLicense: null },
it('renders nothing when the license key is absent', () => {
mockUseActiveLicenseKey.mockReturnValue({
licenseKey: undefined,
isLoading: false,
});
const { container } = render(<LicenseKeyRow />);
expect(container).toBeEmptyDOMElement();
});
it('renders label and masked key when activeLicense key exists', () => {
render(<LicenseKeyRow />, undefined, {
appContextOverrides: {
activeLicense: { key: 'abcdefghij' } as any,
},
it('renders label and masked key when the license key exists', () => {
mockUseActiveLicenseKey.mockReturnValue({
licenseKey: 'abcdefghij',
isLoading: false,
});
render(<LicenseKeyRow />);
expect(screen.getByText('SigNoz License Key')).toBeInTheDocument();
expect(screen.getByText('ab·······ij')).toBeInTheDocument();
@@ -45,6 +53,10 @@ describe('LicenseKeyRow', () => {
it('calls copyToClipboard and shows success toast when clipboard is available', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockUseActiveLicenseKey.mockReturnValue({
licenseKey: 'test-key',
isLoading: false,
});
render(<LicenseKeyRow />);
await user.click(screen.getByRole('button', { name: /copy license key/i }));

View File

@@ -115,8 +115,8 @@ export default function SavedViews({
currentPanelType,
{
query,
name,
id,
viewName: name,
viewKey: id,
},
SOURCEPAGE_VS_ROUTES[selectedEntity],
);

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

@@ -2,9 +2,9 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@signozhq/ui/input';
import { Button, Form } from 'antd';
import apply from 'api/v3/licenses/post';
import { activateLicense } from 'api/generated/services/licenses';
import { useNotifications } from 'hooks/useNotifications';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import {
@@ -26,7 +26,7 @@ function ApplyLicenseForm({
const isDisabled = isLoading || !key;
const onFinish = async (values: unknown | { key: string }): Promise<void> => {
const onFinish = async (values: unknown): Promise<void> => {
const params = values as { key: string };
if (params.key === '' || !params.key) {
notifications.error({
@@ -38,18 +38,19 @@ function ApplyLicenseForm({
setIsLoading(true);
try {
await apply({
await activateLicense({
key: params.key,
});
await Promise.all([licenseRefetch()]);
licenseRefetch();
notifications.success({
message: 'Success',
description: t('license_applied'),
});
} catch (e) {
const apiError = toAPIError(e as Parameters<typeof toAPIError>[0]);
notifications.error({
message: (e as APIError).getErrorCode(),
description: (e as APIError).getErrorMessage(),
message: apiError.getErrorCode(),
description: apiError.getErrorMessage(),
});
}
setIsLoading(false);

View File

@@ -16,7 +16,6 @@ import {
} from 'constants/antlrQueryConstants';
import { FeatureKeys } from 'constants/features';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
@@ -49,7 +48,6 @@ function BodyTitleRenderer({
const { featureFlags } = useAppContext();
const [, setCopy] = useCopyToClipboard();
const { notifications } = useNotifications();
const { viewName } = useGetSavedViewParams();
const cleanedNodeKey = removeObjectFromString(nodeKey);
const isBodyJsonQueryEnabled =
@@ -123,8 +121,6 @@ function BodyTitleRenderer({
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
@@ -137,7 +133,6 @@ function BodyTitleRenderer({
stagedQuery,
updateQueriesData,
value,
viewName,
]);
const onClickHandler = (key: string): void => {

View File

@@ -12,7 +12,6 @@ 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 { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import {
@@ -140,7 +139,6 @@ export default function TableViewActions(
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
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
@@ -201,8 +199,6 @@ export default function TableViewActions(
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
@@ -214,7 +210,6 @@ export default function TableViewActions(
fieldType,
dataType,
handleChangeSelectedView,
viewName,
]);
const handleReplaceFilter = useCallback((): void => {
@@ -264,8 +259,6 @@ export default function TableViewActions(
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
@@ -278,7 +271,6 @@ export default function TableViewActions(
dataType,
fieldData,
handleChangeSelectedView,
viewName,
]);
// Memoize textToCopy computation

View File

@@ -272,8 +272,6 @@ describe('TableViewActions', () => {
expect(defaultProps.handleChangeSelectedView).toHaveBeenCalledWith(
ExplorerViews.TIMESERIES,
expect.objectContaining({
name: '',
id: 'test-query-id',
query: expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([

View File

@@ -5,7 +5,6 @@ import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
@@ -57,7 +56,6 @@ export function useLogAttributeActions({
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const { featureFlags } = useAppContext();
const { viewName } = useGetSavedViewParams();
const isBodyJsonQueryEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
@@ -110,8 +108,6 @@ export function useLogAttributeActions({
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
@@ -120,7 +116,6 @@ export function useLogAttributeActions({
stagedQuery,
isBodyJsonQueryEnabled,
updateQueriesData,
viewName,
handleChangeSelectedView,
onApplyLogFilter,
],
@@ -147,8 +142,6 @@ export function useLogAttributeActions({
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.TIMESERIES, queryData);
@@ -157,7 +150,6 @@ export function useLogAttributeActions({
stagedQuery,
isBodyJsonQueryEnabled,
updateQueriesData,
viewName,
handleChangeSelectedView,
],
);
@@ -183,8 +175,6 @@ export function useLogAttributeActions({
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
@@ -193,7 +183,6 @@ export function useLogAttributeActions({
stagedQuery,
isBodyJsonQueryEnabled,
updateQueriesData,
viewName,
handleChangeSelectedView,
],
);

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