Compare commits

..

35 Commits

Author SHA1 Message Date
Tushar Vats
5d27ee4a39 fix(logs pipelines): default parse_from to body.message under JSON bodies
The processor form pre-fills parse_from with `body` for grok, regex and json
parsers. With use_json_body the collector normalizes every body into a map
before user operators run, so a parser reading `body` gets a map it cannot
parse and silently extracts nothing — a new pipeline is broken by default
without the user ever touching the field.

Resolve the default to `body.message` when the flag is on. Saved processors
are unaffected: edit mode sets the form values explicitly, which overrides
initialValue.
2026-08-14 06:10:13 +05:30
Tushar Vats
0cf3988867 fix(logs pipelines): preview logs with the body the collector sees (#12520)
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 collector gets a `normalize` pipeline prepended ahead of user
pipelines when `use_json_body` is on — injected in
`RecommendAgentConfig` and delivered over opamp — which parses the log
body into JSON. Preview simulated only the user's pipelines, so a
pipeline authored against `body.<field>` behaved differently in preview
than in production, and one written against `body` looked fine in
preview while doing nothing on real logs.
- Preview now evaluates the flag for the caller's org and prepends the
same pipeline, so what it shows is what the collector does.

#### Issues closed by this PR

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

#### Additional Information

Verified end to end against a local stack — devenv ClickHouse, a
collector with `body_json_enabled` connected over opamp, `use_json_body`
on — by driving the two calls the preview screen makes: sample logs,
then preview with those logs. `parse_from: body.message` extracts
attributes; `parse_from: body` extracts nothing, matching what the
collector does with a normalized body.

Log bodies render as stored rather than unwrapped, so what you see is
what the pipeline operates on.

Needs SigNoz/signoz#12534 to pick sample logs by body — without it the
v3 query behind the sample-log list errors for these orgs.
SigNoz/signoz#12535 stacks on this to surface the collector's own
explanation when an operator cannot parse a log.
2026-08-13 23:05:32 +00:00
Tushar Vats
abff2aefd8 fix(metrics): warn when a filtered label is missing from metadata (#12487)
A filter on a metrics label that isn't in metadata ran silently: the
query fell back to reading the label directly, but nothing told the user
the key was unknown. Removes the `TODO(srikanthccv)` in the metrics
statement builder.

### What

The detection was already written, and already in the right place.
`conditionBuilder.ConditionFor` spots a filter key with no metadata
match, warns, and synthesizes an attribute-context key so the query
still runs — and it only ever sees terms in **key position**, so it
cannot mistake a value or a dashboard variable for a key. That is
exactly what the TODO was waiting for.

Two things hid it:

- `Build` pre-seeded the field-key map with a synthesized entry for
every lexer-derived selector, so `MatchingFieldKeys` always matched and
the missing-key branch was dead code.
- The metrics builder never read `PrepareWhereClause`'s warnings — the
visitor collects them and `unionStatements` merges them, but nothing
ever set them.

So: drop the pre-seeding, and carry the warnings out of
`buildTimeSeriesCTE` onto the statement.

### Notes

- **Generated SQL is unchanged.** The key the condition builder
synthesizes (attribute context, name as written) is what the pre-seeding
was injecting, so `test_missing_key_falls_back_to_labels` still expects
byte-identical SQL and only gains the warning.
- A full-text term routes through `labels`, which is a real column, so
it takes the `isColumn` branch and stays silent — bare-word searches
don't start warning.
- The reduced statement prepares the same filter over the same keys, so
only the main path's warnings go into the union; carrying both would
show each warning twice.

### Testing

- `test_missing_key_falls_back_to_labels` gains the expected warning,
same SQL.
-
`queriermetrics/10_key_resolution.py::test_metrics_filter_unknown_label_matches_nothing`
now asserts the warning instead of asserting silence.
- `queriermetrics/02_warnings.py` already covered the TODO's own example
(`my_tag = $tag`). It passed before only because every warning was
suppressed; it is now a real guard that a value-position variable is not
flagged.
- `queriermetrics` integration suite: 118 passed. `go test
./pkg/statementbuilder/... ./pkg/telemetryschema/...` green. `make
go-lint` and `make py-lint` clean.
2026-08-13 21:58:46 +00:00
Pandey
5b3b2865d1 fix(authtypes): restructure auth domain payload into a kind/spec envelope (#12472)
#### Description

- Moves the endpoints to `/api/v2/auth_domains` and removes the
`/api/v1/domains` routes — the request/response shapes changed, so they
live behind new paths instead of breaking v1 in place.
- Restructures the auth domain payload: `config` is now a `{kind, spec}`
discriminated envelope (same pattern as `RuleThresholdData` /
`EvaluationEnvelope`), replacing the old `ssoType` discriminator with
`samlConfig` / `googleAuthConfig` / `oidcConfig` sibling fields;
`ssoEnabled` and `roleMapping` move to the root as `enabled` and
`roleMapping`.
- Renames the provider kind `google_auth` → `google`, and the SAML keys
to metadata-consistent ones: `samlEntity` → `entityId`, `samlIdp` →
`location`, `samlCert` → `certificate`.
- Migrates the persisted documents too: a new sqlmigration rewrites
`auth_domain.data` into `{enabled, config: {kind, spec}, roleMapping}`,
so all legacy-shape code (storable twins, `google_auth` translation,
per-kind conversion switches) is deleted; the remaining per-kind wiring
lives in a single variant registry that `UnmarshalJSON`,
`JSONSchemaOneOf` and the discriminator mapping derive from.
- `AuthDomain` exposes the domain shape (`Enabled()`, `Kind()`,
`Config()`, `RoleMapping()`, typed spec accessors) instead of the
persisted document; `config` presence is enforced explicitly on
Postable/Updatable (the old PUT path never enforced it and could poison
a row).
- Secret fields (`clientSecret`, `serviceAccountJson`) are `format:
password` in the schema, and `GoogleConfig` loses the unused
`redirectURI` (the migration strips it from persisted documents).
- Frontend: regenerated client is a clean discriminated union; both
directions of the envelope↔form translation live in
`CreateEdit.utils.ts` with an explicit kind→provider mapping (no
cross-enum casts).
- The generated OpenAPI spec carries a real `discriminator`; the
kind/spec envelope pattern itself is documented generically in #12494,
and this PR only keeps the auth domain worked example in `types.md` in
step with the refactored types.
- Updates the google authn integration tests (#12486) to the new API,
and adds parametrized POST→GET roundtrip cases pinning the response
contract per kind (server-side defaulting, role-name normalization, null
maps) plus enforcement-toggle update coverage.

#### Issues closed by this PR

Closes SigNoz/platform-pod#2268

#### Additional Information

- Breaking change: `/api/v1/domains` is gone; the resource is now
`/api/v2/auth_domains` with the new shape. Login and SSO callback flows
are behaviorally unchanged, and existing rows are migrated in place at
startup.
- The `AuthNProvider` rename also surfaces in `/api/v2/sessions/context`
responses (`provider: "google"`) — the login page only consumes the
callback `url` — and in the reported stats key, which changes from
`authdomain.google_auth.count` to `authdomain.google.count`.
- Verified: `make go-test`, Go lint, frontend jest suites for
AuthDomain, `pnpm build`, `pnpm tsgo --noEmit`, and the full
`callbackauthn` domain suites (17 tests: roundtrip pins, the enforcement
toggle, and the google E2E flows) against a container rebuilt from this
branch — including a live run of the data migration over legacy-format
rows.
2026-08-13 16:39:27 +00:00
Tushar Vats
a7fd14eac9 refactor(metrics): alias group-by columns positionally like logs and traces (#12464)
A metric label may be named after a column the generated query builds
for itself, and the metrics and meter builders selected group-by columns
under the label's own name — so `group by ts` or `group by value`
produced SQL with two columns of that name, which ClickHouse rejects.

### What

- Metrics and meter now alias group-by columns
`__GROUP_BY_KEY_<i>_<name>`, the scheme the logs and traces statement
builders already use.
- `pkg/querier/consume.go` already strips that prefix on all three read
paths (time-series, scalar, raw), so API responses are unchanged.
- Metrics' `ColumnExpressionFor` now returns the bare expression like
the logs and traces mappers. It was the only one returning an aliased
expression (`expr AS <name>`), which `agg_rewrite.go` splices inside a
function argument — giving `sum(expr AS <name>)` if metrics ever grows
expression aggregations. Callers alias and escape, as logs does.
- The histogram pipeline derives its CTE-side query once — `le` appended
last, plus the existing rate/sum rewrite — instead of mutating the query
and restoring it around the whole pipeline. The final select takes the
original minus `le`, so the remaining keys hold the positions their CTE
aliases were built from.

With a label named `ts`, before:

```sql
SELECT ts, `ts`, multiIf(…) … GROUP BY fingerprint, ts, `ts`
```

and after:

```sql
SELECT ts, `__GROUP_BY_KEY_0_ts`, multiIf(…) …
```

A label named `value` was the quieter case — the spatial CTE selected it
next to the aggregate of the same name:

```sql
SELECT ts, `value`, sum(per_series_value) AS value …
```

### Notes

- Meter comes along because it holds a
`*metricsstatementbuilder.StatementBuilder` and calls the shared
`BuildFinalSelect`; aliasing metrics alone would leave meter ordering by
an alias its own select never produced. `GroupByColumnAlias` /
`GroupByAliases` are exported for it, alongside the `GetKeySelectors` /
`RateTmpl` already shared across that boundary.
- Meter had the identical collision, so this fixes it there too.

### Testing

- `TestGroupByAliasAvoidsColumnCollision` covers `ts`, `value`,
`fingerprint` and an ordinary label, in both the metrics and meter
builders; all three collision cases fail without the change.
- `reduced_test.go` gains `histogram_p99_group_by` and
`gauge_avg_avg_group_by` — the reduced path had no group-by coverage at
all, so neither the aliases in its four CTE builders nor the union's
`ORDER BY` were exercised. The histogram case pins that both `UNION ALL`
branches emit the same columns.
- `test_histogram_count_no_param` pins the `SELECT *` branch, where `le`
stays unaliased so `ORDER BY toFloat64(le)` resolves.
- Both new behaviours were mutation-checked: appending `le` first
instead of last, and returning the bare name from `GroupByColumnAlias`,
each turn the relevant tests red.
- Twelve expected-SQL blobs regenerated across the metrics and meter
statement builder tests — alias-only diffs.
- `go test ./...` green, `make go-lint` clean.

Fixes https://github.com/SigNoz/engineering-pod/issues/5868
2026-08-13 16:35:36 +00:00
Vikrant Gupta
faaed20dbd fix(user): revoke sessions on password reset (#12531)
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

- Resetting a password via a reset token left existing sessions valid.
Changing a password voluntarily already revoked them; the reset path now
does the same.

#### Issues closed by this PR

closes: SigNoz/platform-pod#2667
2026-08-13 13:08:30 +00:00
Vinicius Lourenço
b5851ce388 feat(infrastructure-monitoring-details): add chart description (#12430)
## Pull Request

---

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

Instead of generic phrase, we created a single line summary for each
chart title

#### Screenshots / Screen Recordings (if applicable)
> Include screenshots or screen recordings that clearly show the
behavior before the change and the result after the change. This helps
reviewers quickly understand the impact and verify the update.

Before:

<img width="1755" height="1269" alt="image"
src="https://github.com/user-attachments/assets/5d68bdde-1dd8-4405-ba68-90c89e685d50"
/>

After:

<img width="1756" height="1269" alt="image"
src="https://github.com/user-attachments/assets/003e2740-e25c-4cd0-8b3c-4e28959e194a"
/>

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

Closes https://github.com/SigNoz/pulse-pod/issues/196

---

###  Change Type
_Select all that apply_

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

---

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

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

---

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

- Blast radius: Infrastructure Monitoring - Details
- Potential regressions: None
- Rollback plan: Revert this commit

---

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

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Feature |
| Description | We updated the documentation for each chart title to
include a single line summary to give you a brief explanation about each
chart. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-13 12:33:22 +00:00
Gaurav Tewari
35d1869314 feat: add e2e for llm o11y (#12235)
## Pull Request

---

### 📄 Summary

This is PR ads basic happy flow E2E for our LLM observability flow.
Since most of the cases are covered by the integration test itself, we
have added happy path here.


We have also added test for Test tab here. 


So we have added an integration test for a test endpoint here. 


#### Issues closed by this PR
Closes - https://github.com/SigNoz/engineering-pod/issues/5764
---

###  Change Type
_Select all that apply_

- [x] 🧪 Test-only — E2E + integration coverage for LLM Observability
- [x] 🛠️ Infra / Tooling — E2E helpers + e2e-scoped feature-flag
conftest

---

### 🧪 Testing Strategy

- **Tests added:**
- **E2E (Playwright):**
`tests/e2e/tests/llm-o11y/attribute-mapping.spec.ts`,
`tests/e2e/tests/llm-o11y/llm-pricing.spec.ts` +
`tests/e2e/helpers/{attribute-mapping,llm-pricing}.ts`. Both specs are
`test.describe.configure({ mode: 'serial' })` and tear down what they
create via the API.
- **Integration (Jest/RTL/MSW):** `TestTab/__tests__/TestTab.test.tsx` —
4 cases:

    | Case | Time |
    |---|---|
| runs the sample span through the mappers and renders the populated
result | 447 ms |
    | surfaces a backend error and renders no results | 122 ms |
| persists an edited span to local storage and restores it on remount |
657 ms |
    | resets to the sample span and clears the persisted input | 70 ms |

- **Manual verification:** full LLM Observability Jest suite is green
locally — **7 suites / 70 tests passed in 24.3 s** (`npx jest --verbose
src/container/LLMObservability`), including the 4 new Test-tab cases.
- **Edge cases covered:** Test-tab backend 500 → error surfaced and no
results rendered; span input persisted across remount and cleared on
reset; E2E fixture cleanup is failure-tolerant (`.catch()` on
list/delete so a broken run doesn't mask the real assertion failure).
- **Not yet captured:** a fully-green E2E run in CI — the e2e workflow
only fires on PRs carrying the `safe-to-e2e` label (see Notes).

---

### ⚠️ Risk & Impact Assessment

- **Blast radius:** none in product code. No `frontend/src` runtime
file, no Go package, and no shared pytest fixture is modified — the only
non-test-file change is the new `tests/e2e/conftest.py`, scoped to the
`e2e` package.
- **Potential regressions:** limited to CI surface.
`tests/e2e/conftest.py` overrides the package-scoped `signoz` fixture
with a distinct `cache_key`, so it brings up **one additional container
set** for the e2e package rather than mutating the shared one —
integration suites keep the stock feature set.
- **Rollback plan:** revert the PR. No schema/migration changes, no
runtime behaviour to unwind.

---

### 📝 Changelog

| Field | Value |
|------|-------|
| Deployment Type | N/A |
| Change Type | Maintenance |
| Description | N/A — test-only, no user-facing change. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested (LLM Observability Jest suite green: 7 suites / 70
tests; E2E specs exercised against a local flag-enabled stack)
- [x] Breaking changes documented (none)
- [x] Backward compatibility considered

---

## Notes for Reviewers

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-13 12:31:03 +00:00
Nikhil Mantri
fe2200e887 feat(infra-monitoring): remove v1 infra-monitoring endpoints and any dangling code (#12538)
#### Description

- Remove all 34 v1 infra-monitoring routes:
`/api/v1/{hosts,processes,pods,nodes,namespaces,clusters,deployments,daemonsets,statefulsets,jobs,pvcs}/{list,attribute_keys,attribute_values}`
and `/api/v1/infra_onboarding/k8s/status`. The frontend is fully on
`/api/v2/infra_monitoring/*` (checks supersedes the onboarding-status
endpoint).
- Delete the now-dead implementation: `pkg/query-service/app/infra.go`,
the whole `pkg/query-service/app/inframetrics` package, and
`pkg/query-service/model/infra.go` (~8.2k lines).
- Drop `Reader` methods only used by the removed code:
`GetCountOfThings`, `GetActiveHostsFromMetricMetadata`,
`GetMetricsExistenceAndEarliestTime`.
- Delete the last dead frontend caller
`src/api/infraMonitoring/getHostLists.ts` (missed by #12415).

#### Issues closed by this PR

Part of SigNoz/pulse-pod#246

#### Additional Information

- `/api/v1/processes/list` is removed without a v2 equivalent — it was
never shipped in the UI.
- The per-entity `attribute_keys`/`attribute_values` endpoints are
superseded by the generic fields/autocomplete APIs, which the infra
pages already use.
- Verified locally: full build, and the `inframonitoring` integration
suite passes 580/580.
2026-08-13 10:40:14 +00:00
Vikrant Gupta
52a9a893c2 chore(user): remove deprecated user endpoints (#12530)
#### Description

- Removes the deprecated user endpoints that now have v2 replacements:
- `POST /api/v1/invite`, `GET /api/v1/user`, `GET
/api/v1/getResetPasswordToken/{id}`, `POST /api/v1/resetPassword`
- `POST /api/v2/users/{id}/roles` and `DELETE
/api/v2/users/{id}/roles/{roleId}`, superseded by `/api/v2/user_roles`
- `GET /api/v1/user/me` stays registered but returns 501 pointing at
`GET /api/v2/users/me`, following the v1 dashboard endpoints.
- Drops the handlers, module methods and types that only existed to
serve them (`DeprecatedUser`, the `user_invite` types, `PostableRole`).
- Moves the four remaining `*_cleanup` teardown tests off `DELETE
/api/v2/users/{id}/roles/{roleId}` onto `DELETE
/api/v2/user_roles/{id}`. Removal is keyed by the `user_role` entry id,
so they read it from `GET /api/v2/users/{id}` — that endpoint is not
deprecated and its other uses are untouched.
- Regenerates `docs/api/openapi.yml` and the frontend client. No
hand-written frontend code referenced the removed operations.

#### Issues closed by this PR

Closes: https://github.com/SigNoz/platform-pod/issues/2667

#### Additional Information

- `/api/v1/user/me` is a stub rather than a deletion because an
unregistered `/api/*` path falls through to the SPA catch-all and
answers 200 with `index.html`, which older mcp reads as a successful
response — a 501 fails loudly instead.
2026-08-13 10:37:28 +00:00
Vinicius Lourenço
013e631c68 fix(translate): do not crash on use google translate (#12466)
<!--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

This PR aims to fix the issues we have with translation, today when we
enable the translation, some parts of the app crash due to how the react
works and how the translation works.

In simple works, if you have: `<button>{(var ? 'text' : 'other text')}
{icon}` it will crash because the React will represent the `text` and
`other text` as `TextNode`, and when translate is performed, it changes
the parent of this element to `font` and causes the react to be "blind",
and when trying to delete the element, it cannot find.

> Read
https://martijnhols.nl/blog/everything-about-google-translate-crashing-react
to understand more

There's many fixes that includes ignore the errors and let the app with
invalid data, or actually go ahead and find the places with this pattern
and avoid them.

I kinda mixed two approaches, I introduced a new plugin based on
https://github.com/getcouped/eslint-plugin-react-google-translate/ but
adapted a little bit for our necessity and for our codebase (with oxc).
If we only use this plugin to find and fix the places, we will find most
of the issues crashing the app, but not all of them.

Why not all? Because even our component library is not safe enough for
google translate, eg: https://github.com/SigNoz/components/issues/351

So, I also included https://npmx.dev/package/translation-resilience,
this lib has another approach to fix the issue with the TextNode:

```
Instead of swallowing errors, this shim puts the original text nodes **back** the moment the renderer touches them:

1. A document-wide `MutationObserver` recognizes translation's displacement pattern (merge, wrap, remove — a pattern renderer commits never produce) and tracks each replaced text run as a *displacement group*: the ordered renderer-owned originals with their pre-translation values, plus the wrapper nodes currently standing in for them.
2. Patched `Node.prototype.removeChild` / `insertBefore` / `appendChild` and the `nodeValue` / `data` setters detect operations on displaced text nodes and first **restore the group** — originals go back into the wrappers' position, wrappers are removed — then let the native operation proceed on a consistent tree.
3. The translator's own observer notices the restored (now updated) text and re-translates it, so the user sees fresh, translated content. The loop is self-healing: update → restore → re-translate.

The result: no crashes, **and** live data keeps updating on translated pages — in the visitor's language.
```

We could keep the lib only and no plugin? Yes, but I want to make our
app more resilient without need the help of the lib, so we can continue
to adopt/fix places that has the pattern to crash the app, and
eventually, we can remove the lib because our app is resilient enough.

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

Closes https://github.com/SigNoz/platform-pod/issues/2912

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


https://github.com/user-attachments/assets/0225464b-1afe-46ad-afe7-25f79e25201a

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

This lib has a performance cost but the lib only enable itself when it
detects the translation is enabled, so our app (and users) should not
see/perceive any performance cost due to this lib. But again, this is
another reason to slowly adapt and fix all places that offers a
potential problem to google translate.
2026-08-13 08:23:47 +00:00
Abhi kumar
535a29adbf fix(query-builder): let tag add-on fields grow instead of spilling their tags (#12541)
<!--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

- Adding a few values to **Group By** made the tags wrap onto a second
row that rendered outside the field, on top of the add-on toggles below
it. **Order By** and the formula Order By row had the same bug.
- The add-on field pinned the antd select and its selector to `height:
36px`, so it could never grow. Both are `min-height: 36px` now —
single-line selects keep the same 36px row, tag selects grow with their
rows.

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

Before
<img width="1742" height="117" alt="image"
src="https://github.com/user-attachments/assets/12dfccbe-d087-4857-9bd0-3b6dfa0a1da1"
/>

After
<img width="1778" height="164" alt="image"
src="https://github.com/user-attachments/assets/abeb0b38-b0ce-4749-bf1c-32fc865833b9"
/>

#### Issues Closed
Closes https://github.com/SigNoz/pulse-pod/issues/270


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

- Broke in #11992, which put `height: 36px` on `.ant-select-selector`
and moved the field's border onto it. The same `height` on the root
`.ant-select` predates that but never applied, because
`GroupByFilter`/`OrderByFilter` pass an inline `height: 100%` — antd was
left to size the selector from its content, so the field used to grow as
tags wrapped.
- Checked in a headless-Chromium repro of the field using antd 5.11's
select rules: 12 tags in a ~900px field hang 20px below the box on
`main`, and sit inside it with this change.
2026-08-13 08:16:25 +00:00
Tushar Vats
3d5aab744d fix(logs): stringify body_v2 in the v3 logs select (#12534)
#### Description

Two commits: a clean revert of #12523, then a reland with the body
stringified.

**Why the revert.** #12523 selected `body_v2 as body` for orgs on JSON
bodies. ClickHouse resolves identifiers in `WHERE` against SELECT
aliases, and the v3 filter builder emits a bare `body` (`body != ''` for
exists, `lower(body) like …` for contains), so every body filter started
running against the JSON column and failed with `Code: 117 … Cannot
parse JSON object here: while converting '' to JSON`. The pipelines
preview always sends the pipeline's filter, so picking sample logs by
body errored outright.

**What the reland changes.** The select is `toString(body_v2) as body`,
so the alias stays a String and those filters compare against the body
text again. As a bonus they now actually match — before #12523 they ran
against the legacy `body` column, which the collector writes empty for
these orgs, so they silently matched nothing. The JSON column decoding
#12523 added to `GetListResultV3` is not relanded: nothing selects a
JSON column on this path now, and it failed the entire query on a row it
could not unmarshal rather than just that row.

Reproduced directly against ClickHouse:

```sql
SELECT body_v2 AS body FROM signoz_logs.distributed_logs_v2 WHERE body != '' LIMIT 1;
-- Code: 117. DB::Exception: Cannot parse JSON object here: while converting '' to JSON(...)

SELECT toString(body_v2) AS body FROM signoz_logs.distributed_logs_v2 WHERE body != '' LIMIT 1;
-- {"level":"error","message":"json log line","user":"alice"}
```

#### Additional Information

Verified end to end on a local stack (devenv ClickHouse + a collector
with `body_json_enabled`, `use_json_body` on): `body EXISTS` and `body
CONTAINS` both return rows, and the body comes back as the stringified
JSON.

v5 is unaffected — its field mapper builds a real JSON expression
instead of emitting a bare `body`, so `body EXISTS` there already worked
and still returns object bodies.

The response shape for v3 is a JSON string rather than the object #12523
returned. Consumers that need structure can parse it; the pipelines
preview endpoint accepts either, since it types the log body as `any`
and re-parses through the `normalize` pipeline.

Known gaps left alone, since they predate this or need the filter
builder to become JSON-aware: aggregation and group-by queries still
read the empty legacy `body` column (only the list select carries the
alias), body filters cannot use the `body_v2` skip indexes while
stringified, and the v4 endpoint never sets the flag.
2026-08-13 08:04:42 +00:00
Gaurav Tewari
a8309a1a02 test(qb): add integration tests for recent searches dropdown (#12045)
#### Description

Adds integration tests for the Recent Searches dropdown in the query
builder search editor.

What's covered:

- A saved recent shows up under "Recent searches" on focus
- Recents filter by substring as you type
- Recents stay partitioned by signal — a `traces` recent never leaks
into the `logs` editor
- A recent identical to what's already typed is excluded
- Clicking a recent applies the whole expression and closes the popup
- The dropdown caps at `RECENTS_DISPLAY_CAP` entries, newest first
(asserted as a full ordered array)
- The per-entry delete button removes the recent from both the dropdown
and the store, without applying it

#### Issues closed by this PR

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

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-13 08:04:03 +00:00
Aditya Singh
897036968c feat(trace-details): add analytics events to span percentile flow (#12540)
<!--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
Instrument the span percentile widget with product analytics: 

- panel toggle
- time-range change
- resource-attributes selector toggle
- attribute selection change. 

Events go through the existing useTraceDetailLogEvent hook so view and
traceId are injected.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/engineering-pod/issues/5908
2026-08-13 07:56:36 +00:00
Vinicius Lourenço
55ef5fbc3c fix(infrastructure-monitoring-namespaces): wrong division for available/desired and use lastest instead of avg (#12429)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
build-staging / prepare (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
## Pull Request

---

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

This follows the same pattern as
https://github.com/SigNoz/signoz/pull/11681 to use `latest` instead of
`avg`, and also fixes the calculation of `util %` that was suppose to be
`desired/available * 100` instead of current value `available/desired`.

#### Screenshots / Screen Recordings (if applicable)
> Include screenshots or screen recordings that clearly show the
behavior before the change and the result after the change. This helps
reviewers quickly understand the impact and verify the update.

Before:

<img width="857" height="364" alt="image"
src="https://github.com/user-attachments/assets/949db1a8-c27d-41da-8573-398a4d53af24"
/>

After:

<img width="851" height="336" alt="image"
src="https://github.com/user-attachments/assets/5181849a-28e4-45f8-b7a5-0d611b5ae02e"
/>

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

Closes https://github.com/SigNoz/pulse-pod/issues/210

---

###  Change Type
_Select all that apply_

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

---

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

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

---

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

- Blast radius: Infrastructure Monitoring - Namespaces
- Potential regressions: None
- Rollback plan: Revert this commit

---

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

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Bug Fix |
| Description | We updated the table for Desired (pods) inside the
Namespace Details on Infrastructure Monitoring to correctly show the
`util %`. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-13 05:52:36 +00:00
Shivam Gupta
49749626dc fix(onboarding): list multi-signal data sources under every signal they support (#12522)
#### Description

- Some data sources ship a single doc that sets up two or three signals,
but carried only one tag, so they showed up in exactly one section of
the picker. Searching `temporal` surfaced it only under APM/Traces even
though both Temporal docs configure traces, metrics and logs.
- Tagged them with every signal their doc actually configures, so they
list under each matching section — the same way `Deno` already does. No
UI changes needed: `groupDataSourcesByTags` already fans an entry out
across its tags.

| entry | was | now |
| --- | --- | --- |
| Temporal | `apm/traces` | `apm/traces`, `logs`, `metrics` |
| Nginx - OpenTelemetry (was "Nginx - Tracing") | `apm/traces` |
`apm/traces`, `logs`, `metrics` |
| OpenTelemetry eBPF (OBI) | `apm/traces` | `apm/traces`, `metrics` |
| DBOS | `apm/traces` | `apm/traces`, `logs` |
| Cloudflare Workers | `apm/traces` | `apm/traces`, `logs` |

- "Nginx - Tracing" is renamed to "Nginx - OpenTelemetry" since it no
longer lists only under traces, and to stay distinct from the existing
built-in Nginx integration entry.

#### Additional Information

- All 82 docs behind the 70 single-signal-tagged entries were read to
decide this; the other 77 are genuinely single-signal. Every language
APM doc explicitly sets `OTEL_METRICS_EXPORTER=none` /
`OTEL_LOGS_EXPORTER=none`, and the matching metrics docs set
`OTEL_TRACES_EXPORTER=none` — so splits like `Java` / `Java logs` /
`Java Metrics` are correct as they stand.
- Left unchanged, but worth a second opinion: the logs docs for Java,
Python, Node.js (Pino/Winston/Bunyan) and Golang (Logrus/Zerolog) run
auto-instrumentation that emits traces, but only ever mention traces to
tell you how to switch them off. Read as logs-only here.
2026-08-13 04:03:15 +00:00
Pandey
061eb1f867 chore(deps): bump clickhouse-sql-parser to v0.5.6 (#12536)
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

Bumps `github.com/AfterShip/clickhouse-sql-parser` from v0.5.5 to
v0.5.6.

- v0.5.6 parses a parenthesized left operand of a set operator (upstream
https://github.com/AfterShip/clickhouse-sql-parser/pull/312), e.g.
`(SELECT 1) UNION ALL (SELECT 2)`.
- Moves the three now-passing parenthesized set-operation cases into the
pass table in `clickhouse_sql_test.go` as regression canaries.
- Records the outstanding `NULLS FIRST|LAST` ORDER BY gap in the
known-gap table — the parser still rejects it, so it stays tracked until
fixed upstream.
2026-08-12 19:25:17 +00:00
Tushar Vats
092e0b7d99 refactor(qb): build IN as an OR of equalities (#12504)
#### Description

- `IN` and `NOT IN` now route each value back through the condition
builder with `=` / `!=` instead of assembling the comparisons a second
time, so whatever a builder does for a scalar comparison applies to the
list form too. Applied to logs, traces and audit — the three that
already fanned a list out into per-value comparisons.
- Fixes `body.<path>[*] IN [...]` with `use_json_body` off returning a
**500**. The list shape made the path extract as `Array(String)`, and
ClickHouse refuses to compare an array to a scalar (code 130);
extracting per value reads the field instead. The new case in
`querierlogs/06_json_body.py` fails on `main` and passes here — verified
both ways against a real ClickHouse.

#### Additional Information

- **resourcefilter is deliberately left out**: it asserts the key index
filter (`labels LIKE '%key%'`) once for the whole list, alongside one
value filter per value. A recursed arm derives its own key filter, so
the same predicate would be repeated per value — `(e1 AND kIdx AND l1)
OR (e2 AND kIdx AND l2)` instead of `(e1 OR e2) AND kIdx AND (l1 OR
l2)`. Same rows either way, but no reason to emit the duplicate.
- **telemetrymetadata is deliberately left out**: it applies a
key-existence guard at a single exit, so a recursed arm comes back
already wrapped and the guard would either nest or the case would have
to skip the shared tail — losing the invariant that every condition is
guarded.
- **metrics and rulestatehistory** build a real `sb.In`; there is no
per-value fan-out to delegate to.
- Mixed-type lists are safe: `DataTypeCollisionHandledFieldName`
normalises the whole list before the loop, so `IN ('200', 5)` emits
identical SQL before and after.
- Base of a stack — the follow-ups add index-friendly predicates to `=`,
which `IN` then picks up.
2026-08-12 15:36:25 +00:00
Tushar Vats
16849967c5 feat(frontend): register search() as a query builder function (#12513)
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

Stacked on SigNoz/signoz#12491 — review that one first.

#12491 makes the frontend parser lex and parse `search()`. This makes
the editor act on it. Scoped to `search('x')` and `search(x)` for now —
scope arguments are not handled yet.

- **Function suggestion.** The cursor on `search` resolved to no
context, so the suggestion list never opened and `search()` was never
offered as a completion. Registered alongside the `has` family, in the
same two places `hasToken` needed.
- **The term is free text, not a key.** `search(x)` lexes its term as a
key, so the editor offered attribute keys inside the call and would
complete one into the term — and pair extraction turned it into a filter
item keyed `x`, which the log detail drawer rebuilds into a real filter.
Key and value suggestions are now suppressed inside a `search()` call,
and its argument no longer produces a pair. `has(key, value)` does take
a real key, so its suggestions are untouched.
- **Logs only.** `FilterOperatorSearch` is implemented in
`logstelemetryschema` alone; traces and metrics reject it as an
unsupported operator, so the suggestion is gated to the logs signal.
- **Recents.** `SEARCH(...)` and `search(...)` no longer dedup as two
distinct recent queries.

#### Additional Information

`search` is a reserved word now, so `search = 'x'` and `search exists`
no longer parse — the same tradeoff `has`/`hasAny` already carry,
matching the backend grammar.

Three things deliberately left out:

- After picking any function from the autocomplete the cursor lands
outside the brackets, so you have to arrow back before typing the
argument. Long-standing behaviour across the whole `has` family, but
`search()` is the case where an empty call is always a syntax error.
Filed as SigNoz/engineering-pod#5893.
- `has(key, value)` still contributes a phantom filter item keyed on its
first argument, which reaches the trace waterfall's API query, the
metrics drilldown and the infra filter telemetry. Fixing it needs the
autocomplete to keep reading that argument as a key, so it is not a
one-liner.
- Scope arguments (`search('err', body)`) parse but are not supported
here.

`isCursorInSearchTerm` runs on every cursor move, so it text-matches
`search` before paying for a lex. A `SEARCH` token only exists where the
lexer matched exactly those six letters — a word character on either
side would have produced a `KEY` — so the pre-check cannot produce a
false negative. `body = 'search this'` is covered by a test, since only
the lexer can tell that one is a quoted value.

Unrelated to this PR: `QuerySearch.test.tsx › fetches key suggestions on
mount for LOGS` is flaky on `main` too. An earlier test in that file
types `http.` and never unmounts, so its debounced `getKeySuggestions`
resolves after this test's `mockClear()` and wins the `mock.calls[length
- 1]` read.
2026-08-12 12:49:25 +00:00
Vikrant Gupta
c8e7685f06 test(integration): drop the deprecated user endpoints from the suite (#12529)
#### Description

- The integration suite was the last consumer of the five deprecated v1
user endpoints. It now provisions through `POST /api/v2/users`, `PUT
/api/v2/users/{id}/reset_password_tokens` and `POST
/api/v2/factor_password/reset`, so those routes can be deleted once the
remaining upstream consumer is deployed.
- Role assignment moves off the deprecated `POST
/api/v2/users/{id}/roles` and `DELETE /api/v2/users/{id}/roles/{roleId}`
onto `/api/v2/user_roles`. Removal is keyed by the `user_role` entry id,
so the tests read it from `GET /api/v2/users/{id}`. `GET
/api/v2/users/{id}/roles` is not deprecated and stays.
- `create_active_user` takes managed role names (`signoz-viewer`),
matching `change_user_role` and `create_service_account`.
- `find_role_by_name` moves to `fixtures/role.py` as a plain function
and replaces the `find_role_id` fixture — a stateless lookup shouldn't
be a fixture factory.

#### Issues closed by this PR

Contributes to SigNoz/platform-pod#2667

#### Additional Information

- `test_provision_user` now makes the provisioning calls inline, in
order, and covers the conflict branch that had no coverage before.
- `test_reset_password_v2` is gone; `test_reset_password` now targets v2
and absorbed its single-use-token assertion.
2026-08-12 12:41:15 +00:00
Tushar Vats
a3caaaf7f2 chore(frontend): regenerate ANTLR parser with search() support (#12491)
#### Description

- The frontend parser under `frontend/src/parser/` is generated from
`grammar/FilterQuery.g4` — the same grammar the backend query builder
uses — but the committed output predates the `search()` rule, so the UI
couldn't lex or parse `search('term')` even though the backend accepts
it. Regenerated it.
- Also fixed `scripts/grammar/generate-frontend-parser.sh`: ANTLR
reproduces the input's relative path under `-o`, so the old command
wrote to `frontend/src/parser/grammar/` instead of
`frontend/src/parser/`. It now runs from inside `grammar/`.
- Generated with ANTLR 4.13.2 (was 4.13.1), matching the `antlr4`
runtime in `frontend/package.json` and the version used for the Go
parser. That bump also adds `.js` extensions to the generated relative
imports — tsc (`moduleResolution: bundler`) and ts-jest resolve them
fine.
- Codegen only: no visitor/UI wiring for `search()` yet, so surfacing it
in autocomplete/validation is follow-up work.

#### Issues closed by this PR

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

#### Additional Information

- Verified with a throwaway spec (not committed) that the regenerated
parser parses `search('error')`, `search('error', body, attribute)` and
`search('error') AND service.name = 'redis'` with zero syntax errors,
and still parses `has(payload.user_ids, 123)`.
- `src/parser/**` is in both the oxfmt and oxlint ignore lists, so the
raw ANTLR output is committed unformatted, as before. Because every
staged frontend file is ignored, lint-staged's `oxfmt --write` step
errors with "Expected at least one target file" — the commit needed
`--no-verify`.
- Ran the jest suites that consume the parser (`src/utils`,
`src/components/QueryBuilderV2`, `src/lib/recentQueries`): 371 passed, 1
pre-existing failure in `QuerySearch.test.tsx` that reproduces on `main`
without these changes.
2026-08-12 12:24:52 +00:00
Aditya Singh
a355996a5d feat(logs-explorer): resolve body json fields in logs table columns (#12503)
<!--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
In the logs explorer **table view**, a column for a field that lives
inside a JSON body now shows its value instead of coming up empty.
Scoped to `use_json_body` tenants.

- Added a wrapper util which sits on top of existing util which provides
the col values(FlatLogData).
- This searches for the attribute in body json. If its not present there
we will get it from attribute/resources as its happening currently.
- Only does this if `use_json_body` is true.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/engineering-pod/issues/4610
<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

Before:
DB Operation col is empty
<img width="3402" height="1850" alt="image"
src="https://github.com/user-attachments/assets/7f8c6945-2c43-4ea6-9b82-5fd07b36c52f"
/>


After:
DB Operation col is populated from body

<img width="3346" height="1778" alt="image"
src="https://github.com/user-attachments/assets/c1ae2953-986e-42d5-bef3-9bc10e932fc2"
/>
2026-08-12 11:38:25 +00:00
Tushar Vats
eea11972a9 fix(logs): return the JSON body from v3 query_range (#12523)
#### Description

- For orgs on JSON bodies the collector writes the legacy `body` column
empty (`processBody` blanks it unless `body_json_old_body_enabled`) and
keeps the log body in `body_v2`. The v3 logs list still selected `body`,
so every log came back with an empty body — verified on a tenant: all
2159 rows had `body = ''` and `body_v2` populated.
- `queryRangeV3` now resolves `use_json_body` for the caller's org and
the list query selects `body_v2 as body`, the same expression v5 uses.
- `GetListResultV3` decodes JSON columns into a map, mirroring the
querier's raw-row consumption — the driver cannot decode JSON into
native Go values, so it is read as raw bytes and unmarshalled. A v3
response now carries the same body object v5 returns, so clients need no
change when they move to v5.

#### Additional Information

Scoped to the v3 endpoint: `QueryRangeV4` does not set the flag, so v4
keeps selecting the legacy column even though it shares the builder. The
livetail select is untouched — `/api/v3/logs/livetail` is served by the
v5 handler now, and `PrepareLiveTailQuery` has no callers.

One difference from v5 remains by choice: v5's `postProcessLogBody`
drops an empty `message` key, so a log with an empty message reads
`{"message":""}` here and `{}` there. Nothing branches on it, and
copying that logic would be a second implementation to keep in sync on a
path we are retiring.

#12520 is stacked on this branch — it types the pipelines preview log
body as `any`, which that endpoint needs before it starts receiving the
object bodies this PR returns.
2026-08-12 11:10:13 +00:00
Swapnil Nakade
2616885d22 feat: adding mysql GCP service (#12514)
<!--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
- Adding GCP integration MySQL service
- Related fix: adding formula to convert CPU utilization fraction into
percentage for Postgres dashboard

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
https://github.com/SigNoz/platform-pod/issues/2942
2026-08-12 09:53:16 +00:00
Aditya Singh
52dd57074e feat: filter fields with no name in field selector (#12512)
<!--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
Filter field selector options with name field empty

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

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings
No screen recording as this is hard to reproduce.
2026-08-12 07:54:51 +00:00
Naman Verma
fe94b817db fix(promql): remove NaN and Inf values from PromQL query range response (#12388)
## Pull Request

---

### 📄 Summary

Currently, builder and clickhouse queries remove NaN and Inf values, but
PromQL does not. This way, it ends up in the final response. While the
UI handles these values, a lot of other places in the flow do not, such
as our query response caching. This can lead to unexpected issues.

The current issue at hand is that while the first query range call shows
the correct data, the second call (that fetches from cache) does not.

Instead of fixing the caching, better to solve the problem at root level
and not return non-finite values for PromQL altogether.

#### Recordings

On local data before the change:


https://github.com/user-attachments/assets/c08ec796-a7e5-47d8-8cc5-3dfd302dba49

After the change:


https://github.com/user-attachments/assets/9162c963-1a98-4ebc-83ce-759a9127b772


#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/185

---

### 🧪 Testing Strategy

- Tests added/updated: Yes, integration and unit tests
- Manual verification: Added data locally to reproduce the exact
scenario

---

### ⚠️ Risk & Impact Assessment

- Blast radius: PromQL queries
- Rollback plan: Revert PR or just add a fix

---
2026-08-12 07:35:38 +00:00
Aditya Singh
ea36032d96 fix(sentry): drop benign cancellation errors from reporting on sentry (#12524)
<!--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
This PR drops Cancelation error from monaco on sentry to reduce noise



<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
Pager: https://signoz-1.pagerduty.com/incidents/Q1JG9MJ5DRA4LW
Sentry: https://signoz-io.sentry.io/issues/7491905006
2026-08-12 07:22:33 +00:00
Abhi kumar
6ecfa839f3 fix(service-map): stop the resource attribute filter bar from clearing (#12521)
#### Description

Selecting a filter on the Service Map cleared the filter bar instead of
applying it, and the same filter then turned up applied on the Services
tab. Three separate causes:

- The resource attribute context filtered its queries by the current
route, so a filter the map cannot apply vanished from the bar while
staying in state and in the `resourceAttribute` URL param — which the
sidebar carries across routes, hence it reappearing on Services. The
context now exposes whatever is in the URL, and the Service Map narrows
the queries for its own `/dependency_graph` request, so the request
payload is unchanged.
- `ServiceMap` returned early with the filter bar under a different
parent element in each branch, so React tore the bar down and rebuilt it
whenever the map flipped between having services and being empty (and it
wasn't rendered at all while loading). It now renders once, above the
loading / empty / map states. As a side effect the graph tooltip styles
in `Container` finally wrap the graph rather than only the empty state.
- The environment `Select` was keyed on its own value, remounting an
already-controlled select on every pick and closing the dropdown before
a second environment could be chosen.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#199

#### Additional Information

- Related but deliberately left out of scope: `whilelistedKeys` lists
`resource_k8s_cluster_namespace`, while the backend column is
`k8s_namespace_name` (`pkg/query-service/app/services/map.go`), so that
filter is accepted by the UI and silently dropped server side.
2026-08-12 06:53:55 +00:00
Ashwin Bhatkal
62d382b3cc fix(alerts): tolerate a null channels field when editing an existing alert rule (#12510)
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
## Summary

Opening an existing alert rule for editing crashes the whole page with
`Cannot read properties of null (reading 'length')`. It happens when a
rule's threshold has `channels: null`, which is the case for rules not
created through the UI.

The generated type is right — `RuletypesBasicRuleThresholdDTO.channels`
is `string[] | null`. The problem is our own `BasicThreshold` wrapper
type, which we keep because v1 and v2 alert shapes both exist. It says
`channels: string[]`, and `fromRuleDTOToPostableRuleV2` casts the DTO
straight into it with `as unknown as`. So the null reaches our code
while the compiler thinks it can't.

`getThresholdStateFromAlertDef` copied that null into state, and the
footer validator then read `.length` on it during render, which takes
down the page instead of failing one field.

This PR defaults `channels` to `[]` where the API data becomes local
state, so the validator, the payload builder and both channel dropdowns
are all safe. The validator also gets an optional chain, since a throw
there can't be recovered.

This is a guard, not the real fix. The cast in
`fromRuleDTOToPostableRuleV2` is the actual gap, and the same wrapper
also claims `spec` is non-nullable when the generated type allows null —
so `spec.map` and `spec[0].op` in the same function can still crash.
Worth fixing at the converter.

## Test plan

One test per guard. Both fail with the original error when the fix is
reverted.

- `pnpm jest src/container/CreateAlertV2/` — 420 pass, 28 suites
- `oxfmt`, `oxlint`, `tsgo --noEmit` clean

Closes https://github.com/SigNoz/pulse-pod/issues/261
2026-08-11 20:40:30 +00:00
Ashwin Bhatkal
cc07e2fa24 fix(alert-channel-integrations): de-flake the Google Chat alert channel save tests (#12509)
## Summary

The Google Chat save test fails on CI now and then with `Exceeded
timeout of 5000 ms for a test`.

The two Google Chat tests fill the form with `userEvent.type()`, which
sends one keystroke at a time. Each keystroke re-renders the whole form.
The payload test types 91 characters, so it takes ~850ms locally. CI is
about 5x slower, which puts it near the 5s limit. A busy runner then
pushes it over.

This PR pastes the values instead of typing them. One event per field,
same assertions.

| Test | Before | After |
| --- | --- | --- |
| `saving sends a googlechat_configs payload` | 847 ms | 283 ms |
| `saving with a webhook url outside chat.googleapis.com` | 590 ms | 326
ms |

Nothing regressed. The new test was added recently to an already
existing suite. It was always close to the limit.

## Test plan

- `pnpm jest
src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx` —
57/57 pass, 3 runs
- `oxfmt`, `oxlint`, `tsgo --noEmit` clean

Closes https://github.com/SigNoz/pulse-pod/issues/259
2026-08-11 20:17:56 +00:00
Vinicius Lourenço
d7aa63f1bc fix(infrastructure-monitoring): migrate having clause to new format (#12467)
<!--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

This fixes the bad migration I did at
https://github.com/SigNoz/signoz/pull/11060, and correctly fixes the
expressions for `having` clause inside the charts.

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

Closes https://github.com/SigNoz/platform-pod/issues/2905

Closes https://github.com/SigNoz/pulse-pod/issues/212

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

| Before | After |
|--------|--------|
| <img width="2156" height="1081" alt="screenshot-2026-08-07_17-15-41"
src="https://github.com/user-attachments/assets/b5617e88-2d63-4a21-915f-d21ed78f9f8b"
/> | <img width="2148" height="1075"
alt="screenshot-2026-08-07_17-12-06"
src="https://github.com/user-attachments/assets/4de3ffd0-533f-4b3b-81ac-df1515b4a076"
/> |
| <img width="2145" height="357" alt="screenshot-2026-08-07_17-16-08"
src="https://github.com/user-attachments/assets/0bfb92f3-e0c4-4cf6-9e4e-62068501da96"
/> | <img width="2146" height="360" alt="screenshot-2026-08-07_17-11-54"
src="https://github.com/user-attachments/assets/bba8ee82-68cd-4205-951f-711adaad07e0"
/> |
| <img width="1074" height="356" alt="screenshot-2026-08-07_17-15-55"
src="https://github.com/user-attachments/assets/574430b4-8547-4a1e-b53a-982ef33344ae"
/> | <img width="1074" height="363" alt="screenshot-2026-08-07_17-11-44"
src="https://github.com/user-attachments/assets/bf5698af-f7fb-45b6-886a-bc8ed4aba808"
/> |
2026-08-11 19:59:53 +00:00
Nityananda Gohain
c36b748370 feat: ai-011y quickfilters support (#12406)
## Pull Request

---

### 📄 Summary
AI 011y quickfilter

Will add the migration later.



#### Issues closed by this PR
Part of https://github.com/SigNoz/engineering-pod/issues/5714

---

###  Change Type
_Select all that apply_

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

---

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

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

---

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

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

---
2026-08-11 19:19:35 +00:00
Vikrant Gupta
89bf0f953a chore(frontend): drop the dead v1 invite client and its mocks (#12511)
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

- `sendInvite` has had no call sites since the invite flow moved to
`POST /api/v2/users`. Removing it orphans its types file, the two MSW
handlers for `/api/v1/invite` and `/api/v1/user`, and the members mock
data they served.
- No product behaviour changes; nothing in the app or the tests
requested either endpoint.

#### Issues closed by this PR

Contributes to SigNoz/platform-pod#2667
2026-08-11 12:42:42 +00:00
Srikanth Chekuri
a850f548f8 chore: remove normalized metrics compatibility (#12470)
we no longer need it, helps with the upcoming sem conv change

- the `transition.go` will go away completely when the sem conv support
for metrics added
- we will add deprecation notice and migration guide for infra
monitoring v1 apis just in case if anyone using it and then remove it
altogether


areas touched and tested

- services
- logs detailed page node/pod metrics for a log
- message queues
2026-08-11 12:17:56 +00:00
292 changed files with 9951 additions and 14538 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -349,7 +349,7 @@ func (Step) JSONSchema() (jsonschema.Schema, error) {
### `oneOf` with a discriminator
For a sum type whose variants are keyed by a property (e.g. `kind`), expose the variants via `JSONSchemaOneOf()` and add a discriminator. Without it, code generators intersect the variants (`A & B & C`) instead of producing a clean discriminated union (`A | B | C`). How to model the sum type itself is covered in [types.md](types.md#sum-types-the-kindspec-envelope) — this section is only about its schema.
For a sum type whose variants are keyed by a property (e.g. `kind`), expose the variants via `JSONSchemaOneOf()` and add a discriminator. Without it, code generators intersect the variants (`A & B & C`) instead of producing a clean discriminated union (`A | B | C`).
The parent keeps its `JSONSchemaOneOf()` (the `oneOf` itself) and *additionally* tags it via `PrepareJSONSchema` with the `x-signoz-discriminator` extension; `signoz.attachDiscriminators` then promotes that marker to a real OpenAPI 3 `discriminator` (and strips the duplicate parent properties) after reflection.

View File

@@ -61,31 +61,37 @@ type Channel struct {
```go
type AuthDomain struct {
storableAuthDomain *StorableAuthDomain
authDomainConfig *AuthDomainConfig
storableAuthDomain *StorableAuthDomain
storableAuthDomainConfig *StorableAuthDomainConfig
}
type StorableAuthDomain struct {
bun.BaseModel `bun:"table:auth_domain"`
types.Identifiable
Name string `bun:"name"`
Data string `bun:"data"` // AuthDomainConfig serialized as JSON
Data string `bun:"data"` // StorableAuthDomainConfig serialized as JSON
OrgID valuer.UUID `bun:"org_id"`
types.TimeAuditable
}
type PostableAuthDomain struct {
Config AuthDomainConfig `json:"config"`
Name string `json:"name"`
Name string `json:"name" required:"true"`
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config" required:"true"`
RoleMapping *RoleMapping `json:"roleMapping"`
}
type UpdateableAuthDomain struct {
Config AuthDomainConfig `json:"config"` // Name intentionally absent
type UpdatableAuthDomain struct {
Enabled bool `json:"enabled"` // Name intentionally absent
Config AuthDomainConfig `json:"config" required:"true"`
RoleMapping *RoleMapping `json:"roleMapping"`
}
type GettableAuthDomain struct {
*StorableAuthDomain
*AuthDomainConfig
StorableAuthDomain
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config"`
RoleMapping *RoleMapping `json:"roleMapping"`
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
}
```
@@ -93,11 +99,11 @@ type GettableAuthDomain struct {
Each flavor exists for a concrete reason:
- `StorableAuthDomain` stores the typed config as an opaque `Data string` column, so the schema does not need to migrate every time a config field is added.
- `PostableAuthDomain` carries the config as a structured object (not a string) for the request.
- `UpdateableAuthDomain` excludes `Name` because a domain's name cannot change after creation.
- `PostableAuthDomain` carries the config as a structured object (not a string) for the request; `AuthDomainConfig` is a kind/spec envelope.
- `UpdatableAuthDomain` excludes `Name` because a domain's name cannot change after creation.
- `GettableAuthDomain` adds `AuthNProviderInfo`, which is derived at read time and never persisted.
The core `AuthDomain` holds the two live halves — `storableAuthDomain` and `authDomainConfig` — and owns business methods such as `Update(config)`. Conversions use the `New<Output>From<Input>` form: `NewAuthDomainFromConfig`, `NewAuthDomainFromStorableAuthDomain`, `NewGettableAuthDomainFromAuthDomain`.
The core `AuthDomain` holds the two live halves — `storableAuthDomain` and `storableAuthDomainConfig` — and owns business methods such as `Update(updatable)` and `Patch(patchable)`. Conversions use the `New<Output>From<Input>` form: `NewAuthDomainFromPostableAuthDomain`, `NewAuthDomainFromStorableAuthDomain`, `NewGettableAuthDomainFromAuthDomain`.
## Sum types: the kind/spec envelope

View File

@@ -53,10 +53,6 @@ func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSett
}
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderOIDC {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not oidc")
}
_, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, siteURL, authDomain)
if err != nil {
return "", err
@@ -85,6 +81,11 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
oidcConfig, err := authDomain.Config().OIDCConfig()
if err != nil {
return nil, err
}
oidcProvider, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, state.URL, authDomain)
if err != nil {
return nil, err
@@ -106,14 +107,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, err
}
if claims == nil && authDomain.AuthDomainConfig().OIDC.GetUserInfo {
if claims == nil && oidcConfig.GetUserInfo {
claims, err = a.claimsFromUserInfo(ctx, oidcProvider, token)
if err != nil {
return nil, err
}
}
emailClaim, ok := claims[authDomain.AuthDomainConfig().OIDC.ClaimMapping.Email].(string)
emailClaim, ok := claims[oidcConfig.ClaimMapping.Email].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email in claims")
}
@@ -123,7 +124,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to parse email").WithAdditional(err.Error())
}
if !authDomain.AuthDomainConfig().OIDC.InsecureSkipEmailVerified {
if !oidcConfig.InsecureSkipEmailVerified {
emailVerifiedClaim, ok := claims["email_verified"].(bool)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email_verified in claims")
@@ -135,14 +136,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
name := ""
if nameClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Name; nameClaim != "" {
if nameClaim := oidcConfig.ClaimMapping.Name; nameClaim != "" {
if n, ok := claims[nameClaim].(string); ok {
name = n
}
}
var groups []string
if groupsClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Groups; groupsClaim != "" {
if groupsClaim := oidcConfig.ClaimMapping.Groups; groupsClaim != "" {
if claimValue, exists := claims[groupsClaim]; exists {
switch g := claimValue.(type) {
case []any:
@@ -161,7 +162,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
role := ""
if roleClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Role; roleClaim != "" {
if roleClaim := oidcConfig.ClaimMapping.Role; roleClaim != "" {
if r, ok := claims[roleClaim].(string); ok {
role = r
}
@@ -177,11 +178,16 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
}
func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (*oidc.Provider, *oauth2.Config, error) {
if authDomain.AuthDomainConfig().OIDC.IssuerAlias != "" {
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().OIDC.IssuerAlias)
oidcConfig, err := authDomain.Config().OIDCConfig()
if err != nil {
return nil, nil, err
}
oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().OIDC.Issuer)
if oidcConfig.IssuerAlias != "" {
ctx = oidc.InsecureIssuerURLContext(ctx, oidcConfig.IssuerAlias)
}
oidcProvider, err := oidc.NewProvider(ctx, oidcConfig.Issuer)
if err != nil {
return nil, nil, err
}
@@ -189,13 +195,13 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
scopes := make([]string, len(defaultScopes))
copy(scopes, defaultScopes)
if authDomain.AuthDomainConfig().RoleMapping != nil && len(authDomain.AuthDomainConfig().RoleMapping.GroupMappings) > 0 {
if authDomain.RoleMapping() != nil && len(authDomain.RoleMapping().GroupMappings) > 0 {
scopes = append(scopes, "groups")
}
return oidcProvider, &oauth2.Config{
ClientID: authDomain.AuthDomainConfig().OIDC.ClientID,
ClientSecret: authDomain.AuthDomainConfig().OIDC.ClientSecret,
ClientID: oidcConfig.ClientID,
ClientSecret: oidcConfig.ClientSecret,
Endpoint: oidcProvider.Endpoint(),
Scopes: scopes,
RedirectURL: (&url.URL{
@@ -212,7 +218,12 @@ func (a *AuthN) claimsFromIDToken(ctx context.Context, authDomain *authtypes.Aut
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "oidc: no id_token in token response")
}
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().OIDC.ClientID})
oidcConfig, err := authDomain.Config().OIDCConfig()
if err != nil {
return nil, err
}
verifier := provider.Verifier(&oidc.Config{ClientID: oidcConfig.ClientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "oidc: failed to verify token").WithAdditional(err.Error())

View File

@@ -40,10 +40,6 @@ func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Li
}
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderSAML {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "saml: domain type is not saml")
}
sp, err := a.serviceProvider(siteURL, authDomain)
if err != nil {
return "", err
@@ -73,6 +69,11 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
samlConfig, err := authDomain.Config().SamlConfig()
if err != nil {
return nil, err
}
sp, err := a.serviceProvider(state.URL, authDomain)
if err != nil {
return nil, err
@@ -101,19 +102,19 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
}
name := ""
if nameAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Name; nameAttribute != "" {
if nameAttribute := samlConfig.AttributeMapping.Name; nameAttribute != "" {
if val := assertionInfo.Values.Get(nameAttribute); val != "" {
name = val
}
}
var groups []string
if groupAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Groups; groupAttribute != "" {
if groupAttribute := samlConfig.AttributeMapping.Groups; groupAttribute != "" {
groups = assertionInfo.Values.GetAll(groupAttribute)
}
role := ""
if roleAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Role; roleAttribute != "" {
if roleAttribute := samlConfig.AttributeMapping.Role; roleAttribute != "" {
if val := assertionInfo.Values.Get(roleAttribute); val != "" {
role = val
}
@@ -131,7 +132,12 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
}
func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDomain) (*saml2.SAMLServiceProvider, error) {
certStore, err := a.getCertificateStore(authDomain)
samlConfig, err := authDomain.Config().SamlConfig()
if err != nil {
return nil, err
}
certStore, err := a.getCertificateStore(samlConfig)
if err != nil {
return nil, err
}
@@ -142,32 +148,32 @@ func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDoma
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.
// For AWSSSO, this is the value of Application SAML audience.
return &saml2.SAMLServiceProvider{
IdentityProviderSSOURL: authDomain.AuthDomainConfig().SAML.SamlIdp,
IdentityProviderIssuer: authDomain.AuthDomainConfig().SAML.SamlEntity,
IdentityProviderSSOURL: samlConfig.Location,
IdentityProviderIssuer: samlConfig.EntityID,
ServiceProviderIssuer: siteURL.Host,
AssertionConsumerServiceURL: acsURL.String(),
SignAuthnRequests: !authDomain.AuthDomainConfig().SAML.InsecureSkipAuthNRequestsSigned,
SignAuthnRequests: !samlConfig.InsecureSkipAuthNRequestsSigned,
AllowMissingAttributes: true,
IDPCertificateStore: certStore,
SPKeyStore: dsig.RandomKeyStoreForTest(),
}, nil
}
func (a *AuthN) getCertificateStore(authDomain *authtypes.AuthDomain) (dsig.X509CertificateStore, error) {
func (a *AuthN) getCertificateStore(samlConfig authtypes.SamlConfig) (dsig.X509CertificateStore, error) {
certStore := &dsig.MemoryX509CertificateStore{
Roots: []*x509.Certificate{},
}
var certBytes []byte
if strings.Contains(authDomain.AuthDomainConfig().SAML.SamlCert, "-----BEGIN CERTIFICATE-----") {
block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().SAML.SamlCert))
if strings.Contains(samlConfig.Certificate, "-----BEGIN CERTIFICATE-----") {
block, _ := pem.Decode([]byte(samlConfig.Certificate))
if block == nil {
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no valid pem cert found")
}
certBytes = block.Bytes
} else {
certData, err := base64.StdEncoding.DecodeString(authDomain.AuthDomainConfig().SAML.SamlCert)
certData, err := base64.StdEncoding.DecodeString(samlConfig.Certificate)
if err != nil {
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to read certificate: %s", err.Error())
}

View File

@@ -98,14 +98,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {
featureSet[idx].Active = true
}
}
}
ah.Respond(w, featureSet)
}

View File

@@ -183,7 +183,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
apiHandler.RegisterLogsRoutes(r, am)
apiHandler.RegisterIntegrationRoutes(r, am)
apiHandler.RegisterQueryRangeV3Routes(r, am)
apiHandler.RegisterInfraMetricsRoutes(r, am)
apiHandler.RegisterQueryRangeV4Routes(r, am)
apiHandler.RegisterWebSocketPaths(r, am)
apiHandler.RegisterMessagingQueuesRoutes(r, am)

View File

@@ -17,15 +17,3 @@ func GetOrDefaultEnv(key string, fallback string) string {
}
return v
}
// constant functions that override env vars
const DotMetricsEnabled = "DOT_METRICS_ENABLED"
var IsDotMetricsEnabled = false
func init() {
if GetOrDefaultEnv(DotMetricsEnabled, "true") == "true" {
IsDotMetricsEnabled = true
}
}

View File

@@ -487,8 +487,11 @@
// Simplifies boolean returns
"sonarjs/prefer-while": "error",
// Suggests while loops over for loops
"sonarjs/elseif-without-else": "off"
"sonarjs/elseif-without-else": "off",
// Requires final else in if-else-if chains (was disabled)
"signoz/no-conditional-text-nodes-with-siblings": "warn",
// Vendored from eslint-plugin-react-google-translate
"signoz/no-return-text-nodes": "warn"
},
"ignorePatterns": [
"src/parser/*.ts",

View File

@@ -16,6 +16,7 @@
"lint:generated": "oxlint ./src/api/generated --fix",
"lint:fix": "oxlint ./src --fix",
"lint:styles": "stylelint \"src/**/*.scss\"",
"test:plugins": "node --test \"plugins/__tests__/*.test.mjs\"",
"jest": "jest",
"jest:coverage": "jest --coverage",
"jest:watch": "jest --watch",
@@ -125,6 +126,7 @@
"rrule": "2.8.1",
"styled-components": "^5.3.11",
"timestamp-nano": "^1.0.0",
"translation-resilience": "^0.2.0",
"typescript": "5.9.3",
"uplot": "1.6.31",
"uuid": "14.0.1",

View File

@@ -0,0 +1,130 @@
# Plugin rule tests
Tests for the custom oxlint rules in `plugins/rules/`.
```bash
pnpm test:plugins
```
Runs on `node --test` rather than jest. The jest config is built for application
code — jsdom, ts-jest ESM transforms, a large `transformIgnorePatterns` wall —
and none of it applies to a suite whose only job is to shell out to the linter.
## Why it drives the real binary
Each case is written to a temp file and linted by the actual `oxlint` binary,
with every builtin category switched off so the only diagnostics that can appear
belong to the rule under test. Assertions therefore describe what CI enforces.
The alternative — walking the AST in-process — would need a stand-in for
oxlint's JS plugin AST. That AST is ESTree-shaped but not ESTree, and it carries
no type information, so a stand-in would drift from the runtime it claims to
model and the tests would certify behaviour that never happens.
All cases in a suite share one `oxlint` invocation and are mapped back by
filename. Per-case spawning costs roughly 80ms each; batching keeps both suites
together at around 250ms.
## Adding a suite
```js
import { ruleTester } from './rule-tester.mjs';
await ruleTester({
rule: 'no-navigator-clipboard',
valid: ['const x = 1;'],
invalid: [
{
code: 'navigator.clipboard.writeText("x");',
errors: [{ message: 'useCopyToClipboard', line: 1, column: 1 }],
},
],
});
```
`ruleTester` must be awaited at the top level — it loads the plugin and runs
`oxlint` before declaring the tests.
- `rule` — the key the plugin exports it under. `plugin` defaults to
`plugins/signoz.mjs`; pass a path relative to `frontend/` for another plugin.
- Cases are `.tsx` unless a `filename` gives another extension.
- `errors` takes a count or an array. Each entry may assert `message` (substring
or `RegExp`), `line` and `column`; omitted fields are not checked.
- `name` labels the case in the output and defaults to its first line of code.
- `output` asserts the source after suggestions are applied — see below.
- `todo` marks a case as a known defect — see below.
## Suggestions
Both Google Translate rules attach their wrap as a *suggestion*, not a fix, so
`--fix` leaves the code alone and `--fix-suggestions` applies it. The wrap is
`<span className="translate-safe">`, and `.translate-safe` is `display: contents`
in `src/styles.scss`: React owns an element that absorbs Translate's `<font>`
swap, while the box tree stays as it was, so a flex or grid parent still sees one
contiguous text run rather than a new item with its own `gap`.
It stays a suggestion because the element is still a DOM child even with no box:
`> *`, `:nth-child` and sibling selectors still count it, and a component that
inspects its children — `React.Children.map`, antd `Tooltip`, `Space` — sees an
element where a string used to be. That is what oxlint means by "May change
program behavior" in `--fix-suggestions --help`.
An invalid case carrying `output` is linted twice: once for diagnostics, and
once with `--fix-suggestions` over an untouched copy of the same files. The
second run costs one extra `oxlint` spawn per suite and only happens when at
least one case asks for it.
```js
{
code: "export const A = () => <div>{f ? 'a' : 'b'}<b/></div>;",
errors: 2,
output:
'export const A = () => <div>{f ? <span className="translate-safe">a</span> : <span className="translate-safe">b</span>}<b/></div>;',
}
```
Suggestions do not reformat, so a real run is `oxlint --fix-suggestions` then
`oxfmt`.
## Known defects
A case carrying `todo` asserts what the rule *should* do. It still runs, but a
failure is reported as a todo rather than failing the suite, so a bug can be
pinned as an executable spec instead of prose. Fixing the rule turns the todo
green; deleting the flag then makes it a regression guard.
Cases are prefixed `FP:` where the rule reports something it should not, `GAP:`
where it misses something it should catch, and `TYPE-AWARE:` where the miss is
only fixable once the linter can resolve types. Everything without a flag is a
characterisation test recording current behaviour.
The current todos:
**Gaps — constructs the rules never visit.** `isProblematicConditional` requires
a `JSXElement` parent, so a conditional inside a fragment is never inspected even
though the failure does not care about the parent's kind. `no-return-text-nodes`
listens only for `FunctionDeclaration` and reads the name off `node.id`, so
arrow-function components and anonymous default exports are invisible — this
codebase writes components as arrow functions, which is why that rule reports
nothing across `src`.
Class components are left out deliberately rather than pinned as a gap: there
are none in `src`.
**Type-aware gaps.** Upstream resolves branch types through
`@typescript-eslint/utils` and reports anything typed `string` or `number`.
oxlint's JS plugin runtime exposes no type information — `sourceCode.parserServices`
is always `{}` — so those code paths were removed rather than left dormant. The
`TYPE-AWARE:` todos record what they used to catch, and become the acceptance
criteria if oxlint ever hands types to JS plugins.
## Not a defect
Without types, `no-conditional-text-nodes-with-siblings` falls back to a callee
allowlist (`t`, `formatMessage`, `toString`, `toLocaleString`). Cases around
that allowlist pin its edges; widening it is the supported way to catch more
call expressions.
Both branches of a ternary are reported separately, so one fix can clear two
diagnostics. That inflates the count but every reported node is genuinely a text
node, so the cases assert both.

View File

@@ -0,0 +1,302 @@
import { ruleTester } from './rule-tester.mjs';
const CONDITIONAL = 'Conditionally rendered text nodes with siblings';
const PRECEDED = 'Text nodes which are preceded by a conditional expression';
await ruleTester({
rule: 'no-conditional-text-nodes-with-siblings',
valid: [
{
name: 'conditional text node without siblings',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? 'yes' : 'no'}\n\t</div>\n);",
},
{
name: 'boolean branches are not text',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? true : false}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'null branches are not text',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? null : null}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'element branches are already wrapped',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? <b>y</b> : <i>n</i>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'text node before the conditional is safe',
code:
'export const A = () => (\n\t<div>\n\t\tleading text\n\t\t{flag && <b>y</b>}\n\t</div>\n);',
},
{
name: 'member expression on the condition side is not rendered',
code:
'export const A = () => (\n\t<div>\n\t\t{obj.name && <b>y</b>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'binary comparison on the condition side is not rendered',
code:
"export const A = () => (\n\t<div>\n\t\t{obj.name === 'x' && <b>y</b>}\n\t\t<span>x</span>\n\t</div>\n);",
},
// An empty string renders no text node at all, so there is nothing for
// Google Translate to wrap and nothing for React to lose. Reporting it used
// to be the rule's most common false positive.
{
name: 'element branch with an empty-string fallback',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? <b>Free Trial</b> : ''}\n\t\t<span>s</span>\n\t</div>\n);",
},
{
name: 'both branches empty',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? '' : ''}\n\t\t<span>s</span>\n\t</div>\n);",
},
{
name: 'logical and with an empty-string right-hand side',
code:
"export const A = () => (\n\t<div>\n\t\t{flag && ''}\n\t\t<span>s</span>\n\t</div>\n);",
},
// A template literal is checked the same way as the quoted form, so `{' '}`
// and ``{` `}`` agree.
{
name: 'empty template literal branch',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? `` : ''}\n\t\t<span>s</span>\n\t</div>\n);",
},
{
name: 'whitespace-only template literal branch is skipped',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? ` ` : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
},
],
invalid: [
{
name: 'string literal branches with an element sibling',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? 'yes' : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 19 },
],
output:
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">yes</span> : <span className="translate-safe">no</span>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'logical and with a string right-hand side',
code:
"export const A = () => (\n\t<div>\n\t\t{flag && 'yes'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [{ message: CONDITIONAL, line: 3, column: 12 }],
},
{
name: 'numeric literals render as text',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? 1 : 2}\n\t\t<span>x</span>\n\t</div>\n);',
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 15 },
],
output:
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{1}</span> : <span className="translate-safe">{2}</span>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'template literal branch',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? `yes ${n}` : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 24 },
],
output:
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{`yes ${n}`}</span> : <span className="translate-safe">no</span>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'member expression branch',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? obj.name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 22 },
],
output:
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{obj.name}</span> : <span className="translate-safe">no</span>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'a string needing escapes stays inside braces',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? "it\'s" : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
errors: [{ message: CONDITIONAL, line: 3, column: 11 }],
output:
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{"it\'s"}</span> : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
},
{
name: 'optional chaining branch',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? obj?.deep?.name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 29 },
],
},
{
name: 'nested ternary reports every text branch',
code:
"export const A = () => (\n\t<div>\n\t\t{a ? (b ? 'x' : 'y') : 'z'}\n\t\t<span>s</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 13 },
{ message: CONDITIONAL, line: 3, column: 19 },
{ message: CONDITIONAL, line: 3, column: 26 },
],
},
{
name: 'static text following a conditional',
code:
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\ttrailing text\n\t</div>\n);',
errors: [{ message: PRECEDED, line: 3, column: 21 }],
// Only the visible run is wrapped; the surrounding newlines and tabs are
// formatting and must stay outside the element.
output:
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t<span className="translate-safe">trailing text</span>\n\t</div>\n);',
},
{
name: 'conditional text plus trailing static text reports both kinds',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? 'a' : 'b'}\n\t\tliteral tail\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 17 },
{ message: PRECEDED, line: 3, column: 21 },
],
},
// The callee allowlist below is the untyped fallback. Without type
// information the rule can only recognise known string-returning helpers,
// so `t()` and `formatMessage()` are reported while an arbitrary call is
// not. These cases pin that boundary.
{
name: 't() branch is reported via the callee allowlist',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? t('key') : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 22 },
],
},
{
name: 'formatMessage() branch is reported via the callee allowlist',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? formatMessage({id:'k'}) : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 37 },
],
},
{
name: 'arbitrary call is not recognised, only the literal branch reports',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? getString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [{ message: CONDITIONAL, line: 3, column: 25 }],
},
{
name: 'bare identifier is not recognised, only the literal branch reports',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [{ message: CONDITIONAL, line: 3, column: 18 }],
},
{
name: 'toString() branch is reported',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? val.toString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 28 },
],
},
{
name: 'toLocaleString() branch is reported',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? val.toLocaleString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 34 },
],
},
{
name: 't() in its own container following a conditional',
code:
"export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t{t('key')}\n\t</div>\n);",
errors: [{ message: PRECEDED, line: 4, column: 4 }],
},
{
name: 'toString() in its own container following a conditional',
code:
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t{val.toString()}\n\t</div>\n);',
errors: [{ message: PRECEDED, line: 4, column: 4 }],
// The whole container is replaced, so the result is not `{<span>{…}</span>}`.
output:
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t<span className="translate-safe">{val.toString()}</span>\n\t</div>\n);',
},
{
name: 'whitespace-only string branch is skipped, the other branch reports',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? ' ' : 'x'}\n\t\t<span>s</span>\n\t</div>\n);",
errors: [{ message: CONDITIONAL, line: 3, column: 17 }],
},
{
name: 'template literal holding an expression is not blank',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? `${n}` : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
errors: [{ message: CONDITIONAL, line: 3, column: 11 }],
},
// Upstream resolves branch types through `@typescript-eslint/utils` and
// reports anything typed `string` or `number`. oxlint's JS plugin runtime
// exposes no type information, so those paths were dropped and only the
// callee allowlist remains. Kept as todos: if oxlint ever hands types to JS
// plugins these become the acceptance criteria.
{
todo: 'needs type information to know the call returns a string',
name: 'TYPE-AWARE: call returning a string',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? getString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 25 },
],
},
{
todo: 'needs type information to know the identifier is a string',
name: 'TYPE-AWARE: identifier holding a string',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 18 },
],
},
{
todo: 'needs type information to know the identifier is a string',
name: 'TYPE-AWARE: string identifier following a conditional',
code:
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t{label}\n\t</div>\n);',
errors: [{ message: PRECEDED, line: 4, column: 4 }],
},
// `isChildOfJSXElement` matches only `JSXElement`, so a fragment parent is
// never inspected. The Google Translate failure does not care whether the
// parent is an element or a fragment.
{
todo: 'fragment parents are never inspected',
name: 'GAP: conditional text with a sibling inside a fragment',
code:
"export const A = () => (\n\t<>\n\t\t{flag ? 'yes' : 'no'}\n\t\t<span>x</span>\n\t</>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 19 },
],
},
],
});

