Compare commits

..

20 Commits

Author SHA1 Message Date
vikrantgupta25
0d5c288dd6 feat(licensing): add resource authz to license endpoints 2026-08-26 16:32:14 +05:30
vikrantgupta25
35abee54b1 chore(licensing): regenerate frontend api clients 2026-08-26 16:17:32 +05:30
vikrantgupta25
6a21345203 fix(licensing): advertise api key auth on get active license 2026-08-26 16:11:22 +05:30
vikrantgupta25
9f128ee00b refactor(licensing): rename api interface to handler 2026-08-26 16:03:12 +05:30
vikrantgupta25
ad06c14557 refactor(licensing): rename licensing api wiring to licensing handler 2026-08-26 16:01:50 +05:30
vikrantgupta25
92734c0c2b chore(licensing): remove unused community licenses list stub 2026-08-26 15:58:53 +05:30
vikrantgupta25
4c752655f3 feat(licensing): serve license endpoints from apiserver 2026-08-26 15:56:21 +05:30
vikrantgupta25
76211b3233 feat(zeus): add api/v2/zeus/licenses endpoints 2026-08-26 14:00:39 +05:30
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
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
245 changed files with 12098 additions and 5516 deletions

View File

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

View File

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

View File

@@ -62,7 +62,6 @@ jobs:
- role
- rootuser
- savedview
- semconvfamilies
- serviceaccount
- spanmapper
- querier_json_body

View File

@@ -5641,6 +5641,15 @@ components:
- total
- endTimeBeforeRetention
type: object
LicensetypesGettableLicense:
additionalProperties: {}
nullable: true
type: object
LicensetypesPostableLicense:
properties:
key:
type: string
type: object
LlmpricingruletypesGettablePricingRules:
properties:
items:
@@ -8024,13 +8033,6 @@ components:
- schemaVersion
- spec
type: object
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
properties:
id:
type: string
required:
- id
type: object
ServiceaccounttypesGettableFactorAPIKey:
properties:
createdAt:
@@ -13262,117 +13264,6 @@ paths:
summary: Gets service account roles
tags:
- serviceaccount
post:
deprecated: true
description: This endpoint assigns a role to a service account
operationId: CreateServiceAccountRoleDeprecated
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:attach
- role:attach
- tokenizer:
- serviceaccount:attach
- role:attach
summary: Create service account role
tags:
- serviceaccount
/api/v1/service_accounts/{id}/roles/{rid}:
delete:
deprecated: true
description: This endpoint revokes a role from service account
operationId: DeleteServiceAccountRoleDeprecated
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: rid
required: true
schema:
type: string
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:detach
- role:detach
- tokenizer:
- serviceaccount:detach
- role:detach
summary: Delete service account role
tags:
- serviceaccount
/api/v1/service_accounts/me:
get:
deprecated: false
@@ -14376,9 +14267,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- auth-domain:list
- tokenizer:
- ADMIN
- auth-domain:list
summary: List all auth domains
tags:
- authdomains
@@ -14438,9 +14329,13 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- auth-domain:create
- auth-domain:attach
- role:attach
- tokenizer:
- ADMIN
- auth-domain:create
- auth-domain:attach
- role:attach
summary: Create auth domain
tags:
- authdomains
@@ -14484,9 +14379,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- auth-domain:delete
- tokenizer:
- ADMIN
- auth-domain:delete
summary: Delete auth domain
tags:
- authdomains
@@ -14541,9 +14436,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- auth-domain:read
- tokenizer:
- ADMIN
- auth-domain:read
summary: Get auth domain by ID
tags:
- authdomains
@@ -14597,9 +14492,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- auth-domain:update
- auth-domain:attach
- auth-domain:detach
- role:attach
- role:detach
- tokenizer:
- ADMIN
- auth-domain:update
- auth-domain:attach
- auth-domain:detach
- role:attach
- role:detach
summary: Update auth domain
tags:
- authdomains
@@ -24167,6 +24070,166 @@ paths:
summary: Put profile in Zeus for a deployment.
tags:
- zeus
/api/v3/licenses:
post:
deprecated: false
description: This endpoint validates the license key with upstream and activates
the license for the organization.
operationId: ActivateLicense
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LicensetypesPostableLicense'
responses:
"202":
description: Accepted
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- license:create
- tokenizer:
- license:create
summary: Activate a license.
tags:
- licenses
put:
deprecated: false
description: This endpoint refreshes the active license of the organization
from upstream.
operationId: RefreshLicense
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- license:update
- tokenizer:
- license:update
summary: Refresh the active license.
tags:
- licenses
/api/v3/licenses/active:
get:
deprecated: false
description: This endpoint gets the active license of the organization.
operationId: GetActiveLicense
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/LicensetypesGettableLicense'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key: []
- tokenizer: []
summary: Get the active license.
tags:
- licenses
/api/v3/metrics/dashboards:
get:
deprecated: false

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

