Compare commits

...

61 Commits

Author SHA1 Message Date
nityanandagohain
2fe033ec78 fix: trigger build 2026-08-26 18:20:23 +05:30
nityanandagohain
02a2800f89 Merge remote-tracking branch 'origin/main' into issue_5947 2026-08-26 18:20:04 +05:30
Nityananda Gohain
02c5555a48 fix: add ai_observability to saved views (#12675)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description
* adds `ai_observability` to saved view for ai explorer

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/engineering-pod/issues/5955
2026-08-26 11:28:54 +00:00
nityanandagohain
915aa2eb70 Merge remote-tracking branch 'origin/issue_5947' into issue_5947 2026-08-26 16:44:26 +05:30
nityanandagohain
518caff0f2 Merge remote-tracking branch 'origin/main' into issue_5947 2026-08-26 16:44:09 +05:30
nityanandagohain
0d3ac28286 fix: update openapi spec 2026-08-26 16:41:50 +05:30
Nityananda Gohain
47beef07de Merge branch 'main' into issue_5947 2026-08-26 16:32:51 +05:30
nityanandagohain
5f2891bd6c fix: test cleanup 2026-08-26 16:32:01 +05:30
nityanandagohain
97f0e832ab fix: minor cleanup 2026-08-26 16:31:17 +05:30
Naman Verma
0bdc7bf6a1 fix: resolve aggregate column for exp histograms before samples table (#12640)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

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

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

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

---

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

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

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

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

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


#### Alerts page shell (7)

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

#### Alert rules list (19)

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

#### Create alert (52)

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

#### Edit alert (22)

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

#### Alert details (15)

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

#### Alert history (75)

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

#### Notification channels (1)

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

#### Skipped tests

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


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

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

---

###  Change Type
_Select all that apply_

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

---

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

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

---

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

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

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [ ] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-26 06:37:32 +00:00
nityanandagohain
770a8f7b0a fix: add quick filters v2 api to support TelemetryFieldKey 2026-08-26 11:12:59 +05:30
Aditya Singh
73719a3f60 feat(explorer): separate same name columns by dataType (#12685)
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
<!--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

Fixes the bug where user is not able to add a field key with same names
and context but different dataType.

- only columns that actually carry a dataType get a new key; at most
their width/order resets once and re-heals on interaction. selection is
stored as field objects so it's never affected
- shared code (options menu + field picker) so it applies to both logs
and traces
- added/updated unit tests for the logs column factory and the
options-menu reorder/remove
- Saved views are unharmed

<!--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/5962

#### Screen Recording

Before



https://github.com/user-attachments/assets/e160a7fd-f0f8-4cf0-bad5-27178f9e29e0



After



https://github.com/user-attachments/assets/f9baab26-d7d8-47b1-b953-3adb684c19df
2026-08-26 05:08:00 +00:00
Aditya Singh
724f7ce78b feat(traces): table migration to tanstack for traces view in traces explorer (#12672)
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
<!--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
- migrates the traces view from the antd `ResizeTable` to the shared
TanStack table, the same one list view uses now, so both views share the
renderer.
- updated `FieldCell` to handle for `trace_id` columns as well.
- columns are resizable and reorderable now in trace view as well. which
was not possible earlier
- toolbar always renders now (root spans note + download + prev/next),
so pagination doesn't disappear when data is loading
- removed the styled-components file for this view, layout is a css
module now
- tests added for both views

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

<!--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/e0ad657e-a74e-41fa-badb-8dea40007701
2026-08-25 12:39:19 +00:00
Aditya Singh
6f4af0a2a1 fix: stop prev/next buttons shifting on load (#12670)
<!--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 fixes the Prev and next buttons shifting down on click due to
loader .

- cause was recent [icon
migration](https://github.com/SigNoz/signoz/pull/11222) away from antd
which restyled the loader.
- removed the loader on these buttons. they already disable while
loading, so the spinner was redundant and it was what caused the shift
- moved the buttons from antd (`Button`/`Flex`/`Spin`) to the
`@signozhq/ui` button
- removed the styled-components file, layout is a css module now

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

https://github.com/SigNoz/engineering-pod/issues/5942

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


https://github.com/user-attachments/assets/db76270b-60f3-441f-adad-96abec9dd04b

After


https://github.com/user-attachments/assets/bb844652-b274-4849-9d49-485080308484
2026-08-25 07:58:47 +00:00
Aditya Singh
fe68b8e8b7 feat(traces): table migration to tanstack for list view in traces explorer (#12667)
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
<!--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
- moved list view from antd `ResizeTable` to Tanstack table.
functionalities kept same.
- pulled out a reusable trace table. new shared table + per field column
builder. This is added to keep the table renderer common for both
ListView and Trace View because they do not need to be different. Trace
view will integrate this component in following stacked PR.
- two new override vars on `TanStackTableView` (header height, first
column header padding)


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

Part of https://github.com/SigNoz/engineering-pod/issues/5052

<!--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/d3a75b38-7cf5-4ab0-a7b4-fce404a03e63



<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
- Touches the shared `TanStackTableView` component.. two new override
vars, defaults unchanged for other tables. cc. @H4ad

<!--Please delete paragraphs that you did not use before submitting.-->
2026-08-24 16:32:44 +00:00
Aditya Singh
77fbf74092 feat(logs): allow adding free-typed columns in logs explorer (#12602)
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
<!--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
- Lets users add a free-typed column in the logs explorer "Edit columns"
panel, even if the key is not in the fields suggestions (e.g. nested
body json paths). Logs only.
- Shows the typed value as an addable option when it is not already a
suggestion or added. Exact, case-insensitive name match.
- Value shows via the existing body-first lookup. Nothing new is sent to
the backend for logs.
- Changed the column key separator from `.` to `:` so a typed dotted
name cannot clash with a context key (e.g. `resource.severity_text`).
Old saved keys self-heal, no migration.

<!--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/5877

<!--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/0e91bb00-4be5-4dc7-ad3e-0e005ee6eb6b



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

Value needs `use_json_body` on for nested body paths, else the cell is
empty. Array paths and a leading `body.` dont resolve on the frontend
for now.
<!--Please delete paragraphs that you did not use before submitting.-->
2026-08-24 05:53:28 +00:00
Vinicius Lourenço
9997c3da9c chore(packages): bump @signozhq/ui to 0.1.0 (#12634)
Some checks failed
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
cacheci / tests (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
<!--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 bumps the version from 0.2.3 to 0.1.0 (which also requires the bump
in the design-token to latest version), the changes can be found at
https://github.com/SigNoz/components/releases/tag/v0.1.0

<!--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/5926

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

The main diffs is the breaking changes in the vars names, other than
that, we mainly added new features for the components instead of
changing their look/usage, so we can expect no breaking-change in the
behavior or UI.

About Triggered Alerts (with new rewrite version of combobox simple).


https://github.com/user-attachments/assets/18cb117b-9a24-428e-8f6b-7dbf5012f7ea

The combobox also now emits `undefined` in case you have `allowClear`
enabled, this does not affect existing usages:


https://github.com/user-attachments/assets/70ca146f-0145-46d7-bf51-57f93b973ce4
2026-08-21 14:09:48 +00:00
Abhi kumar
485aed0e1a feat(charts): support none/normal/percent stacking on TimeSeries and Bar (#12632)
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

- Charts take a `stack` prop (`none` | `normal` | `percent`) and hand it
to their config, which derives the fill bands, and for `percent` the
percentage y-axis and a 0–100 soft range. Callers stop computing bands
or transforming data — V1/V2 bar panels, Meter Explorer and Billing each
drop their `setBands` call and declare `stack` instead.
- Stacking is no longer bar-specific, so TimeSeries stacks too. The
upcoming area chart is built on TimeSeries and needs this.
- `percent` rescales each x-slice to its column total. Mixed-sign
columns divide by the signed total, so shares can fall outside 0–100 and
still sum to it; a column summing to zero yields zero. The percent range
is soft rather than hard so those out-of-band shares stay visible.
- Tooltips now report the pre-stack value, identically in every mode.
They used to recover it by subtracting the series below, which only
works while stacking is cumulative — `percent` discards the column
total, so the raw value cannot be derived from the plot's data at all.
- `stack` lives on the two chart prop types rather than the shared
config builder props, so the ~10 other consumers of that builder
(histogram, alert previews, infra metrics, …) never expose an option
they cannot honour.

No spec or API change: both bar panels still read the existing
`stackedBarChart` boolean and map it to `normal`/`none`. `percent` is
reachable from the chart layer but nothing selects it yet — that arrives
with the panel spec change.

#### Additional Information

- Behaviour outside dashboards should be unchanged, with one exception:
Meter Explorer and the V1 bar panel previously passed `seriesCount + 1`
when computing bands, emitting a trailing band pointing at a series that
does not exist (Billing passed the correct count). Deriving bands
centrally normalises all three.
- Thresholds still draw under `percent`, but no longer widen the scale —
they carry source-unit values, so one at 500ms would stretch a
percentage axis to 0–500.
- percent also swaps the unit to a percent formatter and sets *soft* 0–1
limits (they normalise to 0–1, we use 0–100). It applies those limits
only when the user set none; we always apply them, because our soft
limits come from `spec.axes` in the source unit and are meaningless once
values are normalised.
- Commits are split so each one builds and is reviewable on its own: the
stacking algorithm, the config derivation, the tooltip change, then the
chart/consumer migration.
2026-08-20 20:16:14 +00:00
Gaurav Tewari
4d69e3f9e5 chore: remove last min trim logic in uplotScaleBuilder (#12627)
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
<!--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

- `UPlotScaleBuilder` was overriding the x-axis max with `endTime - 1
minute`, rounded down to the minute — behaviour carried over from the
legacy `getXAxisScale`.
- On short time windows the trimmed max lands at or before the min, so
the scale range is empty/inverted and the chart draws no data.
- Removes the trim so the requested `min`/`max` pass through as-is and
the scale always matches the selected time range.
- Updates the scale builder tests, including a case for a sub-minute
window.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes -
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=231774376&issue=SigNoz%7Cengineering-pod%7C5902

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



https://github.com/user-attachments/assets/11ca2fa4-9a07-42eb-9d8d-3a42daf4cfe1


Now - 




https://github.com/user-attachments/assets/0114fccd-a6ef-4717-8d1c-aa3faf820da7





#### Additional Information

- Only the uPlotV2 path changes

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-20 11:41:49 +00:00
Vikrant Gupta
dca6aa497d chore(serviceaccount): remove deprecated nested role endpoints (#12591)
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

- Removes the deprecated `POST /api/v1/service_accounts/{id}/roles` and
`DELETE /api/v1/service_accounts/{id}/roles/{rid}` routes, their HTTP
handlers, and the `DeprecatedPostableServiceAccountRole` type, now that
all consumers use `/api/v1/service_account_roles`.
- Keeps the `GET /api/v1/service_accounts/{id}/roles` listing endpoint.
- Regenerates `docs/api/openapi.yml` and the frontend client.

#### Issues closed by this PR

Closes SigNoz/platform-pod#2919

#### Additional Information

- Final step of the migration; the frontend (#12589) and
integration-test (#12590) consumer moves are already merged.
2026-08-19 20:03:16 +00:00
Vikrant Gupta
bb47550c01 feat(authz): enable FGA for auth domains (#12588)
#### Description

- Auth domain routes (`/api/v2/auth_domains`) now use `CheckResources` +
`ResourceDef` instead of the coarse `AdminAccess` gate — per-resource
FGA checks on enterprise, admin role gate on community.
- Create and update also check `attach` on the roles the request's
`roleMapping` will grant at SSO login (mapped roles + default role,
`signoz-viewer` when unset, `role:*` when `useRoleAttribute` is on);
update additionally checks `detach` on the roles the stored mapping was
granting, since a `PUT` replaces the mapping.
- Migration `117_add_auth_domain_tuples` backfills the admin
`auth-domain` tuples for existing organizations and re-syncs the stored
managed-role transaction groups; new organizations get both from the
registry at bootstrap.
- Regenerated OpenAPI spec: the auth-domain operations advertise
`auth-domain:*` and `role:attach`/`role:detach` scopes instead of
`ADMIN`.
- Added `callbackauthn/05_authz.py` covering managed-role gating,
custom-role wildcard/instance grants, and the role-mapping attach/detach
checks.

#### Issues closed by this PR

Closes SigNoz/platform-pod#2649
2026-08-19 19:16:14 +00:00
Vinicius Lourenço
ecf510ed67 refactor(query-builder): drop ClientSideQBSearch & QueryBuilderSearch (#12427)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Pull Request

---

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

These are pending code that was supposed to be deleted after
Infrastructure Monitoring & Alert History adopt the QBv5.

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

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

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

---

###  Change Type
_Select all that apply_

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

---

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

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

---

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

- Blast radius: Query Builder
- Potential regressions: Deleting more code than needed
- Rollback plan: Revert the deletion.

---

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

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Maintenance |
| Description | N/A |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-19 18:00:15 +00:00
Vinicius Lourenço
5b94d79e46 fix(infrastructure-monitoring): page reset on switch category & page outside total (#12453)
## Pull Request

---

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

This PR fixes the following issues:

- page not resetting to 1 when switch
  - bug was only detected/present when coming from deep link
- page not resetting to 1 when page produces a offset higher than total
  - you had to switch to hosts to be able to see data again

#### 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:

Issue with page not reseting to 1 when changing category (after
refresh):


https://github.com/user-attachments/assets/00872b38-1263-43c1-8322-64d31ee1ee6a

Issue with page outside the offset:


https://github.com/user-attachments/assets/5194fb2e-5af3-491b-baf7-b4aa3a330c83

---

After:

Issue with page not reseting to 1 when changing category (after
refresh):


https://github.com/user-attachments/assets/545e5914-c26f-4189-b15a-dc399bdee28b

Issue with page outside the offset:


https://github.com/user-attachments/assets/1b93d162-22a3-41c8-802e-aa2aa6012db9

#### 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/208

---

###  Change Type
_Select all that apply_

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

---

### 🐛 Bug Context
> Required if this PR fixes a bug

Both issues are caused after the refactor to the new table component and
after joining the categories into single component (without
unmount/mount when switching categories).

#### Root Cause
> What caused the issue?  
> Regression, faulty assumption, edge case, refactor, etc.

Lack of reset the page to 1, and no proper way to detect and reset page
to 1 when outside the boundaries.

#### Fix Strategy
> How does this PR address the root cause?

Reset to page 1 after switch category and also include hook on tanstack
to ensure we reset page to last when outside the params.

---

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

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

---

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

- Blast radius: Infrastructure Monitoring
- Potential regressions: -
- Rollback plan: Open a new PR to fix the issue

---

### 📝 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 fixed two issues around pagination inside
Infrastructure Monitoring causing the page not resetting to 1 after
switch category or when offset is higher than total amount of items. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-19 17:56:21 +00:00
Jugal Kishore
60a6a5b38c feat(onboarding): add Grok Build, GitHub Copilot, Serilog, GCP Integration datasources (#12595)
#### Description

- Adds Grok Build, GitHub Copilot, Serilog, and GCP Integration to the
onboarding data source picker.
- Adds a runtime step under AWS Lambda → Traces, so the new Go SDK guide
is reachable alongside the auto-instrumentation layers.
- New `github-copilot.svg`; the other three reuse existing logos
(`grok`, `dotnet`, `gcp`).

#### Issues closed by this PR

Closes SigNoz/signoz.io#3999
Closes SigNoz/signoz.io#3982
Closes SigNoz/signoz.io#3972
Closes SigNoz/signoz.io#3947
Closes SigNoz/signoz.io#3806
2026-08-19 17:25:12 +00:00
Srikanth Chekuri
b47245d46a feat: support semconv evolution in services (#12443)
#### Description

- The v2 services module (`/api/v2/services`) needs no change here: it
renders a QBv5 filter expression and runs through the querier, so the
merged #12442 resolution covers it when the `resolve_semconv_families`
flag is on.
- This layer covers the services read paths that do not go through QBv5,
behind the same flag (default: disabled). Part of #6143.
- The v1 services endpoints (services list, top operations) build the
legacy resource sub-query: it merges family members with current-wins
precedence, keeps the trailing `''` so keyless rows stay in negative
filters, widens positive index hints to any member, and drops negated
hints for correctness.
- The dependency graph accepts every family spelling as a filter key;
each spelling targets the historical `deployment_environment` column.
- The reader evaluates the flag per request from the org in the request
claims. The legacy logs and traces v4 explorer paths stay literal. With
the flag off, every generated query is the same as main; tests pin this.

#### Additional Information

- Stack: #12441 (merged) → #12442 (merged) → **#12443** → #12444 → …
This layer bases on main.
- The quick-filter default change and the stored-row migration from the
earlier version of this layer are deferred to the rollout phase:
persisted rows cannot be gated by a flag.
2026-08-19 15:31:12 +00:00
Aditya Singh
52ec7bf128 fix(logs): correct log details timestamp; enable new drawer view (#12624)
<!--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

- Fixes the wrong timestamp shown in the log details drawer on the
dashboard list panel.
- Enables the new log details drawer on dashboards.

<!--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/5939

<!--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="1631" height="865" alt="dashboard before"
src="https://github.com/user-attachments/assets/ebfcab65-9d5e-4a71-bf50-b67cdfceba9a"
/>

After

<img width="1608" height="813" alt="dashboard after"
src="https://github.com/user-attachments/assets/80d3e7ad-ffe2-4b5d-9d42-647b06f2c44d"
/>
2026-08-19 14:37:57 +00:00
Aditya Singh
afe62d77b0 fix(logs-infra): body filtering on infra monitoring; enable new drawer view (#12623)
<!--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
- Enables the new log details drawer on infra monitoring.
- Fixes filtering from the drawer on infra. Filters, including body and
nested-field filters that were not working.
[RCA](https://github.com/SigNoz/engineering-pod/issues/5937#issuecomment-5339825308)
This is now fixed since body and other keys are all rendered from the
same place which uses the same passed addQuery util from EntityLogs

- Group by only show on logs explorer page.

<!--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/5937

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



https://github.com/user-attachments/assets/da6cd2bf-4409-4362-af6b-f4871bf49005



After



https://github.com/user-attachments/assets/4967af5a-d3c9-4198-86a4-3b865983b7c2
2026-08-19 14:02:26 +00:00
Aditya Singh
1aa6346a4c fix(logs): render log details v2 only on the logs explorer route (#12616)
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
<!--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
Render Log details on log explorer only. disabled on other places for
now.

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

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

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

<!--Please delete paragraphs that you did not use before submitting.-->
2026-08-19 05:21:07 +00:00
Swapnil Nakade
0f3b3dfb07 refactor: adding FunctionName variable in Lambda dashboard (#12599)
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
<!--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
AWS Cloud Integration's Lambda dashboard was missing FunctionName
variable, this PR adds that variable for better UX.

<!--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/2988

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings
<img width="1503" height="815" alt="image"
src="https://github.com/user-attachments/assets/56203f8d-1f06-4c39-b3c1-c1dabf028045"
/>

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2026-08-18 19:39:25 +00:00
Nikhil Mantri
7e2cd441f2 fix(alertmanager): remove threading from google chat notifier (#12609)
#### Description

- Remove `threadKey` + `messageReplyOption` query params from the Google
Chat notifier; every notification now posts as a standalone message
instead of a threaded reply.
- Post to the user-configured webhook URL verbatim (no parse/re-encode
of its query string).
- Replace `TestGoogleChatThreading` with
`TestGoogleChatWebhookURLVerbatim`, asserting the webhook's own params
(`key`, `token`) pass through untouched and nothing is appended.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#285

#### Additional Information

- Context: threading behavior wasn't planned holistically
(SigNoz/pulse-pod#281); it will return later as a consistent, opt-in
feature across all chat integrations (Slack, MS Teams, Google Chat,
etc.).
- No config/migration impact: `threadKey` was never user-facing config.
Existing channels simply start receiving new messages (no threaded
replies on refire) from the next evaluation cycle after deploy.
- `notify.ExtractGroupKey` is intentionally kept — still used for the
debug log, consistent with other notifiers.
2026-08-18 18:39:06 +00:00
Abhi kumar
098448330d fix(apm): stop the service drilldown crashing on an empty operations list (#12578)
<!--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

Fixes an unhandled promise rejection on the APM service detail page —
opening a chart's drilldown menu could throw `syntax errors in
expression: [line 1:48 missing {BOOL, NUMBER, QUOTED_TEXT, KEY} at
']']`.

The chain:

- The overview's top-level-operations query keys on `minTime`/`maxTime`
with no `keepPreviousData`, so every time-range change blanks the list.
The widgets below are then rebuilt with `service.name in ['<service>']
AND operation in []`.
- `valueList` in the filter grammar needs at least one value, so `in []`
is a hard parse error. The panel itself is guarded (`isQueryEnabled`
requires a non-empty list); the drilldown is not.
- The drilldown menu resolves the widget query through
`/substitute_vars` on every click — on this page there are no dashboard
variables at all, so it is pure overhead — and the 400 landed on a
floating promise with no rejection handler.

What changed:

- `useBaseAggregateOptions` catches the failure, falls back to the
unresolved query (already its initial state) and shows the same "Unable
to resolve variables" toast `useNavigateToExplorer` uses. `oxlint` was
already flagging this line under `no-floating-promises`; that warning is
gone.
- `useResolveQuery` short-circuits when there are no variables to
substitute, so APM / Celery / API monitoring drilldowns stop making the
call at all.
- The overview keeps its previous operations list across a time-range
change, so the widget queries are never built with an empty list — which
also stopped the bad filter riding into the explorer URL the drilldown
opens.

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


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

- Skipping the round-trip doesn't lose the filter: both consumers of the
resolved query rebuild `filter.expression` from `filters.items`
themselves (`getViewQuery`, `useGetCompositeQueryParam`), and the APM
query factory never sets `filter.expression` to begin with.
- `keepPreviousData` is safe across service navigation —
`topLevelOperations[servicename]` already returns `[]` for a mismatched
service, and `isQueryEnabled` still guards that case.
- Deliberately left out: dropping empty `IN []` items globally in
`convertFiltersToExpression`. It would silence a wider class of 400s but
flips the semantics — `IN []` means "match nothing", dropping the clause
means "match everything" — and there is an existing test asserting
today's behaviour. Happy to do it separately as a match-nothing rewrite
if reviewers want the broader guard.
- Sentry: SIGNOZ-UI-5JV.
2026-08-18 18:35:30 +00:00
Tushar Vats
eb01617c15 fix(metrics): type-match filter values against the labels JSON read (#12519)
#### Description

- Every label lives in the `labels` JSON and reads back as `String`
whatever data type the metadata claims, so `success = true` compared
`String` with `Bool` and failed the whole query with ClickHouse error
386. The read is now cast with `accurateCastOrNull(..., 'Bool')`, which
also matches the `1`/`True` spellings exporters write.
- `IN`/`NOT IN` expand into `=`/`!=` chains like the logs and traces
condition builders already do. The driver binds `IN (?)` as a single
array literal, which needs one common supertype across the set, so a set
mixing text with numbers or bools failed the same way. Each value is now
type-matched on its own.
- Intrinsic columns keep their own type and are compared as they are,
which also stops `toFloat64OrNull()` being applied to
`unix_milli`/`fingerprint` (error 43).

#### Additional Information

- A label value that isn't boolean text casts to NULL and so matches
neither side of the comparison —
`tests/integration/tests/queriermetrics/13_bool_label_filter.py` asserts
that, alongside the statement-builder unit tests.
- `BETWEEN` takes its cast from the lower bound: the where-clause
visitor already rejects mixed-type operands (and bool ones outright), so
both bounds are the same number-or-string type by the time the condition
builder sees them.
2026-08-18 18:20:54 +00:00
Gaurav Tewari
7bcfaab35e feat: add override can edit dashboard (#12580)
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

- `DashboardContainer` now takes an optional `overrideCanEditDashboard`
prop that forces a dashboard into read-only mode, independent of the
viewer's role o
- It is typed as `false` on purpose: the prop can only take edit rights
away, never grant them, so it can't be used to slip past the existing
permission checks. When it isn't passed, behaviour is unchanged
(`overrideCanEditDashboard ?? canEditDashboard`), so no other dashboard
is affected.
- LLM Observability's Overview passes `overrideCanEditDashboard={false}`
so its built-in dashboard stays view-only, and the dashboard JSON's
`locked` flag goes back to `false` since the lock is no longer what
makes it read-only.
- `DashboardActions` also gates the Lock / Unlock menu item behind
`canEditDashboard`, so a read-only dashboard no longer offers an action
that would let the viewer flip its lock state.
- The prop is marked `@deprecated` with a TODO 

#### Issues closed by this PR

Covers the read-only dashboard requirement discussed in
SigNoz/engineering-pod#5920.

#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/e49bf5f7-ca36-4353-be47-f6ca80a2f0d2


#### Additional Information

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-18 14:58:51 +00:00
Vinicius Lourenço
5b62b31d34 fix(infrastructure-monitoring): preserving staled params after navigation (#12585)
<!--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

Similar to bug fixed at
83a6ed46e9,
this caused an incident at
https://github.com/SigNoz/platform-pod/issues/3011 that causes the
navigation to be back to pods after going to nodes category.

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

Related to https://github.com/SigNoz/platform-pod/issues/3011

<!--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:


https://github.com/user-attachments/assets/ad9b70fb-350a-48de-8846-37a75457766e

After:


https://github.com/user-attachments/assets/5c6095b5-a387-4c28-adff-be0b45c993ef

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

<!--Please delete paragraphs that you did not use before submitting.-->

Because this issue is caused by de-sync between nuqs/react-router, this
is just a temporary fix, the best fix is to migrate to react-router to
v6.
2026-08-18 14:30:33 +00:00
Manika Malhotra
f6a9b4b1f6 feat(changelog): make links open in new tab (#12557)
#### Description

Currently, any external links such as link to the SigNoz docs open in
the same tab when linked in the changelog, taking the user away from the
changelog and the in-app context.

Opening the links in new tabs allows users to check out external sources
without losing the current context.

#### Issues closed by this PR

Closes https://github.com/SigNoz/growth-pod/issues/1267 


#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/3f3b19b2-c914-450e-b5a2-ae30ed78cb30


#### Additional Information

I checked if changing how we create changelog entries only can work, but
failed, markdown method to open in new tab didn't work
https://stackoverflow.com/questions/75633163/how-can-i-use-target-blank-in-an-external-link-on-mdx
2026-08-18 13:32:49 +00:00
Aditya Singh
b46f099966 fix(log-details): prevent scrollbar click closing log details drawer (#12603)
<!--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
Prevent log details drawer closing on clicking on scrollbar

<!--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/5934
<!--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


https://github.com/user-attachments/assets/b59e2a64-951e-49eb-9b05-ae39f00991ae



After



https://github.com/user-attachments/assets/5eb89c95-fb76-49b7-9928-1bd13df16002
2026-08-18 12:24:31 +00:00
Aditya Singh
fcfc1923c3 feat(log-details): support json view for nested attributes + enable new log details (#12597)
<!--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 adds functionality on top of the new log details drawer changes.
- Show JSON view for nested attributes instead of stringified json.
These dont support filter/group-by right now
- Change `resources.*` to `resource.*` to match traces and otel
convention
- remove groupBy for fields nested or not matching the following names:
'trace_id' and 'body'
- enables new log details experience for all users


<!--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/5921
<!--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/876fb331-015d-42bb-83c7-5ff543ddadec
2026-08-18 11:26:43 +00:00
Gaurav Tewari
dc836bb67c chore: update dependencies resolution for e2e (#12594)
#### Description

- `tests/e2e` is a standalone pnpm project with no overrides of its own,
so `eslint-plugin-playwright > eslint > minimatch` resolved a vulnerable
`brace-expansion@5.0.5` (4 advisories, incl. CVE-2026-13149).
- Adds `tests/e2e/pnpm-workspace.yaml` flooring it to `>=5.0.9 <6`,
which stays inside `minimatch@10.2.5`'s `^5.0.5` range — no breaking
bump, and bumping minimatch instead wouldn't help (10.2.6 only widens to
`^5.0.8`).
- `pnpm audit` in `tests/e2e` now reports no known vulnerabilities.
- This also resolves the vulnerabilities reported by vanta


#### Issues closed by this PR


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


#### Screenshots / Screen Recordings

#### Additional Information

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-18 10:29:46 +00:00
Nityananda Gohain
b86e536432 fix: support for query type in cache fingerprint (#12598)
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
We have added a new query type "builder_ai_query" , now previously the
cache fingerprint didn't consider type because of which ai query cache
and builder query cache might result in same fingerprint leading to
cache issue.

#### Issues closed by this PR
Part of https://github.com/SigNoz/engineering-pod/issues/5602 and
https://github.com/SigNoz/engineering-pod/issues/5603
2026-08-18 08:38:03 +00:00
Tushar Vats
a76a7ede70 fix(logs pipelines): default parse_from to body.message under JSON bodies (#12556)
#### Description

- The processor form pre-fills `parse_from` with `body` for the grok,
regex and json parsers (`initialValue: 'body'` in
`AddNewProcessor/config.ts`).
- With `use_json_body` the collector prepends a `normalize` pipeline, so
by the time user operators run the body is a map. A parser pointed at
`body` gets a map it cannot parse and silently extracts nothing — the
pipeline is broken by default, without the user ever touching the field.
- Resolve the default to `body.message` when the flag is on. Keying off
`initialValue === 'body'` rather than a hardcoded processor list keeps
`time_parser` (`attributes.timestamp`) and `severity_parser`
(`attributes.logLevel`) untouched, and covers any future processor that
defaults to the body.

#### Additional Information

- Saved processors are unaffected — edit mode calls
`form.setFieldsValue(savedData)`, which overrides `initialValue`. This
only changes what a newly added processor starts with, and only while
the flag is on.
- The helper returns new objects rather than mutating the shared config;
there is a test asserting `processorFields.grok_parser` still reads
`body`.
- This does not help pipelines already saved with a bare `body`. Preview
shows them making no change at all, with nothing explaining why —
surfacing that is a follow-up.
- Filters have the same problem and are not addressed here: `body
contains "x"` cannot match a map, and unlike a failing operator a
skipped filter produces no collector log. `queryBuilderToExpr` already
special-cases `body.<key>` for EXISTS; extending that to value
comparisons is the separate fix.
2026-08-18 08:25:56 +00:00
Srikanth Chekuri
cfc7a04bc8 feat: resolve semantic convention names in trace queries (#12442)
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

- A referenced name in a trace query now resolves to a `LogicalField`
(#12499): one field, addressed by the requested spelling, backed by its
physical member keys. A semantic-convention family
(`deployment.environment.name` / `deployment.environment`) merges into
one expression with current-wins precedence; the response keeps the
requested spelling.
- `FieldMapper` gets one new method, `ExistsFor` (the per-key presence
primitive). `LogicalValueExpr` and `LogicalExistsExpr` build all family
SQL in one place from `FieldFor` and `ExistsFor`; no signal implements
family logic.
- Statement builders prefetch sibling spellings; the metadata store
stays family-blind and autocomplete stays literal. Traces and the
resource filter compile per logical field; logs, metrics, and the other
signals keep their SQL unchanged.
- The `resolve_semconv_families` feature flag (default: disabled) gates
all family behavior. With the flag off, the generated SQL is the same as
main; tests pin this. Part of #6143.

#### Additional Information

- Stack: #12441 (merged) → **#12442** → #12443#12444#12445#12446#12447. This layer bases on main.
- Rollback: turn the flag off; stored telemetry is untouched.
2026-08-17 21:59:26 +00:00
Vikrant Gupta
014cab564e feat(serviceaccount): assign roles through the service_account_roles API (#12589)
#### Description

- Moves the service account role drawer off the deprecated nested
`/api/v1/service_accounts/{id}/roles` endpoints onto
`/api/v1/service_account_roles`, mirroring the earlier member →
`user_roles` migration.
- `useServiceAccountRoleManager` now reads role assignments from the
service account detail (`serviceAccountRoles` join rows), creates with
`{serviceAccountId, roleId}`, and deletes by the join-row id; the manual
query invalidation is dropped since the drawer already refetches the
same query.

#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/dd60e1d1-5d78-4f17-80f7-bb1a53730736
2026-08-17 20:51:49 +00:00
Vikrant Gupta
9cf2ffe000 test(serviceaccount): move role provisioning to the service_account_roles API (#12590)
#### Description

- Moves the `serviceaccount` integration fixtures and suites off the
deprecated nested `/api/v1/service_accounts/{id}/roles` endpoints onto
`/api/v1/service_account_roles`.
- Roles are assigned via `POST /api/v1/service_account_roles` (201) and
revoked via `DELETE /api/v1/service_account_roles/{id}` (204), reading
join-row ids from the service account detail.

#### Additional Information

- Part of SigNoz/platform-pod#2919 — the integration-test half of the
consumer migration. The frontend migration and the deprecated-endpoint
removal are separate PRs.
2026-08-17 20:42:25 +00:00
Vikrant Gupta
81d1716c7e fix(user): clear role assignments when a user is deleted (#12554)
#### Description

- Soft-deleting a user revoked the FGA grant but left the `user_role`
rows behind. The role-delete guard (`OnBeforeRoleDelete` →
`GetUsersByOrgIDAndRoleID`) still counted the deleted user, so the role
could never be deleted — and detaching the assignment was also blocked
because the user is deleted. That left the role permanently undeletable.
- `SoftDeleteUser` now deletes the user's `user_role` rows in the same
transaction that already clears its password, tokens, and preferences,
so the SQL side matches the FGA revoke.
- Migration `delete_orphan_user_roles` clears the orphan `user_role`
rows left by users deleted before this change.

#### Additional Information

- Regression test in `role/02_crud.py`: assign a custom role to a user,
delete the user, then delete the role → now `204` (was the deadlock).
2026-08-17 19:11:43 +00:00
Gaurav Tewari
a7935ca668 test(qb): de-flake the recent searches dropdown (#12586)
#### Description

`RecentSearches.test.tsx` was failing intermittently on CI. Two separate
timing races, both in the test itself:

- Clicking a recent used `userEvent.click`, whose `pointerdown` blurs
the editor and closes the dropdown ~10ms later — before CodeMirror
applies the completion on `mousedown`. On a slow runner the dropdown was
already gone. Now uses `fireEvent.mouseDown`, which is what a browser
actually does here.
- `filters recents by substring as the user types` waited on a dropdown
that typing can close, with nothing to reopen it. All waits now
re-request completions if it closed.

No production code changed.

#### Issues closed by this PR

#### Screenshots / Screen Recordings

#### Additional Information

Ran the file 5x, the whole `QueryBuilderV2` directory 3x (211 tests),
and 3x under CPU load to mimic a slow runner — all green.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-17 19:09:23 +00:00
Vinicius Lourenço
30823c520d refactor(infrastructure-monitoring): always represent IO as binBps instead of bytes for hosts (#12428)
## Pull Request

---

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

The other charts related to network uses binBps instead of `bytes`, and
similar to System Disk IO, we can use binBps since we are representing
throghutput

#### 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.

> The changes are on `Network Usage` and `System Disk Chart`, pay
attention to the Y unit.

Before:

<img width="1726" height="1090" alt="image"
src="https://github.com/user-attachments/assets/6a71f9fd-ae19-4ed8-bba1-3851ecf815f4"
/>

After:

<img width="1726" height="1091" alt="image"
src="https://github.com/user-attachments/assets/b3fc41dc-af20-4784-afe2-c3c1936ceba8"
/>

#### 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/211

---

###  Change Type
_Select all that apply_

- [ ]  Feature
- [ ] 🐛 Bug fix
- [x] ♻️ 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 - Hosts
- 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 charts of Network IO and System disk IO
to use unit of bytes per second instead of bytes. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-17 18:42:58 +00:00
Gaurav Tewari
8e4aedcecd chore: dependencies update resolutions (#12583)
<!--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

- Clears all 19 high-severity advisories reported by `pnpm audit` (24
findings → 4, none high).
- Most of this is security floors in `pnpm-workspace.yaml`, following
the file's existing capped-override convention — each entry records the
vulnerable path and what would let us drop it again.
- `brace-expansion` needs three separate entries because three majors
coexist in the tree: minimatch@3 (via `test-exclude`), minimatch@9 (via
jest's `glob@10`), and minimatch@10 (via `eslint-plugin-sonarjs`).
- `image-size` has no patched release at all, so the only fix is
dropping the dependency — `less@4.5.0` removed it, and that lands inside
`typescript-plugin-css-modules`' `^4.2.0` range.
- `postcss` 8.5.14 → 8.5.26 is the one direct bump; it's a direct
devDep, so a floor override would only hide a stale version in
`package.json`.

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

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

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

- Deliberately scoped to highs. The 4 remaining moderates (`dompurify`,
`@remix-run/router`, and two `react-router` advisories) are left for a
follow-up.
<!--Please delete paragraphs that you did not use before submitting.-->

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-17 18:13:06 +00:00
Vinicius Lourenço
1c7fd32f91 fix(infrastructure-monitoring): do not overflow outside table for large error message (#12450)
## Pull Request

---

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

Prevent the error message to overflow outside the table.

#### 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:


https://github.com/user-attachments/assets/d53845ed-7db9-4451-a2ca-31bc6127ec31

After:


https://github.com/user-attachments/assets/78e1e2d4-a5e1-46d7-9f66-117d1868e9f5

#### 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/222

---

###  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
- Potential regressions: -
- 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 error layout to ensure it won't overflow
the table in case the APIs fail with a large message. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-17 17:49:51 +00:00
Aditya Singh
fb106b3253 feat(logs): render DataViewer with filter/group-by actions in log details overview (#12426)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Pull Request

---

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

Renders the new `DataViewer` (Pretty tree + JSON) inside the V2
log-details Overview tab
Wires up filter / group-by on each attribute similar to pretty view in
trace details

**Change points**

**Rendering the View**
- Data Viewer renders using `aggregateAttributesResourcesToObject` which
is not written fresh, just extracted from an existing logic
`aggregateAttributesResourcesToString`.
- jsonData is separately sent to the DataViewer as rendering logic for
pretty and json view is different in this case. unlike trace details
where both views had same source data.

**Group by / FIlter and other logic**

- All the related logic resides in 2 major files:
`useLogAttributeActions.tsx` and `logAttributeActions.utils.ts`.
- we build the fieldKey ourselves (`buildLogFilterTarget`) as this is
now different from old representation.
- filter / group by / replace build the query locally now...we do not
make the `getAggregateKeys` call at all. we fabricate the telemetry
field key ourselves with just the name and dataType filled and rest kept
empty. so no prefetch, no resolver, no loader.
- the query building is extracted into 3 utils: `getFilterQueryData`,
`getGroupByQueryData`, `getReplaceFilterQueryData`. the hook just calls
these over `updateQueriesData`.
- restricted fields apply for body as well: `timestamp` / `id` / `date`
inside body no longer show filter / group by. reuses
`RESTRICTED_SELECTED_FIELDS`.

**Pretty View**
- renderLeafValue: introduce to render custom leaf value. we are using
this here for body. This is extensible for other usecases as well...like
showing md format leaf for LLMs in the future.
- fixed leaf vs nested row indentation so keys line up at every depth.

**Other changes**
- Filter value keeps its data type: dataType is threaded through so a
numeric/bool filter value stays unquoted.
- Removed the redundant outer JSON tab in V2.


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


https://github.com/user-attachments/assets/ec1f0842-0a3b-407b-807c-0c3b0f9ed86a



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

###  Change Type
_Select all that apply_

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

---
2026-08-17 11:11:26 +00:00
Nityananda Gohain
edb63ae7be feat[ai-011y]: fields API for ai query builder (#12140)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Pull Request

---

### 📄 Summary
- Add a `type` param to `/api/v1/fields/keys`; for type=builder_ai_query
(flag-gated) the metadata store returns the per-trace aggregate columns
(llm_call_count, input_tokens, …) as
trace-context keys — they're computed at query time, never ingested, so
the attribute scan can't serve them.
- Split `TraceColumn.Orderable` into `Orderable + Filterable`: ORDER BY
uses orderable, the trace-level filter validates against filterable, and
the API only returns keys that are both. `last_activity_time` is
order-only and now rejected in filters with a targeted error.**
- UI note: last_activity_time should be added to client-side list (it's
the default sort).

#### 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-17 07:57:57 +00:00
Gaurav Tewari
e7dc01de45 fix: add history replace in trace detail (#12404)
## Pull Request

We have issues in TraceDetailsV3:
- When you open TraceDetailsV3 and click on spans, if you keep clicking
on these spans, it will change the URL. When you click on Go Back, it
will just navigate you through the history of URLs you have clicked,
which is not the right experience.
- The same thing happens if you have opened span details, The drawerless
modal on the right-hand side: if you close it and click on the Back
button, it will just open that drawer once again.

### 📄 Summary


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



https://github.com/user-attachments/assets/a60f544c-83ea-4f06-9233-8fc722428a04


#### Issues closed by this PR

Closes - 

Before - 

https://github.com/orgs/SigNoz/projects/39/views/11?filterQuery=assignee%3Atewarig&pane=issue&itemId=223289782&issue=SigNoz%7Cengineering-pod%7C5851


Now - 


https://github.com/user-attachments/assets/ec28925b-c61a-43f1-b01e-510fab7c5a62



---

###  Change Type
_Select all that apply_

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

---

### 🐛 Bug Context

#### Root Cause

We are pushing span click as well as when the span detail modal closes
and opens to the history. Ideally, we should just replace it.


#### Fix Strategy

Pass `{ replace: true }` to `safeNavigate` at both call sites. Every
route that mutates `spanId` in trace details now replaces rather than
pushes:

| Site | Trigger | Before | After |
|---|---|---|---|
| `Success.tsx:693` | waterfall span click | push | **replace** |
| `index.tsx:83` | close span details panel | push | **replace** |


`useCopySpanLink` also builds a `spanId` URL but only writes it to the
clipboard — it never navigates, so it is correctly untouched.

---

### 🧪 Testing Strategy

- **Tests added/updated:** `UnifiedSpanClick.test.tsx` 
I have tested manually.

---

### ⚠️ Risk & Impact Assessment

- **Blast radius:** Small and contained. Two one-line changes, both
inside `pages/TraceDetailsV3`. No API, schema, or shared-utility
changes. Nothing outside trace details reads or writes the `spanId`
param.
- **Rollback plan:** Revert the commit. There is no state, migration, or
persisted data involved, so a revert fully restores the prior behaviour
with no cleanup.

---

### 📝 Changelog

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Bug Fix |
| Description | The browser Back button on the trace detail page now
returns you to the page you came from, instead of stepping back through
each span you had clicked within the trace. |

---

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

---

## 👀 Notes for Reviewers


Two smaller notes:

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-17 05:32:21 +00:00
Nikhil Soni
c40ebb027b Revert "fix(querier): use collector-stamped insert time for last_observed stats" (#12560)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
cacheci / tests (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
Reverts SigNoz/signoz#12455 since the new column the PR was querying on
is not indexed and leading to latency across deployments.

Part of https://github.com/SigNoz/engineering-pod/issues/5916
2026-08-14 17:36:08 +00:00
Nikhil Soni
789a4626fc fix(saved-views): recover legacy-shaped selectedFields entries (#12549)
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

Old saved views still store `selectedFields` as `key`/`dataType`/`type`.
That shape unmarshals cleanly into a zero-valued `TelemetryFieldKey`, so
migration 111 saw no error and skipped those rows — they now read back
with an empty `name`, which breaks the explorer UI.

- Migration 113 remaps `key` → `name`, `type` → `fieldContext`,
`dataType` → `fieldDataType`, including the old spellings with no
current alias (`spanSearchScope`, `array(string)` and friends). Entries
with neither `name` nor `key` are dropped; valid entries are left
untouched.
- `SavedViewSpec.Validate` now requires `selectedFields[].name`, so this
can't be written again.

Closes https://github.com/SigNoz/engineering-pod/issues/5909
2026-08-14 14:24:40 +00:00
Nikhil Mantri
2dcd4d9a66 feat(alert-channel-integrations): improve the existing go tests (#12526)
#### Description

- Splits testify usage in the existing alert channel tests (email,
slack, pagerduty, opsgenie, msteamsv2, webhook) per the convention
established in #12314: `require` for error checks and guards before
indexing/dereferencing, `assert` for the independent value checks so one
failure doesn't mask the rest.
- Fixes illegal `require`/`t.Fatal` calls inside `httptest` handlers
(pagerduty, slack), which run on the server's goroutine where `FailNow`
must not be called; these now use `assert`.
- Adds missing guards before unchecked indexing and pointer dereferences
(opsgenie request slice, msteamsv2 blocks, slack field pointer, email
HTML/Text pointers).
- Normalizes leftover raw `t.Fatal`/`t.Errorf` and redundant `if err !=
nil { require.NoError }` patterns to plain testify calls.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#164
2026-08-14 10:55:02 +00:00
Aditya Singh
abf60c0af3 feat: move out of using monaco cdn to using monaco from node modules (#12515)
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
<!--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 prevents `@monaco-editor/react` from loading the core
monaco-editor package from a third party CDN. This fixes an issue where
the CDN is blocked for certain users/tenants, preventing monaco-editor
from loading.

<!--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/5871

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

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
Pager: https://signoz-1.pagerduty.com/incidents/Q108N2EUVQAJN2
Sentry: https://signoz-io.sentry.io/issues/7583880805/

<!--Please delete paragraphs that you did not use before submitting.-->
2026-08-14 06:04:17 +00:00
Abhi kumar
ebc8d86a8d feat(dashboard-v2): format panel values with thousand separators (#12327)
## Pull Request

---

### 📄 Summary

Large numbers in dashboard panels rendered as an undelimited digit run —
`1234567` instead of `1,234,567` — which is hard to read at a glance and
hard to compare between panels.

Unitless values were the visible gap. They format through the `'none'`
unit, which routes to `formatDecimalWithLeadingZeros`, whose
`Intl.NumberFormat` is constructed with `useGrouping: false`. Units that
scale their own value (`bytes` → `1.18 MiB`, `short` → `1.23 Mil`) never
reach four integer digits, so they never showed the problem.

The grouping is applied inside `formatPanelValue` — the single seam
through which V2 panels reach `getYAxisFormattedValue` — rather than at
one call site. That is deliberate: readability of a large scalar is not
specific to one panel kind, so the Number panel, Table value cells and
the threshold-row previews all pick it up from one place instead of each
opting in.

`groupThousands` itself is conservative. It touches only the first
numeric token's integer digits, so fractions, unit labels and
formatter-scaled values pass through untouched, and exponent notation is
skipped (grouping a mantissa reads as noise).

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

No capture attached. The visible delta is purely the separators:

| Panel | Unit | Before | After |
|---|---|---|---|
| Number | — | `1234567` | `1,234,567` |
| Number | `percent` | `1234567%` | `1,234,567%` |
| Number | `bytes` | `1.18 MiB` | `1.18 MiB` (unchanged) |
| Table cell | — | `1234567` | `1,234,567` |

#### Issues closed by this PR

Closes #7669

---

###  Change Type
_Select all that apply_

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

---

### 🐛 Bug Context

**N/A** — this is an enhancement, not a regression. Grouping was never
implemented; `useGrouping: false` in `formatDecimalWithLeadingZeros` is
longstanding and intentional for the axis-tick path it was written for.

---

### 🧪 Testing Strategy

- **Tests added/updated:**
  - `groupThousands.test.ts` (new) — the transform in isolation.
- `parseFormattedValue.test.ts` (new) — this util had no suite; covers
the unit split, including grouped input.
- `formatPanelValue.test.ts` — asserts grouping at the seam, and that
unit-scaled values stay ungrouped.
- `NumberPanel/__tests__/Renderer.test.tsx` — grouped value, grouped
value + separate unit, and unit-scaled value left alone.
- `TablePanel/__tests__/Renderer.test.tsx` — pins the grouping that
Table cells inherit. Worth noting for reviewers: `tableColumns.test.ts`
and `tableCsv.test.ts` both stub `formatPanelValue`, so they are blind
to this change by construction — the renderer test is what actually
covers the Table path.

- **Manual verification:** not a click-through of a live dashboard.
Instead: the full frontend jest suite (7,312 passing; the 3 failures are
in unrelated suites — `QuerySearch`, `AuthDomain` — and were confirmed
flaky/pre-existing by re-running them alone and against a stashed
pristine tree), plus `tsgo --noEmit`, `oxlint`, `oxfmt --check` and
`vite build` all clean. The real `getYAxisFormattedValue` output was
probed directly for 11 value/unit combinations before writing the
transform, and `papaparse.unparse` was run directly to confirm exactly
how a grouped cell serializes.

- **Edge cases covered:** negative values (sign stays outside the first
group), fractions (never grouped), exponent notation (skipped), `∞` /
`-∞` / `NaN` (untouched), prefix and suffix unit decoration (`$
1,234,567`, `1,234,567%`, `1,234,567 ms`), formatter-scaled units,
values below 1000, zero, and idempotency on already-grouped input.

---

### ⚠️ Risk & Impact Assessment

- **Blast radius:** every `formatPanelValue` consumer — the Number
panel, Table panel value cells, the three threshold-row previews in the
config pane, and the Table CSV export. Display-only in all cases; no
spec/DTO or API change, nothing persisted.

- **Potential regressions:**
- **The CSV export is the one behavior change worth a reviewer's
attention.** `formatTableCellText` is shared by the Table renderer and
the export, so a unitless numeric column now serializes as `"1,234,567"`
(papaparse quotes any field containing the delimiter) instead of
`1234567` — spreadsheet `SUM`/`AVG` and downstream `parseFloat` would
read it as text. Columns with a unit were *already* display-text in the
export (`295.43 ms`, `1.18 MiB`) by design ("reusing the on-screen cell
formatting", V1 parity), so only unitless numeric columns change in
kind. Called out explicitly because it is an accepted trade-off, not an
oversight — if we would rather keep the export numeric, the contained
fix is a flag threaded through `formatTableCellText` from `tableCsv.ts`
only, leaving the render path grouped.
- Table **sorting and threshold evaluation are unaffected** — both read
`toCellNumber(raw)`, never the formatted string.
- `parseFormattedValue` had to learn to accept `,`, otherwise a grouped
value would fall through to the whole-string fallback and lose its unit
split. Covered by its new suite.

- **Rollback plan:** revert the PR. Display-only with no migration or
persisted state, so a revert is immediate and total.

---

### 📝 Changelog

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Feature |
| Description | Large numbers in dashboard panels are now formatted with
thousand separators (`1,234,567`), in the Number panel, Table panel
value cells and threshold labels. |

---

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

---

## 👀 Notes for Reviewers

Two things I would want a second pair of eyes on:

1. **"Manually tested" is deliberately unchecked.** This was validated
through tests and direct probes of the real formatter, not by clicking
through a running dashboard. A quick look at a Number panel and a Table
panel — plus a screenshot for this PR — is worth doing before merge.
2. **The CSV trade-off** under Risk & Impact. Grouping at the seam is
what makes the change one line instead of four call sites, but the
export rides the same path. The narrower alternative is described there
if you would rather not accept it.

The three commits are independently reviewable: the transform, the
parser tolerance it requires, then the seam that turns it on.
2026-08-14 06:00:09 +00:00
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
492 changed files with 24989 additions and 7857 deletions

View File

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

View File

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

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

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

View File

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

@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -62,6 +62,40 @@ if (typeof window.ResizeObserver === 'undefined') {
(window as any).ResizeObserver = ResizeObserverMock;
}
if (typeof globalThis.DOMRect === 'undefined') {
(globalThis as any).DOMRect = class DOMRect {
x = 0;
y = 0;
width = 0;
height = 0;
top = 0;
right = 0;
bottom = 0;
left = 0;
constructor(x = 0, y = 0, width = 0, height = 0) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.top = y;
this.right = x + width;
this.bottom = y + height;
this.left = x;
}
toJSON(): any {
return { x: this.x, y: this.y, width: this.width, height: this.height };
}
static fromRect(rect?: {
x?: number;
y?: number;
width?: number;
height?: number;
}): DOMRect {
return new DOMRect(rect?.x, rect?.y, rect?.width, rect?.height);
}
};
}
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),

View File

@@ -48,9 +48,9 @@
"@monaco-editor/react": "^4.7.0",
"@sentry/react": "10.57.0",
"@sentry/vite-plugin": "5.3.0",
"@signozhq/design-tokens": "2.1.4",
"@signozhq/design-tokens": "2.1.6",
"@signozhq/icons": "0.4.0",
"@signozhq/ui": "0.0.23",
"@signozhq/ui": "0.1.0",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
@@ -88,6 +88,7 @@
"jest": "30.2.0",
"js-base64": "^3.7.2",
"lodash-es": "^4.17.21",
"monaco-editor": "0.55.1",
"motion": "12.4.13",
"nuqs": "2.8.8",
"overlayscrollbars": "^2.16.0",
@@ -196,7 +197,7 @@
"oxfmt": "0.54.0",
"oxlint": "1.69.0",
"oxlint-tsgolint": "0.23.0",
"postcss": "8.5.14",
"postcss": "8.5.26",
"postcss-scss": "4.0.9",
"react-resizable": "3.0.4",
"redux-mock-store": "1.5.4",

1088
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,16 @@ overrides:
'@babel/core@<=7.29.0': '>=7.29.6 <8'
# via: jest > babel-plugin-istanbul > @istanbuljs/load-nyc-config@1.1.0 (js-yaml ^3.13.1)
# remove: blocked — 1.1.0 is latest and still depends on js-yaml 3.x
'@istanbuljs/load-nyc-config>js-yaml': '>=4.2.0 <5'
'@istanbuljs/load-nyc-config>js-yaml': '>=4.3.1 <5'
# via: babel-plugin-istanbul > test-exclude@6 > minimatch@3.1.5 (^1.1.7); also glob@7
# remove: blocked — babel-plugin-istanbul pins test-exclude@6, which pins minimatch@3
brace-expansion@<1.1.18: '>=1.1.18 <2'
# via: jest > glob@10 > minimatch@9.0.9 (^2.0.1); also minimatch@5.1.9 (^2.0.1)
# remove: blocked — jest 30 resolves glob@10 > minimatch@9 internally
'brace-expansion@>=2.0.0 <2.1.4': '>=2.1.4 <3'
# via: eslint-plugin-sonarjs@4.0.2 (minimatch ^10.2.4) > minimatch@10.2.5 (^5.0.5)
# remove: blocked — minimatch@10.2.6 (latest) only widens to ^5.0.8, still vulnerable
'brace-expansion@>=5.0.0 <5.0.9': '>=5.0.9 <6'
# via: msw@1.3.2 (devDep) > cookie ^0.4.2
# remove: upgrade msw to >=2 (ships cookie ^1). Do NOT open the cap: cookie >=1 is
# ESM-only and breaks msw under jest's CJS sandbox (kills every test suite)
@@ -27,13 +36,26 @@ overrides:
# remove: blocked on rolldown-vite (7.3.1 is latest, still ^0.27.0);
# orval >=8.20.0 and ts-jest >=29.4.11 already fixed on their side
esbuild@>=0.27.3 <0.28.1: '>=0.28.1 <0.29.0'
# via: @commitlint/cli > @commitlint/config-validator > ajv@8 (fast-uri ^3.0.1)
# remove: blocked — ajv@8 caps fast-uri at ^3, and only 3.1.5 carries the fix
fast-uri@<3.1.5: '>=3.1.5 <4'
# via: direct devDep sass@1.97.3 (immutable ^5.0.2)
# remove: blocked — plain sass bumps stay within ^5, so the floor is what pulls 5.1.8
immutable@<5.1.8: '>=5.1.8 <6'
# via: react-use@17.5.1 (direct, js-cookie ^2.2.1); @grafana/data > react-use@17.6.0
# remove: bump react-use to >=17.6.1 (js-cookie ^3); @grafana/data side blocked
js-cookie@<=3.0.5: '>=3.0.7 <4'
# via: @orval/core@8.9.1 (devDep, js-yaml 4.1.1 EXACT pin — not deletable);
# json-schema-to-typescript@15 > @apidevtools/json-schema-ref-parser (^4.1.0)
# remove: upgrade orval to >=8.20.0 (drops js-yaml dependency entirely)
js-yaml@>=4.0.0 <=4.1.1: '>=4.2.0 <5'
'js-yaml@>=4.0.0 <4.3.1': '>=4.3.1 <5'
# via: typescript-plugin-css-modules@5.2.0 (less ^4.2.0) > less@4.4.0 (image-size ~0.5.0)
# remove: bump typescript-plugin-css-modules once it floors less itself; image-size has
# no patched release at all, so dropping the dep is the only fix — less@4.5.0 did
less@<4.5.0: '>=4.5.0 <5'
# via: direct dep postcss (nanoid ^3.3.17)
# remove: blocked — postcss 8.5.26 (latest) caps nanoid at ^3, fix landed in 3.3.18
nanoid@<3.3.18: '>=3.3.18 <4'
# via: react-syntax-highlighter@15.5.0 (prismjs ^1.27.0 + refractor@3 ~1.27.0 tilde-pinned)
# remove: bump react-syntax-highlighter to >=16.1.1 (prismjs ^1.30.0, refractor@5)
prismjs@<1.30.0: '>=1.30.0 <2'

View File

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

View File

@@ -0,0 +1,236 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useQuery } from 'react-query';
import type {
InvalidateOptions,
QueryClient,
QueryFunction,
QueryKey,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
GetAIObservabilityFieldsKeys200,
GetAIObservabilityFieldsKeysParams,
GetAIObservabilityFieldsValues200,
GetAIObservabilityFieldsValuesParams,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
/**
* This endpoint returns the field keys the AI observability explorer can filter on, including the computed per-trace aggregates
* @summary Get AI observability field keys
*/
export const getAIObservabilityFieldsKeys = (
params?: GetAIObservabilityFieldsKeysParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetAIObservabilityFieldsKeys200>({
url: `/api/v1/ai_observability/fields/keys`,
method: 'GET',
params,
signal,
});
};
export const getGetAIObservabilityFieldsKeysQueryKey = (
params?: GetAIObservabilityFieldsKeysParams,
) => {
return [
`/api/v1/ai_observability/fields/keys`,
...(params ? [params] : []),
] as const;
};
export const getGetAIObservabilityFieldsKeysQueryOptions = <
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: GetAIObservabilityFieldsKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetAIObservabilityFieldsKeysQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>
> = ({ signal }) => getAIObservabilityFieldsKeys(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetAIObservabilityFieldsKeysQueryResult = NonNullable<
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>
>;
export type GetAIObservabilityFieldsKeysQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get AI observability field keys
*/
export function useGetAIObservabilityFieldsKeys<
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: GetAIObservabilityFieldsKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetAIObservabilityFieldsKeysQueryOptions(
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get AI observability field keys
*/
export const invalidateGetAIObservabilityFieldsKeys = async (
queryClient: QueryClient,
params?: GetAIObservabilityFieldsKeysParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetAIObservabilityFieldsKeysQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint returns the values the AI observability explorer can filter a field key on
* @summary Get AI observability field values
*/
export const getAIObservabilityFieldsValues = (
params?: GetAIObservabilityFieldsValuesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetAIObservabilityFieldsValues200>({
url: `/api/v1/ai_observability/fields/values`,
method: 'GET',
params,
signal,
});
};
export const getGetAIObservabilityFieldsValuesQueryKey = (
params?: GetAIObservabilityFieldsValuesParams,
) => {
return [
`/api/v1/ai_observability/fields/values`,
...(params ? [params] : []),
] as const;
};
export const getGetAIObservabilityFieldsValuesQueryOptions = <
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: GetAIObservabilityFieldsValuesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetAIObservabilityFieldsValuesQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>
> = ({ signal }) => getAIObservabilityFieldsValues(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetAIObservabilityFieldsValuesQueryResult = NonNullable<
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>
>;
export type GetAIObservabilityFieldsValuesQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get AI observability field values
*/
export function useGetAIObservabilityFieldsValues<
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: GetAIObservabilityFieldsValuesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetAIObservabilityFieldsValuesQueryOptions(
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get AI observability field values
*/
export const invalidateGetAIObservabilityFieldsValues = async (
queryClient: QueryClient,
params?: GetAIObservabilityFieldsValuesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetAIObservabilityFieldsValuesQueryKey(params) },
options,
);
return queryClient;
};

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

@@ -0,0 +1,301 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
GetSignalQuickFilters200,
GetSignalQuickFiltersPathParameters,
ListQuickFilters200,
QuickfiltertypesUpdatableQuickFiltersDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Returns the org's quick filters for every signal, each filter as a telemetry field key.
* @summary List quick filters
*/
export const listQuickFilters = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListQuickFilters200>({
url: `/api/v2/orgs/me/filters`,
method: 'GET',
signal,
});
};
export const getListQuickFiltersQueryKey = () => {
return [`/api/v2/orgs/me/filters`] as const;
};
export const getListQuickFiltersQueryOptions = <
TData = Awaited<ReturnType<typeof listQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listQuickFilters>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListQuickFiltersQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listQuickFilters>>> = ({
signal,
}) => listQuickFilters(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listQuickFilters>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListQuickFiltersQueryResult = NonNullable<
Awaited<ReturnType<typeof listQuickFilters>>
>;
export type ListQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List quick filters
*/
export function useListQuickFilters<
TData = Awaited<ReturnType<typeof listQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listQuickFilters>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListQuickFiltersQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List quick filters
*/
export const invalidateListQuickFilters = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListQuickFiltersQueryKey() },
options,
);
return queryClient;
};
/**
* Replaces the org's quick filters for the signal named in the body.
* @summary Update quick filters
*/
export const updateQuickFilters = (
quickfiltertypesUpdatableQuickFiltersDTO?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/orgs/me/filters`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: quickfiltertypesUpdatableQuickFiltersDTO,
signal,
});
};
export const getUpdateQuickFiltersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
TContext
> => {
const mutationKey = ['updateQuickFilters'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateQuickFilters>>,
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> }
> = (props) => {
const { data } = props ?? {};
return updateQuickFilters(data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateQuickFiltersMutationResult = NonNullable<
Awaited<ReturnType<typeof updateQuickFilters>>
>;
export type UpdateQuickFiltersMutationBody =
| BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>
| undefined;
export type UpdateQuickFiltersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update quick filters
*/
export const useUpdateQuickFilters = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
TContext
> => {
return useMutation(getUpdateQuickFiltersMutationOptions(options));
};
/**
* Returns the org's quick filters for one signal, each filter as a telemetry field key.
* @summary Get a signal's quick filters
*/
export const getSignalQuickFilters = (
{ signalName }: GetSignalQuickFiltersPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetSignalQuickFilters200>({
url: `/api/v2/orgs/me/filters/${signalName}`,
method: 'GET',
signal,
});
};
export const getGetSignalQuickFiltersQueryKey = ({
signalName,
}: GetSignalQuickFiltersPathParameters) => {
return [`/api/v2/orgs/me/filters/${signalName}`] as const;
};
export const getGetSignalQuickFiltersQueryOptions = <
TData = Awaited<ReturnType<typeof getSignalQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ signalName }: GetSignalQuickFiltersPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSignalQuickFilters>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetSignalQuickFiltersQueryKey({ signalName });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getSignalQuickFilters>>
> = ({ signal }) => getSignalQuickFilters({ signalName }, signal);
return {
queryKey,
queryFn,
enabled: !!signalName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getSignalQuickFilters>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetSignalQuickFiltersQueryResult = NonNullable<
Awaited<ReturnType<typeof getSignalQuickFilters>>
>;
export type GetSignalQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get a signal's quick filters
*/
export function useGetSignalQuickFilters<
TData = Awaited<ReturnType<typeof getSignalQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ signalName }: GetSignalQuickFiltersPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSignalQuickFilters>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetSignalQuickFiltersQueryOptions(
{ signalName },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get a signal's quick filters
*/
export const invalidateGetSignalQuickFilters = async (
queryClient: QueryClient,
{ signalName }: GetSignalQuickFiltersPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetSignalQuickFiltersQueryKey({ signalName }) },
options,
);
return queryClient;
};

View File

@@ -22,10 +22,7 @@ import type {
CreateServiceAccountKey201,
CreateServiceAccountKeyPathParameters,
CreateServiceAccountRole201,
CreateServiceAccountRoleDeprecated201,
CreateServiceAccountRoleDeprecatedPathParameters,
DeleteServiceAccountPathParameters,
DeleteServiceAccountRoleDeprecatedPathParameters,
DeleteServiceAccountRolePathParameters,
GetMyServiceAccount200,
GetServiceAccount200,
@@ -39,7 +36,6 @@ import type {
ListServiceAccounts200,
RenderErrorResponseDTO,
RevokeServiceAccountKeyPathParameters,
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
ServiceaccounttypesPostableFactorAPIKeyDTO,
ServiceaccounttypesPostableServiceAccountDTO,
ServiceaccounttypesPostableServiceAccountRoleDTO,
@@ -1253,194 +1249,6 @@ export const invalidateGetServiceAccountRoles = async (
return queryClient;
};
/**
* This endpoint assigns a role to a service account
* @deprecated
* @summary Create service account role
*/
export const createServiceAccountRoleDeprecated = (
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
url: `/api/v1/service_accounts/${id}/roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
signal,
});
};
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
> => {
const mutationKey = ['createServiceAccountRoleDeprecated'];
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 createServiceAccountRoleDeprecated>>,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return createServiceAccountRoleDeprecated(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
>;
export type CreateServiceAccountRoleDeprecatedMutationBody =
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
| undefined;
export type CreateServiceAccountRoleDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create service account role
*/
export const useCreateServiceAccountRoleDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
> => {
return useMutation(
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
);
};
/**
* This endpoint revokes a role from service account
* @deprecated
* @summary Delete service account role
*/
export const deleteServiceAccountRoleDeprecated = (
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/${id}/roles/${rid}`,
method: 'DELETE',
signal,
});
};
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
> => {
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
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 deleteServiceAccountRoleDeprecated>>,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteServiceAccountRoleDeprecated(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
>;
export type DeleteServiceAccountRoleDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Delete service account role
*/
export const useDeleteServiceAccountRoleDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
> => {
return useMutation(
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
);
};
/**
* This endpoint gets my service account
* @summary Gets my service account

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 {
@@ -3470,6 +3490,7 @@ export enum TelemetrytypesFieldContextDTO {
metric = 'metric',
log = 'log',
span = 'span',
trace = 'trace',
resource = 'resource',
attribute = 'attribute',
body = 'body',
@@ -8414,6 +8435,28 @@ export enum Querybuildertypesv5QueryTypeDTO {
clickhouse_sql = 'clickhouse_sql',
promql = 'promql',
}
export interface QuickfiltertypesSignalFiltersDTO {
/**
* @type array,null
*/
filters?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
/**
* @type string
*/
signal?: string;
}
export interface QuickfiltertypesUpdatableQuickFiltersDTO {
/**
* @type array,null
*/
filters?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
/**
* @type string
*/
signal?: string;
}
export interface RenderErrorResponseDTO {
error: ErrorsJSONDTO;
/**
@@ -9000,6 +9043,7 @@ export enum SavedviewtypesSourceDTO {
logs = 'logs',
metrics = 'metrics',
meter = 'meter',
ai_observability = 'ai_observability',
}
export interface SavedviewtypesSavedViewSpecDTO {
display?: SavedviewtypesDisplayDTO;
@@ -9071,13 +9115,6 @@ export interface SavedviewtypesUpdatableSavedViewDTO {
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
/**
* @type string
*/
id: string;
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -10155,6 +10192,93 @@ export interface ZeustypesPostableProfileDTO {
where_did_you_discover_signoz: string;
}
export type GetAIObservabilityFieldsKeysParams = {
/**
* @type string
* @description undefined
*/
searchText?: string;
/**
* @description undefined
*/
fieldContext?: TelemetrytypesFieldContextDTO;
/**
* @description undefined
*/
fieldDataType?: TelemetrytypesFieldDataTypeDTO;
/**
* @type integer
* @format int64
* @description undefined
*/
startUnixMilli?: number;
/**
* @type integer
* @format int64
* @description undefined
*/
endUnixMilli?: number;
/**
* @type integer
* @description undefined
*/
limit?: number;
};
export type GetAIObservabilityFieldsKeys200 = {
data: TelemetrytypesGettableFieldKeysDTO;
/**
* @type string
*/
status: string;
};
export type GetAIObservabilityFieldsValuesParams = {
/**
* @type string
* @description undefined
*/
searchText?: string;
/**
* @description undefined
*/
fieldContext?: TelemetrytypesFieldContextDTO;
/**
* @description undefined
*/
fieldDataType?: TelemetrytypesFieldDataTypeDTO;
/**
* @type integer
* @format int64
* @description undefined
*/
startUnixMilli?: number;
/**
* @type integer
* @format int64
* @description undefined
*/
endUnixMilli?: number;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type string
* @description undefined
*/
name?: string;
};
export type GetAIObservabilityFieldsValues200 = {
data: TelemetrytypesGettableFieldValuesDTO;
/**
* @type string
*/
status: string;
};
export type GetAlerts200 = {
/**
* @type array
@@ -10417,42 +10541,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
@@ -10948,21 +11036,6 @@ export type GetServiceAccountRoles200 = {
status: string;
};
export type CreateServiceAccountRoleDeprecatedPathParameters = {
id: string;
};
export type CreateServiceAccountRoleDeprecated201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
id: string;
rid: string;
};
export type GetMyServiceAccount200 = {
data: ServiceaccounttypesServiceAccountWithRolesDTO;
/**
@@ -11088,6 +11161,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;
/**
@@ -11702,6 +11811,28 @@ export type GetMyOrganization200 = {
status: string;
};
export type ListQuickFilters200 = {
/**
* @type array,null
*/
data: QuickfiltertypesSignalFiltersDTO[] | null;
/**
* @type string
*/
status: string;
};
export type GetSignalQuickFiltersPathParameters = {
signalName: string;
};
export type GetSignalQuickFilters200 = {
data: QuickfiltertypesSignalFiltersDTO;
/**
* @type string
*/
status: string;
};
export type GetPublicDashboardDataV2PathParameters = {
id: string;
};

View File

@@ -0,0 +1,3 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#0078D4" d="M22.379 23.343a1.62 1.62 0 0 0 1.536-2.14v.002L17.35 1.76A1.62 1.62 0 0 0 15.816.657H8.184A1.62 1.62 0 0 0 6.65 1.76L.086 21.204a1.62 1.62 0 0 0 1.536 2.139h4.741a1.62 1.62 0 0 0 1.535-1.103l.977-2.892 4.947 3.675c.28.208.618.32.966.32m-3.084-12.531 3.624 10.739a.54.54 0 0 1-.51.713v-.001h-.03a.54.54 0 0 1-.322-.106l-9.287-6.9h4.853m6.313 7.006c.116-.326.13-.694.007-1.058L9.79 1.76a1.722 1.722 0 0 0-.007-.02h6.034a.54.54 0 0 1 .512.366l6.562 19.445a.54.54 0 0 1-.338.684"/>
</svg>

After

Width:  |  Height:  |  Size: 583 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,5 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Dify</title>
<path d="M7.043 6.487c1.635 0 2.241-1.003 2.241-2.243S8.681 2 7.044 2C5.405 2 4.801 3.003 4.801 4.244c0 1.24.604 2.243 2.241 2.243z" fill="#03F" />
<path d="M14.883 6.97v1.443h-3.679v3.203h3.68v8.012H8.801V8.41h-8v3.203h4.48v8.012H0v3.203h24v-3.203h-5.6v-8.012H24V8.41h-5.6V5.206H24V2.003h-4.161a4.97 4.97 0 00-4.961 4.967h.005z" fill="#03F" />
</svg>

After

Width:  |  Height:  |  Size: 447 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill-rule:evenodd;clip-rule:evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill:#D1D5DB;fill-rule:evenodd;clip-rule:evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,3 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#9CA3AF" d="M23.922 16.997C23.061 18.492 18.063 22.02 12 22.02 5.937 22.02.939 18.492.078 16.997A.641.641 0 0 1 0 16.741v-2.869a.883.883 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.098 10.098 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952C7.255 2.937 9.248 1.98 11.978 1.98c2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.841.841 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256Zm-11.75-5.992h-.344a4.359 4.359 0 0 1-.355.508c-.77.947-1.918 1.492-3.508 1.492-1.725 0-2.989-.359-3.782-1.259a2.137 2.137 0 0 1-.085-.104L4 11.746v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.359 4.359 0 0 1-.355-.508Zm2.328 3.25c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm-5 0c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm3.313-6.185c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,24 @@
<svg viewBox="0 0 44.8 40" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="kong-a" x1="38.204" x2="8.732" y1="18.417" y2="48.543" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
<linearGradient id="kong-b" x1="38.107" x2="8.635" y1="18.322" y2="48.448" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
<linearGradient id="kong-c" x1="29.439" x2="-0.033" y1="9.842" y2="39.968" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
<linearGradient id="kong-d" x1="30.291" x2="0.819" y1="10.676" y2="40.801" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
</defs>
<path d="m14.7 32.9-1.1 1.4 2.5 3.9-.3 1.8h10.6l.7-1.8-4.2-5.3z" fill="url(#kong-a)" />
<path d="M20.5 9.4 16.7 16l18.6 22-.5 2h8.5l1.5-7.1L24.9 9.4z" fill="url(#kong-b)" />
<path d="m23 4.4-1.8 3.3h4.5l7.7 9.2 4.6-3.8v-2.4l-1.6-2.2 1.2-1.2L28.4 0z" fill="url(#kong-c)" />
<path d="M9.1 22.9H6.6L0 31.3V40h7.1l1.3-1.6 5.5-7.1h7.9l2.4-3.7-8.6-10.2z" fill="url(#kong-d)" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg fill="none" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M63 0.018v63.535L38.418 42.25v21.303H0V0l63 .018ZM7.723 55.839h22.972V25.323l24.583 21.725V7.729L7.723 7.716v48.123Z" fill="#37C38F" />
</svg>

After

Width:  |  Height:  |  Size: 226 B

View File

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

View File

@@ -8,12 +8,19 @@ import {
import ChangelogRenderer from '../components/ChangelogRenderer';
// Mock react-markdown to just render children as plain text
// Mock react-markdown to render children as plain text and a sample
// anchor through the `components.a` override
jest.mock(
'react-markdown',
() =>
function ReactMarkdown({ children }: any) {
return <div>{children}</div>;
function ReactMarkdown({ children, components }: any) {
const Anchor = components?.a;
return (
<div>
{children}
{Anchor && <Anchor href="https://signoz.io/docs">docs</Anchor>}
</div>
);
},
);
@@ -62,4 +69,14 @@ describe('ChangelogRenderer', () => {
expect(screen.getByAltText('Media')).toBeInTheDocument();
expect(screen.getByText('Description for feature 1')).toBeInTheDocument();
});
it('renders markdown links that open in a new tab', () => {
render(<ChangelogRenderer changelog={mockChangelog} />);
const links = screen.getAllByRole('link', { name: 'docs' });
expect(links.length).toBeGreaterThan(0);
links.forEach((link) => {
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
});
});

View File

@@ -13,6 +13,19 @@ interface Props {
changelog: ChangelogSchema;
}
interface LinkProps {
href?: string;
children?: React.ReactNode;
}
function Link({ href, children }: LinkProps): JSX.Element {
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
}
function renderMedia(media: Media): JSX.Element | null {
if (SupportedImageTypes.includes(media.ext)) {
return (
@@ -62,7 +75,9 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div key={feature.id}>
<div className="changelog-renderer-section-title">{feature.title}</div>
{feature.media && renderMedia(feature.media)}
<ReactMarkdown>{feature.description}</ReactMarkdown>
<ReactMarkdown components={{ a: Link }}>
{feature.description}
</ReactMarkdown>
</div>
))}
</div>
@@ -71,7 +86,9 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div className="changelog-renderer-bug-fixes">
<div className="changelog-renderer-section-title">Bug Fixes</div>
{changelog.bug_fixes && (
<ReactMarkdown>{changelog.bug_fixes}</ReactMarkdown>
<ReactMarkdown components={{ a: Link }}>
{changelog.bug_fixes}
</ReactMarkdown>
)}
</div>
)}
@@ -79,7 +96,9 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div className="changelog-renderer-maintenance">
<div className="changelog-renderer-section-title">Maintenance</div>
{changelog.maintenance && (
<ReactMarkdown>{changelog.maintenance}</ReactMarkdown>
<ReactMarkdown components={{ a: Link }}>
{changelog.maintenance}
</ReactMarkdown>
)}
</div>
)}

View File

@@ -1,5 +0,0 @@
.client-side-qb-search {
.ant-select-selection-search {
width: max-content !important;
}
}

View File

@@ -1,661 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Select, Tooltip } from 'antd';
import {
OPERATORS,
QUERY_BUILDER_OPERATORS_BY_TYPES,
QUERY_BUILDER_SEARCH_VALUES,
} from 'constants/queryBuilder';
import { CustomTagProps } from 'container/QueryBuilder/filters/QueryBuilderSearch';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config';
import { PLACEHOLDER } from 'container/QueryBuilder/filters/QueryBuilderSearch/constant';
import { TypographyText } from 'container/QueryBuilder/filters/QueryBuilderSearch/style';
import {
checkCommaInValue,
getOperatorFromValue,
getOperatorValue,
getTagToken,
isInNInOperator,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import {
DropdownState,
ITag,
Option,
} from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import Suggestions from 'container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { validationMapper } from 'hooks/queryBuilder/useIsValidTag';
import { operatorTypeMapper } from 'hooks/queryBuilder/useOperatorType';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { isArray, isEmpty, isEqual, isObject } from 'lodash-es';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import type { BaseSelectRef } from 'rc-select';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import './ClientSideQBSearch.styles.scss';
import { Badge } from '@signozhq/ui/badge';
export interface AttributeKey {
key: string;
}
export interface AttributeValuesMap {
[key: string]: AttributeValue;
}
interface ClientSideQBSearchProps {
filters: TagFilter;
onChange: (value: TagFilter) => void;
whereClauseConfig?: WhereClauseConfig;
placeholder?: string;
className?: string;
suffixIcon?: React.ReactNode;
attributeValuesMap?: AttributeValuesMap;
attributeKeys: AttributeKey[];
}
interface AttributeValue {
stringAttributeValues: string[] | [];
numberAttributeValues: number[] | [];
boolAttributeValues: boolean[] | [];
}
function ClientSideQBSearch(
props: ClientSideQBSearchProps,
): React.ReactElement {
const {
onChange,
placeholder,
className,
suffixIcon,
whereClauseConfig,
attributeValuesMap,
attributeKeys,
filters,
} = props;
const isDarkMode = useIsDarkMode();
const selectRef = useRef<BaseSelectRef>(null);
const [isOpen, setIsOpen] = useState<boolean>(false);
// create the tags from the initial query here, this should only be computed on the first load as post that tags and query will be always in sync.
const [tags, setTags] = useState<ITag[]>(filters.items as ITag[]);
// this will maintain the current state of in process filter item
const [currentFilterItem, setCurrentFilterItem] = useState<ITag | undefined>();
const [currentState, setCurrentState] = useState<DropdownState>(
DropdownState.ATTRIBUTE_KEY,
);
// to maintain the current running state until the tokenization happens for the tag
const [searchValue, setSearchValue] = useState<string>('');
const [dropdownOptions, setDropdownOptions] = useState<Option[]>([]);
const attributeValues = useMemo(() => {
if (currentFilterItem?.key?.key) {
return attributeValuesMap?.[currentFilterItem.key.key];
}
return {
stringAttributeValues: [],
numberAttributeValues: [],
boolAttributeValues: [],
};
}, [attributeValuesMap, currentFilterItem?.key?.key]);
const handleDropdownSelect = useCallback(
(value: string) => {
let parsedValue: BaseAutocompleteData | string;
try {
parsedValue = JSON.parse(value);
} catch {
parsedValue = value;
}
if (currentState === DropdownState.ATTRIBUTE_KEY) {
setCurrentFilterItem((prev) => ({
...prev,
key: parsedValue as BaseAutocompleteData,
op: '',
value: '',
}));
setCurrentState(DropdownState.OPERATOR);
setSearchValue((parsedValue as BaseAutocompleteData)?.key);
} else if (currentState === DropdownState.OPERATOR) {
if (value === OPERATORS.EXISTS || value === OPERATORS.NOT_EXISTS) {
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: value,
value: '',
} as ITag,
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: value as string,
value: '',
}));
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
setSearchValue(`${currentFilterItem?.key?.key} ${value}`);
}
} else if (currentState === DropdownState.ATTRIBUTE_VALUE) {
const operatorType =
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
const isMulti = operatorType === QUERY_BUILDER_SEARCH_VALUES.MULTIPLY;
if (isMulti) {
const { tagKey, tagOperator, tagValue } = getTagToken(searchValue);
// this condition takes care of adding the IN/NIN multi values when pressed enter on an already existing value.
// not the best interaction but in sync with what we have today!
if (tagValue.includes(String(value))) {
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
setCurrentFilterItem(undefined);
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: currentFilterItem?.op,
value: tagValue,
} as ITag,
]);
return;
}
// this is for adding subsequent comma seperated values
const newSearch = [...tagValue];
newSearch[newSearch.length === 0 ? 0 : newSearch.length - 1] = value;
const newSearchValue = newSearch.join(',');
setSearchValue(`${tagKey} ${tagOperator} ${newSearchValue},`);
} else {
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
setCurrentFilterItem(undefined);
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: currentFilterItem?.op,
value,
} as ITag,
]);
}
}
},
[currentFilterItem?.key, currentFilterItem?.op, currentState, searchValue],
);
const handleSearch = useCallback((value: string) => {
setSearchValue(value);
}, []);
const onInputKeyDownHandler = useCallback(
(event: KeyboardEvent<Element>): void => {
if (event.key === 'Backspace' && !searchValue) {
event.stopPropagation();
setTags((prev) => prev.slice(0, -1));
}
},
[searchValue],
);
const handleOnBlur = useCallback((): void => {
if (searchValue) {
const operatorType =
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
// if key is added and operator is not present then convert to body CONTAINS key
if (
currentFilterItem?.key &&
isEmpty(currentFilterItem?.op) &&
whereClauseConfig?.customKey === 'body' &&
whereClauseConfig?.customOp === OPERATORS.CONTAINS
) {
setTags((prev) => [
...prev,
{
key: {
key: 'body',
dataType: DataTypes.String,
type: '',
id: 'body--string----true',
},
op: OPERATORS.CONTAINS,
value: currentFilterItem?.key?.key,
},
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else if (
currentFilterItem?.op === OPERATORS.EXISTS ||
currentFilterItem?.op === OPERATORS.NOT_EXISTS
) {
// is exists and not exists operator is present then convert directly to tag! no need of value here
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: currentFilterItem?.op,
value: '',
},
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else if (
// if the current state is in sync with the kind of operator used then convert into a tag
validationMapper[operatorType]?.(
isArray(currentFilterItem?.value)
? currentFilterItem?.value.length || 0
: 1,
)
) {
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key as BaseAutocompleteData,
op: currentFilterItem?.op as string,
value: currentFilterItem?.value || '',
},
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
}
}
}, [
currentFilterItem?.key,
currentFilterItem?.op,
currentFilterItem?.value,
searchValue,
whereClauseConfig?.customKey,
whereClauseConfig?.customOp,
]);
// this useEffect takes care of tokenisation based on the search state
useEffect(() => {
// if there is no search value reset to the default state
if (!searchValue) {
setCurrentFilterItem(undefined);
setCurrentState(DropdownState.ATTRIBUTE_KEY);
}
// split the current search value based on delimiters
const { tagKey, tagOperator, tagValue } = getTagToken(searchValue);
if (
// Case 1 - if key is defined but the search text doesn't match with the set key,
// can happen when user selects from dropdown and then deletes a few characters
currentFilterItem?.key &&
currentFilterItem?.key?.key !== tagKey.split(' ')[0]
) {
setCurrentFilterItem(undefined);
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else if (tagOperator && isEmpty(currentFilterItem?.op)) {
// Case 2 -> key is set and now typing for the operator
if (
tagOperator === OPERATORS.EXISTS ||
tagOperator === OPERATORS.NOT_EXISTS
) {
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: tagOperator,
value: '',
} as ITag,
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: tagOperator,
value: '',
}));
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
}
} else if (
// Case 3 -> selected operator from dropdown and then erased a part of it
!isEmpty(currentFilterItem?.op) &&
tagOperator !== currentFilterItem?.op
) {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: '',
value: '',
}));
setCurrentState(DropdownState.OPERATOR);
} else if (currentState === DropdownState.ATTRIBUTE_VALUE) {
// Case 4 -> the final value state where we set the current filter values and the tokenisation happens on either
// dropdown click or blur event
const currentValue = {
key: currentFilterItem?.key as BaseAutocompleteData,
op: currentFilterItem?.op as string,
value: tagValue,
};
if (!isEqual(currentValue, currentFilterItem)) {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: prev?.op as string,
value: tagValue,
}));
}
}
}, [
currentFilterItem,
currentFilterItem?.key,
currentFilterItem?.op,
searchValue,
currentState,
]);
// the useEffect takes care of setting the dropdown values correctly on change of the current state
useEffect(() => {
if (currentState === DropdownState.ATTRIBUTE_KEY) {
const filteredAttributeKeys = attributeKeys.filter((key) =>
key.key.startsWith(searchValue),
);
setDropdownOptions(
filteredAttributeKeys?.map(
(key) =>
({
label: key.key,
value: key,
}) as Option,
) || [],
);
}
if (currentState === DropdownState.OPERATOR) {
const keyOperator = searchValue.split(' ');
const partialOperator = keyOperator?.[1];
const strippedKey = keyOperator?.[0];
let operatorOptions;
if (currentFilterItem?.key?.dataType) {
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES[
currentFilterItem.key
.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
].map((operator) => ({
label: operator,
value: operator,
}));
if (partialOperator) {
operatorOptions = operatorOptions.filter((op) =>
op.label.startsWith(partialOperator.toLocaleUpperCase()),
);
}
setDropdownOptions(operatorOptions);
} else if (strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')) {
operatorOptions = [OPERATORS.HAS, OPERATORS.NHAS].map((operator) => ({
label: operator,
value: operator,
}));
setDropdownOptions(operatorOptions);
} else {
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES.universal.map(
(operator) => ({
label: operator,
value: operator,
}),
);
if (partialOperator) {
operatorOptions = operatorOptions.filter((op) =>
op.label.startsWith(partialOperator.toLocaleUpperCase()),
);
}
setDropdownOptions(operatorOptions);
}
}
if (currentState === DropdownState.ATTRIBUTE_VALUE) {
const values: Array<string | number | boolean> = [];
const { tagValue } = getTagToken(searchValue);
if (isArray(tagValue)) {
if (!isEmpty(tagValue[tagValue.length - 1])) {
values.push(tagValue[tagValue.length - 1]);
}
} else if (!isEmpty(tagValue)) {
values.push(tagValue);
}
const currentAttributeValues =
attributeValues?.stringAttributeValues ||
attributeValues?.numberAttributeValues ||
attributeValues?.boolAttributeValues ||
[];
values.push(...currentAttributeValues);
if (attributeValuesMap) {
setDropdownOptions(
values.map(
(val) =>
({
label: checkCommaInValue(String(val)),
value: val,
}) as Option,
),
);
} else {
// If attributeValuesMap is not provided, don't set dropdown options
setDropdownOptions([]);
}
}
}, [
attributeValues,
currentFilterItem?.key?.dataType,
currentState,
attributeKeys,
searchValue,
attributeValuesMap,
]);
useEffect(() => {
const filterTags: IBuilderQuery['filters'] = {
op: 'AND',
items: [],
};
tags.forEach((tag) => {
const computedTagValue =
tag.value &&
Array.isArray(tag.value) &&
tag.value[tag.value.length - 1] === ''
? tag.value?.slice(0, -1)
: (tag.value ?? '');
filterTags.items.push({
id: tag.id || uuid().slice(0, 8),
key: tag.key,
op: getOperatorValue(tag.op),
value: computedTagValue,
});
});
if (!isEqual(filters, filterTags)) {
onChange(filterTags);
setTags(
filterTags.items.map((tag) => ({
...tag,
op: getOperatorFromValue(tag.op),
})) as ITag[],
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tags]);
const queryTags = useMemo(
() => tags.map((tag) => `${tag.key.key} ${tag.op} ${tag.value}`),
[tags],
);
const onTagRender = ({
value,
closable,
onClose,
}: CustomTagProps): React.ReactElement => {
const { tagOperator } = getTagToken(value);
const isInNin = isInNInOperator(tagOperator);
const chipValue = isInNin
? value?.trim()?.replace(/,\s*$/, '')
: value?.trim();
const indexInQueryTags = queryTags.findIndex((qTag) => isEqual(qTag, value));
const tagDetails = tags[indexInQueryTags];
const onCloseHandler = (): void => {
onClose();
setSearchValue('');
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
};
const tagEditHandler = (value: string): void => {
setCurrentFilterItem(tagDetails);
setSearchValue(value);
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
};
const isDisabled = !!searchValue;
return (
<span className="qb-search-bar-tokenised-tags">
<Badge
color="vanilla"
className={tagDetails?.key?.type || ''}
closable={!searchValue && closable}
onClose={(e): void => {
e.preventDefault();
onCloseHandler();
}}
>
<Tooltip title={chipValue}>
<TypographyText
$isInNin={isInNin}
$isEnabled={!!searchValue}
$disabled={isDisabled}
onClick={(): void => {
if (!isDisabled) {
tagEditHandler(value);
}
}}
>
{chipValue}
</TypographyText>
</Tooltip>
</Badge>
</span>
);
};
const suffixIconContent = useMemo(() => {
if (suffixIcon) {
return suffixIcon;
}
return isOpen ? (
<ChevronUp
size={14}
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
/>
) : (
<ChevronDown
size={14}
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
/>
);
}, [isDarkMode, isOpen, suffixIcon]);
return (
<div className="query-builder-search-v2 ">
<Select
ref={selectRef}
getPopupContainer={popupContainer}
virtual={false}
showSearch
tagRender={onTagRender}
transitionName=""
choiceTransitionName=""
filterOption={false}
open={isOpen}
suffixIcon={suffixIconContent}
onDropdownVisibleChange={setIsOpen}
autoClearSearchValue={false}
mode="multiple"
placeholder={placeholder}
value={queryTags}
searchValue={searchValue}
className={className}
rootClassName="query-builder-search client-side-qb-search"
disabled={!attributeKeys.length}
style={selectStyle}
onSearch={handleSearch}
onSelect={handleDropdownSelect}
onInputKeyDown={onInputKeyDownHandler}
notFoundContent={null}
showAction={['focus']}
onBlur={handleOnBlur}
>
{dropdownOptions.map((option) => {
let val = option.value;
try {
if (isObject(option.value)) {
val = JSON.stringify(option.value);
} else {
val = option.value;
}
} catch {
val = option.value;
}
return (
<Select.Option key={isObject(val) ? `select-option` : val} value={val}>
<Suggestions
label={option.label}
value={option.value}
option={currentState}
searchValue={searchValue}
/>
</Select.Option>
);
})}
</Select>
</div>
);
}
ClientSideQBSearch.defaultProps = {
placeholder: PLACEHOLDER,
className: '',
suffixIcon: null,
whereClauseConfig: {},
attributeValuesMap: {},
};
export default ClientSideQBSearch;

View File

@@ -28,6 +28,9 @@ interface FieldsSelectorProps {
signal: DataSource;
maxFields?: number;
requiredFields?: readonly string[];
// Lets users add a free-typed field which
// does not show up in the suggestions
allowCustomFields?: boolean;
width?: number;
height?: number;
defaultPosition?: { x: number; y: number };
@@ -46,6 +49,7 @@ function FieldsSelectorContent({
signal,
maxFields,
requiredFields,
allowCustomFields,
width = DEFAULT_PANEL_WIDTH,
height,
defaultPosition,
@@ -67,7 +71,7 @@ function FieldsSelectorContent({
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value = e.target.value.trim().toLowerCase();
const value = e.target.value.trim();
setInputValue(value);
debouncedUpdate(value);
},
@@ -153,6 +157,7 @@ function FieldsSelectorContent({
addedFields={draftFields}
onAdd={handleAdd}
isAtLimit={isAtLimit}
allowCustomFields={allowCustomFields}
/>
{hasUnsavedChanges && (
@@ -192,7 +197,7 @@ function FieldsSelector({
() =>
fields.map((f) => ({
...f,
key: f.key ?? buildCompositeKey(f.name, f.fieldContext),
key: buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
})),
[fields],
);

View File

@@ -21,6 +21,7 @@ interface OtherFieldsProps {
addedFields: TelemetryFieldKey[];
onAdd: (field: TelemetryFieldKey) => void;
isAtLimit: boolean;
allowCustomFields?: boolean;
}
function OtherFields({
@@ -29,6 +30,7 @@ function OtherFields({
addedFields,
onAdd,
isAtLimit,
allowCustomFields,
}: OtherFieldsProps): JSX.Element {
const { data, isFetching } = useGetQueryKeySuggestions(
{
@@ -45,25 +47,47 @@ function OtherFields({
},
);
const otherFields: TelemetryFieldKey[] = useMemo(() => {
const suggestions = Object.values(data?.data.data.keys || {}).flat();
const otherFields = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data.data.keys || {}).flat();
// Normalize: synthesize `key` once so downstream reads can trust it.
const normalizedSuggestions: TelemetryFieldKey[] = suggestions.map(
(attr) => ({
...attr,
key: buildCompositeKey(attr.name, attr.fieldContext as string),
signal: attr.signal as SignalType,
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType,
}),
);
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
...attr,
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
signal: attr.signal as SignalType,
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType,
}));
const addedIds = new Set(
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
addedFields.map((f) =>
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
),
);
return normalizedSuggestions.filter(
const available = suggestions.filter(
(attr) => !addedIds.has(attr.key as string),
);
}, [data, addedFields]);
// Prepend the custom field when its name is not in suggestions and
// not already added.
const typed = debouncedInputValue.trim();
const nameMatches = (list: TelemetryFieldKey[]): boolean =>
list.some((f) => f.name.toLowerCase() === typed.toLowerCase());
const showCustom =
!!allowCustomFields &&
typed.length > 0 &&
!nameMatches(suggestions) &&
!nameMatches(addedFields);
if (!showCustom) {
return available;
}
const customField: TelemetryFieldKey = {
name: typed,
fieldContext: '',
fieldDataType: '',
key: buildCompositeKey(typed, ''),
};
return [customField, ...available];
}, [data, addedFields, allowCustomFields, debouncedInputValue]);
if (isFetching) {
return (

View File

@@ -11,7 +11,7 @@ const makeField = (name: string, fieldContext = 'log'): TelemetryFieldKey => ({
signal: 'logs',
fieldContext: fieldContext as TelemetryFieldKey['fieldContext'],
fieldDataType: 'string',
key: `${fieldContext}.${name}`,
key: `${fieldContext}:${name}`,
});
describe('AddedFields — requiredFields', () => {
@@ -33,7 +33,7 @@ describe('AddedFields — requiredFields', () => {
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.a', 'log.c']}
requiredFields={['log:a', 'log:c']}
/>,
);
@@ -50,7 +50,7 @@ describe('AddedFields — requiredFields', () => {
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.a']}
requiredFields={['log:a']}
/>,
);
@@ -68,7 +68,7 @@ describe('AddedFields — requiredFields', () => {
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.body']}
requiredFields={['log:body']}
/>,
);
@@ -101,11 +101,11 @@ describe('AddedFields — requiredFields', () => {
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.body']}
requiredFields={['log:body']}
/>,
);
// 'log.body' locked, 'log.body_extra' removable.
// 'log:body' locked, 'log:body_extra' removable.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
});
});

View File

@@ -0,0 +1,188 @@
import { act, fireEvent, render, screen } from 'tests/test-utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import FieldsSelector from '../FieldsSelector';
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
// FloatingPanel is a react-rnd/portal shell — presentation only. Render its
// children directly so the test exercises the column-editing behavior.
jest.mock('periscope/components/FloatingPanel', () => ({
FloatingPanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
const mockSuggestions = (names: string[]): void => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
},
},
},
},
isFetching: false,
});
};
const field = (name: string, fieldContext = 'log'): TelemetryFieldKey => ({
name,
signal: 'logs',
fieldContext: fieldContext as TelemetryFieldKey['fieldContext'],
fieldDataType: 'string',
});
const renderPanel = (
props: Partial<React.ComponentProps<typeof FieldsSelector>> = {},
): { onFieldsChange: jest.Mock } => {
const onFieldsChange = jest.fn();
render(
<FieldsSelector
isOpen
title="Edit columns"
fields={props.fields ?? []}
onFieldsChange={onFieldsChange}
onClose={jest.fn()}
signal={DataSource.LOGS}
allowCustomFields
{...props}
/>,
);
return { onFieldsChange };
};
// Type into the search box and flush the 400ms debounce so OtherFields (driven
// by the debounced value) recomputes.
const typeSearch = (value: string): void => {
const input = screen.getByPlaceholderText('Search for a field...');
act(() => {
fireEvent.change(input, { target: { value } });
});
act(() => {
jest.advanceTimersByTime(400);
});
};
describe('FieldsSelector — edit columns (integration)', () => {
beforeEach(() => {
jest.useFakeTimers();
mockSuggestions([]);
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('adds a free-typed field end to end and saves the synthesized key', () => {
const { onFieldsChange } = renderPanel({ fields: [field('body')] });
typeSearch('orderId');
// custom option surfaces in OTHER FIELDS (only Add button, no suggestions)
expect(screen.getByText('orderId')).toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByRole('button', { name: /^add$/i }));
});
// moved into ADDED FIELDS → OTHER FIELDS has nothing left to offer
expect(screen.getByText('No values found')).toBeInTheDocument();
// Save commits the draft
act(() => {
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
});
expect(onFieldsChange).toHaveBeenCalledTimes(1);
const saved = onFieldsChange.mock.calls[0][0] as TelemetryFieldKey[];
expect(saved).toStrictEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'orderId',
fieldContext: '',
fieldDataType: '',
key: 'orderId',
}),
]),
);
});
it('adds a suggested field: it moves from OTHER FIELDS into ADDED FIELDS', () => {
mockSuggestions(['service.name']);
const { onFieldsChange } = renderPanel({ fields: [] });
const addButton = screen.getByRole('button', { name: /^add$/i });
act(() => {
fireEvent.click(addButton);
});
// now removable in ADDED FIELDS, no longer offered in OTHER FIELDS
expect(screen.getByRole('button', { name: /remove/i })).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /^add$/i }),
).not.toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
});
const saved = onFieldsChange.mock.calls[0][0] as TelemetryFieldKey[];
expect(saved.map((f) => f.name)).toContain('service.name');
});
it('hides the custom option when the typed name is already added', () => {
renderPanel({ fields: [field('orderId')] });
typeSearch('ORDERID');
// exact name already added → nothing left to offer in OTHER FIELDS
expect(screen.queryByText('ORDERID')).not.toBeInTheDocument();
expect(screen.getByText('No values found')).toBeInTheDocument();
});
it('does not offer a custom option when allowCustomFields is off', () => {
renderPanel({ fields: [], allowCustomFields: false });
typeSearch('unknown.a.b.c');
// no custom row and nothing addable
expect(screen.queryByText('unknown.a.b.c')).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /^add$/i }),
).not.toBeInTheDocument();
});
it('discards an added field, reverting the draft', () => {
const { onFieldsChange } = renderPanel({ fields: [field('body')] });
typeSearch('orderId');
act(() => {
fireEvent.click(screen.getByRole('button', { name: /^add$/i }));
});
// clear the search so the added list is not filtered
typeSearch('');
act(() => {
fireEvent.click(screen.getByRole('button', { name: /discard/i }));
});
expect(screen.queryByText('orderId')).not.toBeInTheDocument();
expect(onFieldsChange).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,125 @@
import { fireEvent, render, screen } from 'tests/test-utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import OtherFields from '../OtherFields';
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
const mockSuggestions = (names: string[]): void => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
},
},
},
},
isFetching: false,
});
};
const renderOtherFields = (
props: Partial<React.ComponentProps<typeof OtherFields>> = {},
): { onAdd: jest.Mock } => {
const onAdd = jest.fn();
render(
<OtherFields
signal={DataSource.LOGS}
debouncedInputValue=""
addedFields={[]}
onAdd={onAdd}
isAtLimit={false}
allowCustomFields
{...props}
/>,
);
return { onAdd };
};
const addedField = (name: string): TelemetryFieldKey => ({
name,
signal: 'logs',
fieldContext: '',
fieldDataType: '',
key: name,
});
describe('OtherFields — custom (free-typed) option', () => {
beforeEach(() => {
mockSuggestions([]);
});
it('shows a custom option for a typed name that is not a suggestion', () => {
renderOtherFields({ debouncedInputValue: 'unknown.a.b.c' });
expect(screen.getByText('unknown.a.b.c')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /add/i })).toBeInTheDocument();
});
it('synthesizes the field with raw name, empty context/type, on add', () => {
const { onAdd } = renderOtherFields({ debouncedInputValue: 'orderId' });
fireEvent.click(screen.getByRole('button', { name: /add/i }));
expect(onAdd).toHaveBeenCalledWith({
name: 'orderId',
fieldContext: '',
fieldDataType: '',
key: 'orderId',
});
});
it('hides the custom option when an exact suggestion exists (case-insensitive)', () => {
mockSuggestions(['orderId']);
renderOtherFields({ debouncedInputValue: 'orderid' });
// the real suggestion shows, the lowercased custom name does not
expect(screen.getByText('orderId')).toBeInTheDocument();
expect(screen.queryByText('orderid')).not.toBeInTheDocument();
});
it('hides the custom option when the name is already added (case-insensitive)', () => {
renderOtherFields({
debouncedInputValue: 'ORDERID',
addedFields: [addedField('orderId')],
});
expect(screen.queryByText('ORDERID')).not.toBeInTheDocument();
expect(screen.getByText('No values found')).toBeInTheDocument();
});
it('does not show the custom option when allowCustomFields is off', () => {
renderOtherFields({
debouncedInputValue: 'unknown.a.b.c',
allowCustomFields: false,
});
expect(screen.queryByText('unknown.a.b.c')).not.toBeInTheDocument();
expect(screen.getByText('No values found')).toBeInTheDocument();
});
it('does not show the custom option for an empty input', () => {
renderOtherFields({ debouncedInputValue: ' ' });
expect(screen.getByText('No values found')).toBeInTheDocument();
});
it('shows the custom option at the field limit but hides its Add button', () => {
renderOtherFields({ debouncedInputValue: 'unknown.a.b.c', isAtLimit: true });
// same as every other row at the limit: name shown, no Add button
expect(screen.getByText('unknown.a.b.c')).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /add/i }),
).not.toBeInTheDocument();
});
});