View File

@@ -0,0 +1,162 @@
import { ruleTester } from './rule-tester.mjs';
const RETURNS_TEXT = 'React components should avoid returning text nodes';
await ruleTester({
rule: 'no-return-text-nodes',
valid: [
{
name: 'lowercase function is not a component',
code: "export function foo() {\n\treturn 'text';\n}",
},
{
name: 'returning JSX',
code: 'export function Foo() {\n\treturn <div>hi</div>;\n}',
},
{
name: 'returning null',
code: 'export function Foo() {\n\treturn null;\n}',
},
{
name: 'returning boolean',
code: 'export function Foo() {\n\treturn true;\n}',
},
{ name: 'bare return', code: 'export function Foo() {\n\treturn;\n}' },
{
name: 'returning a variable is not a literal',
code: "export function Foo() {\n\tconst s = 'x';\n\treturn s;\n}",
},
{
name: 'lowercase nested function inside a component',
code:
"export function Foo() {\n\tfunction helper() {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
},
{
// The repo has no class components, so this is out of scope rather than
// a gap worth closing.
name: 'class method',
code: "export class Foo {\n\trender() {\n\t\treturn 'text';\n\t}\n}",
},
],
invalid: [
{
name: 'string literal',
code: "export function Foo() {\n\treturn 'text';\n}",
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
output:
'export function Foo() {\n\treturn <span className="translate-safe">{\'text\'}</span>;\n}',
},
{
// JSX does not parse in a `.ts` file, so no suggestion is offered there.
name: 'string literal in a non-JSX file',
filename: 'case.ts',
code: "export function Foo() {\n\treturn 'text';\n}",
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
output: "export function Foo() {\n\treturn 'text';\n}",
},
{
name: 'numeric literal',
code: 'export function Foo() {\n\treturn 42;\n}',
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
},
{
name: 'template literal',
code: 'export function Foo() {\n\treturn `text ${x}`;\n}',
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
},
{
name: 'inside an if consequent',
code:
"export function Foo() {\n\tif (a) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'inside an else block',
code:
"export function Foo() {\n\tif (a) {\n\t\treturn <div/>;\n\t} else {\n\t\treturn 'x';\n\t}\n}",
errors: [{ message: RETURNS_TEXT, line: 5, column: 3 }],
},
{
name: 'inside an else-if chain',
code:
"export function Foo() {\n\tif (a) {\n\t\treturn <div/>;\n\t} else if (b) {\n\t\treturn 'x';\n\t}\n\treturn null;\n}",
errors: [{ message: RETURNS_TEXT, line: 5, column: 3 }],
},
{
name: 'inside a switch case',
code:
"export function Foo() {\n\tswitch (a) {\n\t\tcase 1:\n\t\t\treturn 'x';\n\t\tdefault:\n\t\t\treturn <div/>;\n\t}\n}",
errors: [{ message: RETURNS_TEXT, line: 4, column: 4 }],
},
{
name: 'inside try, catch and finally',
code:
"export function Foo() {\n\ttry {\n\t\treturn 'a';\n\t} catch {\n\t\treturn 'b';\n\t} finally {\n\t\treturn 'c';\n\t}\n}",
errors: [
{ message: RETURNS_TEXT, line: 3, column: 3 },
{ message: RETURNS_TEXT, line: 5, column: 3 },
{ message: RETURNS_TEXT, line: 7, column: 3 },
],
},
{
name: 'inside a for loop',
code:
"export function Foo() {\n\tfor (let i = 0; i < 3; i++) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'inside a for-of loop',
code:
"export function Foo() {\n\tfor (const i of list) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'inside a for-in loop',
code:
"export function Foo() {\n\tfor (const k in obj) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'inside a while loop',
code:
"export function Foo() {\n\twhile (a) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'inside a do-while loop',
code: "export function Foo() {\n\tdo {\n\t\treturn 'x';\n\t} while (a);\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'capitalised nested function is treated as a component',
code:
"export function Foo() {\n\tfunction Helper() {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
// The rule listens only for `FunctionDeclaration` and reads the component
// name off `node.id`. Everything below returns a text node from something
// React renders as a component, and none of it is reported. This codebase
// writes components as arrow functions, which is why the rule currently
// finds nothing in `src`.
{
todo: 'arrow function components are never visited',
name: 'GAP: arrow component with an expression body',
code: "export const Foo = () => 'text';",
errors: 1,
},
{
todo: 'arrow function components are never visited',
name: 'GAP: arrow component with a block body',
code: "export const Foo = () => {\n\treturn 'text';\n};",
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
},
{
todo: 'anonymous declarations have no node.id to read a name from',
name: 'GAP: anonymous default-exported component',
code: "export default function () {\n\treturn 'text';\n}",
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
},
],
});

View File

@@ -0,0 +1,257 @@
/**
* Test harness for oxlint JS plugins.
*
* Rules are exercised through the real `oxlint` binary rather than a hand-rolled
* AST walker, so what the tests assert is exactly what CI enforces. oxlint's JS
* plugin AST is close to ESTree but not identical, and it exposes no type
* information, so any in-process fake would drift from the real runtime.
*
* All cases in a suite are written to one temp directory and linted in a single
* oxlint invocation, then mapped back by filename. Spawning per case costs ~80ms
* each; batching keeps a full suite under a second.
*/
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath, pathToFileURL } from 'node:url';
const FRONTEND_DIR = path.resolve(fileURLToPath(import.meta.url), '../../..');
const OXLINT_BIN = path.join(FRONTEND_DIR, 'node_modules/.bin/oxlint');
// oxlint enables its default categories unless every one is switched off, and a
// stray builtin diagnostic would be indistinguishable from the rule under test.
const CATEGORIES_OFF = {
correctness: 'off',
suspicious: 'off',
pedantic: 'off',
perf: 'off',
style: 'off',
restriction: 'off',
nursery: 'off',
};
function normaliseCase(entry, index) {
const testCase = typeof entry === 'string' ? { code: entry } : entry;
const extension = testCase.filename
? path.extname(testCase.filename).slice(1)
: 'tsx';
return {
...testCase,
index,
basename: `case-${String(index).padStart(3, '0')}.${extension}`,
};
}
function diagnosticFilename(diagnostic) {
const raw = diagnostic.filename ?? '';
const asPath = raw.startsWith('file://') ? fileURLToPath(raw) : raw;
return path.basename(asPath);
}
function toError(diagnostic) {
const span = diagnostic.labels?.[0]?.span;
return {
message: diagnostic.message,
line: span?.line,
column: span?.column,
};
}
function runOxlint(dir, configPath, extraArgs = []) {
const args = ['--config', configPath, '--format', 'json', ...extraArgs, '.'];
try {
return execFileSync(OXLINT_BIN, args, {
cwd: dir,
encoding: 'utf8',
// A rule that reports on every case can produce a lot of output.
maxBuffer: 64 * 1024 * 1024,
});
} catch (error) {
// oxlint exits non-zero whenever it reports a diagnostic, which is the
// expected outcome for every `invalid` case.
if (typeof error.stdout === 'string' && error.stdout.trim() !== '') {
return error.stdout;
}
throw new Error(`oxlint failed to run:\n${error.stderr || error.message}`, {
cause: error,
});
}
}
function writeSuite(dir, cases, { pluginPath, ruleId }) {
for (const testCase of cases) {
const target = path.join(dir, testCase.basename);
mkdirSync(path.dirname(target), { recursive: true });
writeFileSync(target, testCase.code);
}
const configPath = path.join(dir, '.oxlintrc.json');
writeFileSync(
configPath,
JSON.stringify({
jsPlugins: [pluginPath],
categories: CATEGORIES_OFF,
rules: { [ruleId]: 'error' },
}),
);
return configPath;
}
/**
* Lints every case in one pass, and applies suggestions in a second pass over an
* untouched copy when any case declares `output`.
*
* @returns {{errors: Map<string, object[]>, outputs: Map<string, string>}}
*/
function lintCases(cases, options) {
const root = mkdtempSync(path.join(tmpdir(), 'oxlint-rule-tester-'));
try {
const lintDir = path.join(root, 'lint');
mkdirSync(lintDir);
const report = JSON.parse(
runOxlint(lintDir, writeSuite(lintDir, cases, options)),
);
const errors = new Map(cases.map((testCase) => [testCase.basename, []]));
for (const diagnostic of report.diagnostics ?? []) {
const bucket = errors.get(diagnosticFilename(diagnostic));
// oxlint reports config-level problems without a filename; surfacing
// them as a suite failure beats silently testing nothing.
if (!bucket) {
throw new Error(`Unexpected diagnostic: ${diagnostic.message}`);
}
bucket.push(toError(diagnostic));
}
const outputs = new Map();
if (cases.some((testCase) => testCase.output !== undefined)) {
const fixDir = path.join(root, 'fix');
mkdirSync(fixDir);
runOxlint(fixDir, writeSuite(fixDir, cases, options), ['--fix-suggestions']);
for (const testCase of cases) {
outputs.set(
testCase.basename,
readFileSync(path.join(fixDir, testCase.basename), 'utf8'),
);
}
}
return { errors, outputs };
} finally {
rmSync(root, { recursive: true, force: true });
}
}
function assertMessage(actual, expected, label) {
if (expected instanceof RegExp) {
assert.match(actual, expected, label);
} else {
assert.ok(
actual.includes(expected),
`${label}\n expected message to contain: ${expected}\n actual: ${actual}`,
);
}
}
function assertErrors(actual, expected, code) {
const context = `\n--- code ---\n${code}\n--- reported ---\n${JSON.stringify(actual, null, 2)}`;
if (typeof expected === 'number') {
assert.equal(actual.length, expected, `error count${context}`);
return;
}
assert.equal(actual.length, expected.length, `error count${context}`);
expected.forEach((want, i) => {
const got = actual[i];
if (want.message !== undefined) {
assertMessage(got.message, want.message, `error[${i}] message${context}`);
}
if (want.line !== undefined) {
assert.equal(got.line, want.line, `error[${i}] line${context}`);
}
if (want.column !== undefined) {
assert.equal(got.column, want.column, `error[${i}] column${context}`);
}
});
}
/**
* Declares a suite for one rule.
*
* A case carrying `todo` asserts the behaviour the rule *should* have. It still
* runs, but a failure is reported as a todo instead of failing the suite, so a
* known bug can be pinned as an executable spec. Delete the flag once the rule
* is fixed and the case starts guarding the fix.
*
* An invalid case carrying `output` also asserts the source after
* `--fix-suggestions` has been applied.
*
* @param {object} options
* @param {string} options.rule - rule name as exported by the plugin
* @param {string} [options.plugin] - path to the plugin, relative to `frontend/`
* @param {Array<string | {code: string, name?: string, filename?: string, todo?: string}>} options.valid
* @param {Array<{code: string, name?: string, filename?: string, todo?: string, output?: string, errors: number | Array<{message?: string | RegExp, line?: number, column?: number}>}>} options.invalid
*/
export async function ruleTester({
rule,
plugin = 'plugins/signoz.mjs',
valid = [],
invalid = [],
}) {
const pluginPath = path.join(FRONTEND_DIR, plugin);
const { default: pluginModule } = await import(pathToFileURL(pluginPath));
assert.ok(
pluginModule.rules?.[rule],
`plugin ${plugin} does not export a rule named "${rule}"`,
);
const ruleId = `${pluginModule.meta.name}/${rule}`;
const validCases = valid.map(normaliseCase);
const invalidCases = invalid.map((entry, i) =>
normaliseCase(entry, valid.length + i),
);
const { errors, outputs } = lintCases([...validCases, ...invalidCases], {
pluginPath,
ruleId,
});
const declare = (t, testCase, expected) => {
const label = testCase.name ?? testCase.code.trim().split('\n')[0];
return t.test(label, { todo: testCase.todo }, () => {
assertErrors(errors.get(testCase.basename), expected, testCase.code);
if (testCase.output !== undefined) {
assert.equal(
outputs.get(testCase.basename),
testCase.output,
`suggestion output\n--- code ---\n${testCase.code}`,
);
}
});
};
test(ruleId, async (t) => {
await t.test('valid', async (t) => {
for (const testCase of validCases) {
await declare(t, testCase, 0);
}
});
await t.test('invalid', async (t) => {
for (const testCase of invalidCases) {
await declare(t, testCase, testCase.errors);
}
});
});
}