@@ -4,10 +4,10 @@ import (
"net/http"
"time"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/licensing"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
@@ -42,7 +42,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
IntegrationsController: opts.IntegrationsController,
LogsParsingPipelineController: opts.LogsParsingPipelineController,
FluxInterval: opts.FluxInterval,
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
LicensingHandler: licensing.NewHandler(signoz.Licensing),
Signoz: signoz,
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
}, config)
@@ -72,14 +72,9 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// base overrides
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/checkout", am.AdminAccess(ah.LicensingAPI.Checkout)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/checkout", am.AdminAccess(ah.LicensingHandler.Checkout)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost)
// v3
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Activate)).Methods(http.MethodPost)
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Refresh)).Methods(http.MethodPut)
router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(ah.LicensingAPI.GetActive)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingHandler.Portal)).Methods(http.MethodPost)
// v4
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)

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",
@@ -238,4 +238,4 @@
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
}
}
}

823
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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,268 @@
/**
* ! 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 {
GetActiveLicense200,
LicensetypesPostableLicenseDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint validates the license key with upstream and activates the license for the organization.
* @summary Activate a license.
*/
export const activateLicense = (
licensetypesPostableLicenseDTO?: BodyType<LicensetypesPostableLicenseDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v3/licenses`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: licensetypesPostableLicenseDTO,
signal,
});
};
export const getActivateLicenseMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
> => {
const mutationKey = ['activateLicense'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof activateLicense>>,
{ data?: BodyType<LicensetypesPostableLicenseDTO> }
> = (props) => {
const { data } = props ?? {};
return activateLicense(data);
};
return { mutationFn, ...mutationOptions };
};
export type ActivateLicenseMutationResult = NonNullable<
Awaited<ReturnType<typeof activateLicense>>
>;
export type ActivateLicenseMutationBody =
| BodyType<LicensetypesPostableLicenseDTO>
| undefined;
export type ActivateLicenseMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Activate a license.
*/
export const useActivateLicense = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
> => {
return useMutation(getActivateLicenseMutationOptions(options));
};
/**
* This endpoint refreshes the active license of the organization from upstream.
* @summary Refresh the active license.
*/
export const refreshLicense = (signal?: AbortSignal) => {
return GeneratedAPIInstance<void>({
url: `/api/v3/licenses`,
method: 'PUT',
signal,
});
};
export const getRefreshLicenseMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
void,
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
void,
TContext
> => {
const mutationKey = ['refreshLicense'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof refreshLicense>>,
void
> = () => {
return refreshLicense();
};
return { mutationFn, ...mutationOptions };
};
export type RefreshLicenseMutationResult = NonNullable<
Awaited<ReturnType<typeof refreshLicense>>
>;
export type RefreshLicenseMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Refresh the active license.
*/
export const useRefreshLicense = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
void,
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
void,
TContext
> => {
return useMutation(getRefreshLicenseMutationOptions(options));
};
/**
* This endpoint gets the active license of the organization.
* @summary Get the active license.
*/
export const getActiveLicense = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetActiveLicense200>({
url: `/api/v3/licenses/active`,
method: 'GET',
signal,
});
};
export const getGetActiveLicenseQueryKey = () => {
return [`/api/v3/licenses/active`] as const;
};
export const getGetActiveLicenseQueryOptions = <
TData = Awaited<ReturnType<typeof getActiveLicense>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getActiveLicense>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetActiveLicenseQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getActiveLicense>>> = ({
signal,
}) => getActiveLicense(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getActiveLicense>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetActiveLicenseQueryResult = NonNullable<
Awaited<ReturnType<typeof getActiveLicense>>
>;
export type GetActiveLicenseQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get the active license.
*/
export function useGetActiveLicense<
TData = Awaited<ReturnType<typeof getActiveLicense>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getActiveLicense>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetActiveLicenseQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get the active license.
*/
export const invalidateGetActiveLicense = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetActiveLicenseQueryKey() },
options,
);
return queryClient;
};

View File

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

@@ -7171,6 +7171,21 @@ export interface InframonitoringtypesVolumesDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export type LicensetypesGettableLicenseDTOAnyOf = { [key: string]: unknown };
/**
* @nullable
*/
export type LicensetypesGettableLicenseDTO =
LicensetypesGettableLicenseDTOAnyOf | null;
export interface LicensetypesPostableLicenseDTO {
/**
* @type string
*/
key?: string;
}
/**
* @nullable
*/
@@ -9092,13 +9107,6 @@ export interface SavedviewtypesUpdatableSavedViewDTO {
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
/**
* @type string
*/
id: string;
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -11020,21 +11028,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;
/**
@@ -12421,6 +12414,14 @@ export type GetHosts200 = {
status: string;
};
export type GetActiveLicense200 = {
data: LicensetypesGettableLicenseDTO | null;
/**
* @type string
*/
status: string;
};
export type GetMetricDashboardsV2Params = {
/**
* @type string

View File

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

View File

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

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

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

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

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

@@ -11,6 +11,7 @@ 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',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',

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

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

View File

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

View File

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

View File

@@ -4,9 +4,9 @@
gap: var(--spacing-4);
padding: var(--spacing-2) var(--spacing-2);
--tab-content-padding: 0;
--tab-text-color: var(--l1-foreground);
--tab-active-text-color: var(--l1-foreground);
--tabs-content-padding: 0;
--tabs-text-color: var(--l1-foreground);
--tabs-active-text-color: var(--l1-foreground);
}
.pageError {

View File

@@ -4,8 +4,8 @@
height: 100%;
margin-top: var(--spacing-2);
margin-left: var(--spacing-2);
--tab-text-color: var(--l1-foreground);
--tab-active-text-color: var(--l1-foreground);
--tabs-text-color: var(--l1-foreground);
--tabs-active-text-color: var(--l1-foreground);
[role='tabpanel'] {
margin: 0;
padding: var(--spacing-0) var(--spacing-4);

View File

@@ -2,10 +2,10 @@
display: flex;
flex-direction: column;
gap: var(--spacing-8);
--tab-content-padding: 0;
--tabs-content-padding: 0;
margin-top: var(--spacing-3);
--tab-text-color: var(--l1-foreground);
--tab-active-text-color: var(--l1-foreground);
--tabs-text-color: var(--l1-foreground);
--tabs-active-text-color: var(--l1-foreground);
}
.tabLabel {

View File

@@ -275,6 +275,7 @@ function LiveLogsContainer({
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.LOGS}
requiredFields={LOGS_REQUIRED_COLUMNS}
allowCustomFields
/>
)}
</div>

View File

@@ -10,7 +10,7 @@ import {
RESTRICTED_SELECTED_FIELDS,
} from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import {
BaseAutocompleteData,

View File

@@ -113,6 +113,7 @@ function LogsActionsContainer({
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.LOGS}
requiredFields={LOGS_REQUIRED_COLUMNS}
allowCustomFields
/>
)}
</div>

View File

@@ -6,8 +6,8 @@
}
// Remove default tab content padding/margin — the card provides spacing.
--tab-content-padding: 0;
--tab-content-margin: var(--spacing-4) 0 0;
--tabs-content-padding: 0;
--tabs-content-margin: var(--spacing-4) 0 0;
}
.mcp-client-tabs {

View File

@@ -9,6 +9,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { StackMode } from 'lib/uPlotV2/config/types';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { useTimezone } from 'providers/Timezone';
import { AppState } from 'store/reducers';
@@ -137,6 +138,7 @@ function TimeSeries({
key={`${WIDGET_ID}-${index}`}
>
<BarChart
stack={StackMode.Normal}
config={chart.config}
legendConfig={{
position: LegendPosition.BOTTOM,
@@ -144,7 +146,6 @@ function TimeSeries({
data={chart.chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
isStackedBarChart
yAxisUnit={yAxisUnit || 'short'}
timezone={timezone}
/>

View File

@@ -1,6 +1,5 @@
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 {
@@ -89,9 +88,6 @@ export function buildMeterChartConfig({
return builder;
}
const seriesCount = (apiResponse.data.result.length ?? 0) + 1;
builder.setBands(getInitialStackedBands(seriesCount));
apiResponse.data.result.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,

View File

@@ -42,7 +42,7 @@ jest.mock('hooks/useDebounce', () => ({
}));
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer',
'container/QueryBuilder/filters/OptionRenderer/OptionRenderer',
() => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,

View File

@@ -296,12 +296,12 @@ describe('useOptionsMenu', () => {
}),
);
// New order: [attribute.service.name, log.body, resource.service.name, log.timestamp]
// New order: [attribute:service.name, log:body, resource:service.name, log:timestamp]
result.current.config.addColumn?.onReorder([
'attribute.service.name',
'log.body',
'resource.service.name',
'log.timestamp',
'attribute:service.name:string',
'log:body:string',
'resource:service.name:string',
'log:timestamp',
]);
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
@@ -309,13 +309,13 @@ describe('useOptionsMenu', () => {
expect(
reordered.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}.${c.name}`,
`${c.fieldContext}:${c.name}`,
),
).toStrictEqual([
'attribute.service.name',
'log.body',
'resource.service.name',
'log.timestamp',
'attribute:service.name',
'log:body',
'resource:service.name',
'log:timestamp',
]);
});
@@ -329,11 +329,11 @@ describe('useOptionsMenu', () => {
result.current.config.addColumn?.onReorder([
'state-indicator',
'log.timestamp',
'log:timestamp',
'unknown.composite',
'log.body',
'resource.service.name',
'attribute.service.name',
'log:body:string',
'resource:service.name:string',
'attribute:service.name:string',
]);
const reordered = mockUpdateColumns.mock.calls[0][0];
@@ -341,13 +341,13 @@ describe('useOptionsMenu', () => {
expect(
reordered.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}.${c.name}`,
`${c.fieldContext}:${c.name}`,
),
).toStrictEqual([
'log.timestamp',
'log.body',
'resource.service.name',
'attribute.service.name',
'log:timestamp',
'log:body',
'resource:service.name',
'attribute:service.name',
]);
});
@@ -359,17 +359,17 @@ describe('useOptionsMenu', () => {
}),
);
// Removing 'resource.service.name' should drop ONLY the resource variant.
result.current.config.addColumn?.onRemove('resource.service.name');
// Removing 'resource:service.name' should drop ONLY the resource variant.
result.current.config.addColumn?.onRemove('resource:service.name:string');
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
const remaining = mockUpdateColumns.mock.calls[0][0];
expect(
remaining.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}.${c.name}`,
`${c.fieldContext}:${c.name}`,
),
).toStrictEqual(['log.body', 'attribute.service.name', 'log.timestamp']);
).toStrictEqual(['log:body', 'attribute:service.name', 'log:timestamp']);
});
it('removing by a non-matching composite ID is a no-op (filter returns the full list)', () => {

View File

@@ -56,7 +56,7 @@ export function dedupeColumnsByCompositeKey(
const seen = new Set<string>();
let hasDuplicate = false;
const deduped = columns.filter((c) => {
const key = buildCompositeKey(c.name, c.fieldContext);
const key = buildCompositeKey(c.name, c.fieldContext, c.fieldDataType);
if (seen.has(key)) {
hasDuplicate = true;
return false;

View File

@@ -281,7 +281,8 @@ const useOptionsMenu = ({
const handleRemoveSelectedColumn = useCallback(
(columnKey: string) => {
const newSelectedColumns = preferences?.columns?.filter(
(f) => buildCompositeKey(f.name, f.fieldContext) !== columnKey,
(f) =>
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType) !== columnKey,
);
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
@@ -364,7 +365,10 @@ const useOptionsMenu = ({
(orderedIds: string[]): void => {
const current = preferences?.columns ?? [];
const byCompositeKey = new Map(
current.map((f) => [buildCompositeKey(f.name, f.fieldContext), f]),
current.map((f) => [
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
f,
]),
);
const reordered = orderedIds
.map((id) => byCompositeKey.get(id))

View File

@@ -15,8 +15,11 @@ export const getOptionsFromKeys = (
);
};
// Composite identity for a column. Disambiguates same-name fields across
// different fieldContexts (e.g. resource.service.name vs attribute.service.name).
// Falls back to bare name when context is missing.
export const buildCompositeKey = (name: string, context?: string): string =>
context ? `${context}.${name}` : name;
export const buildCompositeKey = (
name: string,
context?: string,
dataType?: string,
): string => {
const withContext = context ? `${context}:${name}` : name;
return dataType ? `${withContext}:${dataType}` : withContext;
};

View File

@@ -35,7 +35,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
class="c0"
>
<p
class="_typography_ulrzs_1"
class="_typography_j4pmm_1"
data-slot="typography"
data-variant="text"
/>
@@ -50,7 +50,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
class="value-text-container"
>
<p
class="_typography_ulrzs_1 value-graph-text"
class="_typography_j4pmm_1 value-graph-text"
data-slot="typography"
data-testid="value-graph-text"
data-variant="text"
@@ -59,7 +59,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
295.43
</p>
<p
class="_typography_ulrzs_1 value-graph-unit"
class="_typography_j4pmm_1 value-graph-unit"
data-slot="typography"
data-testid="value-graph-suffix-unit"
data-variant="text"

View File

@@ -22,11 +22,11 @@ exports[`PipelinePage container test should render DragAction section 1`] = `
class="c0"
>
<div
class="_switch-wrapper_jbsv7_1"
class="_switch-wrapper_1a8sn_6"
>
<button
aria-checked="true"
class="_switch_jbsv7_1"
class="_switch_1a8sn_6"
data-color="robin"
data-state="checked"
id=":r0:"
@@ -35,7 +35,7 @@ exports[`PipelinePage container test should render DragAction section 1`] = `
value="on"
>
<span
class="_switch__thumb_jbsv7_59"
class="_switch__thumb_1a8sn_71"
data-state="checked"
/>
</button>

View File

@@ -74,7 +74,7 @@ exports[`PipelinePage container test should render PipelinePageLayout section 1`
/>
<div>
<p
class="_typography_ulrzs_1"
class="_typography_j4pmm_1"
data-slot="typography"
data-variant="text"
>

View File

@@ -1,11 +1,15 @@
import { ReactNode } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { OrderByFilterProps } from './filters/OrderByFilter/OrderByFilter.interfaces';
export type WhereClauseConfig = {
customKey: string;
customOp: string;
};
type FilterConfigs = {
[Key in keyof Omit<IBuilderQuery, 'filters'>]: {
isHidden: boolean;

View File

@@ -1,45 +0,0 @@
import { useMemo } from 'react';
import { InputNumber, InputNumberProps } from 'antd';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { selectStyle } from '../QueryBuilderSearch/config';
function AggregateEveryFilter({
onChange,
query,
disabled,
}: AggregateEveryFilterProps): JSX.Element {
const isMetricsDataSource = useMemo(
() => query.dataSource === DataSource.METRICS,
[query.dataSource],
);
const onChangeHandler: InputNumberProps<number>['onChange'] = (event) => {
if (event && event >= 0) {
onChange(event);
}
};
const isDisabled =
(isMetricsDataSource && !query.aggregateAttribute?.key) || disabled;
return (
<InputNumber
placeholder="Enter in seconds"
disabled={isDisabled}
style={selectStyle}
value={query?.stepInterval}
onChange={onChangeHandler}
min={0}
/>
);
}
interface AggregateEveryFilterProps {
onChange: (values: number) => void;
query: IBuilderQuery;
disabled: boolean;
}
export default AggregateEveryFilter;

View File

@@ -24,8 +24,8 @@ import { DataSource } from 'types/common/queryBuilder';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
// ** Types
import { AgregatorFilterProps } from './AggregatorFilter.intefaces';

View File

@@ -1,6 +1,6 @@
import { InputNumber } from 'antd';
import { selectStyle } from '../../QueryBuilderSearch/config';
import { selectStyle } from '../../QueryBuilderSearchV2/config';
import { handleKeyDownLimitFilter } from '../../utils';
import { LimitFilterProps } from './types';

View File

@@ -4,7 +4,7 @@ import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { MetricAggregateOperator } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../../QueryBuilderSearch/config';
import { selectStyle } from '../../QueryBuilderSearchV2/config';
import { OrderByProps } from './types';
import { useOrderByFormulaFilter } from './useOrderByFormulaFilter';

View File

@@ -16,7 +16,7 @@ import {
mapLabelValuePairs,
orderByValueDelimiter,
} from '../../OrderByFilter/utils';
import { getRemoveOrderFromValue } from '../../QueryBuilderSearch/utils';
import { getRemoveOrderFromValue } from '../../QueryBuilderSearchV2/utils';
import { getUniqueOrderByValues, getValidOrderByResult } from '../../utils';
import { IOrderByFormulaFilterProps } from './types';
import { transformToOrderByStringValuesByFormula } from './utils';

View File

@@ -23,8 +23,8 @@ import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import { GroupByFilterProps } from './GroupByFilter.interfaces';
export const GroupByFilter = memo(function GroupByFilter({

View File

@@ -1,31 +0,0 @@
import { InputNumber } from 'antd';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { selectStyle } from '../QueryBuilderSearch/config';
import { handleKeyDownLimitFilter } from '../utils';
function LimitFilter({ onChange, query }: LimitFilterProps): JSX.Element {
const isMetricsDataSource = query.dataSource === DataSource.METRICS;
const isDisabled = isMetricsDataSource && !query.aggregateAttribute?.key;
return (
<InputNumber
min={1}
type="number"
value={query.limit}
style={selectStyle}
disabled={isDisabled}
onChange={onChange}
onKeyDown={handleKeyDownLimitFilter}
/>
);
}
interface LimitFilterProps {
onChange: (values: number | null) => void;
query: IBuilderQuery;
}
export default LimitFilter;

View File

@@ -31,7 +31,7 @@ jest.mock('hooks/useDebounce', () => ({
default: <T,>(value: T): T => value,
}));
jest.mock('../QueryBuilderSearch/OptionRenderer', () => ({
jest.mock('../OptionRenderer/OptionRenderer', () => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
}));

View File

@@ -16,8 +16,8 @@ import { MetricAggregation } from 'types/api/v5/queryRange';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import './MetricNameSelector.styles.scss';

View File

@@ -2,7 +2,7 @@ import { memo } from 'react';
import { Select } from 'antd';
// ** Types
import { selectStyle } from '../QueryBuilderSearch/config';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import { OperatorsSelectProps } from './OperatorsSelect.interfaces';
export const OperatorsSelect = memo(function OperatorsSelect({

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