View File

@@ -19,6 +19,7 @@ export type LogDetailProps = {
onScrollToLog?: (logId: string) => void;
handleOpenInExplorer?: MouseEventHandler;
getContainer?: DrawerProps['getContainer'];
onApplyLogFilter?: (expression: string) => void;
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
Pick<DrawerProps, 'onClose'>;

View File

@@ -41,8 +41,7 @@
.ant-drawer-body {
display: flex;
flex-direction: column;
padding: 16px;
padding-bottom: 0;
padding: 0 16px;
}
.title {
@@ -79,6 +78,7 @@
align-items: center;
gap: 4px;
position: relative;
margin-top: 16px;
.log-body {
font-family: 'SF Mono';
@@ -123,6 +123,19 @@
}
}
.log-detail-drawer__section-divider {
height: 8px;
margin: 12px 0;
background-image:
radial-gradient(circle, var(--l3-border) 1px, transparent 1px),
radial-gradient(circle, var(--l3-border) 1px, transparent 1px);
background-size: 6px 2px;
background-position:
left top,
left bottom;
background-repeat: repeat-x;
}
.tabs-and-search {
display: flex;
justify-content: space-between;

View File

@@ -16,6 +16,7 @@ import {
Link,
} from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import { normalizeTimeToMs } from 'utils/timeUtils';
import { ILog } from 'types/api/logs/log';
import { MouseEvent, MouseEventHandler } from 'react';
import { useCopyToClipboard } from 'react-use';
@@ -67,6 +68,11 @@ function LogDetailsHeader({
},
];
const rawTimestamp = log.date ?? log.timestamp;
const displayTimestamp = Number.isNaN(Number(rawTimestamp))
? rawTimestamp
: normalizeTimeToMs(rawTimestamp);
return (
<div className={styles.header} data-log-detail-ignore="true">
<div className={styles.leftSection}>
@@ -76,7 +82,7 @@ function LogDetailsHeader({
data-testid="log-details-header-timestamp"
>
{formatTimezoneAdjustedTimestamp(
log.date ?? log.timestamp,
displayTimestamp,
DATE_TIME_FORMATS.DASH_DATETIME,
)}
</Typography.Text>

View File

@@ -11,10 +11,16 @@ jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
// The flag to be removed later
jest.mock('../constants', () => ({
...jest.requireActual('../constants'),
isLogDetailsV2: true,
// DataViewer pulls in react-json-tree (ESM) + Monaco; mock it (as trace's tests
// do). These drawer tests assert the header/highlights, not the Overview body.
jest.mock('periscope/components/DataViewer', () => ({
__esModule: true,
DataViewer: (): JSX.Element => <div data-testid="overview-data-viewer" />,
}));
// Force v2 for these tests regardless of route.
jest.mock('../useIsLogDetailsV2', () => ({
useIsLogDetailsV2: (): boolean => true,
}));
const mockLog: ILog = {
@@ -66,6 +72,12 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
expect(screen.getByTestId('log-details-header-next')).toBeInTheDocument();
});
it('renders the DataViewer in the Overview tab', () => {
renderDrawer();
expect(screen.getByTestId('overview-data-viewer')).toBeInTheDocument();
});
it('shows the log timestamp formatted (DASH_DATETIME) in the header', () => {
// Pin the timezone to UTC so the formatted output is deterministic across
// machines/CI (Jest doesn't fix a TZ).
@@ -79,6 +91,24 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
);
});
it('normalizes a nanosecond-epoch timestamp in the header', () => {
localStorage.setItem(LOCALSTORAGE.PREFERRED_TIMEZONE, 'UTC');
// Same instant as mockLog but as epoch nanoseconds (e.g. dashboard list panel).
// Must scale to ms, not render a wildly wrong date.
renderDrawer({
log: {
...mockLog,
date: '1705311930000000000',
timestamp: 1705311930000000000,
},
});
expect(screen.getByTestId('log-details-header-timestamp')).toHaveTextContent(
'Jan 15, 2024 ⎯ 09:45:30',
);
});
it('copies the log link from the ⋯ menu', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });

View File

@@ -1,10 +1,3 @@
import getLocalStorage from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
// Temp feature flag before actual roll-out
export const isLogDetailsV2 =
getLocalStorage(LOCALSTORAGE.LOG_DETAILS_V2) === 'true';
export const VIEW_TYPES = {
OVERVIEW: 'OVERVIEW',
JSON: 'JSON',

View File

@@ -51,11 +51,12 @@ import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { isLogDetailsV2, RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
import LogHighlights from './LogHighlights/LogHighlights';
import { useIsLogDetailsV2 } from './useIsLogDetailsV2';
import './LogDetails.styles.scss';
@@ -74,6 +75,7 @@ function LogDetailInner({
onScrollToLog,
handleOpenInExplorer,
getContainer,
onApplyLogFilter,
}: LogDetailInnerProps): JSX.Element {
const initialContextQuery = useInitialQuery(log);
const [contextQuery, setContextQuery] = useState<Query | undefined>(
@@ -92,6 +94,8 @@ function LogDetailInner({
const [isEdit, setIsEdit] = useState<boolean>(false);
const { stagedQuery } = useQueryBuilder();
const isLogDetailsV2 = useIsLogDetailsV2();
// Handle clicks outside to close drawer, except on explicitly ignored regions
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
@@ -100,6 +104,7 @@ function LogDetailInner({
// Don't close if clicking on drawer content, overlays, or portal elements
if (
target.closest('[data-log-detail-ignore="true"]') ||
target.closest('.log-detail-drawer') ||
target.closest('.cm-tooltip-autocomplete') ||
target.closest('.drawer-popover') ||
target.closest('.query-status-popover') ||
@@ -402,6 +407,8 @@ function LogDetailInner({
{isLogDetailsV2 && <LogHighlights log={log} />}
{isLogDetailsV2 && <div className="log-detail-drawer__section-divider" />}
<div className="tabs-and-search">
<ToggleGroupSimple
type="single"
@@ -418,15 +425,21 @@ function LogDetailInner({
</div>
),
},
{
value: VIEW_TYPES.JSON,
label: (
<div className="view-title">
<Braces size={14} />
JSON
</div>
),
},
// V2's DataViewer has its own Pretty/JSON toggle, so the separate
// JSON tab is redundant.
...(isLogDetailsV2
? []
: [
{
value: VIEW_TYPES.JSON,
label: (
<div className="view-title">
<Braces size={14} />
JSON
</div>
),
},
]),
{
value: VIEW_TYPES.CONTEXT,
label: (
@@ -507,9 +520,10 @@ function LogDetailInner({
selectedOptions={options}
listViewPanelSelectedFields={listViewPanelSelectedFields}
handleChangeSelectedView={handleChangeSelectedView}
onApplyLogFilter={onApplyLogFilter}
/>
)}
{selectedView === VIEW_TYPES.JSON && (
{!isLogDetailsV2 && selectedView === VIEW_TYPES.JSON && (
<JsonView data={LogJsonData} height="68vh" />
)}

View File

@@ -0,0 +1,11 @@
import ROUTES from 'constants/routes';
import { useLocation } from 'react-router-dom';
export function useIsLogDetailsV2(): boolean {
const { pathname } = useLocation();
return (
pathname === ROUTES.LOGS_EXPLORER ||
pathname.startsWith(ROUTES.INFRASTRUCTURE_MONITORING_BASE) ||
pathname.startsWith(`${ROUTES.ALL_DASHBOARD}/`)
);
}

View File

@@ -14,10 +14,10 @@ jest.mock('providers/App/App', () => ({
useAppContext: (): { featureFlags: [] } => ({ featureFlags: [] }),
}));
const field = (name: string, type = ''): IField => ({
const field = (name: string, type = '', dataType = ''): IField => ({
name,
type,
dataType: 'string',
dataType,
});
describe('useLogsTableColumns — selectColumns-order respected', () => {
@@ -51,13 +51,13 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
);
// body/timestamp appear where the caller placed them, keyed by their
// composite IDs ('log.*'); contextless user fields collapse to bare name.
// composite IDs ('log:*'); contextless user fields collapse to bare name.
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'service.name',
'log.body',
'log:body',
'request.id',
'log.timestamp',
'log:timestamp',
]);
});
@@ -70,14 +70,14 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
);
const byId = new Map(result.current.map((c) => [c.id, c]));
// Attribute variant is its own column, not a duplicate 'log.body'.
// Attribute variant is its own column, not a duplicate 'log:body'.
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'log.body',
'attribute.body',
'log:body',
'attribute:body',
]);
expect(byId.get('log.body')?.enableRemove).toBe(false);
expect(byId.get('attribute.body')?.enableRemove).toBe(true);
expect(byId.get('log:body')?.enableRemove).toBe(false);
expect(byId.get('attribute:body')?.enableRemove).toBe(true);
});
it('applies the same distinct-column treatment to timestamp variants', () => {
@@ -91,11 +91,11 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
const byId = new Map(result.current.map((c) => [c.id, c]));
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'log.timestamp',
'attribute.timestamp',
'log:timestamp',
'attribute:timestamp',
]);
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
expect(byId.get('attribute.timestamp')?.enableRemove).toBe(true);
expect(byId.get('log:timestamp')?.enableRemove).toBe(false);
expect(byId.get('attribute:timestamp')?.enableRemove).toBe(true);
});
it('skips the synthetic "id" field name', () => {
@@ -127,15 +127,33 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
const byId = new Map(result.current.map((c) => [c.id, c]));
// body + timestamp are locked from the table-X removal pathway.
expect(byId.get('log.body')?.canBeHidden).toBe(false);
expect(byId.get('log.body')?.enableRemove).toBe(false);
expect(byId.get('log.timestamp')?.canBeHidden).toBe(false);
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
expect(byId.get('log:body')?.canBeHidden).toBe(false);
expect(byId.get('log:body')?.enableRemove).toBe(false);
expect(byId.get('log:timestamp')?.canBeHidden).toBe(false);
expect(byId.get('log:timestamp')?.enableRemove).toBe(false);
// User-added fields stay removable. User field has type='' so composite
// collapses to bare name.
expect(byId.get('user_field')?.enableRemove).toBe(true);
});
it('disambiguates same-name/same-context fields by dataType (3-part id)', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [
field('http.status_code', 'attribute', 'int64'),
field('http.status_code', 'attribute', 'string'),
],
fontSize: FontSize.SMALL,
}),
);
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'attribute:http.status_code:int64',
'attribute:http.status_code:string',
]);
});
it('renders only the stateIndicator when fields is empty', () => {
const { result } = renderHook(() =>
useLogsTableColumns({

View File

@@ -92,7 +92,7 @@ export function useLogsTableColumns({
};
const makeUserFieldCol = (f: IField): TableColumnDef<ILog> => ({
id: buildCompositeKey(f.name, f.type),
id: buildCompositeKey(f.name, f.type, f.dataType),
header: f.name,
accessorFn: (log): unknown =>
getLogFieldValue(log, f.name, isBodyJsonEnabled),

View File

@@ -107,14 +107,38 @@ async function renderAndFocus(
return editor;
}
function openRecents(): Promise<void> {
// Re-requests completions while waiting: typing and the async fetches can close the popup.
function waitForRecents(
assertLabels: (labels: string[]) => void,
): Promise<void> {
return waitFor(
() => {
const view = getEditorView();
if (view && !isCompletionOpen()) {
startCompletion(view);
}
expect(getRecentLabels().length).toBeGreaterThan(0);
assertLabels(getRecentLabels());
},
{ timeout: 3000 },
);
}
function openRecents(): Promise<void> {
return waitForRecents((labels) => expect(labels.length).toBeGreaterThan(0));
}
function waitForPopupElement(
find: () => HTMLElement | null | undefined,
): Promise<HTMLElement> {
return waitFor(
() => {
const view = getEditorView();
if (view && !isCompletionOpen()) {
startCompletion(view);
}
const node = find();
expect(node).toBeTruthy();
return node as HTMLElement;
},
{ timeout: 3000 },
);
@@ -132,11 +156,8 @@ describe('QuerySearch recent searches', () => {
await renderAndFocus();
await openRecents();
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([FRONTEND_FILTER]);
},
{ timeout: 3000 },
await waitForRecents((labels) =>
expect(labels).toStrictEqual([FRONTEND_FILTER]),
);
const view = getEditorView() as EditorView;
@@ -152,11 +173,8 @@ describe('QuerySearch recent searches', () => {
await openRecents();
await userEvent.type(editor, 'status_code');
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([STATUS_CODE_FILTER]);
},
{ timeout: 3000 },
await waitForRecents((labels) =>
expect(labels).toStrictEqual([STATUS_CODE_FILTER]),
);
});
@@ -170,11 +188,8 @@ describe('QuerySearch recent searches', () => {
await renderAndFocus();
await openRecents();
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([FRONTEND_FILTER]);
},
{ timeout: 3000 },
await waitForRecents((labels) =>
expect(labels).toStrictEqual([FRONTEND_FILTER]),
);
});
@@ -187,11 +202,8 @@ describe('QuerySearch recent searches', () => {
await openRecents();
await userEvent.type(editor, FRONTEND_FILTER);
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([supersetFilter]);
},
{ timeout: 3000 },
await waitForRecents((labels) =>
expect(labels).toStrictEqual([supersetFilter]),
);
});
@@ -206,11 +218,8 @@ describe('QuerySearch recent searches', () => {
await renderAndFocus();
await openRecents();
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual(expectedLabels);
},
{ timeout: 3000 },
await waitForRecents((labels) =>
expect(labels).toStrictEqual(expectedLabels),
);
});
@@ -221,17 +230,13 @@ describe('QuerySearch recent searches', () => {
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 },
const option = await waitForPopupElement(() =>
Array.from(
document.querySelectorAll<HTMLElement>(COMPLETION_LABEL_SELECTOR),
).find((element) => element.textContent === FRONTEND_FILTER),
);
await userEvent.click(option);
// fireEvent: userEvent's pointerdown blurs the editor, closing the popup before CM's mousedown apply.
fireEvent.mouseDown(option);
await waitFor(
() => {
@@ -256,13 +261,8 @@ describe('QuerySearch recent searches', () => {
await renderAndFocus();
await openRecents();
const deleteButton = await waitFor(
() => {
const button = document.querySelector(DELETE_BUTTON_SELECTOR);
expect(button).toBeInTheDocument();
return button as HTMLElement;
},
{ timeout: 3000 },
const deleteButton = await waitForPopupElement(() =>
document.querySelector<HTMLElement>(DELETE_BUTTON_SELECTOR),
);
// fireEvent: the button preventDefaults pointerdown, which makes userEvent.click drop the mouse chain.

View File

@@ -8,7 +8,7 @@ import {
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import FilterQueryLexer from 'parser/FilterQueryLexer';
import FilterQueryParser, {
AndExpressionContext,

View File

@@ -5,7 +5,7 @@ import {
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { cloneDeep, isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';

View File

@@ -1,4 +1,7 @@
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import {
listRolesSuccessResponse,
managedRoles,
} from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
@@ -18,17 +21,24 @@ const ROLES_ENDPOINT = '*/api/v1/roles';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_DELETE_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
const SA_ROLES_ENDPOINT = '*/api/v1/service_account_roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_account_roles/:id';
const activeAccountResponse = {
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
serviceAccountRoles: [
{
id: 'sar-admin-1',
serviceAccountId: 'sa-1',
roleId: managedRoles[0].id,
role: { ...managedRoles[0], transactionGroups: [] },
},
],
};
function renderDrawer(
@@ -58,22 +68,13 @@ function setupBaseHandlers(): void {
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: listRolesSuccessResponse.data.filter(
(r) => r.name === 'signoz-admin',
),
}),
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
),
),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => res(ctx.status(204))),
);
}

View File

@@ -1,4 +1,7 @@
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import {
listRolesSuccessResponse,
managedRoles,
} from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
@@ -10,23 +13,33 @@ const ROLES_ENDPOINT = '*/api/v1/roles';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_DELETE_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
const SA_ROLES_ENDPOINT = '*/api/v1/service_account_roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_account_roles/:id';
const ADMIN_ASSIGNMENT_ID = 'sar-admin-1';
const activeAccountResponse = {
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
serviceAccountRoles: [
{
id: ADMIN_ASSIGNMENT_ID,
serviceAccountId: 'sa-1',
roleId: managedRoles[0].id,
role: { ...managedRoles[0], transactionGroups: [] },
},
],
};
const deletedAccountResponse = {
...activeAccountResponse,
id: 'sa-2',
status: 'DELETED',
serviceAccountRoles: [],
};
function renderDrawer(
@@ -58,22 +71,13 @@ describe('ServiceAccountDrawer', () => {
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: listRolesSuccessResponse.data.filter(
(r) => r.name === 'signoz-admin',
),
}),
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
),
),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => res(ctx.status(204))),
setupAuthzAdmin(),
);
});
@@ -136,11 +140,14 @@ describe('ServiceAccountDrawer', () => {
server.use(
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
roleSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
return res(
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
);
}),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => {
deleteSpy();
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
return res(ctx.status(204));
}),
);
@@ -159,7 +166,8 @@ describe('ServiceAccountDrawer', () => {
await waitFor(() => {
expect(roleSpy).toHaveBeenCalledWith(
expect.objectContaining({
id: '019c24aa-2248-7585-a129-4188b3473c27',
serviceAccountId: 'sa-1',
roleId: '019c24aa-2248-7585-a129-4188b3473c27',
}),
);
expect(deleteSpy).not.toHaveBeenCalled();
@@ -174,11 +182,14 @@ describe('ServiceAccountDrawer', () => {
server.use(
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
roleSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
return res(
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
);
}),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => {
deleteSpy();
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
rest.delete(SA_ROLE_DELETE_ENDPOINT, (req, res, ctx) => {
deleteSpy(req.params.id);
return res(ctx.status(204));
}),
);
@@ -198,7 +209,7 @@ describe('ServiceAccountDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(deleteSpy).toHaveBeenCalled();
expect(deleteSpy).toHaveBeenCalledWith(ADMIN_ASSIGNMENT_ID);
expect(roleSpy).not.toHaveBeenCalled();
});
});
@@ -246,9 +257,6 @@ describe('ServiceAccountDrawer', () => {
rest.get('*/api/v1/service_accounts/sa-2/keys', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get('*/api/v1/service_accounts/sa-2/roles', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
);
renderDrawer({ account: 'sa-2' });
@@ -312,22 +320,13 @@ describe('ServiceAccountDrawer save-error UX', () => {
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: listRolesSuccessResponse.data.filter(
(r) => r.name === 'signoz-admin',
),
}),
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
),
),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => res(ctx.status(204))),
setupAuthzAdmin(),
);
});
@@ -410,14 +409,17 @@ describe('ServiceAccountDrawer save-error UX', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let roleAddCallCount = 0;
// First call → 429, second call → 200
// First call → 429, second call → 201
server.use(
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) => {
roleAddCallCount += 1;
if (roleAddCallCount === 1) {
return res(ctx.status(429), ctx.json({ message: 'Too Many Requests' }));
}
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
return res(
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
);
}),
);

View File

@@ -44,6 +44,13 @@
--tanstack-first-column-header-bg,
var(--tanstack-table-header-cell-bg, var(--l2-background))
) !important;
padding-left: var(
--tanstack-cell-header-padding-left-first-column,
var(
--tanstack-cell-header-padding-left-override,
var(--tanstack-cell-padding-left, 0.3rem)
)
);
}
}

View File

@@ -161,7 +161,7 @@
.tableHeaderCell {
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
height: 36px;
height: var(--tanstack-table-header-height, 36px);
text-align: left;
font-size: 14px;
font-style: normal;

View File

@@ -664,6 +664,7 @@ function TanStackTableInner<TData, TItemKey = string>(
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => {
value ??= '10';
setLimit(+value);
pagination.onLimitChange?.(+value);
if (page !== 1) {

View File

@@ -0,0 +1,280 @@
import { renderHook } from '@testing-library/react';
import {
useRecoverFromEmptyPage,
UseRecoverFromEmptyPageParams,
} from '../useRecoverFromEmptyPage';
const REPLACE = { history: 'replace' };
function renderRecovery(
overrides: Partial<UseRecoverFromEmptyPageParams> = {},
): { setPage: jest.Mock; rerender: (next?: unknown) => void } {
const setPage = jest.fn();
const props: UseRecoverFromEmptyPageParams = {
page: 1,
pageSize: 10,
rowCount: 10,
total: 100,
isFetching: false,
setPage,
...overrides,
};
const { rerender } = renderHook(
(next: UseRecoverFromEmptyPageParams) => useRecoverFromEmptyPage(next),
{ initialProps: props },
);
return {
setPage,
rerender: (next?: unknown): void =>
rerender({ ...props, ...(next as Partial<UseRecoverFromEmptyPageParams>) }),
};
}
describe('useRecoverFromEmptyPage', () => {
it('leaves the page alone while it still holds rows', () => {
const { setPage } = renderRecovery({ page: 3, rowCount: 10 });
expect(setPage).not.toHaveBeenCalled();
});
it('leaves the page alone on page 1 with no rows at all', () => {
const { setPage } = renderRecovery({ page: 1, rowCount: 0, total: 0 });
expect(setPage).not.toHaveBeenCalled();
});
it('jumps to the last page that holds data when the page is out of range', () => {
const { setPage } = renderRecovery({
page: 7,
pageSize: 10,
rowCount: 0,
total: 25,
});
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
});
it('replaces the history entry so the back button does not return to the empty page', () => {
const { setPage } = renderRecovery({ page: 4, rowCount: 0, total: 10 });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('falls back to page 1 when the total is unknown', () => {
const { setPage } = renderRecovery({ page: 5, rowCount: 0, total: 0 });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('steps back one page when the total claims the page should have data', () => {
// total says 100 rows exist, yet page 5 came back empty — step back rather
// than stall on a page the query cannot actually serve.
const { setPage } = renderRecovery({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
});
expect(setPage).toHaveBeenCalledWith(4, REPLACE);
});
it('clamps a page below the first one', () => {
const { setPage } = renderRecovery({ page: 0, rowCount: 10 });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('falls back to page 1 when pageSize is zero', () => {
const { setPage } = renderRecovery({
page: 5,
pageSize: 0,
rowCount: 0,
total: 100,
});
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('waits for the request to settle before moving the user', () => {
const { setPage, rerender } = renderRecovery({
page: 3,
rowCount: 0,
total: 10,
isFetching: true,
});
expect(setPage).not.toHaveBeenCalled();
rerender({ page: 3, rowCount: 0, total: 10, isFetching: false });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('clamps a page below the first one even when the query failed', () => {
// A negative offset is what made the request fail (400 "offset cannot be
// negative"), so retrying the same page loops forever — clamp regardless.
const { setPage } = renderRecovery({
page: 0,
rowCount: 0,
total: 0,
isDisabled: true,
});
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('clamps a page below the first one while the query is still in flight', () => {
const { setPage } = renderRecovery({
page: -2,
rowCount: 0,
isFetching: true,
});
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('keeps the page when the query failed so a retry lands where the user was', () => {
const { setPage } = renderRecovery({
page: 3,
rowCount: 0,
total: 0,
isDisabled: true,
});
expect(setPage).not.toHaveBeenCalled();
});
it('clamps a page below the first one exactly once while the request settles', () => {
// The clamp runs ahead of both gates, so a request settling underneath an
// uncorrected page must not re-issue the same history rewrite.
const { setPage, rerender } = renderRecovery({
page: 0,
rowCount: 0,
total: 0,
isFetching: true,
});
expect(setPage).toHaveBeenCalledTimes(1);
rerender({ page: 0, rowCount: 0, total: 0, isFetching: false });
expect(setPage).toHaveBeenCalledTimes(1);
});
it('stops correcting once the corrected page comes back with rows', () => {
const { setPage, rerender } = renderRecovery({
page: 7,
pageSize: 10,
rowCount: 0,
total: 25,
});
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
// The correction lands: the query refetches, then resolves with the rows page 3 holds.
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 25, isFetching: true });
rerender({
page: 3,
pageSize: 10,
rowCount: 5,
total: 25,
isFetching: false,
});
expect(setPage).toHaveBeenCalledTimes(1);
});
it('does not correct again while the same page is still being observed', () => {
const { setPage, rerender } = renderRecovery({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
});
expect(setPage).toHaveBeenCalledTimes(1);
// A refetch cycle that leaves the page untouched — the correction is already in flight.
rerender({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
isFetching: true,
});
rerender({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
isFetching: false,
});
expect(setPage).toHaveBeenCalledTimes(1);
});
it('gives up on step-backs and jumps to page 1 when the total keeps lying', () => {
// `total` claims 400 rows exist, but every page comes back empty. Walking back one
// page at a time would cost a request per hop, so bail out to page 1 instead.
const { setPage, rerender } = renderRecovery({
page: 40,
pageSize: 10,
rowCount: 0,
total: 400,
});
expect(setPage).toHaveBeenNthCalledWith(1, 39, REPLACE);
rerender({ page: 39, pageSize: 10, rowCount: 0, total: 400 });
expect(setPage).toHaveBeenNthCalledWith(2, 38, REPLACE);
rerender({ page: 38, pageSize: 10, rowCount: 0, total: 400 });
expect(setPage).toHaveBeenNthCalledWith(3, 1, REPLACE);
expect(setPage).toHaveBeenCalledTimes(3);
});
it('corrects again when the user returns to a page that is still empty', () => {
const { setPage, rerender } = renderRecovery({
page: 3,
pageSize: 10,
rowCount: 0,
total: 10,
});
expect(setPage).toHaveBeenNthCalledWith(1, 1, REPLACE);
rerender({ page: 1, pageSize: 10, rowCount: 10, total: 10 });
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 10 });
expect(setPage).toHaveBeenNthCalledWith(2, 1, REPLACE);
});
it('does not re-run the correction when setPage is a fresh function each render', () => {
// The hook reads setPage through a ref, so an inline arrow must not turn the
// ungated `page < 1` clamp into a per-render history rewrite.
const setPage = jest.fn();
const { rerender } = renderHook(
() =>
useRecoverFromEmptyPage({
page: 0,
pageSize: 10,
rowCount: 0,
total: 0,
isFetching: false,
setPage: (nextPage, options): void => setPage(nextPage, options),
}),
{ initialProps: undefined },
);
rerender(undefined);
rerender(undefined);
expect(setPage).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,91 @@
import { renderHook } from '@testing-library/react';
import {
useStableTotalCount,
UseStableTotalCountParams,
} from '../useStableTotalCount';
function renderStableTotalCount(initial: UseStableTotalCountParams): {
result: { current: number };
rerender: (next: UseStableTotalCountParams) => void;
} {
const { result, rerender } = renderHook(
(params: UseStableTotalCountParams) => useStableTotalCount(params),
{ initialProps: initial },
);
return { result, rerender };
}
describe('useStableTotalCount', () => {
it('reports the total once the load settles', () => {
const { result } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
expect(result.current).toBe(100);
});
it('keeps the last count while the same list refetches', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
rerender({ total: 0, isLoading: true, resetKey: 'pods' });
expect(result.current).toBe(100);
});
it('forgets the count when the list changes', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
rerender({ total: 0, isLoading: true, resetKey: 'nodes' });
expect(result.current).toBe(0);
});
it('reports the new list total once it arrives', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
rerender({ total: 0, isLoading: true, resetKey: 'nodes' });
rerender({ total: 25, isLoading: false, resetKey: 'nodes' });
expect(result.current).toBe(25);
});
it('reports zero for a list that is genuinely empty', () => {
const { result } = renderStableTotalCount({
total: 0,
isLoading: false,
resetKey: 'pods',
});
expect(result.current).toBe(0);
});
it('holds nothing back when no reset key is given', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: undefined,
});
// Without a key there is no context to compare, so the cache behaves as it
// always did: it survives the refetch.
rerender({ total: 0, isLoading: true, resetKey: undefined });
expect(result.current).toBe(100);
});
});

View File

@@ -9,6 +9,7 @@ export * from './useCalculatedPageSize';
export * from './useColumnState';
export * from './useColumnStore';
export * from './usePreferredPageSize.store';
export * from './useRecoverFromEmptyPage';
export * from './useTableParams';
/**
@@ -285,6 +286,51 @@ export * from './useTableParams';
*
* **Pagination shows "Auto" option** when `calculatedPageSize` is passed, allowing users
* to reset to auto-calculated size.
*
* **`setPage` accepts history options**: `setPage(page, { history: 'replace' })` rewrites the
* current history entry instead of pushing a new one. Use `replace` for corrections the user
* did not ask for — otherwise the back button walks straight back into the state that was just
* corrected. Only applies when the page is synced to the URL; local (non-URL) pages ignore it.
*
* @example useRecoverFromEmptyPage — send the user back to a page that has data
*
* When rows disappear underneath the current page (filters narrowed, time range moved, items
* deleted), the user is stranded on an empty page they cannot leave by scrolling. This hook
* watches the fetched result and corrects the page with `history: 'replace'`, so the back
* button does not return to the empty page.
*
* Correction rules:
* - `page < 1` → jump to page 1, even while fetching or disabled. Such a page usually maps to a
* negative offset the API rejects (400 `offset cannot be negative`), so the response can never
* confirm the page is empty — deferring to it would strand the user on a permanent error.
* - Page is empty and not page 1 → go to `min(ceil(total / pageSize), page - 1)`. When `total`
* is trustworthy that lands on the last page holding data; when `total` is unknown or zero it
* lands on page 1; and when `total` claims this page should have had rows it steps back a
* single page. Repeated step-backs give up and jump to page 1 after the second one, so a
* badly inflated `total` cannot walk the user down one request at a time.
* - Page has rows, or the user is already on page 1 → do nothing (an empty page 1 means there
* is genuinely nothing to show).
*
* Pass `isFetching` so the hook waits for the request to settle, and `isDisabled` so a failed
* request is not mistaken for an empty page. Neither gate suppresses the `page < 1` clamp.
*
* ```tsx
* import { useRecoverFromEmptyPage, useTableParams } from 'components/TanStackTableView';
*
* const { page, limit, setPage } = useTableParams(QUERY_PARAMS, { page: 1, limit: 20 });
* const { data, isLoading, isFetching, isError } = useListQuery({ page, limit });
*
* useRecoverFromEmptyPage({
* page,
* pageSize: limit,
* rowCount: data?.rows.length ?? 0,
* total: data?.total ?? 0,
* isFetching: isLoading || isFetching,
* // Skip correction on errors — no rows there means "request failed", not "page is empty".
* isDisabled: isError,
* setPage,
* });
* ```
*/
const TanStackTable = Object.assign(TanStackTableBase, {
Text: TanStackTableText,

View File

@@ -0,0 +1,107 @@
import { useEffect, useRef } from 'react';
import { SetPageOptions } from './useTableParams';
const FIRST_PAGE = 1;
const REPLACE_HISTORY: SetPageOptions = { history: 'replace' };
/**
* How many single-page step-backs to attempt before giving up and going to page 1.
*
* A step-back only happens when `total` claims the current page should hold data but the
* response came back empty. Each hop costs a request, so an inflated `total` on a high page
* number would otherwise walk the user down one page at a time behind a spinner.
*/
const MAX_STEP_BACKS = 2;
type Correction = {
from: number;
to: number;
};
export type UseRecoverFromEmptyPageParams = {
page: number;
pageSize: number;
rowCount: number;
total: number;
isFetching: boolean;
isDisabled?: boolean;
setPage: (page: number, options?: SetPageOptions) => void;
};
export function useRecoverFromEmptyPage({
page,
pageSize,
rowCount,
total,
isFetching,
isDisabled = false,
setPage,
}: UseRecoverFromEmptyPageParams): void {
const setPageRef = useRef(setPage);
const lastCorrectionRef = useRef<Correction | null>(null);
const stepBacksRef = useRef(0);
useEffect(() => {
setPageRef.current = setPage;
});
useEffect(() => {
if (lastCorrectionRef.current && lastCorrectionRef.current.from !== page) {
lastCorrectionRef.current = null;
}
const correctTo = (nextPage: number): boolean => {
if (lastCorrectionRef.current?.to === nextPage) {
return false;
}
lastCorrectionRef.current = { from: page, to: nextPage };
setPageRef.current(nextPage, REPLACE_HISTORY);
return true;
};
// A page below the first one is invalid on its own terms — it usually maps to a
// negative offset the API rejects outright, so waiting for a response that will
// never arrive (or trusting a failed one) would strand the user for good.
if (page < FIRST_PAGE) {
stepBacksRef.current = 0;
void correctTo(FIRST_PAGE);
return;
}
if (isFetching || isDisabled) {
return;
}
// The page has data, or there is genuinely nothing to show anywhere.
if (rowCount > 0 || page === FIRST_PAGE) {
stepBacksRef.current = 0;
return;
}
const currentPage = Math.floor(page);
const lastPageWithData =
pageSize > 0 && total > 0 ? Math.ceil(total / pageSize) : FIRST_PAGE;
const nextPage = Math.max(
FIRST_PAGE,
Math.min(lastPageWithData, currentPage - 1),
);
// `total` disagrees with the response: it says this page should have rows, so the
// only safe move is one page back. Cap how often that repeats — every hop is a
// request, and a badly inflated `total` would otherwise crawl down from page 40.
const isStepBack = nextPage === currentPage - 1;
if (isStepBack && stepBacksRef.current >= MAX_STEP_BACKS) {
if (correctTo(FIRST_PAGE)) {
stepBacksRef.current = 0;
}
return;
}
if (correctTo(nextPage) && isStepBack) {
stepBacksRef.current += 1;
}
}, [isFetching, isDisabled, page, pageSize, rowCount, total]);
}

View File

@@ -0,0 +1,35 @@
import { useRef } from 'react';
export type UseStableTotalCountParams = {
total: number | undefined;
isLoading: boolean;
/**
* Identifies the list being counted. When it changes, the cached count is
* dropped so the previous list's page count cannot outlive it.
*/
resetKey: string | undefined;
};
/**
* Holds on to the last non-zero total so the pagination does not flash while the
* same list refetches, and forgets it as soon as `resetKey` moves to another list.
*/
export function useStableTotalCount({
total,
isLoading,
resetKey,
}: UseStableTotalCountParams): number {
const prevTotalRef = useRef(total || 0);
const prevResetKeyRef = useRef(resetKey);
if (prevResetKeyRef.current !== resetKey) {
prevResetKeyRef.current = resetKey;
prevTotalRef.current = 0;
}
if (total && total > 0) {
prevTotalRef.current = total;
}
return isLoading ? prevTotalRef.current : total || 0;
}

View File

@@ -29,12 +29,16 @@ type Defaults = {
cleanupOnUnmount?: boolean;
};
export type SetPageOptions = {
history?: 'push' | 'replace';
};
export type TableParamsResult = {
page: number;
limit: number;
orderBy: SortState | null;
expanded: ExpandedState;
setPage: (p: number) => void;
setPage: (p: number, options?: SetPageOptions) => void;
setLimit: (l: number) => void;
setOrderBy: (s: SortState | null) => void;
setExpanded: (updaterOrValue: Updater<ExpandedState>) => void;
@@ -249,6 +253,17 @@ export function useTableParams(
[],
);
const setUrlPageWithOptions = useCallback(
(page: number, options?: SetPageOptions): void => {
void setUrlPage(page, options);
},
[setUrlPage],
);
const setLocalPageValue = useCallback((page: number): void => {
setLocalPage(page);
}, []);
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
const prevOrderByRef = useRef<string | null>(null);
@@ -303,7 +318,7 @@ export function useTableParams(
limit: useUrlForLimit ? urlLimit : localLimit,
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
setPage: useUrlForPage ? setUrlPage : setLocalPage,
setPage: useUrlForPage ? setUrlPageWithOptions : setLocalPageValue,
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist,
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,

View File

@@ -11,9 +11,9 @@ export enum LOCALSTORAGE {
TRACES_LIST_OPTIONS = 'TRACES_LIST_OPTIONS',
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOG_DETAILS_V2 = 'LOG_DETAILS_V2',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
LOGGED_IN_USER_EMAIL = 'LOGGED_IN_USER_EMAIL',
CHAT_SUPPORT = 'CHAT_SUPPORT',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import BarChart from 'container/DashboardContainer/visualization/charts/BarChart
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { StackMode } from 'lib/uPlotV2/config/types';
import {
LegendPosition,
TooltipRenderArgs,
@@ -131,9 +132,9 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
<div ref={graphRef} className={styles.graphContainer}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
stack={StackMode.Normal}
config={config}
data={chartData}
isStackedBarChart
legendConfig={{ position: LegendPosition.BOTTOM }}
customTooltip={renderBillingTooltip}
width={containerDimensions.width}

View File

@@ -58,26 +58,17 @@ describe('prepareBillingBarConfig', () => {
expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500);
});
it('sets stacking bands, padding, and focus alpha for behavioral parity', () => {
it('sets padding and focus alpha for behavioral parity', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
});
const config = builder.getConfig();
expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
// Stacking bands come from the chart now — see useChartStacking.
expect(config.padding).toStrictEqual([32, 32, 16, 16]);
expect(config.focus).toStrictEqual({ alpha: 0.3 });
});
it('sets no bands when result is empty', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse([]),
});
const config = builder.getConfig();
expect(config.bands).toBeUndefined();
});
it('uses queryName as label when legend is undefined', () => {
const apiResponse: MetricRangePayloadProps = {
data: {

View File

@@ -1,7 +1,6 @@
import { Color } from '@signozhq/design-tokens';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
@@ -63,7 +62,6 @@ export function prepareBillingBarConfig({
});
});
builder.setBands(getInitialStackedBands(results.length));
builder.setPadding([32, 32, 16, 16]);
builder.setFocus({ alpha: 0.3 });

View File

@@ -0,0 +1,6 @@
.container {
display: flex;
align-items: center;
gap: 0.5rem;
--button-font-size: var(--periscope-font-size-base, 13px);
}

View File

@@ -1,11 +1,12 @@
import { memo, useMemo } from 'react';
import { ChevronLeft, ChevronRight } from '@signozhq/icons';
import { Button, Flex, Select } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Select } from 'antd';
import { DEFAULT_PER_PAGE_OPTIONS, Pagination } from 'hooks/queryPagination';
import { popupContainer } from 'utils/selectPopupContainer';
import { defaultSelectStyle } from './config';
import { Container } from './styles';
import styles from './Controls.module.scss';
function Controls({
offset = 0,
@@ -34,28 +35,24 @@ function Controls({
);
return (
<Container>
<div className={styles.container}>
<Button
loading={isLoading}
size="small"
type="link"
variant="link"
size="md"
disabled={isPreviousDisabled}
prefix={<ChevronLeft size={16} />}
onClick={handleNavigatePrevious}
>
<Flex align="center" gap="4px">
<ChevronLeft size={16} /> Previous
</Flex>
Previous
</Button>
<Button
loading={isLoading}
size="small"
type="link"
variant="link"
size="md"
disabled={isNextDisabled}
suffix={<ChevronRight size={16} />}
onClick={handleNavigateNext}
>
<Flex align="center" gap="4px">
Next <ChevronRight size={16} />
</Flex>
Next
</Button>
{showSizeChanger && (
@@ -74,7 +71,7 @@ function Controls({
))}
</Select>
)}
</Container>
</div>
);
}

View File

@@ -1,7 +0,0 @@
import styled from 'styled-components';
export const Container = styled.div`
display: flex;
align-items: center;
gap: 0.5rem;
`;

View File

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

View File

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

View File

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

View File

@@ -6,25 +6,24 @@ import {
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { useBarChartStacking } from '../../hooks/useBarChartStacking';
import { StackMode } from 'lib/uPlotV2/config/types';
import { BarChartProps } from '../types';
export default function BarChart(props: BarChartProps): JSX.Element {
const {
children,
isStackedBarChart,
customTooltip,
config,
data,
stack = StackMode.None,
pinnedTooltipElement,
...rest
} = props;
const chartData = useBarChartStacking({
data,
isStackedBarChart,
config,
});
// Written during render so it lands before UPlotChart's effect reads the config,
// which derives the fill bands, percent axis unit and percent range from it.
config.setStackMode(stack);
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
@@ -37,7 +36,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
isStackedBarChart: isStackedBarChart,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
@@ -48,7 +46,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
rest.timezone,
rest.yAxisUnit,
rest.decimalPrecision,
isStackedBarChart,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
@@ -58,7 +55,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
<ChartWrapper
{...rest}
config={config}
data={chartData}
data={data}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>

View File

@@ -6,12 +6,15 @@ import {
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart/UPlotChart';
import { StackMode } from 'lib/uPlotV2/config/types';
import { prepareAlignedData } from 'lib/uPlotV2/components/UPlotChart/utils';
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import noop from 'lodash-es/noop';
import uPlot from 'uplot';
import { ChartProps } from '../types';
import { ChartWrapperProps } from '../types';
import { useChartStacking } from './useChartStacking';
const TOOLTIP_WIDTH_PADDING = 120;
const TOOLTIP_MIN_WIDTH = 300;
@@ -39,9 +42,20 @@ export default function ChartWrapper({
pinnedTooltipElement,
tooltipPortalRoot,
'data-testid': testId,
}: ChartProps): JSX.Element {
}: ChartWrapperProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
const stack = config.getStackMode();
const chartData = useChartStacking({ data, config });
// Tooltips need pre-stack values, gap-processed exactly as UPlotChart processes the
// plot data — otherwise the cursor's index addresses a shorter array.
const unstackedData = useMemo(
() =>
stack === StackMode.None ? undefined : prepareAlignedData({ data, config }),
[data, config, stack],
);
const legendComponent = useCallback(
(averageLegendWidth: number): React.ReactNode => {
if (!showLegend) {
@@ -61,11 +75,11 @@ export default function ChartWrapper({
const renderTooltipCallback = useCallback(
(args: TooltipRenderArgs): React.ReactNode => {
if (customTooltip) {
return customTooltip(args);
return customTooltip({ ...args, unstackedData });
}
return null;
},
[customTooltip],
[customTooltip, unstackedData],
);
const syncMetadata = useMemo(
@@ -91,7 +105,7 @@ export default function ChartWrapper({
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
<UPlotChart
config={config}
data={data}
data={chartData}
width={chartWidth}
height={chartHeight}
plotRef={(plot): void => {

View File

@@ -0,0 +1,98 @@
import { renderHook } from '@testing-library/react';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { StackMode } from 'lib/uPlotV2/config/types';
import uPlot from 'uplot';
import { useChartStacking } from '../useChartStacking';
type Hooks = Record<string, (...args: unknown[]) => void>;
function createConfig(stack: StackMode): {
config: UPlotConfigBuilder;
hooks: Hooks;
} {
const hooks: Hooks = {};
const config = {
getStackMode: (): StackMode => stack,
addHook: jest.fn((type: string, hook: (...args: unknown[]) => void) => {
hooks[type] = hook;
return jest.fn();
}),
} as unknown as UPlotConfigBuilder;
return { config, hooks };
}
const data = [[1], [30], [10]] as unknown as uPlot.AlignedData;
describe('useChartStacking', () => {
it('returns the data untouched and registers nothing when the config says `none`', () => {
const { config } = createConfig(StackMode.None);
const { result } = renderHook(() => useChartStacking({ data, config }));
expect(result.current).toBe(data);
expect(config.addHook).not.toHaveBeenCalled();
});
it('treats a missing config as unstacked', () => {
const { result } = renderHook(() => useChartStacking({ data, config: null }));
expect(result.current).toBe(data);
});
it('accumulates raw values when the config declares `normal`', () => {
const { config } = createConfig(StackMode.Normal);
const { result } = renderHook(() => useChartStacking({ data, config }));
expect(result.current).toStrictEqual([[1], [40], [10]]);
});
it('rescales each column to its total when the config declares `percent`', () => {
const { config } = createConfig(StackMode.Percent);
const { result } = renderHook(() => useChartStacking({ data, config }));
expect(result.current).toStrictEqual([[1], [100], [25]]);
});
it('registers the uPlot hooks that re-stack on data and visibility changes', () => {
const { config } = createConfig(StackMode.Normal);
renderHook(() => useChartStacking({ data, config }));
expect(
(config.addHook as jest.Mock).mock.calls.map(([type]) => type),
).toStrictEqual(['setData', 'setSeries']);
});
it('re-stacks from the raw values when the legend hides a series', () => {
const { config, hooks } = createConfig(StackMode.Normal);
renderHook(() => useChartStacking({ data, config }));
const plot = {
data: [[1]],
series: [{}, { show: true }, { show: false }],
delBand: jest.fn(),
addBand: jest.fn(),
setData: jest.fn(),
};
hooks.setSeries(plot, 2, { show: false });
// The hidden series keeps its raw value and stops contributing to the total.
expect(plot.setData).toHaveBeenCalledWith([[1], [30], [10]]);
expect(plot.delBand).toHaveBeenCalledWith(null);
});
it('ignores a focus-only setSeries so hovering does not re-stack', () => {
const { config, hooks } = createConfig(StackMode.Normal);
renderHook(() => useChartStacking({ data, config }));
const plot = {
data: [[1]],
series: [{}, { show: true }, { show: true }],
delBand: jest.fn(),
addBand: jest.fn(),
setData: jest.fn(),
};
hooks.setSeries(plot, 1, { focus: true });
expect(plot.setData).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,132 @@
import {
MutableRefObject,
useCallback,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { StackMode } from 'lib/uPlotV2/config/types';
import { has } from 'lodash-es';
import uPlot from 'uplot';
import { stackSeries } from '../utils/stackSeriesUtils';
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
return !plot.series[seriesIndex]?.show;
}
function canApplyStacking(
unstackedData: uPlot.AlignedData | null,
plot: uPlot,
isUpdating: boolean,
): boolean {
return (
!isUpdating &&
!!unstackedData &&
!!plot.data &&
unstackedData[0]?.length === plot.data[0]?.length
);
}
function setupStackingHooks(
config: UPlotConfigBuilder,
updateStacksInChart: (plot: uPlot) => void,
isUpdatingRef: MutableRefObject<boolean>,
): () => void {
const onDataChange = (plot: uPlot): void => {
if (!isUpdatingRef.current) {
updateStacksInChart(plot);
}
};
const onSeriesVisibilityChange = (
plot: uPlot,
_seriesIdx: number | null,
opts: uPlot.Series,
): void => {
// uPlot fires setSeries for hover focus too; only visibility changes restack.
if (!has(opts, 'focus')) {
updateStacksInChart(plot);
}
};
const removeSetDataHook = config.addHook('setData', onDataChange);
const removeSetSeriesHook = config.addHook(
'setSeries',
onSeriesVisibilityChange,
);
return (): void => {
removeSetDataHook?.();
removeSetSeriesHook?.();
};
}
export interface UseChartStackingParams {
data: uPlot.AlignedData;
config: UPlotConfigBuilder | null;
}
/**
* Stacks a chart's data for the mode declared on its config, and re-stacks on data or
* visibility changes. The pre-stack values live in a ref because the uPlot hooks that
* read them run outside React's render cycle.
*/
export function useChartStacking({
data,
config,
}: UseChartStackingParams): uPlot.AlignedData {
const stack = config?.getStackMode() ?? StackMode.None;
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
unstackedDataRef.current = stack === 'none' ? null : data;
// Guards the re-entrant setData below, which would otherwise re-trigger our own hook.
const isUpdatingChartRef = useRef(false);
const chartData = useMemo((): uPlot.AlignedData => {
if (stack === StackMode.None || !data || data.length < 2) {
return data;
}
const noSeriesHidden = (): boolean => false; // include all series in initial stack
return stackSeries(data, noSeriesHidden, stack).data;
}, [data, stack]);
const updateStacksInChart = useCallback(
(plot: uPlot): void => {
const unstacked = unstackedDataRef.current;
if (
!unstacked ||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
) {
return;
}
const shouldExcludeSeries = (idx: number): boolean =>
isSeriesHidden(plot, idx);
const { data: stacked, bands } = stackSeries(
unstacked,
shouldExcludeSeries,
stack,
);
plot.delBand(null);
bands.forEach((band: uPlot.Band) => plot.addBand(band));
isUpdatingChartRef.current = true;
plot.setData(stacked);
isUpdatingChartRef.current = false;
},
[stack],
);
useLayoutEffect(() => {
if (stack === StackMode.None || !config) {
return undefined;
}
return setupStackingHooks(config, updateStacksInChart, isUpdatingChartRef);
}, [stack, config, updateStacksInChart]);
return chartData;
}

View File

@@ -6,10 +6,16 @@ import {
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { StackMode } from 'lib/uPlotV2/config/types';
import { TimeSeriesChartProps } from '../types';
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
const { children, customTooltip, ...rest } = props;
const { children, customTooltip, stack = StackMode.None, ...rest } = props;
// Written during render so it lands before UPlotChart's effect reads the config,
// which derives the fill bands, percent axis unit and percent range from it.
rest.config.setStackMode(stack);
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {

View File

@@ -14,6 +14,7 @@ import {
ChartClickData,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { StackMode } from 'lib/uPlotV2/config/types';
interface BaseChartProps {
width: number;
@@ -52,27 +53,26 @@ interface UPlotChartDataProps {
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
}
export interface TimeSeriesChartProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
/** Everything the shared uPlot shell consumes; each chart's props narrow it. */
export interface ChartWrapperProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {}
export interface TimeSeriesChartProps extends ChartWrapperProps {
timezone?: Timezone;
/** How series compose. Defaults to `none`, which draws them independently. */
stack?: StackMode;
}
export interface HistogramChartProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
export interface BarChartProps extends ChartWrapperProps {
timezone?: Timezone;
/** How series compose. Defaults to `none`, which draws them independently. */
stack?: StackMode;
}
export interface HistogramChartProps extends ChartWrapperProps {
isQueriesMerged?: boolean;
}
export interface BarChartProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
isStackedBarChart?: boolean;
timezone?: Timezone;
}
export type ChartProps =
| TimeSeriesChartProps
| BarChartProps
| HistogramChartProps;
/**
* One resolved pie/donut slice: a display label, its (already parsed) positive
* numeric value, and the colour used for the arc + legend swatch.

View File

@@ -0,0 +1,158 @@
import { AlignedData } from 'uplot';
import { StackMode } from 'lib/uPlotV2/config/types';
import { stackSeries } from '../stackSeriesUtils';
const includeAll = (): boolean => false;
// Stacking is top-down: the first series carries the column total, the last its own
// raw value. Every expectation below reads in that order.
describe('stackSeries', () => {
it('is a no-op under `none`, returning the data and no bands', () => {
const data: AlignedData = [[1], [30], [10]];
const { data: result, bands } = stackSeries(data, includeAll, StackMode.None);
expect(result).toBe(data);
expect(bands).toStrictEqual([]);
});
describe('normal', () => {
it('accumulates raw values from the bottom series upward', () => {
const data: AlignedData = [
[1, 2],
[10, 20],
[1, 2],
];
expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([
[1, 2],
[11, 22],
[1, 2],
]);
});
it('treats nulls as 0 without breaking the running total', () => {
const data: AlignedData = [
[1, 2],
[10, null],
[1, 2],
];
expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([
[1, 2],
[11, 2],
[1, 2],
]);
});
it('emits one band per adjacent pair of participating series', () => {
const data: AlignedData = [[1], [10], [5], [1]];
expect(stackSeries(data, includeAll, StackMode.Normal).bands).toStrictEqual([
{ series: [1, 2] },
{ series: [2, 3] },
]);
});
it('copies omitted series through unstacked and skips their bands', () => {
const data: AlignedData = [[1], [10], [5], [1]];
const omitMiddle = (seriesIndex: number): boolean => seriesIndex === 2;
const { data: stacked, bands } = stackSeries(
data,
omitMiddle,
StackMode.Normal,
);
expect(stacked).toStrictEqual([[1], [11], [5], [1]]);
expect(bands).toStrictEqual([{ series: [1, 3] }]);
});
});
describe('percent', () => {
it('rescales each column to its total so the top series reads 100', () => {
const data: AlignedData = [
[1, 2],
[30, 10],
[10, 10],
];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1, 2],
[100, 100],
[25, 50],
]);
});
it('normalises per column, so an identical series differs across x', () => {
const data: AlignedData = [
[1, 2],
[1, 3],
[1, 1],
];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1, 2],
[100, 100],
[50, 25],
]);
});
it('excludes omitted series from the total, so the visible ones still reach 100', () => {
const data: AlignedData = [[1], [30], [10], [60]];
const omitLast = (seriesIndex: number): boolean => seriesIndex === 3;
expect(stackSeries(data, omitLast, StackMode.Percent).data).toStrictEqual([
[1],
[100],
[25],
[60],
]);
});
it('yields 0 for a column whose participating series sum to zero', () => {
const data: AlignedData = [
[1, 2],
[0, 5],
[0, 5],
];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1, 2],
[0, 100],
[0, 50],
]);
});
it('divides by the signed total when a column mixes signs', () => {
// 30 + (-10) = 20, so the shares are 150% and -50% and still sum to 100.
const data: AlignedData = [[1], [30], [-10]];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1],
[100],
[-50],
]);
});
it('yields 0 across a column whose signed total cancels to zero', () => {
const data: AlignedData = [[1], [10], [-10]];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1],
[0],
[0],
]);
});
});
it('defaults to normal when no mode is given', () => {
const data: AlignedData = [[1], [30], [10]];
expect(stackSeries(data, includeAll).data).toStrictEqual(
stackSeries(data, includeAll, StackMode.Normal).data,
);
});
});

View File

@@ -1,117 +0,0 @@
import { AlignedData } from 'uplot';
import { getInitialStackedBands, stack } from '../stackUtils';
describe('stackUtils', () => {
describe('stack', () => {
const neverOmit = (): boolean => false;
it('preserves time axis as first row', () => {
const data: AlignedData = [
[100, 200, 300],
[1, 2, 3],
[4, 5, 6],
];
const { data: result } = stack(data, neverOmit);
expect(result[0]).toStrictEqual([100, 200, 300]);
});
it('stacks value series cumulatively (last = raw, first = total)', () => {
// Time, then 3 value series. Stack order: last series stays raw, then we add upward.
const data: AlignedData = [
[0, 1, 2],
[1, 2, 3], // series 1
[4, 5, 6], // series 2
[7, 8, 9], // series 3
];
const { data: result } = stack(data, neverOmit);
// result[1] = s1+s2+s3, result[2] = s2+s3, result[3] = s3
expect(result[1]).toStrictEqual([12, 15, 18]); // 1+4+7, 2+5+8, 3+6+9
expect(result[2]).toStrictEqual([11, 13, 15]); // 4+7, 5+8, 6+9
expect(result[3]).toStrictEqual([7, 8, 9]);
});
it('treats null values as 0 when stacking', () => {
const data: AlignedData = [
[0, 1],
[1, null],
[null, 10],
];
const { data: result } = stack(data, neverOmit);
expect(result[1]).toStrictEqual([1, 10]); // total
expect(result[2]).toStrictEqual([0, 10]); // last series with null→0
});
it('copies omitted series as-is without accumulating', () => {
// Omit series 2 (index 2); series 1 and 3 are stacked.
const data: AlignedData = [
[0, 1],
[10, 20], // series 1
[100, 200], // series 2 - omitted
[1, 2], // series 3
];
const omitSeries2 = (i: number): boolean => i === 2;
const { data: result } = stack(data, omitSeries2);
// series 3 raw: [1, 2]; series 2 omitted: [100, 200] as-is; series 1 stacked with s3: [11, 22]
expect(result[1]).toStrictEqual([11, 22]); // 10+1, 20+2
expect(result[2]).toStrictEqual([100, 200]); // copied, not stacked
expect(result[3]).toStrictEqual([1, 2]);
});
it('returns bands between consecutive visible series when none omitted', () => {
const data: AlignedData = [
[0, 1],
[1, 2],
[3, 4],
[5, 6],
];
const { bands } = stack(data, neverOmit);
expect(bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
});
it('returns bands only between visible series when some are omitted', () => {
// 4 value series; omit index 2. Visible: 1, 3, 4. Bands: [1,3], [3,4]
const data: AlignedData = [[0], [1], [2], [3], [4]];
const omitSeries2 = (i: number): boolean => i === 2;
const { bands } = stack(data, omitSeries2);
expect(bands).toStrictEqual([{ series: [1, 3] }, { series: [3, 4] }]);
});
it('returns empty bands when only one value series', () => {
const data: AlignedData = [
[0, 1],
[1, 2],
];
const { bands } = stack(data, neverOmit);
expect(bands).toStrictEqual([]);
});
});
describe('getInitialStackedBands', () => {
it('returns one band between each consecutive pair for seriesCount 3', () => {
expect(getInitialStackedBands(3)).toStrictEqual([
{ series: [1, 2] },
{ series: [2, 3] },
]);
});
it('returns empty array for seriesCount 0 or 1', () => {
expect(getInitialStackedBands(0)).toStrictEqual([]);
expect(getInitialStackedBands(1)).toStrictEqual([]);
});
it('returns single band for seriesCount 2', () => {
expect(getInitialStackedBands(2)).toStrictEqual([{ series: [1, 2] }]);
});
it('returns bands [1,2], [2,3], ..., [n-1, n] for seriesCount n', () => {
const bands = getInitialStackedBands(5);
expect(bands).toStrictEqual([
{ series: [1, 2] },
{ series: [2, 3] },
{ series: [3, 4] },
{ series: [4, 5] },
]);
});
});
});

View File

@@ -1,13 +1,20 @@
import { StackMode } from 'lib/uPlotV2/config/types';
import uPlot, { AlignedData } from 'uplot';
/**
* Stack data cumulatively (top-down: first series = top, last = bottom).
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
* When `omit(seriesIndex)` returns true, that series keeps its raw values and
* contributes nothing to the total. `None` is a no-op.
*/
export function stackSeries(
data: AlignedData,
omit: (seriesIndex: number) => boolean,
mode: StackMode = StackMode.Normal,
): { data: AlignedData; bands: uPlot.Band[] } {
if (mode === StackMode.None) {
return { data, bands: [] };
}
const timeAxis = data[0];
const pointCount = timeAxis.length;
const valueSeriesCount = data.length - 1; // exclude time axis
@@ -17,6 +24,7 @@ export function stackSeries(
valueSeriesCount,
pointCount,
omit,
mode,
});
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
@@ -31,6 +39,46 @@ interface BuildStackedSeriesParams {
valueSeriesCount: number;
pointCount: number;
omit: (seriesIndex: number) => boolean;
mode: StackMode;
}
/** Per-point total. Mixed-sign columns sum signed, as "share of total" implies. */
function columnTotals({
data,
valueSeriesCount,
pointCount,
omit,
}: Omit<BuildStackedSeriesParams, 'mode'>): number[] {
const totals = Array(pointCount).fill(0) as number[];
for (let seriesIndex = 1; seriesIndex <= valueSeriesCount; seriesIndex++) {
if (omit(seriesIndex)) {
continue;
}
const rawValues = data[seriesIndex] as (number | null)[];
rawValues.forEach((rawValue, pointIndex) => {
totals[pointIndex] += rawValue == null ? 0 : Number(rawValue);
});
}
return totals;
}
/** A column whose participating series sum to 0 has no share to divide, so every slice is 0. */
function toPercent(value: number, total: number): number {
return total === 0 ? 0 : (value / total) * 100;
}
/** What a raw value adds to the stack at a given point. */
type Contribution = (value: number, pointIndex: number) => number;
function contributionForMode(params: BuildStackedSeriesParams): Contribution {
if (params.mode !== StackMode.Percent) {
return (value): number => value;
}
// Resolved up front: totals span series the accumulation below has not reached yet.
const totals = columnTotals(params);
return (value, pointIndex): number => toPercent(value, totals[pointIndex]);
}
/**
@@ -42,9 +90,17 @@ function buildStackedSeries({
valueSeriesCount,
pointCount,
omit,
mode,
}: BuildStackedSeriesParams): (number | null)[][] {
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
const cumulativeSums = Array(pointCount).fill(0) as number[];
const contributionOf = contributionForMode({
data,
valueSeriesCount,
pointCount,
omit,
mode,
});
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
const rawValues = data[seriesIndex] as (number | null)[];
@@ -54,7 +110,10 @@ function buildStackedSeries({
} else {
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
const numericValue = rawValue == null ? 0 : Number(rawValue);
return (cumulativeSums[pointIndex] += numericValue);
return (cumulativeSums[pointIndex] += contributionOf(
numericValue,
pointIndex,
));
});
}
}
@@ -101,16 +160,3 @@ function findNextVisibleSeriesIndex(
}
return -1;
}
/**
* Returns band indices for initial stacked state (no series omitted).
* Top-down: first series at top, band fills between consecutive series.
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
*/
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
bands.push({ series: [seriesIndex, seriesIndex + 1] });
}
return bands;
}

View File

@@ -1,116 +0,0 @@
import uPlot, { AlignedData } from 'uplot';
/**
* Stack data cumulatively (top-down: first series = top, last = bottom).
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
*/
export function stack(
data: AlignedData,
omit: (seriesIndex: number) => boolean,
): { data: AlignedData; bands: uPlot.Band[] } {
const timeAxis = data[0];
const pointCount = timeAxis.length;
const valueSeriesCount = data.length - 1; // exclude time axis
const stackedSeries = buildStackedSeries({
data,
valueSeriesCount,
pointCount,
omit,
});
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
return {
data: [timeAxis, ...stackedSeries] as AlignedData,
bands,
};
}
interface BuildStackedSeriesParams {
data: AlignedData;
valueSeriesCount: number;
pointCount: number;
omit: (seriesIndex: number) => boolean;
}
/**
* Accumulate from last series upward: last series = raw values, first = total.
* Omitted series are copied as-is (no accumulation).
*/
function buildStackedSeries({
data,
valueSeriesCount,
pointCount,
omit,
}: BuildStackedSeriesParams): (number | null)[][] {
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
const cumulativeSums = Array(pointCount).fill(0) as number[];
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
const rawValues = data[seriesIndex] as (number | null)[];
if (omit(seriesIndex)) {
stackedSeries[seriesIndex - 1] = rawValues;
} else {
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
const numericValue = rawValue == null ? 0 : Number(rawValue);
return (cumulativeSums[pointIndex] += numericValue);
});
}
}
return stackedSeries;
}
/**
* Bands define fill between consecutive visible series for stacked appearance.
* uPlot format: [upperSeriesIdx, lowerSeriesIdx].
*/
function buildFillBands(
seriesLength: number,
omit: (seriesIndex: number) => boolean,
): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesLength; seriesIndex++) {
if (omit(seriesIndex)) {
continue;
}
const nextVisibleSeriesIndex = findNextVisibleSeriesIndex(
seriesLength,
seriesIndex,
omit,
);
if (nextVisibleSeriesIndex !== -1) {
bands.push({ series: [seriesIndex, nextVisibleSeriesIndex] });
}
}
return bands;
}
function findNextVisibleSeriesIndex(
seriesLength: number,
afterIndex: number,
omit: (seriesIndex: number) => boolean,
): number {
for (let i = afterIndex + 1; i < seriesLength; i++) {
if (!omit(i)) {
return i;
}
}
return -1;
}
/**
* Returns band indices for initial stacked state (no series omitted).
* Top-down: first series at top, band fills between consecutive series.
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
*/
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
bands.push({ series: [seriesIndex, seriesIndex + 1] });
}
return bands;
}

View File

@@ -1,313 +0,0 @@
import { renderHook } from '@testing-library/react';
import uPlot from 'uplot';
import type { UseBarChartStackingParams } from '../useBarChartStacking';
import { useBarChartStacking } from '../useBarChartStacking';
type MockConfig = { addHook: jest.Mock };
function asConfig(c: MockConfig): UseBarChartStackingParams['config'] {
return c as unknown as UseBarChartStackingParams['config'];
}
function createMockConfig(): {
config: MockConfig;
invokeSetData: (plot: uPlot) => void;
invokeSetSeries: (
plot: uPlot,
seriesIndex: number | null,
opts: Partial<uPlot.Series> & { focus?: boolean },
) => void;
removeSetData: jest.Mock;
removeSetSeries: jest.Mock;
} {
let setDataHandler: ((plot: uPlot) => void) | null = null;
let setSeriesHandler:
| ((plot: uPlot, seriesIndex: number | null, opts: uPlot.Series) => void)
| null = null;
const removeSetData = jest.fn();
const removeSetSeries = jest.fn();
const addHook = jest.fn(
(
hookName: string,
handler: (plot: uPlot, ...args: unknown[]) => void,
): (() => void) => {
if (hookName === 'setData') {
setDataHandler = handler as (plot: uPlot) => void;
return removeSetData;
}
if (hookName === 'setSeries') {
setSeriesHandler = handler as (
plot: uPlot,
seriesIndex: number | null,
opts: uPlot.Series,
) => void;
return removeSetSeries;
}
return jest.fn();
},
);
const config: MockConfig = { addHook };
const invokeSetData = (plot: uPlot): void => {
setDataHandler?.(plot);
};
const invokeSetSeries = (
plot: uPlot,
seriesIndex: number | null,
opts: Partial<uPlot.Series> & { focus?: boolean },
): void => {
setSeriesHandler?.(plot, seriesIndex, opts as uPlot.Series);
};
return {
config,
invokeSetData,
invokeSetSeries,
removeSetData,
removeSetSeries,
};
}
function createMockPlot(overrides: Partial<uPlot> = {}): uPlot {
return {
data: [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
],
series: [{ show: true }, { show: true }, { show: true }],
delBand: jest.fn(),
addBand: jest.fn(),
setData: jest.fn(),
...overrides,
} as unknown as uPlot;
}
describe('useBarChartStacking', () => {
it('returns data as-is when isStackedBarChart is false', () => {
const data: uPlot.AlignedData = [
[100, 200],
[1, 2],
[3, 4],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: false,
config: null,
}),
);
expect(result.current).toBe(data);
});
it('returns data as-is when config is null and isStackedBarChart is true', () => {
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[4, 5],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
// Still returns stacked data (computed in useMemo); no hooks registered
expect(result.current[0]).toStrictEqual([0, 1]);
expect(result.current[1]).toStrictEqual([5, 7]); // stacked
expect(result.current[2]).toStrictEqual([4, 5]);
});
it('returns stacked data when isStackedBarChart is true and multiple value series', () => {
const data: uPlot.AlignedData = [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
expect(result.current[0]).toStrictEqual([0, 1, 2]);
expect(result.current[1]).toStrictEqual([12, 15, 18]); // s1+s2+s3
expect(result.current[2]).toStrictEqual([11, 13, 15]); // s2+s3
expect(result.current[3]).toStrictEqual([7, 8, 9]);
});
it('returns data as-is when only one value series (no stacking needed)', () => {
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
expect(result.current).toStrictEqual(data);
});
it('registers setData and setSeries hooks when isStackedBarChart and config provided', () => {
const { config } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
expect(config.addHook).toHaveBeenCalledWith('setData', expect.any(Function));
expect(config.addHook).toHaveBeenCalledWith(
'setSeries',
expect.any(Function),
);
});
it('does not register hooks when isStackedBarChart is false', () => {
const { config } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: false,
config: asConfig(config),
}),
);
expect(config.addHook).not.toHaveBeenCalled();
});
it('calls cleanup when unmounted', () => {
const { config, removeSetData, removeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
const { unmount } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
unmount();
expect(removeSetData).toHaveBeenCalled();
expect(removeSetSeries).toHaveBeenCalled();
});
it('re-stacks and updates plot when setData hook is invoked', () => {
const { config, invokeSetData } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
];
const plot = createMockPlot({
data: [
[0, 1, 2],
[5, 7, 9],
[4, 5, 6],
],
});
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
invokeSetData(plot);
expect(plot.delBand).toHaveBeenCalledWith(null);
expect(plot.addBand).toHaveBeenCalled();
expect(plot.setData).toHaveBeenCalledWith(
expect.arrayContaining([
[0, 1, 2],
expect.any(Array), // stacked row 1
expect.any(Array), // stacked row 2
]),
);
});
it('re-stacks when setSeries hook is invoked (e.g. legend toggle)', () => {
const { config, invokeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[10, 20],
[5, 10],
];
// Plot data must match unstacked length so canApplyStacking passes
const plot = createMockPlot({
data: [
[0, 1],
[15, 30],
[5, 10],
],
});
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
invokeSetSeries(plot, 1, { show: false });
expect(plot.setData).toHaveBeenCalled();
});
it('does not re-stack when setSeries is called with focus option', () => {
const { config, invokeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
const plot = createMockPlot();
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
(plot.setData as jest.Mock).mockClear();
invokeSetSeries(plot, 1, { focus: true } as uPlot.Series);
expect(plot.setData).not.toHaveBeenCalled();
});
});

View File

@@ -1,125 +0,0 @@
import {
MutableRefObject,
useCallback,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { has } from 'lodash-es';
import uPlot from 'uplot';
import { stackSeries } from '../charts/utils/stackSeriesUtils';
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
return !plot.series[seriesIndex]?.show;
}
function canApplyStacking(
unstackedData: uPlot.AlignedData | null,
plot: uPlot,
isUpdating: boolean,
): boolean {
return (
!isUpdating &&
!!unstackedData &&
!!plot.data &&
unstackedData[0]?.length === plot.data[0]?.length
);
}
function setupStackingHooks(
config: UPlotConfigBuilder,
applyStackingToChart: (plot: uPlot) => void,
isUpdatingRef: MutableRefObject<boolean>,
): () => void {
const onDataChange = (plot: uPlot): void => {
if (!isUpdatingRef.current) {
applyStackingToChart(plot);
}
};
const onSeriesVisibilityChange = (
plot: uPlot,
_seriesIdx: number | null,
opts: uPlot.Series,
): void => {
if (!has(opts, 'focus')) {
applyStackingToChart(plot);
}
};
const removeSetDataHook = config.addHook('setData', onDataChange);
const removeSetSeriesHook = config.addHook(
'setSeries',
onSeriesVisibilityChange,
);
return (): void => {
removeSetDataHook?.();
removeSetSeriesHook?.();
};
}
export interface UseBarChartStackingParams {
data: uPlot.AlignedData;
isStackedBarChart?: boolean;
config: UPlotConfigBuilder | null;
}
/**
* Handles stacking for bar charts: computes initial stacked data and re-stacks
* when data or series visibility changes (e.g. legend toggles).
*/
export function useBarChartStacking({
data,
isStackedBarChart = false,
config,
}: UseBarChartStackingParams): uPlot.AlignedData {
// Store unstacked source data so uPlot hooks can access it (hooks run outside React's render cycle)
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
unstackedDataRef.current = isStackedBarChart ? data : null;
// Prevents re-entrant calls when we update chart data (avoids infinite loop in setData hook)
const isUpdatingChartRef = useRef(false);
const chartData = useMemo((): uPlot.AlignedData => {
if (!isStackedBarChart || !data || data.length < 2) {
return data;
}
const noSeriesHidden = (): boolean => false; // include all series in initial stack
const { data: stacked } = stackSeries(data, noSeriesHidden);
return stacked;
}, [data, isStackedBarChart]);
const applyStackingToChart = useCallback((plot: uPlot): void => {
const unstacked = unstackedDataRef.current;
if (
!unstacked ||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
) {
return;
}
const shouldExcludeSeries = (idx: number): boolean =>
isSeriesHidden(plot, idx);
const { data: stacked, bands } = stackSeries(unstacked, shouldExcludeSeries);
plot.delBand(null);
bands.forEach((band: uPlot.Band) => plot.addBand(band));
isUpdatingChartRef.current = true;
plot.setData(stacked);
isUpdatingChartRef.current = false;
}, []);
useLayoutEffect(() => {
if (!isStackedBarChart || !config) {
return undefined;
}
return setupStackingHooks(config, applyStackingToChart, isUpdatingChartRef);
}, [isStackedBarChart, config, applyStackingToChart]);
return chartData;
}

View File

@@ -22,6 +22,7 @@ import { prepareBarPanelConfig } from './utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { StackMode } from 'lib/uPlotV2/config/types';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -147,6 +148,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={`${syncMode}-${syncFilterMode}`}
stack={widget.stackedBarChart ? StackMode.Normal : StackMode.None}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -159,7 +161,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
height={containerDimensions.height}
layoutChildren={layoutChildren}
groupByPerQuery={groupByPerQuery}
isStackedBarChart={widget.stackedBarChart ?? false}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}

View File

@@ -35,20 +35,10 @@ jest.mock('lib/getLabelName', () => ({
),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
() => ({
getInitialStackedBands: jest.fn().mockReturnValue([]),
}),
);
const getLegendMock = jest.requireMock('lib/dashboard/getQueryResults')
.getLegend as jest.Mock;
const getLabelNameMock = jest.requireMock('lib/getLabelName')
.default as jest.Mock;
const getInitialStackedBandsMock = jest.requireMock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
).getInitialStackedBands as jest.Mock;
const createApiResponse = (
result: MetricRangePayloadProps['data']['result'] = [],
@@ -247,36 +237,5 @@ describe('BarPanel utils', () => {
}).getConfig();
expect(config.series?.[1]).toMatchObject({ stroke: '#ff0000' });
});
it('calls getInitialStackedBands when widget is stackedBarChart', () => {
const widget = createWidget({ stackedBarChart: true });
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
{
metric: {},
queryName: 'Q2',
values: [[1000, '2']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({ ...baseParams, widget, apiResponse });
// seriesCount = result.length + 1 = 3
expect(getInitialStackedBandsMock).toHaveBeenCalledWith(3);
});
it('does not call getInitialStackedBands for non-stacked chart', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({ ...baseParams, apiResponse });
expect(getInitialStackedBandsMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,7 +1,6 @@
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
@@ -69,11 +68,6 @@ export function prepareBarPanelConfig({
return builder;
}
if (widget.stackedBarChart) {
const seriesCount = (apiResponse.data.result.length ?? 0) + 1; // +1 for 1-based uPlot series indices
builder.setBands(getInitialStackedBands(seriesCount));
}
apiResponse.data.result.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,

View File

@@ -2,7 +2,7 @@ import { memo, useMemo } from 'react';
import { Select, Spin } from 'antd';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { useOrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/useOrderByFilter';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/config';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { StringOperators } from 'types/common/queryBuilder';

View File

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

View File

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

View File

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

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