View File

@@ -0,0 +1,313 @@
/**
* Rule: no-conditional-text-nodes-with-siblings
*
* Conditionally rendered text nodes with siblings should be wrapped in an
* element (for example a `<span>`), otherwise Google Translate causes a browser
* error. Translate replaces the text node with a `<font>` wrapper, React still
* holds a reference to the original node, and the next render throws on
* `removeChild`.
*
* Adapted from https://github.com/getcouped/eslint-plugin-react-google-translate
* (v1.0.4). The upstream rule resolves branch types through
* `@typescript-eslint/utils`; oxlint's JS plugin runtime exposes no type
* information, so those paths are dropped and call expressions are matched
* against the allowlist below instead.
*/
// Calls known to render as text. Without types this is the only way to
// recognise a string-returning call; widen it to catch more helpers.
const TEXT_RETURNING_CALLEES = new Set(['formatMessage', 't']);
const STRINGIFY_METHODS = new Set(['toString', 'toLocaleString']);
function calleeName(node) {
return node.type === 'Identifier' ? node.name : null;
}
function isTextReturningCall(node) {
const { callee } = node;
if (TEXT_RETURNING_CALLEES.has(calleeName(callee))) {
return node.arguments.length > 0;
}
if (callee.type === 'MemberExpression' && !callee.computed) {
return STRINGIFY_METHODS.has(calleeName(callee.property));
}
return STRINGIFY_METHODS.has(calleeName(callee));
}
/**
* True when the node renders no visible text. An empty or whitespace-only value
* produces no DOM text node, so Google Translate has nothing to wrap and React
* nothing to lose.
*/
function isBlankText(node) {
if (node.type === 'Literal' || node.type === 'JSXText') {
return typeof node.value === 'string' && node.value.trim() === '';
}
if (node.type === 'TemplateLiteral') {
return (
node.expressions.length === 0 &&
node.quasis.every((quasi) => (quasi.value.cooked ?? '').trim() === '')
);
}
return false;
}
function isConditionallyRendered(node) {
const parent = node.parent;
return (
parent?.type === 'ConditionalExpression' ||
parent?.type === 'LogicalExpression'
);
}
function isRenderedConditional(node) {
return (
node.type === 'JSXExpressionContainer' &&
(node.expression?.type === 'ConditionalExpression' ||
node.expression?.type === 'LogicalExpression')
);
}
/** Children that produce output, i.e. everything but formatting whitespace. */
function renderedChildren(node) {
const children = node?.children;
if (!children) {
return null;
}
return children.filter((child) => !isBlankText(child));
}
/** True when `node` is a JSX child rendered alongside at least one other child. */
function hasSiblings(node) {
if (!(node?.parent?.children?.length > 1)) {
return false;
}
return renderedChildren(node.parent).some((child) => child !== node);
}
function isPrecededByConditional(node) {
const children = renderedChildren(node?.parent);
if (!children) {
return false;
}
return children.some(
(child) => child.start < node.start && isRenderedConditional(child),
);
}
/** Walk out of nested conditionals so nested branches report against the outer container. */
function getOutermostConditional(node) {
let current = node;
while (isConditionallyRendered(current)) {
current = current.parent;
}
return current;
}
/** True when `node` is a conditional branch rendered directly beside other JSX children. */
function isProblematicConditional(node) {
if (!isConditionallyRendered(node)) {
return false;
}
const container = getOutermostConditional(node);
return (
container.parent?.type === 'JSXExpressionContainer' &&
container.parent.parent?.type === 'JSXElement' &&
hasSiblings(container.parent)
);
}
/** True when `node` renders after a sibling conditional, i.e. the DOM order Translate breaks. */
function followsConditionalSibling(node) {
return (
node.parent?.parent?.type === 'JSXElement' &&
hasSiblings(node.parent) &&
isPrecededByConditional(node.parent)
);
}
/**
* `A && B` and the test of a ternary are conditions, not rendered output.
*/
function isCondition(node) {
let current = node;
while (current.parent?.type === 'LogicalExpression') {
if (current.parent.left === current) {
return true;
}
current = current.parent;
}
if (current.parent?.type === 'ConditionalExpression') {
return current.parent.test === current;
}
return false;
}
function isConditionOperand(node) {
if (node.parent?.type === 'BinaryExpression') {
return isCondition(node.parent);
}
return isCondition(node);
}
// A string may only be inlined as JSX text when it needs no escaping and no
// whitespace of its own: JSX collapses leading and trailing whitespace, and
// these characters would either close the element or start an entity.
const NEEDS_BRACES = /['"{}<>&\r\n]/;
// `display: contents`, declared in src/styles.scss. React owns the element so
// Translate's `<font>` swap is absorbed, while the box tree stays as it was and
// a flex or grid parent still sees one contiguous text run.
const OPEN = '<span className="translate-safe">';
const CLOSE = '</span>';
/** Wraps the reported expression so React owns an element Translate cannot replace. */
function wrapExpression(fixer, sourceCode, node) {
// A call reported on its own already sits in a container. Replacing the
// container yields `<span …>{expr}</span>` rather than `{<span …>{expr}</span>}`.
const target =
node.parent?.type === 'JSXExpressionContainer' ? node.parent : node;
if (
node.type === 'Literal' &&
typeof node.value === 'string' &&
!NEEDS_BRACES.test(node.value) &&
node.value.trim() === node.value
) {
return fixer.replaceText(target, `${OPEN}${node.value}${CLOSE}`);
}
return fixer.replaceText(
target,
`${OPEN}{${sourceCode.getText(node)}}${CLOSE}`,
);
}
/**
* Wraps static JSX text. Only the visible run is wrapped: the node also spans
* the formatting whitespace around it, which has to stay outside the element.
*/
function wrapJsxText(fixer, node) {
const raw = node.value;
const leading = raw.length - raw.trimStart().length;
const trailing = raw.length - raw.trimEnd().length;
return fixer.replaceTextRange(
[node.start + leading, node.end - trailing],
`${OPEN}${raw.trim()}${CLOSE}`,
);
}
export default {
meta: {
type: 'problem',
docs: {
description:
'Conditionally rendered text nodes should be wrapped in an element (for example a `<span>`), otherwise Google Translate can cause a browser error.',
url: 'https://github.com/getcouped/eslint-plugin-react-google-translate#eslint-plugin-react-google-translate',
},
schema: [],
// Wrapping adds a DOM element, which can turn into a flex/grid item or
// break `> *` and `:nth-child` selectors, so it is offered as a suggestion
// (`--fix-suggestions`) rather than applied by a bare `--fix`.
hasSuggestions: true,
messages: {
'conditional-text-node':
'Conditionally rendered text nodes with siblings, rendered as a direct child of a JSX element, must be wrapped so Google Translate cannot break React\'s DOM: `<span className="translate-safe">{value}</span>`. Translate replaces the bare text node with a `<font>` element and React then throws on `removeChild`. This also applies to values returned from functions, so `getString()` becomes `<span className="translate-safe">{getString()}</span>`.',
'text-node-preceded-by-conditional':
'Text nodes which are preceded by a conditional expression, rendered as a direct child of a JSX element, must be wrapped so Google Translate cannot break React\'s DOM: `<span className="translate-safe">text</span>`. Translate replaces the bare text node with a `<font>` element and React then throws on `removeChild`.',
},
},
createOnce(context) {
const suggestWrap = (build) => [{ desc: 'Wrap in a <span>', fix: build }];
const wrap = (fixer, node) => wrapExpression(fixer, context.sourceCode, node);
const reportConditional = (node) => {
context.report({
node,
messageId: 'conditional-text-node',
suggest: suggestWrap((fixer) => wrap(fixer, node)),
});
};
const reportPreceded = (node, build) => {
context.report({
node,
messageId: 'text-node-preceded-by-conditional',
suggest: suggestWrap(build),
});
};
return {
// String and numeric branches: `{flag ? 'yes' : 'no'}`
Literal(node) {
if (node.value === null || typeof node.value === 'boolean') {
return;
}
if (isBlankText(node)) {
return;
}
if (isProblematicConditional(node)) {
reportConditional(node);
}
},
TemplateLiteral(node) {
if (isBlankText(node)) {
return;
}
if (isProblematicConditional(node)) {
reportConditional(node);
}
},
// Static text rendered after a conditional: `{flag && <b/>}trailing`
JSXText(node) {
if (isBlankText(node)) {
return;
}
if (hasSiblings(node) && isPrecededByConditional(node)) {
reportPreceded(node, (fixer) => wrapJsxText(fixer, node));
}
},
CallExpression(node) {
if (isCondition(node) || !isTextReturningCall(node)) {
return;
}
if (isProblematicConditional(node)) {
reportConditional(node);
}
if (followsConditionalSibling(node)) {
reportPreceded(node, (fixer) => wrap(fixer, node));
}
},
// Values read off an object: `{flag ? user.name : 'anonymous'}`
MemberExpression(node) {
if (isConditionOperand(node)) {
return;
}
if (isProblematicConditional(node)) {
reportConditional(node);
}
},
// Optional chaining wraps the member expression: `{flag ? a?.b?.c : 'x'}`
ChainExpression(node) {
if (isConditionOperand(node)) {
return;
}
if (isProblematicConditional(node)) {
reportConditional(node);
}
},
};
},
};

View File

@@ -0,0 +1,115 @@
/**
* Rule: no-return-text-nodes
*
* React components should not return a bare text node. Google Translate keeps
* displaying the stale translated text after a state change and nothing throws,
* which makes the bug very hard to track down. Numbers count too: JSX renders
* them as text.
*
* Adapted from https://github.com/getcouped/eslint-plugin-react-google-translate
* (v1.0.4). Upstream walks the function body statement by statement; this
* version visits `ReturnStatement` directly and walks up to the enclosing
* function, which covers the same constructs without enumerating them.
*/
const FUNCTION_TYPES = new Set([
'FunctionDeclaration',
'FunctionExpression',
'ArrowFunctionExpression',
]);
function isTextNode(node) {
if (!node) {
return false;
}
if (node.type === 'TemplateLiteral') {
return true;
}
return (
node.type === 'Literal' &&
(typeof node.value === 'string' || typeof node.value === 'number')
);
}
function getEnclosingFunction(node) {
let current = node.parent;
while (current) {
if (FUNCTION_TYPES.has(current.type)) {
return current;
}
current = current.parent;
}
return null;
}
function isComponentName(name) {
return (
typeof name === 'string' && name !== '' && name[0] === name[0].toUpperCase()
);
}
// The suggestion introduces JSX, which only parses in a JSX-enabled file.
function allowsJsx(filename) {
return filename.endsWith('.tsx') || filename.endsWith('.jsx');
}
export default {
meta: {
type: 'problem',
docs: {
description:
'React components should avoid returning text nodes directly (or numerical values which will be rendered as text). When a React component returns values other than JSX / null, Google Translate can continue to display stale values after state changes, without any error being thrown. Since this is very hard to debug it is better to avoid it altogether.',
url: 'https://github.com/getcouped/eslint-plugin-react-google-translate#eslint-plugin-react-google-translate',
},
schema: [],
// Wrapping changes what the component renders, so it is offered as a
// suggestion (`--fix-suggestions`) rather than applied by a bare `--fix`.
hasSuggestions: true,
messages: {
'return-value-is-text-node':
'React components should avoid returning text nodes directly (or numerical values which will be rendered as text). When a React component returns values other than JSX / null, Google Translate can continue to display stale values after state changes, without any error being thrown. Since this is very hard to debug it is better to avoid it altogether.',
},
},
createOnce(context) {
const buildSuggestion = (node) => {
if (!allowsJsx(context.filename)) {
return undefined;
}
return [
{
desc: 'Wrap in a <span>',
fix: (fixer) =>
fixer.replaceText(
node.argument,
`<span className="translate-safe">{${context.sourceCode.getText(node.argument)}}</span>`,
),
},
];
};
return {
ReturnStatement(node) {
if (!isTextNode(node.argument)) {
return;
}
// Only named function declarations are recognised as components, so a
// text return from a nested helper or a class method is left alone.
const fn = getEnclosingFunction(node);
if (fn?.type !== 'FunctionDeclaration') {
return;
}
if (!isComponentName(fn.id?.name)) {
return;
}
context.report({
node,
messageId: 'return-value-is-text-node',
suggest: buildSuggestion(node),
});
},
};
},
};

View File

@@ -13,6 +13,8 @@ import noAntdComponents from './rules/no-antd-components.mjs';
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root.mjs';
import noConditionalTextNodesWithSiblings from './rules/no-conditional-text-nodes-with-siblings.mjs';
import noReturnTextNodes from './rules/no-return-text-nodes.mjs';
export default {
meta: {
@@ -27,5 +29,7 @@ export default {
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
'no-css-module-bracket-access': noCssModuleBracketAccess,
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
'no-conditional-text-nodes-with-siblings': noConditionalTextNodesWithSiblings,
'no-return-text-nodes': noReturnTextNodes,
},
};

View File

@@ -294,6 +294,9 @@ importers:
timestamp-nano:
specifier: ^1.0.0
version: 1.0.1
translation-resilience:
specifier: ^0.2.0
version: 0.2.0
typescript:
specifier: 5.9.3
version: 5.9.3
@@ -8475,6 +8478,9 @@ packages:
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
engines: {node: '>=12'}
translation-resilience@0.2.0:
resolution: {integrity: sha512-IxTjhpHGp1SJxVEEPBu/YbBaHnymMRJdYxUY1i5qtYADuz9b8fdWhZSE/EeRS2aVIiuQjRqtYDk69ruS/3fzTg==}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -18232,6 +18238,8 @@ snapshots:
dependencies:
punycode: 2.3.1
translation-resilience@0.2.0: {}
trim-lines@3.0.1: {}
trough@2.1.0: {}

View File

@@ -388,6 +388,10 @@ function App(): JSX.Element {
if (error?.name === 'AbortError') {
return null;
}
// Ignore benign Monaco cancellation errors (name 'Canceled').
if (error?.name === 'Canceled') {
return null;
}
// Drop the event if its level is 'warning' or 'info'
if (event.level === 'warning' || event.level === 'info') {

View File

@@ -38,14 +38,14 @@ import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
*/
export const listAuthDomains = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListAuthDomains200>({
url: `/api/v1/domains`,
url: `/api/v2/auth_domains`,
method: 'GET',
signal,
});
};
export const getListAuthDomainsQueryKey = () => {
return [`/api/v1/domains`] as const;
return [`/api/v2/auth_domains`] as const;
};
export const getListAuthDomainsQueryOptions = <
@@ -125,7 +125,7 @@ export const createAuthDomain = (
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateAuthDomain201>({
url: `/api/v1/domains`,
url: `/api/v2/auth_domains`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: authtypesPostableAuthDomainDTO,
@@ -208,7 +208,7 @@ export const deleteAuthDomain = (
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/domains/${id}`,
url: `/api/v2/auth_domains/${id}`,
method: 'DELETE',
signal,
});
@@ -287,7 +287,7 @@ export const getAuthDomain = (
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetAuthDomain200>({
url: `/api/v1/domains/${id}`,
url: `/api/v2/auth_domains/${id}`,
method: 'GET',
signal,
});
@@ -296,7 +296,7 @@ export const getAuthDomain = (
export const getGetAuthDomainQueryKey = ({
id,
}: GetAuthDomainPathParameters) => {
return [`/api/v1/domains/${id}`] as const;
return [`/api/v2/auth_domains/${id}`] as const;
};
export const getGetAuthDomainQueryOptions = <
@@ -389,7 +389,7 @@ export const updateAuthDomain = (
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/domains/${id}`,
url: `/api/v2/auth_domains/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: authtypesUpdatableAuthDomainDTO,

View File

@@ -1861,8 +1861,19 @@ export interface AuthtypesAttributeMappingDTO {
role?: string;
}
export enum AuthtypesAuthDomainConfigSAMLDTOKind {
saml = 'saml',
}
export interface AuthtypesSamlConfigDTO {
attributeMapping?: AuthtypesAttributeMappingDTO;
/**
* @type string
*/
certificate: string;
/**
* @type string
*/
entityId: string;
/**
* @type boolean
*/
@@ -1870,17 +1881,21 @@ export interface AuthtypesSamlConfigDTO {
/**
* @type string
*/
samlCert?: string;
/**
* @type string
*/
samlEntity?: string;
/**
* @type string
*/
samlIdp?: string;
location: string;
}
export interface AuthtypesAuthDomainConfigSAMLDTO {
/**
* @type string
* @enum saml
*/
kind: AuthtypesAuthDomainConfigSAMLDTOKind;
spec: AuthtypesSamlConfigDTO;
}
export enum AuthtypesAuthDomainConfigGoogleDTOKind {
google = 'google',
}
export type AuthtypesGoogleConfigDTODomainToAdminEmail = {
[key: string]: string;
};
@@ -1893,11 +1908,12 @@ export interface AuthtypesGoogleConfigDTO {
/**
* @type string
*/
clientId?: string;
clientId: string;
/**
* @type string
* @format password
*/
clientSecret?: string;
clientSecret: string;
/**
* @type object
*/
@@ -1916,24 +1932,34 @@ export interface AuthtypesGoogleConfigDTO {
insecureSkipEmailVerified?: boolean;
/**
* @type string
*/
redirectURI?: string;
/**
* @type string
* @format password
*/
serviceAccountJson?: string;
}
export interface AuthtypesAuthDomainConfigGoogleDTO {
/**
* @type string
* @enum google
*/
kind: AuthtypesAuthDomainConfigGoogleDTOKind;
spec: AuthtypesGoogleConfigDTO;
}
export enum AuthtypesAuthDomainConfigOIDCDTOKind {
oidc = 'oidc',
}
export interface AuthtypesOIDCConfigDTO {
claimMapping?: AuthtypesAttributeMappingDTO;
/**
* @type string
*/
clientId?: string;
clientId: string;
/**
* @type string
* @format password
*/
clientSecret?: string;
clientSecret: string;
/**
* @type boolean
*/
@@ -1945,79 +1971,33 @@ export interface AuthtypesOIDCConfigDTO {
/**
* @type string
*/
issuer?: string;
issuer: string;
/**
* @type string
*/
issuerAlias?: string;
}
export type AuthtypesRoleMappingDTOGroupMappingsAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type AuthtypesRoleMappingDTOGroupMappings =
AuthtypesRoleMappingDTOGroupMappingsAnyOf | null;
export interface AuthtypesRoleMappingDTO {
export interface AuthtypesAuthDomainConfigOIDCDTO {
/**
* @type string
* @enum oidc
*/
defaultRole?: string;
/**
* @type object,null
*/
groupMappings?: AuthtypesRoleMappingDTOGroupMappings;
/**
* @type boolean
*/
useRoleAttribute?: boolean;
kind: AuthtypesAuthDomainConfigOIDCDTOKind;
spec: AuthtypesOIDCConfigDTO;
}
export type AuthtypesAuthDomainConfigDTO =
| AuthtypesAuthDomainConfigSAMLDTO
| AuthtypesAuthDomainConfigGoogleDTO
| AuthtypesAuthDomainConfigOIDCDTO;
export enum AuthtypesAuthNProviderDTO {
google_auth = 'google_auth',
google = 'google',
saml = 'saml',
email_password = 'email_password',
oidc = 'oidc',
}
export type AuthtypesAuthDomainConfigDTO =
| (AuthtypesSamlConfigDTO & {
googleAuthConfig?: AuthtypesGoogleConfigDTO;
oidcConfig?: AuthtypesOIDCConfigDTO;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
})
| (AuthtypesGoogleConfigDTO & {
googleAuthConfig?: AuthtypesGoogleConfigDTO;
oidcConfig?: AuthtypesOIDCConfigDTO;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
})
| (AuthtypesOIDCConfigDTO & {
googleAuthConfig?: AuthtypesGoogleConfigDTO;
oidcConfig?: AuthtypesOIDCConfigDTO;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
});
export interface AuthtypesAuthNProviderInfoDTO {
/**
* @type string,null
@@ -2055,6 +2035,31 @@ export interface AuthtypesDeprecatedPostableUserRoleDTO {
id: string;
}
export type AuthtypesRoleMappingDTOGroupMappingsAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type AuthtypesRoleMappingDTOGroupMappings =
AuthtypesRoleMappingDTOGroupMappingsAnyOf | null;
export interface AuthtypesRoleMappingDTO {
/**
* @type string
*/
defaultRole?: string;
/**
* @type object,null
*/
groupMappings?: AuthtypesRoleMappingDTOGroupMappings;
/**
* @type boolean
*/
useRoleAttribute?: boolean;
}
export interface AuthtypesGettableAuthDomainDTO {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
config?: AuthtypesAuthDomainConfigDTO;
@@ -2063,6 +2068,10 @@ export interface AuthtypesGettableAuthDomainDTO {
* @format date-time
*/
createdAt?: string;
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type string
*/
@@ -2075,6 +2084,7 @@ export interface AuthtypesGettableAuthDomainDTO {
* @type string
*/
orgId?: string;
roleMapping?: AuthtypesRoleMappingDTO;
/**
* @type string
* @format date-time
@@ -2271,11 +2281,16 @@ export interface AuthtypesOrgSessionContextDTO {
}
export interface AuthtypesPostableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
config: AuthtypesAuthDomainConfigDTO;
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type string
*/
name?: string;
name: string;
roleMapping?: AuthtypesRoleMappingDTO;
}
export interface AuthtypesPostableEmailPasswordSessionDTO {
@@ -2408,7 +2423,12 @@ export interface AuthtypesTransactionDTO {
}
export interface AuthtypesUpdatableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
config: AuthtypesAuthDomainConfigDTO;
/**
* @type boolean
*/
enabled?: boolean;
roleMapping?: AuthtypesRoleMappingDTO;
}
export interface AuthtypesUpdatableRoleDTO {
@@ -2818,6 +2838,7 @@ export enum CloudintegrationtypesServiceIDDTO {
computeengine = 'computeengine',
gke = 'gke',
cloudstorage = 'cloudstorage',
cloudsql_mysql = 'cloudsql_mysql',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -9933,47 +9954,6 @@ export interface TypesChangePasswordRequestDTO {
oldPassword?: string;
}
export interface TypesDeprecatedUserDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
displayName?: string;
/**
* @type string
*/
email?: string;
/**
* @type string
*/
id: string;
/**
* @type boolean
*/
isRoot?: boolean;
/**
* @type string
*/
orgId?: string;
/**
* @type string
*/
role?: string;
/**
* @type string
*/
status?: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface TypesIdentifiableDTO {
/**
* @type string
@@ -9981,47 +9961,6 @@ export interface TypesIdentifiableDTO {
id: string;
}
export interface TypesInviteDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
email?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
inviteLink?: string;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
orgId?: string;
/**
* @type string
*/
role?: string;
/**
* @type string
*/
token?: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface TypesOrganizationDTO {
/**
* @type string
@@ -10071,25 +10010,6 @@ export interface TypesPostableForgotPasswordDTO {
orgId: string;
}
export interface TypesPostableInviteDTO {
/**
* @type string
*/
email?: string;
/**
* @type string
*/
frontendBaseUrl?: string;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
role?: string;
}
export interface TypesPostableResetPasswordDTO {
/**
* @type string
@@ -10101,13 +10021,6 @@ export interface TypesPostableResetPasswordDTO {
token?: string;
}
export interface TypesPostableRoleDTO {
/**
* @type string
*/
name: string;
}
export interface TypesPostableVerifyResetPasswordTokenDTO {
/**
* @type string
@@ -10524,42 +10437,6 @@ export type CreatePublicDashboard201 = {
export type UpdatePublicDashboardPathParameters = {
id: string;
};
export type ListAuthDomains200 = {
/**
* @type array
*/
data: AuthtypesGettableAuthDomainDTO[];
/**
* @type string
*/
status: string;
};
export type CreateAuthDomain201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteAuthDomainPathParameters = {
id: string;
};
export type GetAuthDomainPathParameters = {
id: string;
};
export type GetAuthDomain200 = {
data: AuthtypesGettableAuthDomainDTO;
/**
* @type string
*/
status: string;
};
export type UpdateAuthDomainPathParameters = {
id: string;
};
export type ListDowntimeSchedulesParams = {
/**
* @type boolean,null
@@ -10750,17 +10627,6 @@ export type GetFieldsValues200 = {
status: string;
};
export type GetResetPasswordTokenDeprecatedPathParameters = {
id: string;
};
export type GetResetPasswordTokenDeprecated200 = {
data: TypesResetPasswordTokenDTO;
/**
* @type string
*/
status: string;
};
export type GetGlobalConfig200 = {
data: GlobaltypesConfigDTO;
/**
@@ -10769,14 +10635,6 @@ export type GetGlobalConfig200 = {
status: string;
};
export type CreateInvite201 = {
data: TypesInviteDTO;
/**
* @type string
*/
status: string;
};
export type ListLLMPricingRulesParams = {
/**
* @type integer
@@ -11189,25 +11047,6 @@ export type GetTraceAggregations200 = {
status: string;
};
export type ListUsersDeprecated200 = {
/**
* @type array
*/
data: TypesDeprecatedUserDTO[];
/**
* @type string
*/
status: string;
};
export type GetMyUserDeprecated200 = {
data: TypesDeprecatedUserDTO;
/**
* @type string
*/
status: string;
};
export type ListUserPreferences200 = {
/**
* @type array
@@ -11233,6 +11072,42 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListAuthDomains200 = {
/**
* @type array
*/
data: AuthtypesGettableAuthDomainDTO[];
/**
* @type string
*/
status: string;
};
export type CreateAuthDomain201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteAuthDomainPathParameters = {
id: string;
};
export type GetAuthDomainPathParameters = {
id: string;
};
export type GetAuthDomain200 = {
data: AuthtypesGettableAuthDomainDTO;
/**
* @type string
*/
status: string;
};
export type UpdateAuthDomainPathParameters = {
id: string;
};
export type ListDashboardViews200 = {
data: DashboardtypesListableDashboardViewDTO;
/**
@@ -12402,13 +12277,6 @@ export type GetRolesByUserID200 = {
status: string;
};
export type SetRoleByUserIDPathParameters = {
id: string;
};
export type RemoveUserRoleByUserIDAndRoleIDPathParameters = {
id: string;
roleId: string;
};
export type GetMyUser200 = {
data: AuthtypesUserWithRolesDTO;
/**

View File

@@ -20,7 +20,6 @@ import type {
import type {
AuthtypesPostableUserDTO,
AuthtypesPostableUserRoleDTO,
CreateInvite201,
CreateResetPasswordToken201,
CreateResetPasswordTokenPathParameters,
CreateUser201,
@@ -28,10 +27,7 @@ import type {
DeleteUserPathParameters,
DeleteUserRolePathParameters,
GetMyUser200,
GetMyUserDeprecated200,
GetResetPasswordToken200,
GetResetPasswordTokenDeprecated200,
GetResetPasswordTokenDeprecatedPathParameters,
GetResetPasswordTokenPathParameters,
GetRolesByUserID200,
GetRolesByUserIDPathParameters,
@@ -42,15 +38,10 @@ import type {
GetUsersByRoleID200,
GetUsersByRoleIDPathParameters,
ListUsers200,
ListUsersDeprecated200,
RemoveUserRoleByUserIDAndRoleIDPathParameters,
RenderErrorResponseDTO,
SetRoleByUserIDPathParameters,
TypesChangePasswordRequestDTO,
TypesPostableForgotPasswordDTO,
TypesPostableInviteDTO,
TypesPostableResetPasswordDTO,
TypesPostableRoleDTO,
TypesPostableVerifyResetPasswordTokenDTO,
TypesUpdatableUserDTO,
UpdateUserPathParameters,
@@ -60,379 +51,12 @@ import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint returns the reset password token by id
* @deprecated
* @summary Get reset password token
*/
export const getResetPasswordTokenDeprecated = (
{ id }: GetResetPasswordTokenDeprecatedPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetResetPasswordTokenDeprecated200>({
url: `/api/v1/getResetPasswordToken/${id}`,
method: 'GET',
signal,
});
};
export const getGetResetPasswordTokenDeprecatedQueryKey = ({
id,
}: GetResetPasswordTokenDeprecatedPathParameters) => {
return [`/api/v1/getResetPasswordToken/${id}`] as const;
};
export const getGetResetPasswordTokenDeprecatedQueryOptions = <
TData = Awaited<ReturnType<typeof getResetPasswordTokenDeprecated>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetResetPasswordTokenDeprecatedPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getResetPasswordTokenDeprecated>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetResetPasswordTokenDeprecatedQueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getResetPasswordTokenDeprecated>>
> = ({ signal }) => getResetPasswordTokenDeprecated({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getResetPasswordTokenDeprecated>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetResetPasswordTokenDeprecatedQueryResult = NonNullable<
Awaited<ReturnType<typeof getResetPasswordTokenDeprecated>>
>;
export type GetResetPasswordTokenDeprecatedQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Get reset password token
*/
export function useGetResetPasswordTokenDeprecated<
TData = Awaited<ReturnType<typeof getResetPasswordTokenDeprecated>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetResetPasswordTokenDeprecatedPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getResetPasswordTokenDeprecated>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetResetPasswordTokenDeprecatedQueryOptions(
{ id },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @deprecated
* @summary Get reset password token
*/
export const invalidateGetResetPasswordTokenDeprecated = async (
queryClient: QueryClient,
{ id }: GetResetPasswordTokenDeprecatedPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetResetPasswordTokenDeprecatedQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint creates an invite for a user
* @deprecated
* @summary Create invite
*/
export const createInvite = (
typesPostableInviteDTO?: BodyType<TypesPostableInviteDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateInvite201>({
url: `/api/v1/invite`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: typesPostableInviteDTO,
signal,
});
};
export const getCreateInviteMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createInvite>>,
TError,
{ data?: BodyType<TypesPostableInviteDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createInvite>>,
TError,
{ data?: BodyType<TypesPostableInviteDTO> },
TContext
> => {
const mutationKey = ['createInvite'];
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 createInvite>>,
{ data?: BodyType<TypesPostableInviteDTO> }
> = (props) => {
const { data } = props ?? {};
return createInvite(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateInviteMutationResult = NonNullable<
Awaited<ReturnType<typeof createInvite>>
>;
export type CreateInviteMutationBody =
| BodyType<TypesPostableInviteDTO>
| undefined;
export type CreateInviteMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create invite
*/
export const useCreateInvite = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createInvite>>,
TError,
{ data?: BodyType<TypesPostableInviteDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createInvite>>,
TError,
{ data?: BodyType<TypesPostableInviteDTO> },
TContext
> => {
return useMutation(getCreateInviteMutationOptions(options));
};
/**
* This endpoint resets the password by token
* @deprecated
* @summary Reset password
*/
export const resetPasswordDeprecated = (
typesPostableResetPasswordDTO?: BodyType<TypesPostableResetPasswordDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/resetPassword`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: typesPostableResetPasswordDTO,
signal,
});
};
export const getResetPasswordDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
TError,
{ data?: BodyType<TypesPostableResetPasswordDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
TError,
{ data?: BodyType<TypesPostableResetPasswordDTO> },
TContext
> => {
const mutationKey = ['resetPasswordDeprecated'];
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 resetPasswordDeprecated>>,
{ data?: BodyType<TypesPostableResetPasswordDTO> }
> = (props) => {
const { data } = props ?? {};
return resetPasswordDeprecated(data);
};
return { mutationFn, ...mutationOptions };
};
export type ResetPasswordDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof resetPasswordDeprecated>>
>;
export type ResetPasswordDeprecatedMutationBody =
| BodyType<TypesPostableResetPasswordDTO>
| undefined;
export type ResetPasswordDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Reset password
*/
export const useResetPasswordDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
TError,
{ data?: BodyType<TypesPostableResetPasswordDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
TError,
{ data?: BodyType<TypesPostableResetPasswordDTO> },
TContext
> => {
return useMutation(getResetPasswordDeprecatedMutationOptions(options));
};
/**
* This endpoint lists all users
* @deprecated
* @summary List users
*/
export const listUsersDeprecated = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListUsersDeprecated200>({
url: `/api/v1/user`,
method: 'GET',
signal,
});
};
export const getListUsersDeprecatedQueryKey = () => {
return [`/api/v1/user`] as const;
};
export const getListUsersDeprecatedQueryOptions = <
TData = Awaited<ReturnType<typeof listUsersDeprecated>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listUsersDeprecated>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListUsersDeprecatedQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listUsersDeprecated>>
> = ({ signal }) => listUsersDeprecated(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listUsersDeprecated>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListUsersDeprecatedQueryResult = NonNullable<
Awaited<ReturnType<typeof listUsersDeprecated>>
>;
export type ListUsersDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary List users
*/
export function useListUsersDeprecated<
TData = Awaited<ReturnType<typeof listUsersDeprecated>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listUsersDeprecated>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListUsersDeprecatedQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @deprecated
* @summary List users
*/
export const invalidateListUsersDeprecated = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListUsersDeprecatedQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoint returns the user I belong to
* This endpoint is deprecated and always fails. Use GET /api/v2/users/me instead.
* @deprecated
* @summary Get my user
*/
export const getMyUserDeprecated = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetMyUserDeprecated200>({
return GeneratedAPIInstance<void>({
url: `/api/v1/user/me`,
method: 'GET',
signal,
@@ -1834,189 +1458,6 @@ export const invalidateGetRolesByUserID = async (
return queryClient;
};
/**
* This endpoint assigns the role to the user roles by user id
* @deprecated
* @summary Set user roles
*/
export const setRoleByUserID = (
{ id }: SetRoleByUserIDPathParameters,
typesPostableRoleDTO?: BodyType<TypesPostableRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/users/${id}/roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: typesPostableRoleDTO,
signal,
});
};
export const getSetRoleByUserIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof setRoleByUserID>>,
TError,
{
pathParams: SetRoleByUserIDPathParameters;
data?: BodyType<TypesPostableRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof setRoleByUserID>>,
TError,
{
pathParams: SetRoleByUserIDPathParameters;
data?: BodyType<TypesPostableRoleDTO>;
},
TContext
> => {
const mutationKey = ['setRoleByUserID'];
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 setRoleByUserID>>,
{
pathParams: SetRoleByUserIDPathParameters;
data?: BodyType<TypesPostableRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return setRoleByUserID(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type SetRoleByUserIDMutationResult = NonNullable<
Awaited<ReturnType<typeof setRoleByUserID>>
>;
export type SetRoleByUserIDMutationBody =
| BodyType<TypesPostableRoleDTO>
| undefined;
export type SetRoleByUserIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Set user roles
*/
export const useSetRoleByUserID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof setRoleByUserID>>,
TError,
{
pathParams: SetRoleByUserIDPathParameters;
data?: BodyType<TypesPostableRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof setRoleByUserID>>,
TError,
{
pathParams: SetRoleByUserIDPathParameters;
data?: BodyType<TypesPostableRoleDTO>;
},
TContext
> => {
return useMutation(getSetRoleByUserIDMutationOptions(options));
};
/**
* This endpoint removes a role from the user by user id and role id
* @deprecated
* @summary Remove a role from user
*/
export const removeUserRoleByUserIDAndRoleID = (
{ id, roleId }: RemoveUserRoleByUserIDAndRoleIDPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/users/${id}/roles/${roleId}`,
method: 'DELETE',
signal,
});
};
export const getRemoveUserRoleByUserIDAndRoleIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof removeUserRoleByUserIDAndRoleID>>,
TError,
{ pathParams: RemoveUserRoleByUserIDAndRoleIDPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof removeUserRoleByUserIDAndRoleID>>,
TError,
{ pathParams: RemoveUserRoleByUserIDAndRoleIDPathParameters },
TContext
> => {
const mutationKey = ['removeUserRoleByUserIDAndRoleID'];
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 removeUserRoleByUserIDAndRoleID>>,
{ pathParams: RemoveUserRoleByUserIDAndRoleIDPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return removeUserRoleByUserIDAndRoleID(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type RemoveUserRoleByUserIDAndRoleIDMutationResult = NonNullable<
Awaited<ReturnType<typeof removeUserRoleByUserIDAndRoleID>>
>;
export type RemoveUserRoleByUserIDAndRoleIDMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Remove a role from user
*/
export const useRemoveUserRoleByUserIDAndRoleID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof removeUserRoleByUserIDAndRoleID>>,
TError,
{ pathParams: RemoveUserRoleByUserIDAndRoleIDPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof removeUserRoleByUserIDAndRoleID>>,
TError,
{ pathParams: RemoveUserRoleByUserIDAndRoleIDPathParameters },
TContext
> => {
return useMutation(getRemoveUserRoleByUserIDAndRoleIDMutationOptions(options));
};
/**
* This endpoint returns the user I belong to
* @summary Get my user v2

View File

@@ -1,82 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
export interface HostListPayload {
filters: TagFilter;
groupBy: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
} | null;
start?: number;
end?: number;
}
export interface TimeSeriesValue {
timestamp: number;
value: string;
}
export interface TimeSeries {
labels: Record<string, string>;
labelsArray: Array<Record<string, string>>;
values: TimeSeriesValue[];
}
export interface HostData {
hostName: string;
active: boolean;
os: string;
/** Present when the list API returns grouped rows or extra resource attributes. */
meta?: Record<string, string>;
cpu: number;
cpuTimeSeries: TimeSeries;
memory: number;
memoryTimeSeries: TimeSeries;
wait: number;
waitTimeSeries: TimeSeries;
load15: number;
load15TimeSeries: TimeSeries;
}
export interface HostListResponse {
status: string;
data: {
type: string;
records: HostData[];
groups: null;
total: number;
sentAnyHostMetricsData: boolean;
isSendingK8SAgentMetrics: boolean;
endTimeBeforeRetention: boolean;
};
}
export const getHostLists = async (
props: HostListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponse<HostListResponse> | ErrorResponse> => {
try {
const response = await axios.post('/hosts/list', props, {
signal,
headers,
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
params: props,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -24,19 +24,3 @@ export const Logout = async (): Promise<void> => {
window.dispatchEvent(new CustomEvent('LOGOUT'));
history.push(ROUTES.LOGIN);
};
export const UnderscoreToDotMap: Record<string, string> = {
k8s_cluster_name: 'k8s.cluster.name',
k8s_cluster_uid: 'k8s.cluster.uid',
k8s_namespace_name: 'k8s.namespace.name',
k8s_node_name: 'k8s.node.name',
k8s_node_uid: 'k8s.node.uid',
k8s_pod_name: 'k8s.pod.name',
k8s_pod_uid: 'k8s.pod.uid',
k8s_deployment_name: 'k8s.deployment.name',
k8s_daemonset_name: 'k8s.daemonset.name',
k8s_statefulset_name: 'k8s.statefulset.name',
k8s_cronjob_name: 'k8s.cronjob.name',
k8s_job_name: 'k8s.job.name',
k8s_persistentvolumeclaim_name: 'k8s.persistentvolumeclaim.name',
};

View File

@@ -1,31 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/setInvite';
/**
* @deprecated Use the generated `useCreateInvite` hook (or `createInvite` fetcher) from
* `api/generated/services/users` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const sendInvite = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>(`/invite`, {
...props,
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default sendInvite;

View File

@@ -10,6 +10,10 @@ jest.mock('providers/Timezone', () => ({
}),
}));
jest.mock('providers/App/App', () => ({
useAppContext: (): { featureFlags: [] } => ({ featureFlags: [] }),
}));
const field = (name: string, type = ''): IField => ({
name,
type,

View File

@@ -2,13 +2,15 @@ import type { ReactElement } from 'react';
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { FeatureKeys } from 'constants/features';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { FlatLogData } from 'lib/logs/flatLogData';
import { getLogFieldValue } from 'lib/logs/flatLogData';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
@@ -26,6 +28,10 @@ export function useLogsTableColumns({
fontSize,
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { featureFlags } = useAppContext();
const isBodyJsonEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
return useMemo<TableColumnDef<ILog>[]>(() => {
const stateIndicatorCol: TableColumnDef<ILog> = {
@@ -88,7 +94,8 @@ export function useLogsTableColumns({
const makeUserFieldCol = (f: IField): TableColumnDef<ILog> => ({
id: buildCompositeKey(f.name, f.type),
header: f.name,
accessorFn: (log): unknown => FlatLogData(log)[f.name],
accessorFn: (log): unknown =>
getLogFieldValue(log, f.name, isBodyJsonEnabled),
enableRemove: true,
width: { min: 192 },
cell: ({ value }): ReactElement => (
@@ -115,5 +122,5 @@ export function useLogsTableColumns({
.filter((c): c is TableColumnDef<ILog> => c !== null);
return [stateIndicatorCol, ...fieldCols];
}, [fields, fontSize, formatTimezoneAdjustedTimestamp]);
}, [fields, fontSize, formatTimezoneAdjustedTimestamp, isBodyJsonEnabled]);
}

View File

@@ -59,6 +59,7 @@ import {
dedupeOptionsByLabel,
getFieldContextPrefix,
getRecentOptions,
isSupportedFunction,
renderRecentDeleteButton,
} from './utils';
@@ -1275,11 +1276,13 @@ function QuerySearch({
}
if (queryContext.isInFunction) {
options = Object.values(QUERY_BUILDER_FUNCTIONS).map((option) => ({
label: option,
apply: `${option}()`,
type: 'function',
}));
options = Object.values(QUERY_BUILDER_FUNCTIONS)
.filter((option) => isSupportedFunction(option, dataSource))
.map((option) => ({
label: option,
apply: `${option}()`,
type: 'function',
}));
// Add space after selection for functions
const optionsWithSpace = addSpaceToOptions(options);

View File

@@ -1,8 +1,12 @@
import { QUERY_BUILDER_FUNCTIONS } from 'constants/antlrQueryConstants';
import { DataSource } from 'types/common/queryBuilder';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
getFieldContextPrefix,
getUserExpressionFromCombined,
isSupportedFunction,
} from '../utils';
describe('entityLogsExpression', () => {
@@ -118,3 +122,19 @@ describe('dedupeOptionsByLabel', () => {
expect(dedupeOptionsByLabel([])).toStrictEqual([]);
});
});
describe('isSupportedFunction', () => {
const { HASANY, SEARCH } = QUERY_BUILDER_FUNCTIONS;
it('allows the has family on every signal', () => {
[DataSource.LOGS, DataSource.TRACES, DataSource.METRICS].forEach((signal) => {
expect(isSupportedFunction(HASANY, signal)).toBe(true);
});
});
it('allows search on logs only', () => {
expect(isSupportedFunction(SEARCH, DataSource.LOGS)).toBe(true);
expect(isSupportedFunction(SEARCH, DataSource.TRACES)).toBe(false);
expect(isSupportedFunction(SEARCH, DataSource.METRICS)).toBe(false);
});
});

View File

@@ -1,6 +1,7 @@
import { closeCompletion, startCompletion } from '@codemirror/autocomplete';
import type { Completion } from '@codemirror/autocomplete';
import type { EditorView } from '@uiw/react-codemirror';
import { QUERY_BUILDER_FUNCTIONS } from 'constants/antlrQueryConstants';
import dayjs from 'dayjs';
import { normalizeFilterExpression } from 'lib/recentQueries/normalize';
import * as recentQueriesStore from 'lib/recentQueries/recentQueriesStore';
@@ -15,6 +16,15 @@ import {
RECENTS_SECTION,
} from './constants';
// search() lives in the logs condition builder only; traces and metrics reject it
// as an unsupported operator. Every other function is implemented for all signals.
export function isSupportedFunction(
functionName: string,
signal: SignalType,
): boolean {
return functionName !== QUERY_BUILDER_FUNCTIONS.SEARCH || signal === 'logs';
}
export interface FieldContextPrefixMatch {
context: string;
remainder: string;

View File

@@ -0,0 +1,280 @@
import {
completionStatus,
currentCompletions,
startCompletion,
} from '@codemirror/autocomplete';
import { EditorView } from '@uiw/react-codemirror';
import { initialQueriesMap } from 'constants/queryBuilder';
import * as recentQueriesStore from 'lib/recentQueries/recentQueriesStore';
import { fireEvent, render, userEvent, waitFor } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import { RECENTS_DISPLAY_CAP, RECENTS_SECTION } from '../QuerySearch/constants';
import QuerySearch from '../QuerySearch/QuerySearch';
import { mockCodeMirrorDomApis } from './codemirrorDomMocks';
const CM_ROOT_SELECTOR = '.cm-editor';
const CM_EDITOR_SELECTOR = '.cm-editor .cm-content';
const TOOLTIP_SELECTOR = '.cm-tooltip-autocomplete';
const COMPLETION_LABEL_SELECTOR = '.cm-completionLabel';
const DELETE_BUTTON_SELECTOR = '.cm-recent-delete';
const FRONTEND_FILTER = "service.name = 'frontend'";
const STATUS_CODE_FILTER = "http.status_code = '500'";
const TRACES_FILTER = "name = 'HTTP GET'";
beforeAll(() => {
mockCodeMirrorDomApis();
});
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { dashboardData: undefined } => ({
dashboardData: undefined,
}),
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn().mockResolvedValue({
data: { data: { keys: {} } },
}),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn().mockResolvedValue({
data: { data: { values: { stringValues: [], numberValues: [] } } },
}),
}));
function renderLogsSearch(onChange: (value: string) => void = jest.fn()): void {
render(
<QuerySearch
onChange={onChange}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
}
function saveLogsRecent(expression: string): void {
recentQueriesStore.save({ signal: 'logs', filter: { expression } });
}
function getEditorView(): EditorView | null {
const root = document.querySelector<HTMLElement>(CM_ROOT_SELECTOR);
return root ? EditorView.findFromDOM(root) : null;
}
function getDocText(): string {
return getEditorView()?.state.doc.toString() ?? '';
}
function isCompletionOpen(): boolean {
const view = getEditorView();
return !!view && completionStatus(view.state) === 'active';
}
// Reads recents from completion state, not the tooltip: the tooltip is a later render
// pass over this same state, so going to the source drops a layer of timing.
function getRecentLabels(): string[] {
const view = getEditorView();
if (!view) {
return [];
}
return currentCompletions(view.state)
.filter((completion) => completion.section === RECENTS_SECTION)
.map((completion) => completion.label);
}
async function renderAndFocus(
onChange: (value: string) => void = jest.fn(),
): Promise<HTMLElement> {
renderLogsSearch(onChange);
const editor = await waitFor(
() => {
const element = document.querySelector(CM_EDITOR_SELECTOR);
expect(element).toBeInTheDocument();
return element as HTMLElement;
},
{ timeout: 2000 },
);
await userEvent.click(editor);
return editor;
}
function openRecents(): Promise<void> {
return waitFor(
() => {
const view = getEditorView();
if (view && !isCompletionOpen()) {
startCompletion(view);
}
expect(getRecentLabels().length).toBeGreaterThan(0);
},
{ timeout: 3000 },
);
}
describe('QuerySearch recent searches', () => {
beforeEach(() => {
recentQueriesStore.useRecentQueriesStore.setState({ buckets: {} });
localStorage.clear();
});
it('shows a saved recent query under "Recent searches" on focus', async () => {
saveLogsRecent(FRONTEND_FILTER);
await renderAndFocus();
await openRecents();
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([FRONTEND_FILTER]);
},
{ timeout: 3000 },
);
const view = getEditorView() as EditorView;
const [recent] = currentCompletions(view.state);
expect(recent.section).toBe(RECENTS_SECTION);
});
it('filters recents by substring as the user types', async () => {
saveLogsRecent(FRONTEND_FILTER);
saveLogsRecent(STATUS_CODE_FILTER);
const editor = await renderAndFocus();
await openRecents();
await userEvent.type(editor, 'status_code');
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([STATUS_CODE_FILTER]);
},
{ timeout: 3000 },
);
});
it('does not surface recents saved under a different signal', async () => {
recentQueriesStore.save({
signal: 'traces',
filter: { expression: TRACES_FILTER },
});
saveLogsRecent(FRONTEND_FILTER);
await renderAndFocus();
await openRecents();
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([FRONTEND_FILTER]);
},
{ timeout: 3000 },
);
});
it('excludes a recent that exactly matches the current editor text', async () => {
const supersetFilter = `${FRONTEND_FILTER} AND ${STATUS_CODE_FILTER}`;
saveLogsRecent(FRONTEND_FILTER);
saveLogsRecent(supersetFilter);
const editor = await renderAndFocus();
await openRecents();
await userEvent.type(editor, FRONTEND_FILTER);
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([supersetFilter]);
},
{ timeout: 3000 },
);
});
it('caps the dropdown at RECENTS_DISPLAY_CAP entries, newest first', async () => {
const filters = Array.from(
{ length: RECENTS_DISPLAY_CAP + 1 },
(_, index) => `attribute_${index + 1} = 'v'`,
);
filters.forEach((filter) => saveLogsRecent(filter));
const expectedLabels = [...filters].reverse().slice(0, RECENTS_DISPLAY_CAP);
await renderAndFocus();
await openRecents();
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual(expectedLabels);
},
{ timeout: 3000 },
);
});
it('applies the full expression to the editor when a recent is clicked', async () => {
saveLogsRecent(FRONTEND_FILTER);
const onChange = jest.fn();
await renderAndFocus(onChange);
await openRecents();
const option = await waitFor(
() => {
const node = Array.from(
document.querySelectorAll<HTMLElement>(COMPLETION_LABEL_SELECTOR),
).find((element) => element.textContent === FRONTEND_FILTER);
expect(node).toBeDefined();
return node as HTMLElement;
},
{ timeout: 3000 },
);
await userEvent.click(option);
await waitFor(
() => {
expect(getDocText()).toBe(FRONTEND_FILTER);
},
{ timeout: 2000 },
);
expect(onChange).toHaveBeenCalledWith(FRONTEND_FILTER);
await waitFor(
() => {
expect(document.querySelector(TOOLTIP_SELECTOR)).not.toBeInTheDocument();
},
{ timeout: 2000 },
);
});
it('removes a recent from the dropdown and the store when delete is clicked', async () => {
saveLogsRecent(FRONTEND_FILTER);
await renderAndFocus();
await openRecents();
const deleteButton = await waitFor(
() => {
const button = document.querySelector(DELETE_BUTTON_SELECTOR);
expect(button).toBeInTheDocument();
return button as HTMLElement;
},
{ timeout: 3000 },
);
// fireEvent: the button preventDefaults pointerdown, which makes userEvent.click drop the mouse chain.
fireEvent.click(deleteButton);
await waitFor(
() => {
expect(recentQueriesStore.list('logs')).toHaveLength(0);
expect(getRecentLabels()).not.toContain(FRONTEND_FILTER);
},
{ timeout: 2000 },
);
expect(getDocText()).toBe('');
});
});

View File

@@ -41,6 +41,7 @@ export const QUERY_BUILDER_FUNCTIONS = {
HASANY: 'hasAny',
HASALL: 'hasAll',
HASTOKEN: 'hasToken',
SEARCH: 'search',
};
export function negateOperator(operatorOrFunction: string): string {

View File

@@ -7,7 +7,6 @@ export enum FeatureKeys {
GATEWAY = 'gateway',
PREMIUM_SUPPORT = 'premium_support',
ANOMALY_DETECTION = 'anomaly_detection',
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
USE_JSON_BODY = 'use_json_body',
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',

View File

@@ -437,6 +437,17 @@ describe('Create Alert Channel', () => {
render(<CreateAlertChannels preType={ChannelType.GoogleChat} />);
});
// paste instead of type: a per-keystroke re-render of the whole form
// pushes these tests past the 5s jest timeout on slower CI runners
async function fillField(
user: ReturnType<typeof userEvent.setup>,
testId: string,
value: string,
): Promise<void> {
await user.click(screen.getByTestId(testId));
await user.paste(value);
}
it('Should check if the selected item in the type dropdown has text "Google Chat"', () => {
expect(screen.getByText('Google Chat')).toBeInTheDocument();
});
@@ -463,14 +474,8 @@ describe('Create Alert Channel', () => {
it('Should check if saving with a webhook url outside chat.googleapis.com displays error notification', async () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'gchat-channel',
);
await user.type(
screen.getByTestId('webhook-url-textbox'),
'https://example.com/webhook',
);
await fillField(user, 'channel-name-textbox', 'gchat-channel');
await fillField(user, 'webhook-url-textbox', 'https://example.com/webhook');
await user.click(screen.getByTestId('save-channel-button'));
@@ -496,11 +501,8 @@ describe('Create Alert Channel', () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'gchat-channel',
);
await user.type(screen.getByTestId('webhook-url-textbox'), validWebhookUrl);
await fillField(user, 'channel-name-textbox', 'gchat-channel');
await fillField(user, 'webhook-url-textbox', validWebhookUrl);
await user.click(screen.getByTestId('save-channel-button'));

View File

@@ -37,8 +37,6 @@ import { ErrorResponse, SuccessResponse } from 'types/api';
import { Exception, PayloadProps } from 'types/api/errors/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../constants/features';
import { useAppContext } from '../../providers/App/App';
import { FilterDropdownExtendsProps } from './types';
import {
extractFilterValues,
@@ -418,11 +416,6 @@ function AllErrors(): JSX.Element {
},
];
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const onChangeHandler: TableProps<Exception>['onChange'] = useCallback(
(
paginations: TablePaginationConfig,
@@ -458,7 +451,7 @@ function AllErrors(): JSX.Element {
useEffect(() => {
if (!isUndefined(errorCountResponse.data?.payload)) {
const selectedEnvironments = queries.find(
(val) => val.tagKey === getResourceDeploymentKeys(dotMetricsEnabled),
(val) => val.tagKey === getResourceDeploymentKeys(),
)?.tagValue;
logEvent('Exception: List page visited', {

View File

@@ -130,6 +130,28 @@ describe('Footer utils', () => {
};
expect(validateCreateAlertState(currentArgs)).toBeNull();
});
it('when threshold channels are null', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...args,
basicAlertState: {
...args.basicAlertState,
name: 'test name',
},
thresholdState: {
...args.thresholdState,
thresholds: [
{
...args.thresholdState.thresholds[0],
channels: null as unknown as string[],
},
],
},
};
expect(validateCreateAlertState(currentArgs)).toBe(
'Please select at least one channel for each threshold or enable routing policies',
);
});
});
describe('getNotificationSettingsProps', () => {

View File

@@ -44,7 +44,8 @@ export function validateCreateAlertState(
if (!threshold.label) {
return 'Please enter a label for each threshold';
}
if (!notificationSettings.routingPolicies && !threshold.channels.length) {
// this runs during render, so a throw here takes down the whole page
if (!notificationSettings.routingPolicies && !threshold.channels?.length) {
return 'Please select at least one channel for each threshold or enable routing policies';
}
}

View File

@@ -316,6 +316,34 @@ describe('CreateAlertV2 utils', () => {
});
});
describe('getThresholdStateFromAlertDef null channels', () => {
it('falls back to an empty array so downstream consumers never see null', () => {
const def: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
condition: {
...defaultPostableAlertRuleV2.condition,
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target: 1,
targetUnit: UniversalYAxisUnit.MINUTES,
channels: null as unknown as string[],
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
op: AlertThresholdOperator.IS_ABOVE,
},
],
},
},
};
expect(
getThresholdStateFromAlertDef(def).thresholds[0].channels,
).toStrictEqual([]);
});
});
describe('normalizeOperator', () => {
it.each([
['1', AlertThresholdOperator.IS_ABOVE],

View File

@@ -258,7 +258,9 @@ export function getThresholdStateFromAlertDef(
recoveryThresholdValue: null,
unit: threshold.targetUnit,
color: getColorForThreshold(threshold.name),
channels: threshold.channels,
// rules created outside the UI can come back with a null channels
// field; drop the guard once the API enforces the schema
channels: threshold.channels ?? [],
})) || [],
selectedQuery: alertDef.condition.selectedQueryName || '',
operator:

View File

@@ -35,7 +35,6 @@ import { openInNewTab } from 'utils/navigation';
import triangleRulerUrl from '@/assets/Icons/triangle-ruler.svg';
import { FeatureKeys } from '../../../constants/features';
import { DOCS_LINKS } from '../constants';
import { columns, TIME_PICKER_OPTIONS } from './constants';
@@ -212,19 +211,13 @@ function ServiceMetrics({
const topLevelOperations = useMemo(() => Object.entries(data || {}), [data]);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const queryRangeRequestData = useMemo(
() =>
getQueryRangeRequestData({
topLevelOperations,
globalSelectedInterval,
dotMetricsEnabled,
}),
[globalSelectedInterval, topLevelOperations, dotMetricsEnabled],
[globalSelectedInterval, topLevelOperations],
);
const dataQueries = useGetQueriesRange(

View File

@@ -64,6 +64,8 @@ export interface K8sDetailsFilters {
export interface K8sDetailsWidgetInfo {
title: string;
yAxisUnit: string;
docPath?: string;
description?: string;
}
export type GetEntityQueryPayload<T> = (

View File

@@ -94,43 +94,59 @@ export const clusterWidgetInfo = [
yAxisUnit: '',
docPath:
'/infrastructure-monitoring/kubernetes/clusters/#cpu-usage-allocatable',
description:
'Avg, max and min pod CPU usage across the cluster against total allocatable CPU.',
},
{
title: 'Memory Usage, allocatable',
yAxisUnit: 'bytes',
docPath:
'/infrastructure-monitoring/kubernetes/clusters/#memory-usage-allocatable',
description:
'Avg, max and min pod memory usage against allocatable memory; usage closing in on it risks evictions.',
},
{
title: 'Ready Nodes',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/clusters/#ready-nodes',
description:
'Nodes currently reporting Ready; a line dropping out means that node stopped accepting pods.',
},
{
title: 'NotReady Nodes',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/clusters/#notready-nodes',
description:
'Nodes whose kubelet reports unhealthy; their pods are evicted after the toleration window.',
},
{
title: 'Deployments available and desired',
yAxisUnit: '',
docPath:
'/infrastructure-monitoring/kubernetes/clusters/#deployments-available-and-desired',
description:
'Desired replicas versus pods available past minReadySeconds; a persistent gap means a stuck rollout.',
},
{
title: 'Statefulset pods',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/clusters/#statefulset-pods',
description:
'Desired, current, ready and updated pod counts per StatefulSet; ready below desired means readiness failures.',
},
{
title: 'Daemonset nodes',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/clusters/#daemonset-nodes',
description:
'Desired, current and ready node counts per DaemonSet; gaps mean node agents are missing.',
},
{
title: 'Jobs',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/clusters/#jobs',
description:
'Active, succeeded, failed and desired successful pod counts per Job; non-zero failed needs triage.',
},
];
@@ -562,13 +578,9 @@ export const getClusterMetricsQueryPayload = (
type: 'tag',
},
],
having: [
{
columnName: `MAX(${INFRA_MONITORING_ATTR_KEYS.K8S_NODE_CONDITION_READY})`,
op: '=',
value: 1,
},
],
having: {
expression: `max(${INFRA_MONITORING_ATTR_KEYS.K8S_NODE_CONDITION_READY}) = 1`,
},
legend: `{{${INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME}}}`,
limit: null,
orderBy: [],
@@ -648,13 +660,9 @@ export const getClusterMetricsQueryPayload = (
type: 'tag',
},
],
having: [
{
columnName: `MAX(${INFRA_MONITORING_ATTR_KEYS.K8S_NODE_CONDITION_READY})`,
op: '=',
value: 0,
},
],
having: {
expression: `max(${INFRA_MONITORING_ATTR_KEYS.K8S_NODE_CONDITION_READY}) = 0`,
},
legend: `{{${INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME}}}`,
limit: null,
orderBy: [],

View File

@@ -76,23 +76,31 @@ export const daemonSetWidgetInfo = [
yAxisUnit: '',
docPath:
'/infrastructure-monitoring/kubernetes/daemonsets/#cpu-usage-request-limits',
description:
'Total CPU usage of the DaemonSet pods against their aggregate CPU requests and limits.',
},
{
title: 'Memory usage, request, limits',
yAxisUnit: 'bytes',
docPath:
'/infrastructure-monitoring/kubernetes/daemonsets/#memory-usage-request-limits',
description:
'Total memory usage of the DaemonSet pods against their aggregate memory requests and limits.',
},
{
title: 'Network IO',
yAxisUnit: 'binBps',
docPath: '/infrastructure-monitoring/kubernetes/daemonsets/#network-io',
description:
'Transmit and receive throughput per interface across all pods of the DaemonSet.',
},
{
title: 'Network errors count',
yAxisUnit: '',
docPath:
'/infrastructure-monitoring/kubernetes/daemonsets/#network-errors-count',
description:
'Per-pod-interface network error counts by direction and interface, reported by the kubelet.',
},
];

View File

@@ -76,23 +76,31 @@ export const deploymentWidgetInfo = [
yAxisUnit: '',
docPath:
'/infrastructure-monitoring/kubernetes/deployments/#cpu-usage-request-limits',
description:
'Total CPU usage of the Deployment pods against their aggregate CPU requests and limits.',
},
{
title: 'Memory usage, request, limits',
yAxisUnit: 'bytes',
docPath:
'/infrastructure-monitoring/kubernetes/deployments/#memory-usage-request-limits',
description:
'Total memory usage of the Deployment pods against their aggregate memory requests and limits.',
},
{
title: 'Network IO',
yAxisUnit: 'binBps',
docPath: '/infrastructure-monitoring/kubernetes/deployments/#network-io',
description:
'Transmit and receive throughput per interface across all pods of the Deployment.',
},
{
title: 'Network error count',
yAxisUnit: '',
docPath:
'/infrastructure-monitoring/kubernetes/deployments/#network-error-count',
description:
'Per-pod-interface network error counts by direction and interface, reported by the kubelet.',
},
];

View File

@@ -24,6 +24,7 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { getMetricsExplorerUrl } from 'utils/explorerUtils';
import { getDrawerDurationMs } from 'container/InfraMonitoringK8sV2/Base/useDrawerLifecycleStore';
import { K8sDetailsWidgetInfo } from 'container/InfraMonitoringK8sV2/Base/types';
import { buildEntityMetricsChartConfig } from './configBuilder';
import ChartHeader from './ChartHeader';
@@ -41,11 +42,7 @@ import ChartTooltipFooter from './ChartTooltipFooter';
interface EntityMetricsProps<T> {
entity: T;
eventEntity: string;
entityWidgetInfo: {
title: string;
yAxisUnit: string;
docPath?: string;
}[];
entityWidgetInfo: K8sDetailsWidgetInfo[];
getEntityQueryPayload: (
node: T,
start: number,
@@ -219,6 +216,7 @@ function EntityMetrics<T>({
<ChartHeader
title={entityWidgetInfo[idx].title}
docPath={entityWidgetInfo[idx].docPath}
tooltip={entityWidgetInfo[idx].description}
metricsExplorerUrl={
queryPayloads[idx] && queryPayloads[idx].graphType !== PANEL_TYPES.TABLE
? getMetricsExplorerUrl({

View File

@@ -121,12 +121,6 @@ jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
plan_version: 'test-plan-version',
},
},
featureFlags: [
{
name: 'DOT_METRICS_ENABLED',
active: false,
},
],
} as any);
const mockEntity = {

View File

@@ -74,21 +74,27 @@ export const jobWidgetInfo = [
title: 'CPU usage',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/jobs/#cpu-usage',
description: 'CPU consumption in cores summed across the pods of the Job.',
},
{
title: 'Memory Usage',
yAxisUnit: 'bytes',
docPath: '/infrastructure-monitoring/kubernetes/jobs/#memory-usage',
description: 'Memory consumption in bytes summed across the pods of the Job.',
},
{
title: 'Network IO',
yAxisUnit: 'binBps',
docPath: '/infrastructure-monitoring/kubernetes/jobs/#network-io',
description:
'Transmit and receive throughput per interface across all pods of the Job.',
},
{
title: 'Network errors count',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/jobs/#network-errors-count',
description:
'Per-pod-interface network error counts by direction and interface, reported by the kubelet.',
},
];

View File

@@ -113,53 +113,73 @@ export const namespaceWidgetInfo = [
yAxisUnit: '',
docPath:
'/infrastructure-monitoring/kubernetes/namespaces/#cpu-usage-cores-1',
description:
'Avg, max and min pod CPU usage in the namespace against the sum of container CPU requests.',
},
{
title: 'Memory Usage (bytes)',
yAxisUnit: 'bytes',
docPath:
'/infrastructure-monitoring/kubernetes/namespaces/#memory-usage-bytes',
description:
'Pod memory usage, working set and RSS in the namespace against the sum of container memory requests.',
},
{
title: 'Pods CPU (top 10)',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#pods-cpu-top-10',
description:
'CPU consumption in cores for the ten highest-consuming pods in the namespace.',
},
{
title: 'Pods Memory (top 10)',
yAxisUnit: 'bytes',
docPath:
'/infrastructure-monitoring/kubernetes/namespaces/#pods-memory-top-10',
description:
'Memory consumption in bytes for the ten highest-consuming pods in the namespace.',
},
{
title: 'Network rate',
yAxisUnit: 'binBps',
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#network-rate',
description:
'Transmit and receive throughput per interface across the pods of the namespace.',
},
{
title: 'Network errors',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#network-errors',
description:
'Per-pod-interface network error counts by direction and interface, reported by the kubelet.',
},
{
title: 'StatefulSets (pods)',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#statefulsets',
description:
'Desired, current and updated pod counts per StatefulSet in the namespace, revealing stalled rollouts.',
},
{
title: 'ReplicaSets (pods)',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#replicasets',
description:
'Desired versus available replicas per ReplicaSet in the namespace, revealing pods stuck pending.',
},
{
title: 'DaemonSets (nodes)',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#daemonsets',
description:
'Desired, current, ready and misscheduled node counts per DaemonSet in the namespace.',
},
{
title: 'Deployments (pods)',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#deployments',
description:
'Desired and available replicas with utilization percentage per Deployment in the namespace.',
},
];
@@ -1208,13 +1228,9 @@ export const getNamespaceMetricsQueryPayload = (
type: 'tag',
},
],
having: [
{
columnName: `MAX(${INFRA_MONITORING_ATTR_KEYS.K8S_REPLICASET_DESIRED})`,
op: '>',
value: 0,
},
],
having: {
expression: `max(${INFRA_MONITORING_ATTR_KEYS.K8S_REPLICASET_DESIRED}) > 0`,
},
legend: 'desired',
limit: null,
orderBy: [],
@@ -1261,13 +1277,9 @@ export const getNamespaceMetricsQueryPayload = (
type: 'tag',
},
],
having: [
{
columnName: `MAX(${INFRA_MONITORING_ATTR_KEYS.K8S_REPLICASET_DESIRED})`,
op: '>',
value: 0,
},
],
having: {
expression: `max(${INFRA_MONITORING_ATTR_KEYS.K8S_REPLICASET_AVAILABLE}) > 0`,
},
legend: 'available',
limit: null,
orderBy: [],
@@ -1625,13 +1637,13 @@ export const getNamespaceMetricsQueryPayload = (
reduceTo: ReduceOperators.LAST,
spaceAggregation: 'max',
stepInterval: 60,
timeAggregation: 'avg',
timeAggregation: 'latest',
},
],
queryFormulas: [
{
disabled: false,
expression: 'A/B',
expression: '(B/A) * 100',
legend: 'util %',
queryName: 'F1',
},

View File

@@ -58,52 +58,71 @@ export const nodeWidgetInfo = [
title: 'CPU Usage (cores)',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/nodes/#cpu-usage-cores-1',
description:
'Avg, max and min node CPU usage against allocatable capacity and the CPU requests scheduled on the node.',
},
{
title: 'Memory Usage (bytes)',
yAxisUnit: 'bytes',
docPath: '/infrastructure-monitoring/kubernetes/nodes/#memory-usage-bytes',
description:
'Node memory usage, working set and RSS against allocatable memory and scheduled memory requests.',
},
{
title: 'CPU Usage (%)',
yAxisUnit: 'percentunit',
docPath: '/infrastructure-monitoring/kubernetes/nodes/#cpu-usage-',
description:
'Node CPU usage as a percentage of allocatable capacity and of the CPU requests scheduled on the node.',
},
{
title: 'Memory Usage (%)',
yAxisUnit: 'percentunit',
docPath: '/infrastructure-monitoring/kubernetes/nodes/#memory-usage-',
description:
'Node memory usage as a percentage of allocatable memory and of the memory requests scheduled on the node.',
},
{
title: 'Pods by CPU (top 10)',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/nodes/#pods-by-cpu-top-10',
description:
'CPU consumption in cores for the ten highest-consuming pods on this node.',
},
{
title: 'Pods by Memory (top 10)',
yAxisUnit: 'bytes',
docPath: '/infrastructure-monitoring/kubernetes/nodes/#pods-by-memory-top-10',
description:
'Memory consumption in bytes for the ten highest-consuming pods on this node.',
},
{
title: 'Network error count',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/nodes/#network-error-count',
description:
'Per-interface network error counts by direction, from the kubelet error counters.',
},
{
title: 'Network IO rate',
yAxisUnit: 'binBps',
docPath: '/infrastructure-monitoring/kubernetes/nodes/#network-io-rate',
description:
'Transmit and receive throughput per network interface on the node.',
},
{
title: 'Filesystem usage (bytes)',
yAxisUnit: 'bytes',
docPath:
'/infrastructure-monitoring/kubernetes/nodes/#filesystem-usage-bytes',
description:
'Capacity, available and used bytes for the primary filesystem of the node.',
},
{
title: 'Filesystem usage (%)',
yAxisUnit: 'percentunit',
docPath: '/infrastructure-monitoring/kubernetes/nodes/#filesystem-usage-',
description: 'Percentage of the nodefs filesystem currently consumed.',
},
];

View File

@@ -68,73 +68,97 @@ export const podWidgetInfo = [
title: 'CPU Usage (cores)',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/pods/#cpu-usage-cores-1',
description:
'Avg, max and min CPU consumption of the pod in cores, showing how volatile it is.',
},
{
title: 'CPU Request, Limit Utilization',
yAxisUnit: 'percentunit',
docPath:
'/infrastructure-monitoring/kubernetes/pods/#cpu-request-limit-utilization',
description:
'Pod CPU usage as a fraction of its total container CPU requests and limits, to spot throttling.',
},
{
title: 'Memory Usage (bytes)',
yAxisUnit: 'bytes',
docPath: '/infrastructure-monitoring/kubernetes/pods/#memory-usage-bytes',
description:
'Avg, max and min memory consumption of the pod, including reclaimable page cache.',
},
{
title: 'Memory Request, Limit Utilization',
yAxisUnit: 'percentunit',
docPath:
'/infrastructure-monitoring/kubernetes/pods/#memory-request-limit-utilization',
description:
'Pod memory usage as a fraction of its total container memory requests and limits.',
},
{
title: 'Memory by State',
yAxisUnit: 'bytes',
docPath: '/infrastructure-monitoring/kubernetes/pods/#memory-by-state',
description:
'RSS, working set and cache memory of the pod, separating heap growth from file cache.',
},
{
title: 'Memory Major Page Faults',
yAxisUnit: '',
docPath:
'/infrastructure-monitoring/kubernetes/pods/#memory-major-page-faults',
description:
'Major page fault rate of the pod; sustained values mean the working set is paging to disk.',
},
{
title: 'CPU Usage by Container (cores)',
yAxisUnit: '',
docPath:
'/infrastructure-monitoring/kubernetes/pods/#cpu-usage-by-container-cores',
description:
'CPU consumption in cores per container, showing which container drives the pod CPU.',
},
{
title: 'CPU Request, Limit Utilization by Container',
yAxisUnit: 'percentunit',
docPath:
'/infrastructure-monitoring/kubernetes/pods/#cpu-request-limit-utilization-by-container',
description:
'Each container CPU usage as a fraction of its own request and limit, to find the throttled one.',
},
{
title: 'Memory Usage by Container (bytes)',
yAxisUnit: 'bytes',
docPath:
'/infrastructure-monitoring/kubernetes/pods/#memory-usage-by-container-bytes',
description: 'Usage, working set and RSS memory per container of the pod.',
},
{
title: 'Memory Request, Limit Utilization by Container',
yAxisUnit: 'percentunit',
docPath:
'/infrastructure-monitoring/kubernetes/pods/#memory-request-limit-utilization-by-container',
description:
'Each container memory usage as a fraction of its own request and limit; near 100% risks an OOMKill.',
},
{
title: 'Network rate',
yAxisUnit: 'binBps',
docPath: '/infrastructure-monitoring/kubernetes/pods/#network-rate',
description: 'Pod network throughput in bytes/s by direction and interface.',
},
{
title: 'Network errors',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/pods/#network-errors',
description:
'Network error counts on the pod interfaces; sustained non-zero values point to CNI or MTU issues.',
},
{
title: 'File system (bytes)',
yAxisUnit: 'bytes',
docPath: '/infrastructure-monitoring/kubernetes/pods/#file-system-bytes',
description:
'Capacity, available and used bytes of the local filesystem of the pod.',
},
];

View File

@@ -77,35 +77,47 @@ export const statefulSetWidgetInfo = [
yAxisUnit: '',
docPath:
'/infrastructure-monitoring/kubernetes/statefulsets/#cpu-usage-request-limits',
description:
'Total CPU usage of the StatefulSet pods against their aggregate CPU requests and limits.',
},
{
title: 'CPU request, limit util (%)',
yAxisUnit: 'percentunit',
docPath:
'/infrastructure-monitoring/kubernetes/statefulsets/#cpu-request-limit-utilization-',
description:
'Average CPU usage of the StatefulSet as a percentage of its requests and of its limits.',
},
{
title: 'Memory usage, request, limits',
yAxisUnit: 'bytes',
docPath:
'/infrastructure-monitoring/kubernetes/statefulsets/#memory-usage-request-limits',
description:
'Total memory usage of the StatefulSet pods against their aggregate memory requests and limits.',
},
{
title: 'Memory request, limit util (%)',
yAxisUnit: 'percentunit',
docPath:
'/infrastructure-monitoring/kubernetes/statefulsets/#memory-request-limit-utilization-',
description:
'Average memory usage as a percentage of requests and limits; above 100% of request means it exceeds its reservation.',
},
{
title: 'Network IO',
yAxisUnit: 'binBps',
docPath: '/infrastructure-monitoring/kubernetes/statefulsets/#network-io',
description:
'Transmit and receive throughput per interface across all pods of the StatefulSet.',
},
{
title: 'Network errors count',
yAxisUnit: '',
docPath:
'/infrastructure-monitoring/kubernetes/statefulsets/#network-errors-count',
description:
'Per-pod-interface network error counts by direction and interface, reported by the kubelet.',
},
];

View File

@@ -70,28 +70,38 @@ export const volumeWidgetInfo = [
title: 'Volume available',
yAxisUnit: 'bytes',
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-available-1',
description:
'Free bytes on the volume over time; a steady decline forecasts when the volume fills up.',
},
{
title: 'Volume capacity',
yAxisUnit: 'bytes',
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-capacity-1',
description:
'Total provisioned capacity of the volume in bytes, which steps up only when the PVC is resized.',
},
{
title: 'Volume inodes used',
yAxisUnit: 'short',
docPath:
'/infrastructure-monitoring/kubernetes/volumes/#volume-inodes-used-1',
description:
'Inodes consumed on the volume filesystem; a rising line means many small files are being created.',
},
{
title: 'Volume inodes',
yAxisUnit: 'short',
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-inodes-1',
description:
'Total inodes available on the volume filesystem, the reference for spotting inode exhaustion.',
},
{
title: 'Volume inodes free',
yAxisUnit: 'short',
docPath:
'/infrastructure-monitoring/kubernetes/volumes/#volume-inodes-free-1',
description:
'Unallocated inodes on the volume; near zero, file creation fails with ENOSPC even with free bytes.',
},
];

View File

@@ -800,26 +800,36 @@ export const podUtilizationByPodWidgetInfo = [
title: 'CPU Limit Utilization By Pod Name',
yAxisUnit: 'percentunit',
docPath: '#cpu-limit-utilization-by-pod-name',
description:
'CPU usage against the CPU limit for each pod; near 100% means the kernel is throttling that pod.',
},
{
title: 'CPU Request Utilization By Pod Name',
yAxisUnit: 'percentunit',
docPath: '#cpu-request-utilization-by-pod-name',
description:
'CPU usage against the CPU request for each pod; above 100% means the pod uses more than it reserved.',
},
{
title: 'Memory Limit Utilization By Pod Name',
yAxisUnit: 'percentunit',
docPath: '#memory-limit-utilization-by-pod-name',
description:
'Memory usage against the memory limit for each pod; near 100% means that pod is close to an OOMKill.',
},
{
title: 'Memory Request Utilization By Pod Name',
yAxisUnit: 'percentunit',
docPath: '#memory-request-utilization-by-pod-name',
description:
'Memory usage against the memory request for each pod; above 100% means the pod exceeds its reservation.',
},
{
title: 'FileSystem Usage Percentage By Pod Name',
yAxisUnit: 'percentunit',
docPath: '#filesystem-usage-percentage-by-pod-name',
description:
'Local and ephemeral filesystem fill level as a percentage of capacity for each pod.',
},
];

View File

@@ -0,0 +1,171 @@
import { rest, server } from 'mocks-server/server';
import get from 'api/browser/localstorage/get';
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
jest.mock('@monaco-editor/react', () => ({
__esModule: true,
default: ({
value,
onChange,
}: {
value: string;
onChange: (next?: string) => void;
}): JSX.Element => (
<textarea
aria-label="json-editor"
data-testid="monaco"
value={value}
onChange={(e): void => onChange(e.target.value)}
/>
),
}));
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
import LLMObservabilityAttributeMapping from '../../LLMObservabilityAttributeMapping';
import { SAMPLE_SPAN_JSON } from '../spanInputStorage';
import {
GROUPS_ENDPOINT,
makeGroupsResponse,
makeTestResponse,
mockGroups,
TEST_ENDPOINT,
} from '../../__tests__/fixtures';
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',
},
};
const EDITED_SPAN_JSON = `{
"attributes": {
"gen_ai.request.model": "claude-opus-5"
},
"resource": {
"service.name": "my-edited-gateway"
}
}`;
const SPAN_INPUT_KEY = LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN;
describe('TestTab — sample-span flow', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
remove(SPAN_INPUT_KEY);
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
);
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
it('runs the sample span through the mappers and renders the populated result', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(TEST_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeTestResponse([RESULT_SPAN]))),
),
);
render(<LLMObservabilityAttributeMapping />);
await user.click(screen.getByRole('tab', { name: 'Test' }));
const runBtn = await screen.findByTestId('run-test-button');
expect(screen.getByTestId('test-results-placeholder')).toBeInTheDocument();
await user.click(runBtn);
await expect(
screen.findByTestId('test-results'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('test-result-0')).toBeInTheDocument();
expect(screen.getByTestId('test-result-0-attributes')).toHaveTextContent(
'gen_ai.content.prompt',
);
expect(screen.getByText('populated')).toBeInTheDocument();
expect(screen.queryByTestId('test-error')).not.toBeInTheDocument();
});
it('surfaces a backend error and renders no results', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(TEST_ENDPOINT, (_req, res, ctx) =>
res(
ctx.status(500),
ctx.json({ error: { message: 'span mapper test failed' } }),
),
),
);
render(<LLMObservabilityAttributeMapping />);
await user.click(screen.getByRole('tab', { name: 'Test' }));
await user.click(await screen.findByTestId('run-test-button'));
await expect(screen.findByTestId('test-error')).resolves.toHaveTextContent(
'span mapper test failed',
);
expect(screen.queryByTestId('test-results')).not.toBeInTheDocument();
});
it('persists an edited span to local storage and restores it on remount', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { unmount } = render(<LLMObservabilityAttributeMapping />);
await user.click(screen.getByRole('tab', { name: 'Test' }));
await screen.findByTestId('run-test-button');
const editor = screen.getByTestId('monaco');
expect(editor).toHaveValue(SAMPLE_SPAN_JSON);
await user.clear(editor);
await user.paste(EDITED_SPAN_JSON);
await waitFor(() => expect(get(SPAN_INPUT_KEY)).toBe(EDITED_SPAN_JSON), {
timeout: 2000,
});
unmount();
render(<LLMObservabilityAttributeMapping />);
await user.click(screen.getByRole('tab', { name: 'Test' }));
await screen.findByTestId('run-test-button');
expect(screen.getByTestId('monaco')).toHaveValue(EDITED_SPAN_JSON);
});
it('resets to the sample span and clears the persisted input', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
set(SPAN_INPUT_KEY, EDITED_SPAN_JSON);
render(<LLMObservabilityAttributeMapping />);
await user.click(screen.getByRole('tab', { name: 'Test' }));
const resetBtn = await screen.findByTestId('reset-template-button');
expect(screen.getByTestId('monaco')).toHaveValue(EDITED_SPAN_JSON);
expect(resetBtn).toBeEnabled();
await user.click(resetBtn);
expect(screen.getByTestId('monaco')).toHaveValue(SAMPLE_SPAN_JSON);
expect(get(SPAN_INPUT_KEY)).toBeFalsy();
expect(resetBtn).toBeDisabled();
});
});

View File

@@ -3,6 +3,7 @@ import {
SpantypesSpanMapperDTO as Mapper,
SpantypesSpanMapperGroupDTO as MapperGroup,
SpantypesSpanMapperOperationDTO as MapperOperation,
SpantypesSpanMapperTestSpanDTO as TestSpan,
} from 'api/generated/services/sigNoz.schemas';
// Endpoint globs used by MSW handlers. The generated client hits relative
@@ -12,6 +13,7 @@ export const GROUPS_ENDPOINT = '*/api/v1/span_mapper_groups';
export function mappersEndpoint(groupId: string): string {
return `*/api/v1/span_mapper_groups/${groupId}/span_mappers`;
}
export const TEST_ENDPOINT = '*/api/v1/span_mapper_groups/test';
export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
return {
@@ -71,6 +73,13 @@ export function makeMappersResponse(mappers: Mapper[]): {
return { status: 'ok', data: { items: mappers } };
}
export function makeTestResponse(spans: TestSpan[]): {
status: string;
data: { spans: TestSpan[] };
} {
return { status: 'ok', data: { spans } };
}
export const mockGroups: MapperGroup[] = [
makeGroup({
id: 'group-1',

View File

@@ -17,8 +17,6 @@ import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App';
import {
getHostQueryPayload,
getNodeQueryPayload,
@@ -53,23 +51,12 @@ function NodeMetrics({
};
}, [timestamp]);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const queryPayloads = useMemo(() => {
if (nodeName) {
return getNodeQueryPayload(
clusterName,
nodeName,
start,
end,
dotMetricsEnabled,
);
return getNodeQueryPayload(clusterName, nodeName, start, end);
}
return getHostQueryPayload(hostName, start, end, dotMetricsEnabled);
}, [nodeName, hostName, clusterName, start, end, dotMetricsEnabled]);
return getHostQueryPayload(hostName, start, end);
}, [nodeName, hostName, clusterName, start, end]);
const widgetInfo = nodeName ? nodeWidgetInfo : hostWidgetInfo;
const queries = useQueries(

View File

@@ -12,13 +12,11 @@ import { useResizeObserver } from 'hooks/useDimensions';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { FeatureKeys } from '../../../constants/features';
import { getPodQueryPayload, podWidgetInfo } from './constants';
function PodMetrics({
@@ -54,14 +52,9 @@ function PodMetrics({
scrollLeft: 0,
});
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const queryPayloads = useMemo(
() => getPodQueryPayload(clusterName, podName, start, end, dotMetricsEnabled),
[clusterName, end, podName, start, dotMetricsEnabled],
() => getPodQueryPayload(clusterName, podName, start, end),
[clusterName, end, podName, start],
);
const queries = useQueries(
queryPayloads.map((payload) => ({

View File

@@ -1,56 +1,39 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import type { Having } from 'types/api/queryBuilder/queryBuilderData';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { Having as HavingV5 } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
const buildSumGreaterThanZeroHaving = (
metricKey: string,
useV5HavingFormat: boolean,
): Having[] | HavingV5 =>
useV5HavingFormat
? { expression: `sum(${metricKey}) > 0` }
: [{ columnName: `SUM(${metricKey})`, op: '>', value: 0 }];
export const getPodQueryPayload = (
clusterName: string,
podName: string,
start: number,
end: number,
dotMetricsEnabled: boolean,
): GetQueryResultsProps[] => {
const k8sClusterNameKey = dotMetricsEnabled
? 'k8s.cluster.name'
: 'k8s_cluster_name';
const k8sPodNameKey = dotMetricsEnabled ? 'k8s.pod.name' : 'k8s_pod_name';
const containerCpuUtilKey = dotMetricsEnabled
? 'container.cpu.usage'
: 'container_cpu_usage';
const containerMemUsageKey = dotMetricsEnabled
? 'container.memory.usage'
: 'container_memory_usage';
const k8sContainerCpuReqKey = dotMetricsEnabled
? 'k8s.container.cpu_request'
: 'k8s_container_cpu_request';
const k8sContainerCpuLimitKey = dotMetricsEnabled
? 'k8s.container.cpu_limit'
: 'k8s_container_cpu_limit';
const k8sContainerMemReqKey = dotMetricsEnabled
? 'k8s.container.memory_request'
: 'k8s_container_memory_request';
const k8sContainerMemLimitKey = dotMetricsEnabled
? 'k8s.container.memory_limit'
: 'k8s_container_memory_limit';
const k8sPodFsAvailKey = dotMetricsEnabled
? 'k8s.pod.filesystem.available'
: 'k8s_pod_filesystem_available';
const k8sPodFsCapKey = dotMetricsEnabled
? 'k8s.pod.filesystem.capacity'
: 'k8s_pod_filesystem_capacity';
const k8sPodNetIoKey = dotMetricsEnabled
? 'k8s.pod.network.io'
: 'k8s_pod_network_io';
const podLegendTemplate = dotMetricsEnabled
? '{{k8s.pod.name}}'
: '{{k8s_pod_name}}';
const podLegendUsage = dotMetricsEnabled
? 'usage - {{k8s.pod.name}}'
: 'usage - {{k8s_pod_name}}';
const podLegendLimit = dotMetricsEnabled
? 'limit - {{k8s.pod.name}}'
: 'limit - {{k8s_pod_name}}';
const k8sClusterNameKey = 'k8s.cluster.name';
const k8sPodNameKey = 'k8s.pod.name';
const containerCpuUtilKey = 'container.cpu.usage';
const containerMemUsageKey = 'container.memory.usage';
const k8sContainerCpuReqKey = 'k8s.container.cpu_request';
const k8sContainerCpuLimitKey = 'k8s.container.cpu_limit';
const k8sContainerMemReqKey = 'k8s.container.memory_request';
const k8sContainerMemLimitKey = 'k8s.container.memory_limit';
const k8sPodFsAvailKey = 'k8s.pod.filesystem.available';
const k8sPodFsCapKey = 'k8s.pod.filesystem.capacity';
const k8sPodNetIoKey = 'k8s.pod.network.io';
const podLegendTemplate = '{{k8s.pod.name}}';
const podLegendUsage = 'usage - {{k8s.pod.name}}';
const podLegendLimit = 'limit - {{k8s.pod.name}}';
return [
{
@@ -1027,36 +1010,17 @@ export const getNodeQueryPayload = (
nodeName: string,
start: number,
end: number,
dotMetricsEnabled: boolean,
): GetQueryResultsProps[] => {
const k8sClusterNameKey = dotMetricsEnabled
? 'k8s.cluster.name'
: 'k8s_cluster_name';
const k8sNodeNameKey = dotMetricsEnabled ? 'k8s.node.name' : 'k8s_node_name';
const k8sNodeCpuTimeKey = dotMetricsEnabled
? 'k8s.node.cpu.time'
: 'k8s_node_cpu_time';
const k8sNodeAllocCpuKey = dotMetricsEnabled
? 'k8s.node.allocatable_cpu'
: 'k8s_node_allocatable_cpu';
const k8sNodeMemWsKey = dotMetricsEnabled
? 'k8s.node.memory.working_set'
: 'k8s_node_memory_working_set';
const k8sNodeAllocMemKey = dotMetricsEnabled
? 'k8s.node.allocatable_memory'
: 'k8s_node_allocatable_memory';
const k8sNodeNetIoKey = dotMetricsEnabled
? 'k8s.node.network.io'
: 'k8s_node_network_io';
const k8sNodeFsAvailKey = dotMetricsEnabled
? 'k8s.node.filesystem.available'
: 'k8s_node_filesystem_available';
const k8sNodeFsCapKey = dotMetricsEnabled
? 'k8s.node.filesystem.capacity'
: 'k8s_node_filesystem_capacity';
const podLegend = dotMetricsEnabled
? '{{k8s.node.name}}'
: '{{k8s_node_name}}';
const k8sClusterNameKey = 'k8s.cluster.name';
const k8sNodeNameKey = 'k8s.node.name';
const k8sNodeCpuTimeKey = 'k8s.node.cpu.time';
const k8sNodeAllocCpuKey = 'k8s.node.allocatable_cpu';
const k8sNodeMemWsKey = 'k8s.node.memory.working_set';
const k8sNodeAllocMemKey = 'k8s.node.allocatable_memory';
const k8sNodeNetIoKey = 'k8s.node.network.io';
const k8sNodeFsAvailKey = 'k8s.node.filesystem.available';
const k8sNodeFsCapKey = 'k8s.node.filesystem.capacity';
const podLegend = '{{k8s.node.name}}';
return [
{
@@ -1586,48 +1550,24 @@ export const getHostQueryPayload = (
hostName: string,
start: number,
end: number,
dotMetricsEnabled: boolean,
useV5HavingFormat = false,
): GetQueryResultsProps[] => {
const hostNameKey = dotMetricsEnabled ? 'host.name' : 'host_name';
const cpuTimeKey = dotMetricsEnabled ? 'system.cpu.time' : 'system_cpu_time';
const memUsageKey = dotMetricsEnabled
? 'system.memory.usage'
: 'system_memory_usage';
const load1mKey = dotMetricsEnabled
? 'system.cpu.load_average.1m'
: 'system_cpu_load_average_1m';
const load5mKey = dotMetricsEnabled
? 'system.cpu.load_average.5m'
: 'system_cpu_load_average_5m';
const load15mKey = dotMetricsEnabled
? 'system.cpu.load_average.15m'
: 'system_cpu_load_average_15m';
const netIoKey = dotMetricsEnabled ? 'system.network.io' : 'system_network_io';
const netPktsKey = dotMetricsEnabled
? 'system.network.packets'
: 'system_network_packets';
const netErrKey = dotMetricsEnabled
? 'system.network.errors'
: 'system_network_errors';
const netDropKey = dotMetricsEnabled
? 'system.network.dropped'
: 'system_network_dropped';
const netConnKey = dotMetricsEnabled
? 'system.network.connections'
: 'system_network_connections';
const diskIoKey = dotMetricsEnabled ? 'system.disk.io' : 'system_disk_io';
const diskOpTimeKey = dotMetricsEnabled
? 'system.disk.operation_time'
: 'system_disk_operation_time';
const diskOpsKey = dotMetricsEnabled
? 'system.disk.operations'
: 'system_disk_operations';
const diskPendingKey = dotMetricsEnabled
? 'system.disk.pending_operations'
: 'system_disk_pending_operations';
const fsUsageKey = dotMetricsEnabled
? 'system.filesystem.usage'
: 'system_filesystem_usage';
const hostNameKey = 'host.name';
const cpuTimeKey = 'system.cpu.time';
const memUsageKey = 'system.memory.usage';
const load1mKey = 'system.cpu.load_average.1m';
const load5mKey = 'system.cpu.load_average.5m';
const load15mKey = 'system.cpu.load_average.15m';
const netIoKey = 'system.network.io';
const netPktsKey = 'system.network.packets';
const netErrKey = 'system.network.errors';
const netDropKey = 'system.network.dropped';
const netConnKey = 'system.network.connections';
const diskIoKey = 'system.disk.io';
const diskOpTimeKey = 'system.disk.operation_time';
const diskOpsKey = 'system.disk.operations';
const diskPendingKey = 'system.disk.pending_operations';
const fsUsageKey = 'system.filesystem.usage';
return [
{
@@ -1873,13 +1813,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: [
{
columnName: `SUM(${fsUsageKey})`,
op: '>',
value: 0,
},
],
having: buildSumGreaterThanZeroHaving(fsUsageKey, useV5HavingFormat),
legend: '{{mountpoint}}',
limit: null,
orderBy: [],
@@ -1928,13 +1862,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: [
{
columnName: `SUM(${fsUsageKey})`,
op: '>',
value: 0,
},
],
having: buildSumGreaterThanZeroHaving(fsUsageKey, useV5HavingFormat),
legend: '{{mountpoint}}',
limit: null,
orderBy: [],
@@ -2160,13 +2088,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: [
{
columnName: `SUM(${netIoKey})`,
op: '>',
value: 0,
},
],
having: buildSumGreaterThanZeroHaving(netIoKey, useV5HavingFormat),
legend: '{{device}}::{{direction}}',
limit: 30,
orderBy: [],
@@ -2622,13 +2544,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: [
{
columnName: `SUM(${diskOpsKey})`,
op: '>',
value: 0,
},
],
having: buildSumGreaterThanZeroHaving(diskOpsKey, useV5HavingFormat),
legend: '{{device}}::{{direction}}',
limit: null,
orderBy: [],
@@ -2697,13 +2613,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: [
{
columnName: `SUM(${diskPendingKey})`,
op: '>',
value: 0,
},
],
having: buildSumGreaterThanZeroHaving(diskPendingKey, useV5HavingFormat),
legend: '{{device}}',
limit: null,
orderBy: [],
@@ -2779,13 +2689,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: [
{
columnName: `SUM(${diskOpTimeKey})`,
op: '>',
value: 0,
},
],
having: buildSumGreaterThanZeroHaving(diskOpTimeKey, useV5HavingFormat),
legend: '{{device}}::{{direction}}',
limit: null,
orderBy: [],
@@ -2876,68 +2780,94 @@ export const hostWidgetInfo = [
title: 'CPU Usage',
yAxisUnit: 'percentunit',
docPath: '/infrastructure-monitoring/host-monitoring/#cpu-usage-1',
description:
'CPU time share per state (user, system, wait, steal, idle); sustained wait points to disk I/O blocking.',
},
{
title: 'Memory Usage',
yAxisUnit: 'bytes',
docPath: '/infrastructure-monitoring/host-monitoring/#memory-usage-1',
description:
'Physical memory bytes per state (used, cached, buffers, free); a climbing used line suggests a leak.',
},
{
title: 'Disk Usage (%) by mountpoint',
yAxisUnit: 'percentunit',
docPath:
'/infrastructure-monitoring/host-monitoring/#disk-usage--by-mountpoint',
description:
'Used space as a percentage of capacity for each mountpoint, one line per mountpoint.',
},
{
title: 'System Load Average',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/host-monitoring/#system-load-average',
description:
'The 1m, 5m and 15m load averages together; 1m above 15m means load is building.',
},
{
title: 'Network usage (bytes)',
yAxisUnit: 'bytes',
docPath: '/infrastructure-monitoring/host-monitoring/#network-usage-bytes',
description:
'Throughput in bytes/s per interface and direction, to spot NICs nearing rated bandwidth.',
},
{
title: 'Network usage (packet/s)',
yAxisUnit: 'pps',
docPath: '/infrastructure-monitoring/host-monitoring/#network-usage-packetss',
description:
'Packets per second per interface and direction; a NIC can saturate on packet rate before bytes.',
},
{
title: 'Network errors',
yAxisUnit: 'short',
docPath: '/infrastructure-monitoring/host-monitoring/#network-errors',
description:
'Rate of interface-level network errors per interface and direction; any sustained value needs attention.',
},
{
title: 'Network drops',
yAxisUnit: 'short',
docPath: '/infrastructure-monitoring/host-monitoring/#network-drops',
description:
'Rate of dropped packets per interface and direction, usually buffer overflow rather than link errors.',
},
{
title: 'Network connections',
yAxisUnit: 'short',
docPath: '/infrastructure-monitoring/host-monitoring/#network-connections',
description:
'Active connection counts per protocol and state (ESTABLISHED, TIME_WAIT, SYN_RECV) to spot leaks and churn.',
},
{
title: 'System disk io (bytes transferred)',
yAxisUnit: 'bytes',
docPath: '/infrastructure-monitoring/host-monitoring/#system-disk-io-bytes',
description:
'Disk throughput in bytes/s per device and direction, tracking heavy file I/O or database flushes.',
},
{
title: 'System disk operations/s',
yAxisUnit: 'short',
docPath:
'/infrastructure-monitoring/host-monitoring/#system-disk-operationss',
description:
'Rate of completed read and write operations per device; pair with disk io bytes to size each operation.',
},
{
title: 'Queue size',
yAxisUnit: 'short',
docPath: '/infrastructure-monitoring/host-monitoring/#queue-size',
description:
'Maximum disk request-queue depth per device; sustained high depth means the storage layer is saturated.',
},
{
title: 'System disk operation time/s',
yAxisUnit: 's',
docPath:
'/infrastructure-monitoring/host-monitoring/#system-disk-operation-times',
description:
'Rate of cumulative disk-busy time per device and direction; values near 1s/s mean the device is saturated.',
},
];

View File

@@ -21,7 +21,6 @@ export const databaseCallsRPS = ({
servicename,
legend,
tagFilterItems,
dotMetricsEnabled,
}: DatabaseCallsRPSProps): QueryBuilderData => {
const autocompleteData: BaseAutocompleteData[] = [
{
@@ -33,7 +32,7 @@ export const databaseCallsRPS = ({
const groupBy: BaseAutocompleteData[] = [
{
dataType: DataTypes.String,
key: dotMetricsEnabled ? WidgetKeys.Db_system : WidgetKeys.Db_system_norm,
key: WidgetKeys.DbSystem,
type: 'tag',
},
];
@@ -42,9 +41,7 @@ export const databaseCallsRPS = ({
{
id: '',
key: {
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},
@@ -75,7 +72,6 @@ export const databaseCallsRPS = ({
export const databaseCallsAvgDuration = ({
servicename,
tagFilterItems,
dotMetricsEnabled,
}: DatabaseCallProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozDbLatencySum,
@@ -92,9 +88,7 @@ export const databaseCallsAvgDuration = ({
{
id: '',
key: {
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},

View File

@@ -32,7 +32,6 @@ export const externalCallErrorPercent = ({
servicename,
legend,
tagFilterItems,
dotMetricsEnabled,
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozExternalCallLatencyCount,
@@ -49,9 +48,7 @@ export const externalCallErrorPercent = ({
{
id: '',
key: {
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},
@@ -61,7 +58,7 @@ export const externalCallErrorPercent = ({
{
id: '',
key: {
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
key: WidgetKeys.StatusCode,
dataType: DataTypes.Int64,
type: MetricsType.Tag,
},
@@ -74,9 +71,7 @@ export const externalCallErrorPercent = ({
{
id: '',
key: {
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},
@@ -120,7 +115,6 @@ export const externalCallErrorPercent = ({
export const externalCallDuration = ({
servicename,
tagFilterItems,
dotMetricsEnabled,
}: ExternalCallProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
dataType: DataTypes.Float64,
@@ -141,9 +135,7 @@ export const externalCallDuration = ({
id: '',
key: {
dataType: DataTypes.String,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -183,7 +175,6 @@ export const externalCallRpsByAddress = ({
servicename,
legend,
tagFilterItems,
dotMetricsEnabled,
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
const autocompleteData: BaseAutocompleteData[] = [
{
@@ -198,9 +189,7 @@ export const externalCallRpsByAddress = ({
id: '',
key: {
dataType: DataTypes.String,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -231,7 +220,6 @@ export const externalCallDurationByAddress = ({
servicename,
legend,
tagFilterItems,
dotMetricsEnabled,
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
dataType: DataTypes.Float64,
@@ -251,9 +239,7 @@ export const externalCallDurationByAddress = ({
id: '',
key: {
dataType: DataTypes.String,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
type: MetricsType.Resource,
},
op: OPERATORS.IN,

View File

@@ -37,15 +37,10 @@ export const latency = ({
tagFilterItems,
isSpanMetricEnable = false,
topLevelOperationsRoute,
dotMetricsEnabled,
}: LatencyProps): QueryBuilderData => {
const signozLatencyBucketMetrics = dotMetricsEnabled
? WidgetKeys.Signoz_latency_bucket
: WidgetKeys.Signoz_latency_bucket_norm;
const signozLatencyBucketMetrics = WidgetKeys.SignozLatencyBucket;
const signozMetricsServiceName = dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm;
const signozMetricsServiceName = WidgetKeys.OTelServiceName;
const newAutoCompleteData: BaseAutocompleteData = {
key: isSpanMetricEnable
? signozLatencyBucketMetrics
@@ -287,28 +282,21 @@ export const apDexMetricsQueryBuilderQueries = ({
threashold,
delta,
metricsBuckets,
dotMetricsEnabled,
}: ApDexMetricsQueryBuilderQueriesProps): QueryBuilderData => {
const autoCompleteDataA: BaseAutocompleteData = {
key: dotMetricsEnabled
? WidgetKeys.SignozLatencyCount
: WidgetKeys.SignozLatencyCountNorm,
key: WidgetKeys.SignozLatencyCount,
dataType: DataTypes.Float64,
type: '',
};
const autoCompleteDataB: BaseAutocompleteData = {
key: dotMetricsEnabled
? WidgetKeys.Signoz_latency_bucket
: WidgetKeys.Signoz_latency_bucket_norm,
key: WidgetKeys.SignozLatencyBucket,
dataType: DataTypes.Float64,
type: '',
};
const autoCompleteDataC: BaseAutocompleteData = {
key: dotMetricsEnabled
? WidgetKeys.Signoz_latency_bucket
: WidgetKeys.Signoz_latency_bucket_norm,
key: WidgetKeys.SignozLatencyBucket,
dataType: DataTypes.Float64,
type: '',
};
@@ -317,9 +305,7 @@ export const apDexMetricsQueryBuilderQueries = ({
{
id: '',
key: {
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
@@ -343,7 +329,7 @@ export const apDexMetricsQueryBuilderQueries = ({
{
id: '',
key: {
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
key: WidgetKeys.StatusCode,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
@@ -363,9 +349,7 @@ export const apDexMetricsQueryBuilderQueries = ({
{
id: '',
key: {
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
@@ -399,7 +383,7 @@ export const apDexMetricsQueryBuilderQueries = ({
{
id: '',
key: {
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
key: WidgetKeys.StatusCode,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
@@ -409,9 +393,7 @@ export const apDexMetricsQueryBuilderQueries = ({
{
id: '',
key: {
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Tag,
},
@@ -474,13 +456,10 @@ export const operationPerSec = ({
servicename,
tagFilterItems,
topLevelOperations,
dotMetricsEnabled,
}: OperationPerSecProps): QueryBuilderData => {
const autocompleteData: BaseAutocompleteData[] = [
{
key: dotMetricsEnabled
? WidgetKeys.SignozLatencyCount
: WidgetKeys.SignozLatencyCountNorm,
key: WidgetKeys.SignozLatencyCount,
dataType: DataTypes.Float64,
type: '',
},
@@ -491,9 +470,7 @@ export const operationPerSec = ({
{
id: '',
key: {
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},
@@ -534,7 +511,6 @@ export const errorPercentage = ({
servicename,
tagFilterItems,
topLevelOperations,
dotMetricsEnabled,
}: OperationPerSecProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozCallsTotal,
@@ -553,9 +529,7 @@ export const errorPercentage = ({
{
id: '',
key: {
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},
@@ -575,7 +549,7 @@ export const errorPercentage = ({
{
id: '',
key: {
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
key: WidgetKeys.StatusCode,
dataType: DataTypes.Int64,
type: MetricsType.Tag,
},
@@ -589,9 +563,7 @@ export const errorPercentage = ({
{
id: '',
key: {
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},

View File

@@ -21,12 +21,9 @@ import { getQueryBuilderQuerieswithFormula } from './MetricsPageQueriesFactory';
export const topOperationQueries = ({
servicename,
dotMetricsEnabled,
}: TopOperationQueryFactoryProps): QueryBuilderData => {
const latencyAutoCompleteData: BaseAutocompleteData = {
key: dotMetricsEnabled
? WidgetKeys.Signoz_latency_bucket
: WidgetKeys.Signoz_latency_bucket_norm,
key: WidgetKeys.SignozLatencyBucket,
dataType: DataTypes.Float64,
type: '',
};
@@ -38,9 +35,7 @@ export const topOperationQueries = ({
};
const numOfCallAutoCompleteData: BaseAutocompleteData = {
key: dotMetricsEnabled
? WidgetKeys.SignozLatencyCount
: WidgetKeys.SignozLatencyCountNorm,
key: WidgetKeys.SignozLatencyCount,
dataType: DataTypes.Float64,
type: '',
};
@@ -49,9 +44,7 @@ export const topOperationQueries = ({
{
id: '',
key: {
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
dataType: DataTypes.String,
type: MetricsType.Resource,
},
@@ -65,9 +58,7 @@ export const topOperationQueries = ({
id: '',
key: {
dataType: DataTypes.String,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -77,7 +68,7 @@ export const topOperationQueries = ({
id: '',
key: {
dataType: DataTypes.Int64,
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
key: WidgetKeys.StatusCode,
type: MetricsType.Tag,
},
op: OPERATORS.IN,

View File

@@ -28,8 +28,6 @@ import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App';
import {
GraphTitle,
MENU_ITEMS,
@@ -89,12 +87,7 @@ function DBCall(): JSX.Element {
[queries],
);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const legend = dotMetricsEnabled ? '{{db.system}}' : '{{db_system}}';
const legend = '{{db.system}}';
const databaseCallsRPSWidget = useMemo(
() =>
@@ -106,7 +99,6 @@ function DBCall(): JSX.Element {
servicename,
legend,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -117,7 +109,7 @@ function DBCall(): JSX.Element {
id: SERVICE_CHART_ID.dbCallsRPS,
fillSpans: false,
}),
[servicename, tagFilterItems, dotMetricsEnabled, legend],
[servicename, tagFilterItems, legend],
);
const databaseCallsAverageDurationWidget = useMemo(
() =>
@@ -128,7 +120,6 @@ function DBCall(): JSX.Element {
builder: databaseCallsAvgDuration({
servicename,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -139,7 +130,7 @@ function DBCall(): JSX.Element {
id: GraphTitle.DATABASE_CALLS_AVG_DURATION,
fillSpans: true,
}),
[servicename, tagFilterItems, dotMetricsEnabled],
[servicename, tagFilterItems],
);
const stepInterval = useMemo(
@@ -157,7 +148,7 @@ function DBCall(): JSX.Element {
useEffect(() => {
if (!logEventCalledRef.current) {
const selectedEnvironments = queries.find(
(val) => val.tagKey === getResourceDeploymentKeys(dotMetricsEnabled),
(val) => val.tagKey === getResourceDeploymentKeys(),
)?.tagValue;
logEvent('APM: Service detail page visited', {

View File

@@ -30,8 +30,6 @@ import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App';
import {
GraphTitle,
legend,
@@ -84,10 +82,6 @@ function External(): JSX.Element {
handleNonInQueryRange(resourceAttributesToTagFilterItems(queries)) || [],
[queries],
);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const externalCallErrorWidget = useMemo(
() =>
@@ -99,7 +93,6 @@ function External(): JSX.Element {
servicename,
legend: legend.address,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -109,7 +102,7 @@ function External(): JSX.Element {
yAxisUnit: '%',
id: GraphTitle.EXTERNAL_CALL_ERROR_PERCENTAGE,
}),
[servicename, tagFilterItems, dotMetricsEnabled],
[servicename, tagFilterItems],
);
const selectedTraceTags = useMemo(
@@ -126,7 +119,6 @@ function External(): JSX.Element {
builder: externalCallDuration({
servicename,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -137,7 +129,7 @@ function External(): JSX.Element {
id: GraphTitle.EXTERNAL_CALL_DURATION,
fillSpans: true,
}),
[servicename, tagFilterItems, dotMetricsEnabled],
[servicename, tagFilterItems],
);
const errorApmToTraceQuery = useGetAPMToTracesQueries({
@@ -171,7 +163,7 @@ function External(): JSX.Element {
useEffect(() => {
if (!logEventCalledRef.current) {
const selectedEnvironments = queries.find(
(val) => val.tagKey === getResourceDeploymentKeys(dotMetricsEnabled),
(val) => val.tagKey === getResourceDeploymentKeys(),
)?.tagValue;
logEvent('APM: Service detail page visited', {
@@ -194,7 +186,6 @@ function External(): JSX.Element {
servicename,
legend: legend.address,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -205,7 +196,7 @@ function External(): JSX.Element {
id: GraphTitle.EXTERNAL_CALL_RPS_BY_ADDRESS,
fillSpans: true,
}),
[servicename, tagFilterItems, dotMetricsEnabled],
[servicename, tagFilterItems],
);
const externalCallDurationAddressWidget = useMemo(
@@ -218,7 +209,6 @@ function External(): JSX.Element {
servicename,
legend: legend.address,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -229,7 +219,7 @@ function External(): JSX.Element {
id: GraphTitle.EXTERNAL_CALL_DURATION_BY_ADDRESS,
fillSpans: true,
}),
[servicename, tagFilterItems, dotMetricsEnabled],
[servicename, tagFilterItems],
);
const apmToTraceQuery = useGetAPMToTracesQueries({

View File

@@ -93,15 +93,12 @@ function Application(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
[handleSetTimeStamp],
);
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const logEventCalledRef = useRef(false);
useEffect(() => {
if (!logEventCalledRef.current) {
const selectedEnvironments = queries.find(
(val) => val.tagKey === getResourceDeploymentKeys(dotMetricsEnabled),
(val) => val.tagKey === getResourceDeploymentKeys(),
)?.tagValue;
logEvent('APM: Service detail page visited', {
@@ -159,7 +156,6 @@ function Application(): JSX.Element {
servicename,
tagFilterItems,
topLevelOperations: topLevelOperationsRoute,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -169,7 +165,7 @@ function Application(): JSX.Element {
yAxisUnit: 'ops',
id: SERVICE_CHART_ID.rps,
}),
[servicename, tagFilterItems, topLevelOperationsRoute, dotMetricsEnabled],
[servicename, tagFilterItems, topLevelOperationsRoute],
);
const errorPercentageWidget = useMemo(
@@ -182,7 +178,6 @@ function Application(): JSX.Element {
servicename,
tagFilterItems,
topLevelOperations: topLevelOperationsRoute,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -193,7 +188,7 @@ function Application(): JSX.Element {
id: SERVICE_CHART_ID.errorPercentage,
fillSpans: true,
}),
[servicename, tagFilterItems, topLevelOperationsRoute, dotMetricsEnabled],
[servicename, tagFilterItems, topLevelOperationsRoute],
);
const stepInterval = useMemo(

View File

@@ -22,8 +22,6 @@ import { apDexMetricsQueryBuilderQueries } from 'container/MetricsApplication/Me
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../../../constants/features';
import { useAppContext } from '../../../../../providers/App/App';
import { IServiceName } from '../../types';
import { ApDexMetricsProps } from './types';
@@ -38,10 +36,6 @@ function ApDexMetrics({
}: ApDexMetricsProps): JSX.Element {
const { servicename: encodedServiceName } = useParams<IServiceName>();
const servicename = decodeURIComponent(encodedServiceName);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const apDexMetricsWidget = useMemo(
() =>
getWidgetQueryBuilder({
@@ -55,7 +49,6 @@ function ApDexMetrics({
threashold: thresholdValue || 0,
delta: delta || false,
metricsBuckets: metricsBuckets || [],
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -81,7 +74,6 @@ function ApDexMetrics({
tagFilterItems,
thresholdValue,
topLevelOperationsRoute,
dotMetricsEnabled,
],
);

View File

@@ -3,8 +3,6 @@ import Spinner from 'components/Spinner';
import { useGetMetricMeta } from 'hooks/apDex/useGetMetricMeta';
import useErrorNotification from 'hooks/useErrorNotification';
import { FeatureKeys } from '../../../../../constants/features';
import { useAppContext } from '../../../../../providers/App/App';
import { WidgetKeys } from '../../../constant';
import { IServiceName } from '../../types';
import ApDexMetrics from './ApDexMetrics';
@@ -20,17 +18,8 @@ function ApDexMetricsApplication({
const { servicename: encodedServiceName } = useParams<IServiceName>();
const servicename = decodeURIComponent(encodedServiceName);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const signozLatencyBucketMetrics = dotMetricsEnabled
? WidgetKeys.Signoz_latency_bucket
: WidgetKeys.Signoz_latency_bucket_norm;
const { data, isLoading, error } = useGetMetricMeta(
signozLatencyBucketMetrics,
WidgetKeys.SignozLatencyBucket,
servicename,
);
useErrorNotification(error);

View File

@@ -56,10 +56,6 @@ function ServiceOverview({
[isSpanMetricEnable, queries],
);
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const latencyWidget = useMemo(
() =>
getWidgetQueryBuilder({
@@ -71,7 +67,6 @@ function ServiceOverview({
tagFilterItems,
isSpanMetricEnable,
topLevelOperationsRoute,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -81,13 +76,7 @@ function ServiceOverview({
yAxisUnit: 'ns',
id: SERVICE_CHART_ID.latency,
}),
[
isSpanMetricEnable,
servicename,
tagFilterItems,
topLevelOperationsRoute,
dotMetricsEnabled,
],
[isSpanMetricEnable, servicename, tagFilterItems, topLevelOperationsRoute],
);
const isQueryEnabled =

View File

@@ -19,8 +19,6 @@ import { EQueryType } from 'types/common/dashboard';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../../constants/features';
import { useAppContext } from '../../../../providers/App/App';
import { IServiceName } from '../types';
import { title } from './config';
import ColumnWithLink from './TableRenderer/ColumnWithLink';
@@ -44,11 +42,6 @@ function TopOperationMetrics(): JSX.Element {
convertRawQueriesToTraceSelectedTags(queries) || [],
);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const keyOperationWidget = useMemo(
() =>
getWidgetQueryBuilder({
@@ -57,14 +50,13 @@ function TopOperationMetrics(): JSX.Element {
promql: [],
builder: topOperationQueries({
servicename,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
},
panelTypes: PANEL_TYPES.TABLE,
}),
[servicename, dotMetricsEnabled],
[servicename],
);
const updatedQuery = updateStepInterval(keyOperationWidget.query);

View File

@@ -10,7 +10,6 @@ export interface IServiceName {
export interface TopOperationQueryFactoryProps {
servicename: IServiceName['servicename'];
dotMetricsEnabled: boolean;
}
export interface ExternalCallDurationByAddressProps extends ExternalCallProps {
@@ -20,7 +19,6 @@ export interface ExternalCallDurationByAddressProps extends ExternalCallProps {
export interface ExternalCallProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
dotMetricsEnabled: boolean;
}
export interface BuilderQueriesProps {
@@ -52,7 +50,6 @@ export interface OperationPerSecProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
topLevelOperations: string[];
dotMetricsEnabled: boolean;
}
export interface LatencyProps {
@@ -60,7 +57,6 @@ export interface LatencyProps {
tagFilterItems: TagFilterItem[];
isSpanMetricEnable?: boolean;
topLevelOperationsRoute: string[];
dotMetricsEnabled: boolean;
}
export interface ApDexProps {
@@ -78,5 +74,4 @@ export interface TableRendererProps {
export interface ApDexMetricsQueryBuilderQueriesProps extends ApDexProps {
delta: boolean;
metricsBuckets: number[];
dotMetricsEnabled: boolean;
}

View File

@@ -85,14 +85,11 @@ export enum WidgetKeys {
HasError = 'hasError',
Address = 'address',
DurationNano = 'durationNano',
StatusCodeNorm = 'status_code',
StatusCode = 'status.code',
Operation = 'operation',
OperationName = 'operationName',
Service_name_norm = 'service_name',
Service_name = 'service.name',
OTelServiceName = 'service.name',
ServiceName = 'serviceName',
SignozLatencyCountNorm = 'signoz_latency_count',
SignozLatencyCount = 'signoz_latency.count',
SignozDBLatencyCount = 'signoz_db_latency_count',
DatabaseCallCount = 'signoz_database_call_count',
@@ -101,10 +98,8 @@ export enum WidgetKeys {
SignozCallsTotal = 'signoz_calls_total',
SignozExternalCallLatencyCount = 'signoz_external_call_latency_count',
SignozExternalCallLatencySum = 'signoz_external_call_latency_sum',
Signoz_latency_bucket_norm = 'signoz_latency_bucket',
Signoz_latency_bucket = 'signoz_latency.bucket',
Db_system = 'db.system',
Db_system_norm = 'db_system',
SignozLatencyBucket = 'signoz_latency.bucket',
DbSystem = 'db.system',
}
export const topOperationMetricsDownloadOptions: DownloadOptions = {

View File

@@ -32,5 +32,4 @@ export interface DatabaseCallsRPSProps extends DatabaseCallProps {
export interface DatabaseCallProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
dotMetricsEnabled: boolean;
}

View File

@@ -1521,9 +1521,9 @@ const onboardingConfigWithLinks = [
},
{
dataSource: 'nginx-tracing',
label: 'Nginx - Tracing',
label: 'Nginx - OpenTelemetry',
imgUrl: nginxUrl,
tags: ['apm/traces'],
tags: ['apm/traces', 'logs', 'metrics'],
module: 'apm',
relatedSearchKeywords: [
'apm',
@@ -1626,7 +1626,7 @@ const onboardingConfigWithLinks = [
dataSource: 'cloudflare-workers',
label: 'Cloudflare Workers',
imgUrl: cloudflareUrl,
tags: ['apm/traces'],
tags: ['apm/traces', 'logs'],
module: 'apm',
relatedSearchKeywords: [
'cloudflare',
@@ -5346,13 +5346,17 @@ const onboardingConfigWithLinks = [
dataSource: 'temporal',
label: 'Temporal',
imgUrl: temporalUrl,
tags: ['apm/traces'],
tags: ['apm/traces', 'logs', 'metrics'],
module: 'apm',
relatedSearchKeywords: [
'apm',
'application performance monitoring',
'integrations',
'logs',
'metrics',
'temporal',
'temporal logs',
'temporal metrics',
'temporal traces',
'traces',
'tracing',
@@ -5478,7 +5482,7 @@ const onboardingConfigWithLinks = [
dataSource: 'dbos',
label: 'DBOS',
imgUrl: dbosUrl,
tags: ['apm/traces'],
tags: ['apm/traces', 'logs'],
module: 'apm',
relatedSearchKeywords: [
'database oriented',
@@ -6622,7 +6626,7 @@ const onboardingConfigWithLinks = [
dataSource: 'opentelemetry-ebpf',
label: 'OpenTelemetry eBPF (OBI)',
imgUrl: opentelemetryUrl,
tags: ['apm/traces'],
tags: ['apm/traces', 'metrics'],
module: 'apm',
relatedSearchKeywords: [
'auto instrumentation',

View File

@@ -387,4 +387,42 @@ describe('useOptionsMenu', () => {
expect(remaining).toHaveLength(seedColumns.length);
});
});
describe('fieldsSelector.value drops legacy columns without a name', () => {
it('excludes entries missing name while keeping valid columns', () => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: { data: { data: { keys: {} } } },
isFetching: false,
});
(usePreferenceContext as jest.Mock).mockReturnValue({
traces: {
preferences: {
columns: [
{ name: 'body', fieldContext: 'log' },
{ key: 'legacy-key-no-name', fieldContext: 'log' },
{ name: 'timestamp', fieldContext: 'log' },
],
formatting: { format: 'table', maxLines: 1, fontSize: 'small' },
},
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
logs: {
preferences: { columns: [], formatting: {} },
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
});
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
}),
);
const fields = result.current.config.fieldsSelector?.value ?? [];
expect(fields.map((f) => f.name)).toStrictEqual(['body', 'timestamp']);
});
});
});

View File

@@ -399,7 +399,7 @@ const useOptionsMenu = ({
onReorder: reorderSelectColumns,
},
fieldsSelector: {
value: preferences?.columns ?? [],
value: preferences?.columns?.filter((item) => has(item, 'name')) ?? [],
onFieldsChange: updateColumns,
},
format: {

View File

@@ -16,7 +16,7 @@ interface AuthNProvider {
function getAuthNProviders(samlEnabled: boolean): AuthNProvider[] {
return [
{
key: AuthtypesAuthNProviderDTO.google_auth,
key: AuthtypesAuthNProviderDTO.google,
title: 'Google Apps Authentication',
description: 'Let members sign-in with a Google workspace account',
icon: <SolidGoogle size={37} />,
@@ -78,6 +78,7 @@ function AuthnProviderSelector({
<Button
onClick={(): void => setAuthnProvider(provider.key)}
type="primary"
data-testid={`authn-provider-configure-${provider.key}`}
>
Configure
</Button>

View File

@@ -10,8 +10,6 @@ import {
import {
AuthtypesAuthNProviderDTO,
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
AuthtypesRoleMappingDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
@@ -24,10 +22,11 @@ import APIError from 'types/api/error';
import AuthnProviderSelector from './AuthnProviderSelector';
import {
convertDomainMappingsToRecord,
convertGroupMappingsToRecord,
FormValues,
kindToProvider,
prepareConfig,
prepareInitialValues,
prepareRoleMapping,
} from './CreateEdit.utils';
import ConfigureGoogleAuthAuthnProvider from './Providers/AuthnGoogleAuth';
import ConfigureOIDCAuthnProvider from './Providers/AuthnOIDC';
@@ -41,7 +40,7 @@ function configureAuthnProvider(
switch (authnProvider) {
case 'saml':
return <ConfigureSAMLAuthnProvider isCreate={isCreate} />;
case 'google_auth':
case 'google':
return <ConfigureGoogleAuthAuthnProvider isCreate={isCreate} />;
case 'oidc':
return <ConfigureOIDCAuthnProvider isCreate={isCreate} />;
@@ -61,7 +60,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const [form] = Form.useForm<FormValues>();
const [authnProvider, setAuthnProvider] = useState<
AuthtypesAuthNProviderDTO | ''
>(record?.config?.ssoType || '');
>(kindToProvider(record?.config?.kind));
const { showErrorModal } = useErrorModal();
const { featureFlags } = useAppContext();
@@ -85,68 +84,6 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const { mutate: updateAuthDomain, isLoading: isUpdating } =
useUpdateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
/**
* Prepares Google Auth config for API payload
*/
const getGoogleAuthConfig = useCallback(():
| AuthtypesGoogleConfigDTO
| undefined => {
const config = form.getFieldValue('googleAuthConfig');
if (!config) {
return undefined;
}
const {
domainToAdminEmailList,
allowedGroups,
serviceAccountJson,
domainToAdminEmail: _domainToAdminEmail,
fetchTransitiveGroupMembership,
...rest
} = config;
const domainToAdminEmail = convertDomainMappingsToRecord(
domainToAdminEmailList,
);
return {
...rest,
...(rest.fetchGroups
? {
allowedGroups,
serviceAccountJson,
domainToAdminEmail: domainToAdminEmail ?? {},
fetchTransitiveGroupMembership,
}
: { domainToAdminEmail: {} }),
};
}, [form]);
// Prepares role mapping for API payload
const getRoleMapping = useCallback((): AuthtypesRoleMappingDTO | undefined => {
const roleMapping = form.getFieldValue('roleMapping');
if (!roleMapping) {
return undefined;
}
const { groupMappingsList, ...rest } = roleMapping;
const groupMappings = convertGroupMappingsToRecord(groupMappingsList);
// Only return roleMapping if there's meaningful content
const hasDefaultRole = !!rest.defaultRole;
const hasUseRoleAttribute = rest.useRoleAttribute === true;
const hasGroupMappings =
groupMappings && Object.keys(groupMappings).length > 0;
if (!hasDefaultRole && !hasUseRoleAttribute && !hasGroupMappings) {
return undefined;
}
return {
...rest,
groupMappings: rest.useRoleAttribute ? undefined : (groupMappings ?? {}),
};
}, [form]);
const onSubmitHandler = useCallback(async (): Promise<void> => {
try {
await form.validateFields();
@@ -158,25 +95,23 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
return;
}
const name = form.getFieldValue('name');
const googleAuthConfig = getGoogleAuthConfig();
const samlConfig = form.getFieldValue('samlConfig');
const oidcConfig = form.getFieldValue('oidcConfig');
const roleMapping = getRoleMapping();
const values = form.getFieldsValue(true) as FormValues;
const name = values.name ?? '';
const config = prepareConfig(values, authnProvider);
const roleMapping = prepareRoleMapping(values);
if (!config) {
return;
}
if (isCreate) {
createAuthDomain(
{
data: {
name,
config: {
ssoEnabled: true,
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
enabled: true,
config,
roleMapping,
},
},
{
@@ -196,14 +131,9 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
{
pathParams: { id: record.id },
data: {
config: {
ssoEnabled: form.getFieldValue('ssoEnabled'),
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
enabled: values.enabled ?? false,
config,
roleMapping,
},
},
{
@@ -219,8 +149,6 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
authnProvider,
createAuthDomain,
form,
getGoogleAuthConfig,
getRoleMapping,
handleError,
isCreate,
@@ -243,10 +171,10 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
>
<Form
name="auth-domain"
data-testid="auth-domain-form"
initialValues={defaultTo(prepareInitialValues(record), {
name: '',
ssoEnabled: false,
ssoType: '',
enabled: false,
})}
form={form}
layout="vertical"
@@ -262,12 +190,22 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
{configureAuthnProvider(authnProvider, isCreate)}
<section className="action-buttons">
{isCreate && (
<Button onClick={onBackHandler} variant="solid" color="secondary">
<Button
onClick={onBackHandler}
variant="solid"
color="secondary"
testId="auth-domain-back"
>
Back
</Button>
)}
{!isCreate && (
<Button onClick={onClose} variant="solid" color="secondary">
<Button
onClick={onClose}
variant="solid"
color="secondary"
testId="auth-domain-cancel"
>
Cancel
</Button>
)}
@@ -276,6 +214,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
variant="solid"
color="primary"
loading={isCreating || isUpdating}
testId="auth-domain-save"
>
Save Changes
</Button>

View File

@@ -1,4 +1,8 @@
import { AuthtypesAuthNProviderDTO } from 'api/generated/services/sigNoz.schemas';
import {
AuthtypesAuthDomainConfigGoogleDTOKind,
AuthtypesAuthDomainConfigOIDCDTOKind,
AuthtypesAuthDomainConfigSAMLDTOKind,
} from 'api/generated/services/sigNoz.schemas';
import {
convertDomainMappingsToList,
@@ -82,8 +86,7 @@ describe('prepareInitialValues', () => {
it('returns empty defaults when no record is provided', () => {
expect(prepareInitialValues(undefined)).toStrictEqual({
name: '',
ssoEnabled: false,
ssoType: '',
enabled: false,
});
});
@@ -91,15 +94,20 @@ describe('prepareInitialValues', () => {
const result = prepareInitialValues({
id: 'domain-1',
name: 'example.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: false,
groupMappings: { admins: 'ADMIN', viewers: 'VIEWER' },
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: {
location: 'https://idp.example.com/sso',
entityId: 'urn:example:idp',
certificate: 'CERT',
},
},
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: false,
groupMappings: { admins: 'ADMIN', viewers: 'VIEWER' },
},
});
expect(result.roleMapping?.groupMappingsList).toStrictEqual([
@@ -112,10 +120,10 @@ describe('prepareInitialValues', () => {
const result = prepareInitialValues({
id: 'domain-1',
name: 'example.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
kind: AuthtypesAuthDomainConfigGoogleDTOKind.google,
spec: {
clientId: 'id',
clientSecret: 'secret',
domainToAdminEmail: { 'example.com': 'admin@example.com' },
@@ -132,11 +140,16 @@ describe('prepareInitialValues', () => {
const result = prepareInitialValues({
id: 'domain-1',
name: 'example.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
roleMapping: { defaultRole: 'VIEWER', useRoleAttribute: true },
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: {
issuer: 'https://oidc.example.com',
clientId: 'id',
clientSecret: 'secret',
},
},
roleMapping: { defaultRole: 'VIEWER', useRoleAttribute: true },
});
expect(result.roleMapping?.groupMappingsList).toStrictEqual([]);

View File

@@ -1,4 +1,9 @@
import {
AuthtypesAuthDomainConfigDTO,
AuthtypesAuthDomainConfigGoogleDTOKind,
AuthtypesAuthDomainConfigOIDCDTOKind,
AuthtypesAuthDomainConfigSAMLDTOKind,
AuthtypesAuthNProviderDTO,
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
AuthtypesOIDCConfigDTO,
@@ -6,11 +11,29 @@ import {
AuthtypesSamlConfigDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Maps the config envelope's per-variant kind to the provider enum driving the
* create/edit UI.
*/
export function kindToProvider(
kind?: AuthtypesAuthDomainConfigDTO['kind'],
): AuthtypesAuthNProviderDTO | '' {
switch (kind) {
case AuthtypesAuthDomainConfigSAMLDTOKind.saml:
return AuthtypesAuthNProviderDTO.saml;
case AuthtypesAuthDomainConfigGoogleDTOKind.google:
return AuthtypesAuthNProviderDTO.google;
case AuthtypesAuthDomainConfigOIDCDTOKind.oidc:
return AuthtypesAuthNProviderDTO.oidc;
default:
return '';
}
}
// Form values interface for internal use (includes array-based fields for UI)
export interface FormValues {
name?: string;
ssoEnabled?: boolean;
ssoType?: string;
enabled?: boolean;
googleAuthConfig?: AuthtypesGoogleConfigDTO & {
domainToAdminEmailList?: Array<{ domain?: string; adminEmail?: string }>;
};
@@ -107,33 +130,141 @@ export function prepareInitialValues(
if (!record) {
return {
name: '',
ssoEnabled: false,
ssoType: '',
enabled: false,
};
}
const config = record.config ?? {};
const { config } = record;
return {
name: record.name,
ssoEnabled: config.ssoEnabled,
ssoType: config.ssoType,
samlConfig: config.samlConfig ?? undefined,
oidcConfig: config.oidcConfig ?? undefined,
googleAuthConfig: config.googleAuthConfig
enabled: record.enabled,
samlConfig:
config?.kind === AuthtypesAuthDomainConfigSAMLDTOKind.saml
? config.spec
: undefined,
oidcConfig:
config?.kind === AuthtypesAuthDomainConfigOIDCDTOKind.oidc
? config.spec
: undefined,
googleAuthConfig:
config?.kind === AuthtypesAuthDomainConfigGoogleDTOKind.google
? {
...config.spec,
domainToAdminEmailList: convertDomainMappingsToList(
config.spec.domainToAdminEmail,
),
}
: undefined,
roleMapping: record.roleMapping
? {
...config.googleAuthConfig,
domainToAdminEmailList: convertDomainMappingsToList(
config.googleAuthConfig.domainToAdminEmail,
),
}
: undefined,
roleMapping: config.roleMapping
? {
...config.roleMapping,
...record.roleMapping,
groupMappingsList: convertGroupMappingsToList(
config.roleMapping.groupMappings,
record.roleMapping.groupMappings,
),
}
: undefined,
};
}
/**
* Prepares Google Auth config for API payload
*/
export function prepareGoogleAuthConfig(
values: FormValues,
): AuthtypesGoogleConfigDTO | undefined {
const config = values.googleAuthConfig;
if (!config) {
return undefined;
}
const {
domainToAdminEmailList,
allowedGroups,
serviceAccountJson,
domainToAdminEmail: _domainToAdminEmail,
fetchTransitiveGroupMembership,
...rest
} = config;
const domainToAdminEmail = convertDomainMappingsToRecord(
domainToAdminEmailList,
);
return {
...rest,
...(rest.fetchGroups
? {
allowedGroups,
serviceAccountJson,
domainToAdminEmail: domainToAdminEmail ?? {},
fetchTransitiveGroupMembership,
}
: { domainToAdminEmail: {} }),
};
}
/**
* Prepares role mapping for API payload; only returned when there is
* meaningful content.
*/
export function prepareRoleMapping(
values: FormValues,
): AuthtypesRoleMappingDTO | undefined {
const roleMapping = values.roleMapping;
if (!roleMapping) {
return undefined;
}
const { groupMappingsList, ...rest } = roleMapping;
const groupMappings = convertGroupMappingsToRecord(groupMappingsList);
const hasDefaultRole = !!rest.defaultRole;
const hasUseRoleAttribute = rest.useRoleAttribute === true;
const hasGroupMappings =
groupMappings && Object.keys(groupMappings).length > 0;
if (!hasDefaultRole && !hasUseRoleAttribute && !hasGroupMappings) {
return undefined;
}
return {
...rest,
groupMappings: rest.useRoleAttribute ? undefined : (groupMappings ?? {}),
};
}
/**
* Prepares the kind/spec config envelope for API payload; the inverse of
* prepareInitialValues.
*/
export function prepareConfig(
values: FormValues,
provider: AuthtypesAuthNProviderDTO | '',
): AuthtypesAuthDomainConfigDTO | undefined {
switch (provider) {
case AuthtypesAuthNProviderDTO.saml:
return values.samlConfig
? {
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: values.samlConfig,
}
: undefined;
case AuthtypesAuthNProviderDTO.google: {
const spec = prepareGoogleAuthConfig(values);
return spec
? {
kind: AuthtypesAuthDomainConfigGoogleDTOKind.google,
spec,
}
: undefined;
}
case AuthtypesAuthNProviderDTO.oidc:
return values.oidcConfig
? {
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: values.oidcConfig,
}
: undefined;
default:
return undefined;
}
}

View File

@@ -91,7 +91,11 @@ function ConfigureGoogleAuthAuthnProvider({
{ required: true, message: 'Domain is required', whitespace: true },
]}
>
<Input id="google-domain" disabled={!isCreate} />
<Input
id="google-domain"
disabled={!isCreate}
testId="google-auth-domain"
/>
</Form.Item>
</div>
@@ -109,7 +113,7 @@ function ConfigureGoogleAuthAuthnProvider({
{ required: true, message: 'Client ID is required', whitespace: true },
]}
>
<Input id="google-client-id" />
<Input id="google-client-id" testId="google-auth-client-id" />
</Form.Item>
</div>
@@ -131,7 +135,7 @@ function ConfigureGoogleAuthAuthnProvider({
},
]}
>
<Input id="google-client-secret" />
<Input id="google-client-secret" testId="google-auth-client-secret" />
</Form.Item>
</div>
@@ -143,6 +147,7 @@ function ConfigureGoogleAuthAuthnProvider({
>
<Checkbox
id="google-skip-email-verification"
testId="google-auth-skip-email-verified"
onChange={(checked: boolean): void => {
form.setFieldValue(
['googleAuthConfig', 'insecureSkipEmailVerified'],
@@ -180,7 +185,10 @@ function ConfigureGoogleAuthAuthnProvider({
<Collapse.Panel
key="workspace-groups"
header={
<div className="authn-provider__collapse-header">
<div
className="authn-provider__collapse-header"
data-testid="google-auth-workspace-groups-header"
>
{expandedSection !== 'workspace-groups' ? (
<ChevronRight size={16} />
) : (
@@ -221,6 +229,7 @@ function ConfigureGoogleAuthAuthnProvider({
>
<Checkbox
id="google-fetch-groups"
testId="google-auth-fetch-groups"
onChange={(checked: boolean): void => {
form.setFieldValue(['googleAuthConfig', 'fetchGroups'], checked);
}}
@@ -251,6 +260,7 @@ function ConfigureGoogleAuthAuthnProvider({
>
<AntdInput.TextArea
id="google-service-account-json"
data-testid="google-auth-service-account-json"
rows={3}
placeholder="Paste service account JSON"
className="authn-provider__textarea"
@@ -270,6 +280,7 @@ function ConfigureGoogleAuthAuthnProvider({
>
<Checkbox
id="google-transitive-membership"
testId="google-auth-transitive-membership"
onChange={(checked: boolean): void => {
form.setFieldValue(
['googleAuthConfig', 'fetchTransitiveGroupMembership'],
@@ -299,7 +310,10 @@ function ConfigureGoogleAuthAuthnProvider({
name={['googleAuthConfig', 'allowedGroups']}
className="authn-provider__form-item"
>
<EmailTagInput placeholder="Type a group email and press Enter" />
<EmailTagInput
placeholder="Type a group email and press Enter"
testId="google-auth-allowed-groups"
/>
</Form.Item>
</div>
</div>

View File

@@ -76,7 +76,7 @@ function ConfigureSAMLAuthnProvider({
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlIdp']}
name={['samlConfig', 'location']}
className="authn-provider__form-item"
rules={[
{
@@ -98,7 +98,7 @@ function ConfigureSAMLAuthnProvider({
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlEntity']}
name={['samlConfig', 'entityId']}
className="authn-provider__form-item"
rules={[
{
@@ -120,7 +120,7 @@ function ConfigureSAMLAuthnProvider({
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlCert']}
name={['samlConfig', 'certificate']}
className="authn-provider__form-item"
rules={[
{

View File

@@ -9,12 +9,14 @@ interface EmailTagInputProps {
value?: string[];
onChange?: (value: string[]) => void;
placeholder?: string;
testId?: string;
}
function EmailTagInput({
value = [],
onChange,
placeholder = 'Type an email and press Enter',
testId,
}: EmailTagInputProps): JSX.Element {
const [validationError, setValidationError] = useState('');
@@ -34,7 +36,7 @@ function EmailTagInput({
);
return (
<div className="email-tag-input">
<div className="email-tag-input" data-testid={testId}>
<Tooltip
title={validationError}
open={!!validationError}

View File

@@ -74,6 +74,7 @@ function RoleMappingSection({
role="button"
aria-expanded={expanded}
aria-controls="role-mapping-content"
data-testid="role-mapping-header"
>
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<div className="role-mapping-section__collapse-header-text">
@@ -138,6 +139,7 @@ function RoleMappingSection({
>
<Checkbox
id="use-role-attribute"
testId="role-mapping-use-role-attribute"
onChange={(checked: boolean): void => {
form.setFieldValue([...fieldNamePrefix, 'useRoleAttribute'], checked);
}}
@@ -166,13 +168,20 @@ function RoleMappingSection({
{(fields, { add, remove }): JSX.Element => (
<div className="role-mapping-section__items">
{fields.map((field) => (
<div key={field.key} className="role-mapping-section__row">
<div
key={field.key}
className="role-mapping-section__row"
data-testid="role-mapping-row"
>
<Form.Item
name={[field.name, 'groupName']}
className="role-mapping-section__field role-mapping-section__field--group"
rules={[{ required: true, message: 'Group name is required' }]}
>
<Input placeholder="IDP Group Name" />
<Input
placeholder="IDP Group Name"
testId="role-mapping-group-name"
/>
</Form.Item>
<Form.Item
@@ -199,6 +208,7 @@ function RoleMappingSection({
className="role-mapping-section__remove-btn"
onClick={(): void => remove(field.name)}
aria-label="Remove mapping"
testId="role-mapping-remove"
>
<Trash2 size={12} />
</Button>
@@ -212,6 +222,7 @@ function RoleMappingSection({
add({ groupName: '', role: SIGNOZ_VIEWER_ROLE })
}
prefix={<Plus size={14} />}
testId="role-mapping-add"
>
Add Group Mapping
</Button>

View File

@@ -31,7 +31,7 @@ function SSOEnforcementToggle({
useUpdateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
const onChangeHandler = (checked: boolean): void => {
if (!record.id) {
if (!record.id || !record.config) {
return;
}
@@ -41,14 +41,9 @@ function SSOEnforcementToggle({
{
pathParams: { id: record.id },
data: {
config: {
ssoEnabled: checked,
ssoType: record.config?.ssoType,
googleAuthConfig: record.config?.googleAuthConfig,
oidcConfig: record.config?.oidcConfig,
samlConfig: record.config?.samlConfig,
roleMapping: record.config?.roleMapping,
},
enabled: checked,
config: record.config,
roleMapping: record.roleMapping,
},
},
{
@@ -65,7 +60,12 @@ function SSOEnforcementToggle({
};
return (
<Switch disabled={isLoading} value={isChecked} onChange={onChangeHandler} />
<Switch
disabled={isLoading}
value={isChecked}
onChange={onChangeHandler}
testId="auth-domain-enforce-sso"
/>
);
}

View File

@@ -58,7 +58,7 @@ describe('AuthDomain', () => {
});
});
it('reflects ssoEnabled state from nested config in each row toggle', async () => {
it('reflects the enabled state in each row toggle', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockDomainsListResponse)),
@@ -68,9 +68,9 @@ describe('AuthDomain', () => {
render(<AuthDomain />);
// mockDomainsListResponse rows:
// [0] signoz.io → config.ssoEnabled: true
// [1] example.com → config.ssoEnabled: false
// [2] corp.io → config.ssoEnabled: true
// [0] signoz.io → enabled: true
// [1] example.com → enabled: false
// [2] corp.io → enabled: true
const switches = await screen.findAllByRole('switch');
expect(switches).toHaveLength(3);
expect(switches[0]).toBeChecked();

View File

@@ -112,9 +112,7 @@ describe('CreateEdit — save payload correctness', () => {
await waitFor(() => expect(capturedPayload).not.toBeNull());
expect(capturedPayload).toMatchObject({
config: expect.objectContaining({
roleMapping: expect.objectContaining({ groupMappings: {} }),
}),
roleMapping: expect.objectContaining({ groupMappings: {} }),
});
});
@@ -161,7 +159,7 @@ describe('CreateEdit — save payload correctness', () => {
expect(capturedPayload).toMatchObject({
config: expect.objectContaining({
googleAuthConfig: expect.objectContaining({
spec: expect.objectContaining({
domainToAdminEmail: {},
}),
}),

View File

@@ -188,8 +188,8 @@ describe('CreateEdit — role mapping uses API roles', () => {
// SSO role mapping matches roles by name, so the payload carries the
// role *name*, not the opaque id.
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().config.roleMapping.defaultRole).not.toBe(editorRole.id);
expect(payload.get().roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().roleMapping.defaultRole).not.toBe(editorRole.id);
});
it('defaults a fresh role mapping to the signoz-viewer role name', async () => {
@@ -221,8 +221,8 @@ describe('CreateEdit — role mapping uses API roles', () => {
await waitFor(() => expect(payload.get()).not.toBeNull());
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
expect(payload.get().config.roleMapping.defaultRole).not.toBe(viewerRole.id);
expect(payload.get().roleMapping.defaultRole).toBe(viewerRole.name);
expect(payload.get().roleMapping.defaultRole).not.toBe(viewerRole.id);
});
it('still defaults to signoz-viewer when the roles fetch returns empty', async () => {
@@ -249,7 +249,7 @@ describe('CreateEdit — role mapping uses API roles', () => {
await waitFor(() => expect(payload.get()).not.toBeNull());
// The Form.Item initialValue (signoz-viewer) survives an empty roles list.
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
expect(payload.get().roleMapping.defaultRole).toBe(viewerRole.name);
});
it('loads a stored role mapping by role name and round-trips it on save', async () => {
@@ -280,8 +280,8 @@ describe('CreateEdit — role mapping uses API roles', () => {
await waitFor(() => expect(payload.get()).not.toBeNull());
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().config.roleMapping.groupMappings).toStrictEqual({
expect(payload.get().roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().roleMapping.groupMappings).toStrictEqual({
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',

View File

@@ -1,6 +1,9 @@
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { AuthtypesGettableAuthDomainDTO } from 'api/generated/services/sigNoz.schemas';
import {
AuthtypesAuthDomainConfigGoogleDTO,
AuthtypesGettableAuthDomainDTO,
} from 'api/generated/services/sigNoz.schemas';
import CreateEdit from '../CreateEdit/CreateEdit';
import {
@@ -48,11 +51,10 @@ jest.mock('@signozhq/ui/button', () => ({
type SavedPayload = {
config: {
googleAuthConfig?: Record<string, unknown>;
samlConfig?: Record<string, unknown>;
oidcConfig?: Record<string, unknown>;
roleMapping?: Record<string, unknown>;
kind?: string;
spec?: Record<string, unknown>;
};
roleMapping?: Record<string, unknown>;
};
async function submitForm(
@@ -81,7 +83,7 @@ describe('CreateEdit — payload sanitization', () => {
it('sends core fields and omits workspace fields when fetchGroups is not set', async () => {
const payload = await submitForm(mockGoogleAuthDomain);
const g = payload.config.googleAuthConfig;
const g = payload.config.spec;
expect(g?.clientId).toBe('test-client-id');
expect(g?.clientSecret).toBe('test-client-secret');
expect(g?.allowedGroups).toBeUndefined();
@@ -91,18 +93,20 @@ describe('CreateEdit — payload sanitization', () => {
});
it('strips workspace fields when fetchGroups is false', async () => {
const googleConfig =
mockGoogleAuthWithWorkspaceGroups.config as AuthtypesAuthDomainConfigGoogleDTO;
const payload = await submitForm({
...mockGoogleAuthWithWorkspaceGroups,
config: {
...mockGoogleAuthWithWorkspaceGroups.config,
googleAuthConfig: {
...mockGoogleAuthWithWorkspaceGroups.config?.googleAuthConfig,
...googleConfig,
spec: {
...googleConfig.spec,
fetchGroups: false,
},
},
});
const g = payload.config.googleAuthConfig;
const g = payload.config.spec;
expect(g?.fetchGroups).toBe(false);
expect(g?.allowedGroups).toBeUndefined();
expect(g?.serviceAccountJson).toBeUndefined();
@@ -113,7 +117,7 @@ describe('CreateEdit — payload sanitization', () => {
it('includes all workspace fields when fetchGroups is true', async () => {
const payload = await submitForm(mockGoogleAuthWithWorkspaceGroups);
const g = payload.config.googleAuthConfig;
const g = payload.config.spec;
expect(g?.fetchGroups).toBe(true);
expect(g?.serviceAccountJson).toBe('{"type": "service_account"}');
expect(g?.fetchTransitiveGroupMembership).toBe(true);
@@ -131,10 +135,10 @@ describe('CreateEdit — payload sanitization', () => {
it('sends core and attributeMapping fields', async () => {
const payload = await submitForm(mockSamlWithAttributeMapping);
const s = payload.config.samlConfig;
expect(s?.samlIdp).toBe('https://idp.saml-attrs.com/sso');
expect(s?.samlEntity).toBe('urn:saml-attrs:idp');
expect(s?.samlCert).toBe('MOCK_CERTIFICATE_ATTRS');
const s = payload.config.spec;
expect(s?.location).toBe('https://idp.saml-attrs.com/sso');
expect(s?.entityId).toBe('urn:saml-attrs:idp');
expect(s?.certificate).toBe('MOCK_CERTIFICATE_ATTRS');
expect(s?.insecureSkipAuthNRequestsSigned).toBe(true);
const attr = s?.attributeMapping as Record<string, unknown>;
@@ -148,7 +152,7 @@ describe('CreateEdit — payload sanitization', () => {
it('sends all fields including claimMapping', async () => {
const payload = await submitForm(mockOidcWithClaimMapping);
const o = payload.config.oidcConfig;
const o = payload.config.spec;
expect(o?.issuer).toBe('https://oidc.claims.com');
expect(o?.issuerAlias).toBe('https://alias.claims.com');
expect(o?.clientId).toBe('claims-client-id');
@@ -168,24 +172,21 @@ describe('CreateEdit — payload sanitization', () => {
it('strips groupMappings when useRoleAttribute is true', async () => {
const payload = await submitForm({
...mockDomainWithRoleMapping,
config: {
...mockDomainWithRoleMapping.config,
roleMapping: {
...mockDomainWithRoleMapping.config?.roleMapping,
useRoleAttribute: true,
},
roleMapping: {
...mockDomainWithRoleMapping.roleMapping,
useRoleAttribute: true,
},
});
expect(payload.config.roleMapping?.useRoleAttribute).toBe(true);
expect(payload.config.roleMapping?.groupMappings).toBeUndefined();
expect(payload.roleMapping?.useRoleAttribute).toBe(true);
expect(payload.roleMapping?.groupMappings).toBeUndefined();
});
it('sends groupMappings when useRoleAttribute is false', async () => {
const payload = await submitForm(mockDomainWithRoleMapping);
expect(payload.config.roleMapping?.useRoleAttribute).toBe(false);
expect(payload.config.roleMapping?.groupMappings).toStrictEqual({
expect(payload.roleMapping?.useRoleAttribute).toBe(false);
expect(payload.roleMapping?.groupMappings).toStrictEqual({
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',

View File

@@ -25,6 +25,7 @@ jest.mock('@signozhq/ui/switch', () => ({
import SSOEnforcementToggle from '../SSOEnforcementToggle';
import {
AUTH_DOMAINS_UPDATE_ENDPOINT,
mockDomainWithRoleMapping,
mockErrorResponse,
mockGoogleAuthDomain,
mockUpdateSuccessResponse,
@@ -57,7 +58,7 @@ describe('SSOEnforcementToggle', () => {
isDefaultChecked={false}
record={{
...mockGoogleAuthDomain,
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
enabled: false,
}}
/>,
);
@@ -95,13 +96,42 @@ describe('SSOEnforcementToggle', () => {
expect(mockUpdateAPI).toHaveBeenCalledTimes(1);
expect(mockUpdateAPI).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
ssoEnabled: false,
}),
enabled: false,
config: mockGoogleAuthDomain.config,
}),
);
});
// The toggle sends a full replacement, so anything it fails to echo back is
// dropped from the domain — role mappings included.
it('echoes the existing role mapping when toggling enforcement', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockUpdateAPI = jest.fn();
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
mockUpdateAPI(await req.json());
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(
<SSOEnforcementToggle
isDefaultChecked={true}
record={mockDomainWithRoleMapping}
/>,
);
await user.click(screen.getByRole('switch'));
await waitFor(() => expect(mockUpdateAPI).toHaveBeenCalledTimes(1));
expect(mockUpdateAPI).toHaveBeenCalledWith({
enabled: false,
config: mockDomainWithRoleMapping.config,
roleMapping: mockDomainWithRoleMapping.roleMapping,
});
});
it('shows error modal when update fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });

View File

@@ -1,22 +1,24 @@
import {
AuthtypesAuthNProviderDTO,
AuthtypesAuthDomainConfigGoogleDTOKind,
AuthtypesAuthDomainConfigOIDCDTOKind,
AuthtypesAuthDomainConfigSAMLDTOKind,
AuthtypesGettableAuthDomainDTO,
} from 'api/generated/services/sigNoz.schemas';
// API Endpoints
export const AUTH_DOMAINS_LIST_ENDPOINT = '*/api/v1/domains';
export const AUTH_DOMAINS_CREATE_ENDPOINT = '*/api/v1/domains';
export const AUTH_DOMAINS_UPDATE_ENDPOINT = '*/api/v1/domains/:id';
export const AUTH_DOMAINS_DELETE_ENDPOINT = '*/api/v1/domains/:id';
export const AUTH_DOMAINS_LIST_ENDPOINT = '*/api/v2/auth_domains';
export const AUTH_DOMAINS_CREATE_ENDPOINT = '*/api/v2/auth_domains';
export const AUTH_DOMAINS_UPDATE_ENDPOINT = '*/api/v2/auth_domains/:id';
export const AUTH_DOMAINS_DELETE_ENDPOINT = '*/api/v2/auth_domains/:id';
// Mock Auth Domain with Google Auth
export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-1',
name: 'signoz.io',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
kind: AuthtypesAuthDomainConfigGoogleDTOKind.google,
spec: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
@@ -30,13 +32,13 @@ export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-2',
name: 'example.com',
enabled: false,
config: {
ssoEnabled: false,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.example.com/sso',
samlEntity: 'urn:example:idp',
samlCert: 'MOCK_CERTIFICATE',
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: {
location: 'https://idp.example.com/sso',
entityId: 'urn:example:idp',
certificate: 'MOCK_CERTIFICATE',
},
},
authNProviderInfo: {
@@ -48,10 +50,10 @@ export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-3',
name: 'corp.io',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: {
issuer: 'https://oidc.corp.io',
clientId: 'oidc-client-id',
clientSecret: 'oidc-client-secret',
@@ -66,22 +68,22 @@ export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-4',
name: 'enterprise.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.enterprise.com/sso',
samlEntity: 'urn:enterprise:idp',
samlCert: 'MOCK_CERTIFICATE',
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: {
location: 'https://idp.enterprise.com/sso',
entityId: 'urn:enterprise:idp',
certificate: 'MOCK_CERTIFICATE',
},
roleMapping: {
defaultRole: 'signoz-editor',
useRoleAttribute: false,
groupMappings: {
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',
},
},
roleMapping: {
defaultRole: 'signoz-editor',
useRoleAttribute: false,
groupMappings: {
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',
},
},
authNProviderInfo: {
@@ -94,18 +96,18 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
{
id: 'domain-5',
name: 'direct-role.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: {
issuer: 'https://oidc.direct-role.com',
clientId: 'direct-role-client-id',
clientSecret: 'direct-role-client-secret',
},
roleMapping: {
defaultRole: 'signoz-viewer',
useRoleAttribute: true,
},
},
roleMapping: {
defaultRole: 'signoz-viewer',
useRoleAttribute: true,
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-5',
@@ -116,10 +118,10 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-6',
name: 'oidc-claims.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: {
issuer: 'https://oidc.claims.com',
issuerAlias: 'https://alias.claims.com',
clientId: 'claims-client-id',
@@ -143,13 +145,13 @@ export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
export const mockSamlWithAttributeMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-7',
name: 'saml-attrs.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.saml-attrs.com/sso',
samlEntity: 'urn:saml-attrs:idp',
samlCert: 'MOCK_CERTIFICATE_ATTRS',
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: {
location: 'https://idp.saml-attrs.com/sso',
entityId: 'urn:saml-attrs:idp',
certificate: 'MOCK_CERTIFICATE_ATTRS',
insecureSkipAuthNRequestsSigned: true,
attributeMapping: {
name: 'user_display_name',
@@ -168,10 +170,10 @@ export const mockGoogleAuthWithWorkspaceGroups: AuthtypesGettableAuthDomainDTO =
{
id: 'domain-8',
name: 'google-groups.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
kind: AuthtypesAuthDomainConfigGoogleDTOKind.google,
spec: {
clientId: 'google-groups-client-id',
clientSecret: 'google-groups-client-secret',
insecureSkipEmailVerified: false,
@@ -218,7 +220,7 @@ export const mockUpdateSuccessResponse = {
status: 'success',
data: {
...mockGoogleAuthDomain,
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
enabled: false,
},
};

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { HTMLAttributes, useCallback, useMemo, useState } from 'react';
import { Plus, Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
@@ -26,7 +26,7 @@ import './AuthDomain.styles.scss';
import '../../IngestionSettings/IngestionSettings.styles.scss';
export const SSOType = new Map<string, string>([
['google_auth', 'Google Auth'],
['google', 'Google Auth'],
['saml', 'SAML'],
['email_password', 'Email Password'],
['oidc', 'OIDC'],
@@ -121,8 +121,8 @@ function AuthDomain(): JSX.Element {
},
{
title: 'Enforce SSO',
dataIndex: ['config', 'ssoEnabled'],
key: 'ssoEnabled',
dataIndex: 'enabled',
key: 'enabled',
width: 80,
render: (
value: boolean,
@@ -157,13 +157,15 @@ function AuthDomain(): JSX.Element {
className="auth-domain-list-action-link"
onClick={(): void => setRecord(record)}
variant="link"
testId="auth-domain-configure"
>
Configure {SSOType.get(record.config?.ssoType || '')}
Configure {SSOType.get(record.config?.kind || '')}
</Button>
<Button
className="auth-domain-list-action-link delete"
onClick={(): void => showDeleteModal(record)}
variant="link"
testId="auth-domain-delete"
>
Delete
</Button>
@@ -177,7 +179,9 @@ function AuthDomain(): JSX.Element {
return (
<div className="auth-domain">
<section className="auth-domain-header">
<h3 className="auth-domain-title">Authenticated Domains</h3>
<h3 className="auth-domain-title" data-testid="auth-domain-title">
Authenticated Domains
</h3>
<Button
prefix={<Plus size="md" />}
onClick={(): void => {
@@ -186,6 +190,7 @@ function AuthDomain(): JSX.Element {
variant="solid"
size="sm"
color="primary"
testId="auth-domain-add"
>
Add Domain
</Button>
@@ -195,7 +200,14 @@ function AuthDomain(): JSX.Element {
<Table
columns={columns}
dataSource={authDomainListResponse?.data}
onRow={undefined}
onRow={(
record: AuthtypesGettableAuthDomainDTO,
): HTMLAttributes<HTMLElement> =>
// data-* attributes are valid row props but absent from the antd typing
({
'data-testid': `auth-domain-row-${record.name}`,
}) as unknown as HTMLAttributes<HTMLElement>
}
loading={
isLoadingAuthDomainListResponse || isFetchingAuthDomainListResponse
}
@@ -228,6 +240,7 @@ function AuthDomain(): JSX.Element {
onClick={hideDeleteModal}
className="cancel-btn"
prefix={<X size={16} />}
testId="auth-domain-delete-cancel"
>
Cancel
</Button>,
@@ -237,6 +250,7 @@ function AuthDomain(): JSX.Element {
onClick={handleDeleteDomain}
className="delete-btn"
loading={isLoading}
testId="auth-domain-delete-confirm"
>
Delete Domain
</Button>,

View File

@@ -2,14 +2,17 @@ import { useTranslation } from 'react-i18next';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Form, Select, Space } from 'antd';
import { FeatureKeys } from 'constants/features';
import { ModalFooterTitle } from 'container/PipelinePage/styles';
import { useAppContext } from 'providers/App/App';
import { ProcessorData } from 'types/api/pipeline/def';
import { formValidationRules } from '../config';
import { processorFields, ProcessorFormField } from './config';
import { ProcessorFormField } from './config';
import CSVInput from './FormFields/CSVInput';
import JsonFlattening from './FormFields/JsonFlattening';
import { FormWrapper, PipelineIndexIcon, StyledSelect } from './styles';
import { resolveProcessorFields } from './utils';
import './styles.scss';
@@ -133,16 +136,23 @@ function ProcessorForm({
selectedProcessorData,
isAdd,
}: ProcessorFormProps): JSX.Element {
const { featureFlags } = useAppContext();
const isBodyJsonEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
return (
<div className="processor-form-container">
{processorFields[processorType]?.map((fieldData: ProcessorFormField) => (
<ProcessorFieldInput
key={fieldData.name + String(fieldData.initialValue)}
fieldData={fieldData}
selectedProcessorData={selectedProcessorData}
isAdd={isAdd}
/>
))}
{resolveProcessorFields(processorType, isBodyJsonEnabled).map(
(fieldData: ProcessorFormField) => (
<ProcessorFieldInput
key={fieldData.name + String(fieldData.initialValue)}
fieldData={fieldData}
selectedProcessorData={selectedProcessorData}
isAdd={isAdd}
/>
),
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { processorFields, ProcessorFormField } from './config';
const BODY_PARSE_FROM = 'body';
const JSON_BODY_PARSE_FROM = 'body.message';
// With use_json_body the collector normalizes every body into a map before user
// operators run, so a parser pointed at `body` gets a map it cannot read and
// silently extracts nothing. The log text lives at body.message.
export function resolveProcessorFields(
processorType: string,
isBodyJsonEnabled: boolean,
): Array<ProcessorFormField> {
const fields = processorFields[processorType] ?? [];
if (!isBodyJsonEnabled) {
return fields;
}
return fields.map((field) =>
field.name === 'parse_from' && field.initialValue === BODY_PARSE_FROM
? { ...field, initialValue: JSON_BODY_PARSE_FROM }
: field,
);
}

View File

@@ -0,0 +1,45 @@
import { processorFields } from '../PipelineListsView/AddNewProcessor/config';
import { resolveProcessorFields } from '../PipelineListsView/AddNewProcessor/utils';
const parseFromDefault = (
fields: ReturnType<typeof resolveProcessorFields>,
): unknown => fields.find((field) => field.name === 'parse_from')?.initialValue;
describe('resolveProcessorFields', () => {
it.each(['grok_parser', 'regex_parser', 'json_parser'])(
'defaults %s parse_from to body.message when use_json_body is on',
(processorType) => {
expect(parseFromDefault(resolveProcessorFields(processorType, true))).toBe(
'body.message',
);
},
);
it.each(['grok_parser', 'regex_parser', 'json_parser'])(
'keeps %s parse_from as body when use_json_body is off',
(processorType) => {
expect(parseFromDefault(resolveProcessorFields(processorType, false))).toBe(
'body',
);
},
);
it('leaves parse_from defaults that do not point at the body alone', () => {
expect(parseFromDefault(resolveProcessorFields('time_parser', true))).toBe(
'attributes.timestamp',
);
expect(
parseFromDefault(resolveProcessorFields('severity_parser', true)),
).toBe('attributes.logLevel');
});
it('does not mutate the shared config', () => {
resolveProcessorFields('grok_parser', true);
expect(parseFromDefault(processorFields.grok_parser)).toBe('body');
});
it('returns an empty list for an unknown processor type', () => {
expect(resolveProcessorFields('does_not_exist', true)).toStrictEqual([]);
});
});

View File

@@ -53,8 +53,6 @@ import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../../constants/features';
import { useAppContext } from '../../../../providers/App/App';
import { selectStyle } from './config';
import { PLACEHOLDER } from './constant';
import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs';
@@ -104,11 +102,6 @@ function QueryBuilderSearch({
const [isEditingTag, setIsEditingTag] = useState(false);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const {
updateTag,
handleClearTag,
@@ -128,7 +121,6 @@ function QueryBuilderSearch({
exampleQueries,
} = useAutoComplete(
query,
dotMetricsEnabled,
whereClauseConfig,
isLogsExplorerPage,
isInfraMonitoring,
@@ -146,7 +138,6 @@ function QueryBuilderSearch({
const { sourceKeys, handleRemoveSourceKey } = useFetchKeysAndValues(
searchValue,
query,
dotMetricsEnabled,
searchKey,
isLogsExplorerPage,
isInfraMonitoring,

View File

@@ -14,8 +14,6 @@ import { SelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../constants/features';
import { useAppContext } from '../../providers/App/App';
import QueryChip from './components/QueryChip';
import { QueryChipItem, SearchContainer } from './styles';
@@ -42,12 +40,7 @@ function ResourceAttributesFilter({
SelectOption<string, string>[]
>([]);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const resourceDeploymentKey = getResourceDeploymentKeys(dotMetricsEnabled);
const resourceDeploymentKey = getResourceDeploymentKeys();
const [selectedEnvironments, setSelectedEnvironments] = useState<string[]>([]);
@@ -73,21 +66,20 @@ function ResourceAttributesFilter({
}, [queries, resourceDeploymentKey]);
useEffect(() => {
getEnvironmentTagKeys(dotMetricsEnabled).then((tagKeys) => {
getEnvironmentTagKeys().then((tagKeys) => {
if (tagKeys && Array.isArray(tagKeys) && tagKeys.length > 0) {
getEnvironmentTagValues(dotMetricsEnabled).then((tagValues) => {
getEnvironmentTagValues().then((tagValues) => {
setEnvironments(tagValues);
});
}
});
}, [dotMetricsEnabled]);
}, []);
return (
<div className="resourceAttributesFilter-container">
<div className="environment-selector">
<Select
getPopupContainer={popupContainer}
key={selectedEnvironments.join('')}
showSearch
mode="multiple"
value={selectedEnvironments}

View File

@@ -0,0 +1,175 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Router } from 'react-router-dom';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { createMemoryHistory, MemoryHistory } from 'history';
import { ResourceProvider } from 'hooks/useResourceAttribute';
import { IResourceAttribute } from 'hooks/useResourceAttribute/types';
import { encode } from 'js-base64';
import ResourceAttributesFilter from '../ResourceAttributesFilter';
jest.mock('lib/history', () => ({
__esModule: true,
default: {
push: jest.fn(),
location: { search: '', pathname: '/' },
},
}));
jest.mock('api/metrics/getResourceAttributes', () => ({
getResourceAttributesTagKeys: jest.fn(),
getResourceAttributesTagValues: jest.fn(),
}));
// eslint-disable-next-line import/first, import/order
import {
getResourceAttributesTagKeys,
getResourceAttributesTagValues,
// eslint-disable-next-line import/newline-after-import
} from 'api/metrics/getResourceAttributes';
// eslint-disable-next-line import/first, import/order
import history from 'lib/history';
const mockTagKeys = getResourceAttributesTagKeys as jest.MockedFunction<
typeof getResourceAttributesTagKeys
>;
const mockTagValues = getResourceAttributesTagValues as jest.MockedFunction<
typeof getResourceAttributesTagValues
>;
function tagKeysPayload(keys: string[]): never {
return {
statusCode: 200,
error: null,
message: 'ok',
payload: {
data: {
attributeKeys: keys.map((key) => ({
key,
dataType: 'string',
type: 'resource',
isColumn: false,
})),
},
},
} as unknown as never;
}
function tagValuesPayload(values: string[]): never {
return {
statusCode: 200,
error: null,
message: 'ok',
payload: { data: { stringAttributeValues: values } },
} as unknown as never;
}
function seedUrl(queries: IResourceAttribute[], pathname: string): void {
const location = history.location as { search: string; pathname: string };
location.search = queries.length
? `?resourceAttribute=${encode(JSON.stringify(queries))}`
: '';
location.pathname = pathname;
}
function renderFilter(pathname: string): MemoryHistory {
const routerHistory = createMemoryHistory({
initialEntries: [`${pathname}${history.location.search}`],
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<Router history={routerHistory}>
<ResourceProvider>{children}</ResourceProvider>
</Router>
</QueryClientProvider>
);
}
render(
<Wrapper>
<ResourceAttributesFilter />
</Wrapper>,
);
return routerHistory;
}
describe('ResourceAttributesFilter', () => {
beforeEach(() => {
mockTagKeys.mockReset();
mockTagValues.mockReset();
mockTagKeys.mockResolvedValue(
tagKeysPayload(['resource_deployment.environment']),
);
mockTagValues.mockResolvedValue(tagValuesPayload(['production', 'staging']));
seedUrl([], '/');
});
it('shows every applied filter on the service map, including ones it cannot apply', async () => {
seedUrl(
[
{
id: 'svc',
tagKey: 'resource_service_name',
operator: 'IN',
tagValue: ['frontend'],
},
{
id: 'env',
tagKey: 'resource_deployment.environment',
operator: 'IN',
tagValue: ['production'],
},
],
ROUTES.SERVICE_MAP,
);
renderFilter(ROUTES.SERVICE_MAP);
await waitFor(() =>
expect(screen.getByText(/service\.name/)).toBeInTheDocument(),
);
await waitFor(() =>
expect(
screen
.getByTestId('resource-environment-filter')
.querySelector('.ant-select-selection-item'),
).toHaveTextContent('production'),
);
});
it('keeps the environment dropdown open so more than one environment can be picked', async () => {
const user = userEvent.setup();
renderFilter('/services');
const environmentFilter = screen.getByTestId('resource-environment-filter');
await user.click(
environmentFilter.querySelector('input') as HTMLInputElement,
);
await user.click(await screen.findByTitle('production'));
await waitFor(() =>
expect(
screen.getByTitle('staging').closest('.ant-select-dropdown'),
).not.toHaveClass('ant-select-dropdown-hidden'),
);
await user.click(screen.getByTitle('staging'));
await waitFor(() => {
const selected = Array.from(
environmentFilter.querySelectorAll('.ant-select-selection-item-content'),
).map((node) => node.textContent);
expect(selected).toStrictEqual(['production', 'staging']);
});
});
});

View File

@@ -3,8 +3,6 @@ import {
getResourceDeploymentKeys,
} from 'hooks/useResourceAttribute/utils';
import { FeatureKeys } from '../../../../constants/features';
import { useAppContext } from '../../../../providers/App/App';
import { QueryChipContainer, QueryChipItem } from '../../styles';
import { IQueryChipProps } from './types';
@@ -13,13 +11,7 @@ function QueryChip({ queryData, onClose }: IQueryChipProps): JSX.Element {
onClose(queryData.id);
};
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const isClosable =
queryData.tagKey !== getResourceDeploymentKeys(dotMetricsEnabled);
const isClosable = queryData.tagKey !== getResourceDeploymentKeys();
return (
<QueryChipContainer>

View File

@@ -4,8 +4,6 @@ import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App';
import { ServiceMetricsProps } from '../types';
import { getQueryRangeRequestData } from '../utils';
import ServiceMetricTable from './ServiceMetricTable';
@@ -18,19 +16,13 @@ function ServiceMetricsApplication({
GlobalReducer
>((state) => state.globalTime);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const queryRangeRequestData = useMemo(
() =>
getQueryRangeRequestData({
topLevelOperations,
globalSelectedInterval,
dotMetricsEnabled,
}),
[globalSelectedInterval, topLevelOperations, dotMetricsEnabled],
[globalSelectedInterval, topLevelOperations],
);
return (
<ServiceMetricTable

View File

@@ -19,13 +19,10 @@ import {
export const serviceMetricsQuery = (
topLevelOperation: [keyof ServiceDataProps, string[]],
dotMetricsEnabled: boolean,
): QueryBuilderData => {
const p99AutoCompleteData: BaseAutocompleteData = {
dataType: DataTypes.Float64,
key: dotMetricsEnabled
? WidgetKeys.Signoz_latency_bucket
: WidgetKeys.Signoz_latency_bucket_norm,
key: WidgetKeys.SignozLatencyBucket,
type: '',
};
@@ -53,9 +50,7 @@ export const serviceMetricsQuery = (
id: '',
key: {
dataType: DataTypes.String,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -78,9 +73,7 @@ export const serviceMetricsQuery = (
id: '',
key: {
dataType: DataTypes.String,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -90,7 +83,7 @@ export const serviceMetricsQuery = (
id: '',
key: {
dataType: DataTypes.Int64,
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
key: WidgetKeys.StatusCode,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
@@ -113,9 +106,7 @@ export const serviceMetricsQuery = (
id: '',
key: {
dataType: DataTypes.String,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -138,9 +129,7 @@ export const serviceMetricsQuery = (
id: '',
key: {
dataType: DataTypes.String,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -193,9 +182,7 @@ export const serviceMetricsQuery = (
const groupBy: BaseAutocompleteData[] = [
{
dataType: DataTypes.String,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
key: WidgetKeys.OTelServiceName,
type: MetricsType.Tag,
},
];

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