Compare commits

...

13 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
130 changed files with 9214 additions and 743 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

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

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

@@ -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
*/
@@ -12399,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

@@ -197,7 +197,7 @@ function FieldsSelector({
() =>
fields.map((f) => ({
...f,
key: buildCompositeKey(f.name, f.fieldContext),
key: buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
})),
[fields],
);

View File

@@ -52,13 +52,15 @@ function OtherFields({
// Normalize: synthesize `key` once so downstream reads can trust it.
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
...attr,
key: buildCompositeKey(attr.name, attr.fieldContext as string),
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) => buildCompositeKey(f.name, f.fieldContext)),
addedFields.map((f) =>
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
),
);
const available = suggestions.filter(
(attr) => !addedIds.has(attr.key as string),

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', () => {
@@ -136,6 +136,24 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
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

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

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

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

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

@@ -298,9 +298,9 @@ describe('useOptionsMenu', () => {
// 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',
'attribute:service.name:string',
'log:body:string',
'resource:service.name:string',
'log:timestamp',
]);
@@ -331,9 +331,9 @@ describe('useOptionsMenu', () => {
'state-indicator',
'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];
@@ -360,7 +360,7 @@ describe('useOptionsMenu', () => {
);
// Removing 'resource:service.name' should drop ONLY the resource variant.
result.current.config.addColumn?.onRemove('resource:service.name');
result.current.config.addColumn?.onRemove('resource:service.name:string');
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
const remaining = mockUpdateColumns.mock.calls[0][0];

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

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

View File

@@ -1,3 +1,4 @@
import type { TelemetryFieldKey } from 'api/v5/v5';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const defaultSelectedColumns: string[] = [
@@ -10,3 +11,9 @@ export const defaultSelectedColumns: string[] = [
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
// Pinned timestamp column
export const TIMESTAMP_FIELD = {
name: 'timestamp',
fieldContext: 'span',
} as TelemetryFieldKey;

View File

@@ -0,0 +1,133 @@
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen } from 'tests/test-utils';
import ListView from './index';
// globalTime starts with loading:true, which gates the list query. Force just that
// slice's loading to false so the query fires; every other selector is untouched.
jest.mock('react-redux', () => {
const actual = jest.requireActual('react-redux');
return {
...actual,
useSelector: (selector: (state: unknown) => unknown): unknown => {
const result = actual.useSelector(selector);
if (result && typeof result === 'object' && 'loading' in result) {
return { ...result, loading: false };
}
return result;
},
};
});
// List columns come from the options menu (server-synced preferences). Pin them
// so the query fires and the expected columns render, independent of that API.
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
__esModule: true,
default: (): unknown => ({
options: {
selectColumns: [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'name', fieldContext: 'span' },
{ name: 'duration_nano', fieldContext: 'span' },
{ name: 'http_method', fieldContext: 'span' },
{ name: 'response_status_code', fieldContext: 'span' },
],
},
config: { addColumn: { onRemove: jest.fn() } },
}),
}));
const BASE_URL = ENVIRONMENT.baseURL;
const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
const listRows = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
'service.name': 'frontend',
name: 'HTTP GET',
duration_nano: 55306000,
http_method: 'GET',
response_status_code: '200',
span_id: '772c4d29dd9076ac',
trace_id: '0000000000000000344ded1387b08a7e',
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
'service.name': 'demo-app',
name: 'authenticate_check_db',
duration_nano: 790949390,
// empty status fields to assert the "-" cell
http_method: '',
response_status_code: '',
span_id: '5704353737b6778e',
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
},
},
];
const listResponse = (rows: unknown[]): Record<string, unknown> => ({
data: { type: 'raw', data: { results: [{ queryName: 'A', rows }] } },
});
const mockSuccess = (rows: unknown[] = listRows): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listResponse(rows))),
),
);
};
const renderListView = (): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<ListView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
/>
</VirtuosoMockContext.Provider>,
{},
{
initialRoute: '/traces-explorer',
queryBuilderOverrides: {
panelType: PANEL_TYPES.LIST,
stagedQuery: initialQueriesMap.traces,
currentQuery: initialQueriesMap.traces,
redirectWithQueryBuilderData: jest.fn(),
} as any,
},
);
describe('Traces ListView - Data Loaded', () => {
afterEach(() => {
server.resetHandlers();
});
it('renders backend rows in FieldCell format', async () => {
mockSuccess();
renderListView();
// plain-text columns
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
expect(screen.getByText('authenticate_check_db')).toBeInTheDocument();
// duration_nano renders in milliseconds
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
// http_method / response_status_code render as badges
expect(screen.getAllByTestId('http_method')[0]).toHaveTextContent('GET');
expect(screen.getAllByTestId('response_status_code')[0]).toHaveTextContent(
'200',
);
// empty status fields render "-"
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -12,16 +12,18 @@ import {
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { ResizeTable } from 'components/ResizeTable';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
@@ -32,20 +34,22 @@ import { Pagination } from 'hooks/queryPagination';
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { TracesLoading } from '../TraceLoading/TraceLoading';
import { defaultSelectedColumns, PER_PAGE_OPTIONS } from './configs';
import { Container, tableStyles } from './styles';
import { getListColumns, transformDataWithDate } from './utils';
import {
defaultSelectedColumns,
PER_PAGE_OPTIONS,
TIMESTAMP_FIELD,
} from './configs';
import { getTraceLink, transformSpanRows } from './utils';
import './ListView.styles.scss';
import styles from './ListView.module.scss';
interface ListViewProps {
isFilterApplied: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
@@ -93,7 +97,7 @@ function ListView({
[stagedQuery, orderBy],
);
// TEMP — remove after traces moves to TanStack table.
// Stable sorted-name signature for the queryKey.
// - Drag updates selectColumns; raw queryKey would churn on reorder.
// - Trace API fetches only listed columns → add/remove must refetch.
// - Sorted-name signature: stable on reorder, changes on add/remove.
@@ -186,60 +190,42 @@ function ListView({
[queryTableDataResult],
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns = useMemo(
() =>
getListColumns(
options?.selectColumns || [],
formatTimezoneAdjustedTimestamp,
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
const fields = [
TIMESTAMP_FIELD,
...(options?.selectColumns ?? []).filter(
(field) => field.name !== TIMESTAMP_FIELD.name,
),
[options?.selectColumns, formatTimezoneAdjustedTimestamp],
);
];
return fields.map((field) => getFieldColumn(field));
}, [options?.selectColumns]);
const transformedQueryTableData = useMemo(
() => transformDataWithDate(queryTableData) || [],
const rows = useMemo(
() => transformSpanRows(queryTableData),
[queryTableData],
);
const handleDragColumn = useCallback(
(fromIndex: number, toIndex: number): void => {
const reordered = [...columns];
const [moved] = reordered.splice(fromIndex, 1);
reordered.splice(toIndex, 0, moved);
// `key` is the composite (fieldContext.name) — disambiguates same-name fields.
const orderedIds = reordered
.map((c) => String(c.key || ('dataIndex' in c && c.dataIndex) || ''))
.filter(Boolean);
config?.addColumn?.onReorder(orderedIds);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(cols.map((c) => c.id));
},
[columns, config],
[config],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
const isDataAbsent =
!isLoading &&
!isFetching &&
!isError &&
transformedQueryTableData.length === 0;
useEffect(() => {
if (
!isLoading &&
!isFetching &&
!isError &&
transformedQueryTableData.length !== 0
) {
logEvent('Traces Explorer: Data present', {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
void logEvent('Traces Explorer: Data present', {
panelType,
});
}
}, [isLoading, isFetching, isError, transformedQueryTableData, panelType]);
}, [isLoading, isFetching, isError, rows, panelType]);
return (
<Container>
<div className={styles.container}>
<div className="trace-explorer-controls">
<div className="order-by-container">
<div className="order-by-label">
@@ -266,33 +252,21 @@ function ListView({
/>
</div>
{isError && error && <ErrorInPlace error={error as APIError} />}
{(isLoading || (isFetching && transformedQueryTableData.length === 0)) && (
<TracesLoading />
)}
{isDataAbsent && !isFilterApplied && (
<NoLogs dataSource={DataSource.TRACES} />
)}
{isDataAbsent && isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
)}
{!isError && transformedQueryTableData.length !== 0 && (
<ResizeTable
tableLayout="fixed"
pagination={false}
scroll={{ x: 'max-content' }}
loading={isFetching}
style={tableStyles}
dataSource={transformedQueryTableData}
columns={columns}
onDragColumn={handleDragColumn}
/>
)}
</Container>
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.TRACES_LIST_COLUMNS}
panelType="LIST"
getRowHref={getTraceLink}
isLoading={isLoading}
isFetching={isFetching}
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
/>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import type { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
@@ -41,12 +42,23 @@ export const transformDataWithDate = (
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
[];
export const getTraceLink = (record: RowData): string =>
`${ROUTES.TRACE}/${record.traceID || record.trace_id}${formUrlParams({
spanId: record.spanID || record.span_id,
export const getTraceLink = (record: Record<string, unknown>): string => {
function readId(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') {
return String(value);
}
return '';
}
const traceId = readId(record.traceID) || readId(record.trace_id);
const spanId = readId(record.spanID) || readId(record.span_id);
return `${ROUTES.TRACE}/${traceId}${formUrlParams({
spanId,
levelUp: 0,
levelDown: 0,
})}`;
};
export const getListColumns = (
selectedColumns: TelemetryFieldKey[],
@@ -136,3 +148,21 @@ export const getListColumns = (
return [...initialColumns, ...columns];
};
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
// positional ids; `timestamp` is lifted from the wrapping ListItem.
export const transformSpanRows = (data: QueryDataV3[]): TracesTableRow[] => {
const list = data[0]?.list;
if (!list) {
return [];
}
return list.map((item) => {
const row = item.data as Record<string, unknown>;
return {
...row,
timestamp: item.timestamp,
id: row.span_id,
};
}) as TracesTableRow[];
};

View File

@@ -0,0 +1,77 @@
import { generatePath, Link } from 'react-router-dom';
import { Badge } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { useTimezone } from 'providers/Timezone';
import {
DURATION_FIELD_NAMES,
STATUS_FIELD_NAMES,
TIMESTAMP_FIELD_NAMES,
TRACE_ID_FIELD_NAMES,
} from './constants';
import { stringifyCellValue } from './utils';
type FieldCellProps = {
name: string;
value: unknown;
};
function FieldCell({ name, value }: FieldCellProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
if (TIMESTAMP_FIELD_NAMES.has(name)) {
const ts = value as string | number;
const formatted =
typeof ts === 'string'
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
: formatTimezoneAdjustedTimestamp(
ts / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
const text = String(formatted);
return <TanStackTable.Text title={text}>{text}</TanStackTable.Text>;
}
if (value === '' || value == null) {
return <TanStackTable.Text data-testid={name}>-</TanStackTable.Text>;
}
const text = stringifyCellValue(value);
if (TRACE_ID_FIELD_NAMES.has(name)) {
return (
<Link
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
data-testid="trace-id"
onClick={(e): void => e.stopPropagation()}
>
{text}
</Link>
);
}
if (STATUS_FIELD_NAMES.has(name)) {
return (
<Badge data-testid={name} color="sakura" variant="outline">
{text}
</Badge>
);
}
if (DURATION_FIELD_NAMES.has(name)) {
return (
<TanStackTable.Text data-testid={name}>{getMs(text)}ms</TanStackTable.Text>
);
}
return (
<TanStackTable.Text data-testid={name} title={text}>
{text}
</TanStackTable.Text>
);
}
export default FieldCell;

View File

@@ -0,0 +1,26 @@
.tableWrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.tracesTable {
--tanstack-table-row-height: 54px;
--tanstack-table-header-height: 54px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-right-override: 15px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-header-padding-left-override: 5px;
--tanstack-cell-header-padding-left-first-column: 15px;
--tanstack-plain-body-line-clamp: 1;
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-header-cell-bg: var(--l1-background-hover);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
}

View File

@@ -0,0 +1,116 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import TanStackTable from 'components/TanStackTableView';
import type {
CellTypographySize,
TableColumnDef,
} from 'components/TanStackTableView/types';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
import APIError from 'types/api/error';
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
import { getAbsoluteUrl } from 'utils/basePath';
import type { TracesTableRow } from './getFieldColumn';
import styles from './TracesTable.module.scss';
export type TracesTableProps = {
data: TracesTableRow[];
columns: TableColumnDef<TracesTableRow>[];
columnStorageKey?: string;
respectColumnOrder?: boolean;
panelType: PanelTypeKeys;
/** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */
getRowHref: (row: TracesTableRow) => string;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: APIError | Error | null;
isFilterApplied: boolean;
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
onColumnRemove?: (columnId: string) => void;
cellTypographySize?: CellTypographySize;
};
function TracesTable({
data,
columns,
columnStorageKey,
respectColumnOrder = false,
panelType,
getRowHref,
isLoading,
isFetching,
isError,
error,
isFilterApplied,
onColumnOrderChange,
onColumnRemove,
cellTypographySize = 'medium',
}: TracesTableProps): JSX.Element {
const history = useHistory();
const isDataAbsent =
!isLoading && !isFetching && !isError && data.length === 0;
const handleRowClick = useCallback(
(row: TracesTableRow): void => {
history.push(getRowHref(row));
},
[history, getRowHref],
);
const handleRowClickNewTab = useCallback(
(row: TracesTableRow): void => {
window.open(getAbsoluteUrl(getRowHref(row)), '_blank', 'noopener');
},
[getRowHref],
);
return (
<>
{isError && error && <ErrorInPlace error={error as APIError} />}
{(isLoading || (isFetching && data.length === 0)) && <TracesLoading />}
{isDataAbsent && !isFilterApplied && (
<NoLogs dataSource={DataSource.TRACES} />
)}
{isDataAbsent && isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
)}
{!isError && data.length !== 0 && (
<div className={styles.tableWrapper}>
<TanStackTable<TracesTableRow>
data={data}
columns={columns}
className={styles.tracesTable}
columnStorageKey={columnStorageKey}
respectColumnOrder={respectColumnOrder}
isLoading={isFetching}
cellTypographySize={cellTypographySize}
onColumnOrderChange={onColumnOrderChange}
onColumnRemove={onColumnRemove}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
getRowTestId={(row): string => `traces-table-row-${row.id}`}
/>
</div>
)}
</>
);
}
TracesTable.defaultProps = {
columnStorageKey: undefined,
respectColumnOrder: false,
onColumnOrderChange: undefined,
onColumnRemove: undefined,
cellTypographySize: 'medium',
};
export default TracesTable;

View File

@@ -0,0 +1,18 @@
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
// camelCase and snake_case variants are listed because the API has shipped both.
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
export const STATUS_FIELD_NAMES = new Set([
'httpMethod',
'http_method',
'http.method',
'http.request.method',
'responseStatusCode',
'response_status_code',
'http.status_code',
'http.response.status_code',
]);
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);

View File

@@ -0,0 +1,26 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TIMESTAMP_FIELD_NAMES } from './constants';
import FieldCell from './FieldCell';
export type TracesTableRow = { id: string } & Record<string, unknown>;
export function getFieldColumn(
field: TelemetryFieldKey,
): TableColumnDef<TracesTableRow> {
const { name, fieldContext, fieldDataType } = field;
const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name);
return {
id: buildCompositeKey(name, fieldContext, fieldDataType),
header: name,
accessorFn: (row): unknown => row[name],
enableMove: !isTimestamp,
enableRemove: !isTimestamp,
canBeHidden: !isTimestamp,
width: { min: 192 },
cell: ({ value }): JSX.Element => <FieldCell name={name} value={value} />,
};
}

View File

@@ -0,0 +1,12 @@
export function stringifyCellValue(value: unknown): string {
if (value == null) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}

View File

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

View File

@@ -1,50 +1,25 @@
import { generatePath, Link } from 'react-router-dom';
import type { TableColumnsType as ColumnsType } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ROUTES from 'constants/routes';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
import { ListItem } from 'types/api/widgets/getQuery';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
export const columns: ColumnsType<ListItem['data']> = [
{
title: 'Root Service Name',
dataIndex: 'service.name',
key: 'serviceName',
},
{
title: 'Root Operation Name',
dataIndex: 'name',
key: 'name',
},
{
title: 'Root Duration (in ms)',
dataIndex: 'duration_nano',
key: 'durationNano',
render: (duration: number): JSX.Element => (
<Typography>{getMs(String(duration))}ms</Typography>
),
},
{
title: 'No of Spans',
dataIndex: 'span_count',
key: 'span_count',
},
{
title: 'TraceID',
dataIndex: 'trace_id',
key: 'traceID',
render: (traceID: string): JSX.Element => (
<Link
to={generatePath(ROUTES.TRACE_DETAIL, {
id: traceID,
})}
data-testid="trace-id"
>
{traceID}
</Link>
),
},
];
const TRACE_FIELDS = [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'name' },
{ name: 'duration_nano' },
{ name: 'span_count' },
{ name: 'trace_id' },
] as TelemetryFieldKey[];
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
(field) => ({
...getFieldColumn(field),
enableRemove: false,
canBeHidden: false,
}),
);

View File

@@ -0,0 +1,136 @@
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen, waitFor } from 'tests/test-utils';
import TracesView from './index';
const BASE_URL = ENVIRONMENT.baseURL;
const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
const groupedRows = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
'service.name': 'frontend',
name: 'HTTP GET',
duration_nano: 55306000,
span_count: 8,
trace_id: '0000000000000000344ded1387b08a7e',
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
'service.name': 'demo-app',
// intentionally empty to assert the "-" cell
name: '',
duration_nano: 790949390,
span_count: 3,
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
},
},
];
const groupedResponse = (rows: unknown[]): Record<string, unknown> => ({
data: { type: 'trace', data: { results: [{ queryName: 'A', rows }] } },
});
const mockSuccess = (rows: unknown[] = groupedRows): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(groupedResponse(rows))),
),
);
};
const mockError = (): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error', error: 'boom' })),
),
);
};
const renderTracesView = (
props: Record<string, unknown> = {},
): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<TracesView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
{...props}
/>
</VirtuosoMockContext.Provider>,
{},
{
initialRoute: '/traces-explorer',
queryBuilderOverrides: {
panelType: PANEL_TYPES.TRACE,
stagedQuery: initialQueriesMap.traces,
currentQuery: initialQueriesMap.traces,
} as any,
},
);
describe('TracesView (grouped root-span table)', () => {
afterEach(() => {
server.resetHandlers();
});
it('renders backend rows in FieldCell format', async () => {
mockSuccess();
renderTracesView();
// service.name + name render as plain text
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
expect(screen.getByText('HTTP GET')).toBeInTheDocument();
// duration_nano renders in milliseconds
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
// span_count renders as text
expect(screen.getByText('8')).toBeInTheDocument();
// empty field renders "-"
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
// trace_id renders as a link to the trace detail
const traceLinks = screen.getAllByTestId('trace-id');
expect(traceLinks[0]).toHaveAttribute(
'href',
expect.stringContaining('/trace/0000000000000000344ded1387b08a7e'),
);
});
it('shows the empty state and keeps the toolbar when there are no rows', async () => {
mockSuccess([]);
renderTracesView();
// toolbar (un-gated) stays visible regardless of data
expect(
screen.getByText(/This tab only shows Root Spans/i),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/No traces yet/i)).toBeInTheDocument();
});
});
it('keeps the toolbar visible on API error', async () => {
mockError();
renderTracesView();
expect(
screen.getByText(/This tab only shows Root Spans/i),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,3 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
Dispatch,
memo,
@@ -12,30 +11,29 @@ import { useSelector } from 'react-redux';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { ResizeTable } from 'components/ResizeTable';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import TraceExplorerControls from '../Controls';
import { TracesLoading } from '../TraceLoading/TraceLoading';
import { columns, PER_PAGE_OPTIONS } from './configs';
import { ActionsContainer, Container } from './styles';
import styles from './TracesView.module.scss';
interface TracesViewProps {
isFilterApplied: boolean;
@@ -119,8 +117,13 @@ function TracesView({
}, [data?.payload, data?.warning]);
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
const tableData = useMemo(
() => responseData?.map((listItem) => listItem.data),
const rows = useMemo<TracesTableRow[]>(
() =>
(responseData ?? []).map((item) => {
const row = item.data;
return { ...row, id: row.trace_id };
}) as TracesTableRow[],
[responseData],
);
@@ -133,71 +136,52 @@ function TracesView({
}, [isLoading, isFetching, setIsLoadingQueries]);
useEffect(() => {
if (!isLoading && !isFetching && !isError && (tableData || []).length !== 0) {
logEvent('Traces Explorer: Data present', {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
void logEvent('Traces Explorer: Data present', {
panelType: 'TRACE',
});
}
}, [isLoading, isFetching, isError, panelType, tableData]);
}, [isLoading, isFetching, isError, rows.length]);
return (
<Container>
{(tableData || []).length !== 0 && (
<ActionsContainer>
<Typography>
This tab only shows Root Spans. More details
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
{' '}
here
</Typography.Link>
</Typography>
<div className={styles.container}>
<div className={styles.actionsContainer}>
<Typography>
This tab only shows Root Spans. More details
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
{' '}
here
</Typography.Link>
</Typography>
<div className="trace-explorer-controls">
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
/>
<div className="trace-explorer-controls">
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
/>
<TraceExplorerControls
isLoading={isLoading}
totalCount={responseData?.length || 0}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
</ActionsContainer>
)}
<TraceExplorerControls
isLoading={isLoading}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
</div>
{isError && error && <ErrorInPlace error={error as APIError} />}
{(isLoading || (isFetching && (tableData || []).length === 0)) && (
<TracesLoading />
)}
{!isLoading &&
!isFetching &&
!isError &&
!isFilterApplied &&
(tableData || []).length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
{!isLoading &&
!isFetching &&
(tableData || []).length === 0 &&
!isError &&
isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="TRACE" />
)}
{(tableData || []).length !== 0 && (
<ResizeTable
loading={isLoading}
columns={columns}
tableLayout="fixed"
dataSource={tableData}
scroll={{ x: true }}
pagination={false}
/>
)}
</Container>
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.TRACES_VIEW_COLUMNS}
respectColumnOrder
panelType="TRACE"
getRowHref={getTraceLink}
isLoading={isLoading}
isFetching={isFetching}
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
/>
</div>
);
}

View File

@@ -1,12 +0,0 @@
import styled from 'styled-components';
export const Container = styled.div`
display: flex;
flex-direction: column;
`;
export const ActionsContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;

View File

@@ -94,6 +94,8 @@ function AlertDetails(): JSX.Element {
>
<div
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
data-testid="alert-details-root"
data-schema-version={isV2Alert ? NEW_ALERT_SCHEMA_VERSION : 'v1'}
>
<AlertBreadcrumb
className="alert-details__breadcrumb"

View File

@@ -117,7 +117,11 @@ function AlertActionButtons({
<div className="alert-action-buttons">
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
{isAlertRuleDisabled !== undefined && (
<Switch onChange={toggleAlertRule} value={!isAlertRuleDisabled} />
<Switch
onChange={toggleAlertRule}
value={!isAlertRuleDisabled}
testId="alert-actions-toggle"
/>
)}
</Tooltip>
<CopyToClipboard textToCopy={window.location.href} />
@@ -129,6 +133,7 @@ function AlertActionButtons({
<Tooltip title="More options">
<Button
type="text"
data-testid="alert-actions-menu"
icon={
<Ellipsis
size={16}

View File

@@ -47,21 +47,26 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
<div className="alert-info__info-wrapper">
<div className="top-section">
<div className="alert-title-wrapper">
<AlertState state={alertRuleState ?? state ?? ''} />
<div className="alert-title">
<AlertState
state={alertRuleState ?? state ?? ''}
testId="alert-header-state"
/>
<div className="alert-title" data-testid="alert-header-title">
<LineClampedText text={displayName || ''} />
</div>
</div>
</div>
<div className="bottom-section">
{labels?.severity && <AlertSeverity severity={labels.severity} />}
{labels?.severity && (
<AlertSeverity severity={labels.severity} testId="alert-header-severity" />
)}
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
{/* <AlertStatus
status="firing"
timestamp={dayjs().subtract(1, 'd').valueOf()}
/> */}
<AlertLabels labels={labelsWithoutSeverity} />
<AlertLabels labels={labelsWithoutSeverity} testId="alert-header-labels" />
</div>
</div>
);

View File

@@ -6,14 +6,16 @@ import './AlertLabels.styles.scss';
export type AlertLabelsProps = {
labels: Record<string, any>;
initialCount?: number;
testId?: string;
};
function AlertLabels({
labels,
initialCount = 2,
testId,
}: AlertLabelsProps): JSX.Element {
return (
<div className="alert-labels">
<div className="alert-labels" data-testid={testId}>
<SeeMore initialCount={initialCount} moreLabel="More">
{Object.entries(labels).map(([key, value]) => (
<KeyValueLabel key={`label-${key}`} badgeKey={key} badgeValue={value} />
@@ -25,6 +27,7 @@ function AlertLabels({
AlertLabels.defaultProps = {
initialCount: 2,
testId: undefined,
};
export default AlertLabels;

View File

@@ -32,8 +32,10 @@ const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
export default function AlertSeverity({
severity,
testId,
}: {
severity: string;
testId?: string;
}): JSX.Element {
const severityDetails = useMemo(() => {
if (severityConfig[severity]) {
@@ -52,9 +54,16 @@ export default function AlertSeverity({
};
}, [severity]);
return (
<div className={`alert-severity ${severityDetails.className}`}>
<div
className={`alert-severity ${severityDetails.className}`}
data-testid={testId}
>
<div className="alert-severity__icon">{severityDetails.icon}</div>
<div className="alert-severity__text">{severityDetails.text}</div>
</div>
);
}
AlertSeverity.defaultProps = {
testId: undefined,
};

View File

@@ -8,11 +8,13 @@ import './AlertState.styles.scss';
type AlertStateProps = {
state: RuletypesAlertStateDTO | string;
showLabel?: boolean;
testId?: string;
};
export default function AlertState({
state,
showLabel,
testId,
}: AlertStateProps): JSX.Element {
let icon;
let label;
@@ -64,7 +66,7 @@ export default function AlertState({
}
return (
<div className="alert-state">
<div className="alert-state" data-testid={testId}>
{icon} {showLabel && <div className="alert-state__label">{label}</div>}
</div>
);
@@ -72,4 +74,5 @@ export default function AlertState({
AlertState.defaultProps = {
showLabel: false,
testId: undefined,
};

View File

@@ -127,7 +127,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
{
Component: EditRules,
name: (
<div className="tab-item">
<div className="tab-item" data-testid="alert-details-tab-overview">
<Table size={14} />
Overview
</div>
@@ -138,7 +138,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
{
Component: AlertHistory,
name: (
<div className="tab-item">
<div className="tab-item" data-testid="alert-details-tab-history">
<History size={14} />
History
<BetaTag />

View File

@@ -13,6 +13,8 @@ interface Tab {
disabled?: boolean;
icon?: string | JSX.Element;
isBeta?: boolean;
/** Optional `data-testid` for the tab button. */
testId?: string;
}
interface TimelineTabsProps {
@@ -63,6 +65,7 @@ function Tabs2({
disabled={tab.disabled}
icon={tab.icon}
style={{ minWidth: buttonMinWidth }}
data-testid={tab.testId}
>
{tab.label}

View File

@@ -0,0 +1,84 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/gorilla/mux"
)
func (provider *provider) addLicensingRoutes(router *mux.Router) error {
if err := router.Handle("/api/v3/licenses", handler.New(
provider.authzMiddleware.CheckResources(provider.licensingHandler.Activate, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ActivateLicense",
Tags: []string{"licenses"},
Summary: "Activate a license.",
Description: "This endpoint validates the license key with upstream and activates the license for the organization.",
Request: new(licensetypes.PostableLicense),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusAccepted,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLicense.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceLicense,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v3/licenses", handler.New(
provider.authzMiddleware.CheckResources(provider.licensingHandler.Refresh, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "RefreshLicense",
Tags: []string{"licenses"},
Summary: "Refresh the active license.",
Description: "This endpoint refreshes the active license of the organization from upstream.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLicense.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceLicense,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v3/licenses/active", handler.New(provider.authzMiddleware.OpenAccess(provider.licensingHandler.GetActive), handler.OpenAPIDef{
ID: "GetActiveLicense",
Tags: []string{"licenses"},
Summary: "Get the active license.",
Description: "This endpoint gets the active license of the organization.",
Request: nil,
RequestContentType: "",
Response: new(licensetypes.GettableLicense),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes(nil),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
@@ -67,6 +68,7 @@ type provider struct {
authzHandler authz.Handler
rawDataExportHandler rawdataexport.Handler
zeusHandler zeus.Handler
licensingHandler licensing.Handler
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
serviceAccountGetter serviceaccount.Getter
@@ -105,6 +107,7 @@ func NewFactory(
authzHandler authz.Handler,
rawDataExportHandler rawdataexport.Handler,
zeusHandler zeus.Handler,
licensingHandler licensing.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
@@ -146,6 +149,7 @@ func NewFactory(
authzHandler,
rawDataExportHandler,
zeusHandler,
licensingHandler,
querierHandler,
serviceAccountHandler,
serviceAccountGetter,
@@ -189,6 +193,7 @@ func newProvider(
authzHandler authz.Handler,
rawDataExportHandler rawdataexport.Handler,
zeusHandler zeus.Handler,
licensingHandler licensing.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
@@ -231,6 +236,7 @@ func newProvider(
authzHandler: authzHandler,
rawDataExportHandler: rawDataExportHandler,
zeusHandler: zeusHandler,
licensingHandler: licensingHandler,
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
serviceAccountGetter: serviceAccountGetter,
@@ -332,6 +338,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addLicensingRoutes(router); err != nil {
return err
}
if err := provider.addZeusRoutes(router); err != nil {
return err
}

View File

@@ -1,4 +1,4 @@
package httplicensing
package licensing
import (
"context"
@@ -8,21 +8,20 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type licensingAPI struct {
licensing licensing.Licensing
type handler struct {
licensing Licensing
}
func NewLicensingAPI(licensing licensing.Licensing) licensing.API {
return &licensingAPI{licensing: licensing}
func NewHandler(licensing Licensing) Handler {
return &handler{licensing: licensing}
}
func (api *licensingAPI) Activate(rw http.ResponseWriter, r *http.Request) {
func (handler *handler) Activate(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -45,7 +44,7 @@ func (api *licensingAPI) Activate(rw http.ResponseWriter, r *http.Request) {
return
}
err = api.licensing.Activate(r.Context(), orgID, req.Key)
err = handler.licensing.Activate(r.Context(), orgID, req.Key)
if err != nil {
render.Error(rw, err)
return
@@ -54,7 +53,7 @@ func (api *licensingAPI) Activate(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusAccepted, nil)
}
func (api *licensingAPI) GetActive(rw http.ResponseWriter, r *http.Request) {
func (handler *handler) GetActive(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -70,7 +69,7 @@ func (api *licensingAPI) GetActive(rw http.ResponseWriter, r *http.Request) {
return
}
license, err := api.licensing.GetActive(r.Context(), orgID)
license, err := handler.licensing.GetActive(r.Context(), orgID)
if err != nil {
render.Error(rw, err)
return
@@ -80,7 +79,7 @@ func (api *licensingAPI) GetActive(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, gettableLicense)
}
func (api *licensingAPI) Refresh(rw http.ResponseWriter, r *http.Request) {
func (handler *handler) Refresh(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -96,7 +95,7 @@ func (api *licensingAPI) Refresh(rw http.ResponseWriter, r *http.Request) {
return
}
err = api.licensing.Refresh(r.Context(), orgID)
err = handler.licensing.Refresh(r.Context(), orgID)
if err != nil {
render.Error(rw, err)
return
@@ -105,7 +104,7 @@ func (api *licensingAPI) Refresh(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
func (api *licensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) {
func (handler *handler) Checkout(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -127,7 +126,7 @@ func (api *licensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) {
return
}
gettableSubscription, err := api.licensing.Checkout(ctx, orgID, req)
gettableSubscription, err := handler.licensing.Checkout(ctx, orgID, req)
if err != nil {
render.Error(rw, err)
return
@@ -136,7 +135,7 @@ func (api *licensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusCreated, gettableSubscription)
}
func (api *licensingAPI) Portal(rw http.ResponseWriter, r *http.Request) {
func (handler *handler) Portal(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -158,7 +157,7 @@ func (api *licensingAPI) Portal(rw http.ResponseWriter, r *http.Request) {
return
}
gettableSubscription, err := api.licensing.Portal(ctx, orgID, req)
gettableSubscription, err := handler.licensing.Portal(ctx, orgID, req)
if err != nil {
render.Error(rw, err)
return

View File

@@ -37,7 +37,7 @@ type Licensing interface {
statsreporter.StatsCollector
}
type API interface {
type Handler interface {
Activate(http.ResponseWriter, *http.Request)
Refresh(http.ResponseWriter, *http.Request)
GetActive(http.ResponseWriter, *http.Request)

View File

@@ -1,35 +0,0 @@
package nooplicensing
import (
"net/http"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/licensing"
)
type noopLicensingAPI struct{}
func NewLicenseAPI() licensing.API {
return &noopLicensingAPI{}
}
func (api *noopLicensingAPI) Activate(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented"))
}
func (api *noopLicensingAPI) GetActive(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented"))
}
func (api *noopLicensingAPI) Refresh(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented"))
}
func (api *noopLicensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented"))
}
func (api *noopLicensingAPI) Portal(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented"))
}

View File

@@ -119,7 +119,7 @@ type APIHandler struct {
// Websocket connection upgrader
Upgrader *websocket.Upgrader
LicensingAPI licensing.API
LicensingHandler licensing.Handler
QueryParserAPI *queryparser.API
@@ -139,7 +139,7 @@ type APIHandlerOpts struct {
// Flux Interval
FluxInterval time.Duration
LicensingAPI licensing.API
LicensingHandler licensing.Handler
QueryParserAPI *queryparser.API
@@ -176,7 +176,7 @@ func NewAPIHandler(opts APIHandlerOpts, config signoz.Config) (*APIHandler, erro
LogsParsingPipelineController: opts.LogsParsingPipelineController,
querier: querier,
querierV2: querierv2,
LicensingAPI: opts.LicensingAPI,
LicensingHandler: opts.LicensingHandler,
Signoz: opts.Signoz,
QueryParserAPI: opts.QueryParserAPI,
}
@@ -457,13 +457,6 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/register", am.OpenAccess(aH.registerUser)).Methods(http.MethodPost)
router.HandleFunc("/api/v3/licenses", am.ViewAccess(func(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, []any{})
})).Methods(http.MethodGet)
router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(func(rw http.ResponseWriter, req *http.Request) {
aH.LicensingAPI.Activate(rw, req)
})).Methods(http.MethodGet)
router.HandleFunc("/api/v1/span_percentile", am.ViewAccess(aH.Signoz.Handlers.SpanPercentile.GetSpanPercentileDetails)).Methods(http.MethodPost)
// Query Filter Analyzer api used to extract metric names and grouping columns from a query

View File

@@ -16,7 +16,7 @@ import (
"github.com/soheilhy/cmux"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
@@ -84,7 +84,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
IntegrationsController: integrationsController,
LogsParsingPipelineController: logParsingPipelineController,
FluxInterval: config.Querier.FluxInterval,
LicensingAPI: nooplicensing.NewLicenseAPI(),
LicensingHandler: licensing.NewHandler(signoz.Licensing),
Signoz: signoz,
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
}, config)

View File

@@ -77,6 +77,7 @@ type Handlers struct {
AIObservability aiobservability.Handler
AuthzHandler authz.Handler
ZeusHandler zeus.Handler
LicensingHandler licensing.Handler
QuerierHandler querier.Handler
ServiceAccountHandler serviceaccount.Handler
RegistryHandler factory.Handler
@@ -95,7 +96,7 @@ func NewHandlers(
providerSettings factory.ProviderSettings,
analytics analytics.Analytics,
querierHandler querier.Handler,
licensing licensing.Licensing,
licensingService licensing.Licensing,
global global.Global,
flaggerService flagger.Flagger,
gatewayService gateway.Gateway,
@@ -125,7 +126,8 @@ func NewHandlers(
Fields: implfields.NewHandler(providerSettings, telemetryMetadataStore),
AIObservability: implaiobservability.NewHandler(telemetryMetadataStore),
AuthzHandler: signozauthzapi.NewHandler(authz),
ZeusHandler: zeus.NewHandler(zeusService, licensing),
ZeusHandler: zeus.NewHandler(zeusService, licensingService),
LicensingHandler: licensing.NewHandler(licensingService),
QuerierHandler: querierHandler,
ServiceAccountHandler: implserviceaccount.NewHandler(modules.ServiceAccount, modules.ServiceAccountGetter),
RegistryHandler: registryHandler,

View File

@@ -17,6 +17,7 @@ import (
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
@@ -80,6 +81,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ authz.Handler }{},
struct{ rawdataexport.Handler }{},
struct{ zeus.Handler }{},
struct{ licensing.Handler }{},
struct{ querier.Handler }{},
struct{ serviceaccount.Handler }{},
struct{ serviceaccount.Getter }{},

View File

@@ -244,6 +244,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewDeleteOrphanUserRolesFactory(),
sqlmigration.NewMigrateLambdaDashboardsFactory(),
sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore),
sqlmigration.NewAddLicenseTuplesFactory(sqlstore),
)
}
@@ -334,6 +335,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.AuthzHandler,
handlers.RawDataExport,
handlers.ZeusHandler,
handlers.LicensingHandler,
handlers.QuerierHandler,
handlers.ServiceAccountHandler,
modules.ServiceAccountGetter,

View File

@@ -0,0 +1,159 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addLicenseTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddLicenseTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_license_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addLicenseTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addLicenseTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addLicenseTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
tuples := []migrationTuple{
{authtypes.SigNozAdminRoleName, "metaresource", "license", "create"},
{authtypes.SigNozAdminRoleName, "metaresource", "license", "read"},
{authtypes.SigNozAdminRoleName, "metaresource", "license", "update"},
{authtypes.SigNozAdminRoleName, "metaresource", "license", "delete"},
{authtypes.SigNozAdminRoleName, "metaresource", "license", "list"},
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
if err != nil {
return err
}
managedRoleGroups[roleName] = string(data)
}
for _, orgID := range orgIDs {
for roleName, data := range managedRoleGroups {
if _, err := tx.NewUpdate().
Model(new(roles)).
Set("transaction_groups = ?", data).
Where("org_id = ?", orgID).
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
Where("name = ?", roleName).
Exec(ctx); err != nil {
return err
}
}
}
return tx.Commit()
}
func (migration *addLicenseTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -64,10 +64,10 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorPassword}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorPassword}, WildCardSelectorString)},
// license — admin only.
// Uniform LCRUD shape; actual ee routes are POST /api/v3/licenses (create
// = Activate), PUT /api/v3/licenses (update = Refresh), GET
// /api/v3/licenses/active (read; currently exposed as ViewAccess on the
// route side). delete and list are placeholders for shape parity, no
// Uniform LCRUD shape; routes are POST /api/v3/licenses (create =
// Activate) and PUT /api/v3/licenses (update = Refresh). GET
// /api/v3/licenses/active is OpenAccess, so the read grant is not
// route-enforced. delete and list are placeholders for shape parity, no
// route serves them today.
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)},

View File

@@ -0,0 +1,459 @@
import type { Browser } from '@playwright/test';
import {
createEmailChannelViaApi,
createLogsAlertViaApi,
createMetricAlertViaApi,
createNoDataAlertViaApi,
createTracesAlertViaApi,
deleteAlertViaApi,
deleteChannelViaApi,
setRuleDisabledViaApi,
} from '../../helpers/alerts/api';
import {
readTimelineTotal,
waitForTimelineEntries,
waitForTimelineStates,
} from '../../helpers/alerts/history';
import {
seedAlertHistoryLogs,
seedAlertHistoryMetrics,
seedAlertHistoryTraces,
} from '../../helpers/alerts/seeding';
import { expect, test as base, withAdminPage } from './alert-rules';
import {
FIXTURE_ALERT_HISTORY_TIMEOUT,
FIXTURE_EMPTY_HISTORY_TIMEOUT,
FIXTURE_METRICS_HISTORY_TIMEOUT,
FIXTURE_NODATA_HISTORY_TIMEOUT,
FIXTURE_RESOLVED_HISTORY_TIMEOUT,
FIXTURE_TRACES_HISTORY_TIMEOUT,
WAIT_METRICS_TIMELINE_TIMEOUT,
WAIT_NODATA_TIMELINE_TIMEOUT,
} from './timeouts';
// Worker-scoped alert-history fixtures. Extends `alert-rules`, so a spec that
// imports `test` from here also gets `alertChannel` / `alertList` / `ownedRules`
// — the details specs need a history seed *and* their own throwaway rules.
//
// Every history row has to come from the ruler actually evaluating a rule (there
// is no seeder endpoint for `rule_state_history_v0`), so each fixture pays a
// real ruler wait: ~20-35s for logs, ~10s for metrics, ~105s for firing→resolved.
// Worker scope means one wait per worker instead of one per test, and Playwright
// creates each fixture lazily — a spec that never asks for `resolvedHistory`
// never pays its 105s.
/** Service count for `alertHistory`. 25 yields multi-page timeline + pagination tests. */
const LOGS_HISTORY_SERVICES = 25;
/** Service count for `resolvedHistory`. 3 services + 1m window = resolves in ~105s. */
const RESOLVED_HISTORY_SERVICES = 3;
/** Hosts for `metricsHistory`. 2 rows fit one page, no related-logs links. */
const METRICS_HISTORY_HOSTS = ['host-0', 'host-1'];
/** Service count for `tracesHistory`. 3 keeps wait short while proving traces link. */
const TRACES_HISTORY_SERVICES = 3;
/** Team label for the v1 rule in `alertHistory`, so its header labels row is non-empty. */
export const V1_RULE_TEAM_LABEL = 'e2e-platform';
export interface AlertHistorySeed {
/** v2 (`schemaVersion: v2alpha1`) rule — the default history subject. */
ruleId: string;
/** Legacy v1 rule over the same logs. Its `threshold.name` is `warning`. */
ruleIdV1: string;
channelName: string;
/** The `body CONTAINS` marker both rules match. */
marker: string;
/** The seeded `service.name` values, in creation order. */
services: string[];
/** Baseline `total` for {@link ruleId}, read after the rule was frozen. */
total: number;
/** Baseline `total` for {@link ruleIdV1}. */
totalV1: number;
}
export interface MetricsHistorySeed {
ruleId: string;
channelName: string;
metricName: string;
hosts: string[];
total: number;
}
export interface TracesHistorySeed {
ruleId: string;
channelName: string;
/** The span `name` the rule matches (`name = '<marker>'`). */
marker: string;
services: string[];
total: number;
}
export interface ResolvedHistorySeed {
ruleId: string;
channelName: string;
marker: string;
services: string[];
/** Rows in the `firing` state — equals `stats.totalCurrentTriggers`. */
firingCount: number;
/** Rows in the `inactive` state, i.e. what the `Resolved` filter shows. */
resolvedCount: number;
}
export interface NoDataHistorySeed {
ruleId: string;
channelName: string;
}
export interface EmptyHistorySeed {
ruleId: string;
channelName: string;
}
async function cleanup(
browser: Browser,
{ ruleIds, channelId }: { ruleIds: string[]; channelId?: string },
): Promise<void> {
await withAdminPage(browser, async (page) => {
for (const id of ruleIds) {
// eslint-disable-next-line no-await-in-loop
await deleteAlertViaApi(page, id);
}
if (channelId) {
await deleteChannelViaApi(page, channelId);
}
});
}
// --- Fixture setup functions ---
interface HistoryFixtureResult<T> {
seed: T;
ruleIds: string[];
channelId: string;
}
async function createAlertHistorySeed(
browser: Browser,
): Promise<HistoryFixtureResult<AlertHistorySeed>> {
const stamp = Date.now();
const marker = `e2e alert history ${stamp}`;
const result = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(page, `e2e-ah-ch-${stamp}`);
const services = await seedAlertHistoryLogs(page, {
marker,
services: LOGS_HISTORY_SERVICES,
servicePrefix: `e2e-ah-svc`,
});
const ruleId = await createLogsAlertViaApi(page, {
name: `e2e-ah-rule-v2-${stamp}`,
marker,
channels: [channel.name],
schema: 'v2',
});
const ruleIdV1 = await createLogsAlertViaApi(page, {
name: `e2e-ah-rule-v1-${stamp}`,
marker,
channels: [channel.name],
schema: 'v1',
extraLabels: { team: V1_RULE_TEAM_LABEL },
});
await waitForTimelineEntries(page, ruleId, { min: LOGS_HISTORY_SERVICES });
await waitForTimelineEntries(page, ruleIdV1, { min: LOGS_HISTORY_SERVICES });
await setRuleDisabledViaApi(page, ruleId, true);
await setRuleDisabledViaApi(page, ruleIdV1, true);
return {
seed: {
ruleId,
ruleIdV1,
channelName: channel.name,
marker,
services,
total: await readTimelineTotal(page, ruleId),
totalV1: await readTimelineTotal(page, ruleIdV1),
},
ruleIds: [ruleId, ruleIdV1],
channelId: channel.id,
};
});
if (result.seed.total !== LOGS_HISTORY_SERVICES) {
throw new Error(
`alertHistory expected ${LOGS_HISTORY_SERVICES} timeline rows, got ${result.seed.total}`,
);
}
return result;
}
async function createMetricsHistorySeed(
browser: Browser,
): Promise<HistoryFixtureResult<MetricsHistorySeed>> {
const stamp = Date.now();
const metricName = `e2e_ah_probe_metric_${stamp}`;
return withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-metrics-ch-${stamp}`,
);
await seedAlertHistoryMetrics(page, {
metricName,
hosts: METRICS_HISTORY_HOSTS,
});
const ruleId = await createMetricAlertViaApi(page, {
name: `e2e-ah-metrics-rule-${stamp}`,
metricName,
channels: [channel.name],
});
await waitForTimelineEntries(page, ruleId, {
min: METRICS_HISTORY_HOSTS.length,
timeoutMs: WAIT_METRICS_TIMELINE_TIMEOUT,
});
await setRuleDisabledViaApi(page, ruleId, true);
return {
seed: {
ruleId,
channelName: channel.name,
metricName,
hosts: METRICS_HISTORY_HOSTS,
total: await readTimelineTotal(page, ruleId),
},
ruleIds: [ruleId],
channelId: channel.id,
};
});
}
async function createTracesHistorySeed(
browser: Browser,
): Promise<HistoryFixtureResult<TracesHistorySeed>> {
const stamp = Date.now();
const marker = `e2e-aht-span-${stamp}`;
return withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-traces-ch-${stamp}`,
);
const services = await seedAlertHistoryTraces(page, {
marker,
services: TRACES_HISTORY_SERVICES,
servicePrefix: 'e2e-aht-svc',
});
const ruleId = await createTracesAlertViaApi(page, {
name: `e2e-ah-traces-rule-${stamp}`,
marker,
channels: [channel.name],
});
await waitForTimelineEntries(page, ruleId, { min: TRACES_HISTORY_SERVICES });
await setRuleDisabledViaApi(page, ruleId, true);
return {
seed: {
ruleId,
channelName: channel.name,
marker,
services,
total: await readTimelineTotal(page, ruleId),
},
ruleIds: [ruleId],
channelId: channel.id,
};
});
}
async function createResolvedHistorySeed(
browser: Browser,
): Promise<HistoryFixtureResult<ResolvedHistorySeed>> {
const stamp = Date.now();
const marker = `e2e alert resolved ${stamp}`;
return withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-resolved-ch-${stamp}`,
);
const services = await seedAlertHistoryLogs(page, {
marker,
services: RESOLVED_HISTORY_SERVICES,
ageSeconds: 40,
minAgeSeconds: 28,
servicePrefix: 'e2e-ahr-svc',
});
const ruleId = await createLogsAlertViaApi(page, {
name: `e2e-ah-resolved-rule-${stamp}`,
marker,
channels: [channel.name],
evalWindow: '1m0s',
});
const timeline = await waitForTimelineStates(page, ruleId, {
states: {
firing: RESOLVED_HISTORY_SERVICES,
inactive: RESOLVED_HISTORY_SERVICES,
},
});
await setRuleDisabledViaApi(page, ruleId, true);
return {
seed: {
ruleId,
channelName: channel.name,
marker,
services,
firingCount: timeline.items.filter((i) => i.state === 'firing').length,
resolvedCount: timeline.items.filter((i) => i.state === 'inactive').length,
},
ruleIds: [ruleId],
channelId: channel.id,
};
});
}
async function createNoDataHistorySeed(
browser: Browser,
): Promise<HistoryFixtureResult<NoDataHistorySeed>> {
const stamp = Date.now();
return withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-nodata-ch-${stamp}`,
);
const ruleId = await createNoDataAlertViaApi(page, {
name: `e2e-ah-nodata-rule-${stamp}`,
marker: `e2e alert nodata ${stamp}`,
channels: [channel.name],
});
await waitForTimelineEntries(page, ruleId, {
min: 1,
state: 'nodata',
timeoutMs: WAIT_NODATA_TIMELINE_TIMEOUT,
});
await setRuleDisabledViaApi(page, ruleId, true);
return {
seed: { ruleId, channelName: channel.name },
ruleIds: [ruleId],
channelId: channel.id,
};
});
}
async function createEmptyHistorySeed(
browser: Browser,
): Promise<HistoryFixtureResult<EmptyHistorySeed>> {
const stamp = Date.now();
return withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-empty-ch-${stamp}`,
);
const ruleId = await createLogsAlertViaApi(page, {
name: `e2e-ah-empty-rule-${stamp}`,
marker: `e2e alert never seeded ${stamp}`,
channels: [channel.name],
});
await setRuleDisabledViaApi(page, ruleId, true);
return {
seed: { ruleId, channelName: channel.name },
ruleIds: [ruleId],
channelId: channel.id,
};
});
}
// --- Fixture definitions ---
export const test = base.extend<
// eslint-disable-next-line @typescript-eslint/ban-types
{},
{
alertHistory: AlertHistorySeed;
metricsHistory: MetricsHistorySeed;
tracesHistory: TracesHistorySeed;
resolvedHistory: ResolvedHistorySeed;
noDataHistory: NoDataHistorySeed;
emptyHistory: EmptyHistorySeed;
}
>({
alertHistory: [
async ({ browser }, use) => {
const { seed, ruleIds, channelId } = await createAlertHistorySeed(browser);
await use(seed);
await cleanup(browser, { ruleIds, channelId });
},
{ scope: 'worker', timeout: FIXTURE_ALERT_HISTORY_TIMEOUT },
],
metricsHistory: [
async ({ browser }, use) => {
const { seed, ruleIds, channelId } = await createMetricsHistorySeed(browser);
await use(seed);
await cleanup(browser, { ruleIds, channelId });
},
{ scope: 'worker', timeout: FIXTURE_METRICS_HISTORY_TIMEOUT },
],
tracesHistory: [
async ({ browser }, use) => {
const { seed, ruleIds, channelId } = await createTracesHistorySeed(browser);
await use(seed);
await cleanup(browser, { ruleIds, channelId });
},
{ scope: 'worker', timeout: FIXTURE_TRACES_HISTORY_TIMEOUT },
],
resolvedHistory: [
async ({ browser }, use) => {
const { seed, ruleIds, channelId } =
await createResolvedHistorySeed(browser);
await use(seed);
await cleanup(browser, { ruleIds, channelId });
},
{ scope: 'worker', timeout: FIXTURE_RESOLVED_HISTORY_TIMEOUT },
],
noDataHistory: [
async ({ browser }, use) => {
const { seed, ruleIds, channelId } = await createNoDataHistorySeed(browser);
await use(seed);
await cleanup(browser, { ruleIds, channelId });
},
{ scope: 'worker', timeout: FIXTURE_NODATA_HISTORY_TIMEOUT },
],
emptyHistory: [
async ({ browser }, use) => {
const { seed, ruleIds, channelId } = await createEmptyHistorySeed(browser);
await use(seed);
await cleanup(browser, { ruleIds, channelId });
},
{ scope: 'worker', timeout: FIXTURE_EMPTY_HISTORY_TIMEOUT },
],
});
export { expect };

View File

@@ -0,0 +1,245 @@
import type { Browser, Page } from '@playwright/test';
import {
createEmailChannelViaApi,
createLogsAlertViaApi,
createThresholdAlertViaApi,
deleteAlertViaApi,
deleteChannelViaApi,
} from '../../helpers/alerts/api';
import { seedAlertRules } from '../../helpers/alerts/seeding';
import type {
AlertSchema,
LogsAlertSeed,
ThresholdAlertSeed,
} from '../../helpers/alerts/types';
import { newAdminContext } from '../../helpers/auth';
import { expect, test as base } from '../auth';
import { FIXTURE_ALERT_LIST_TIMEOUT } from './timeouts';
// Alert *rule* fixtures — the API-only half of the alerts suite. Nothing here
// waits on the ruler: a rule is created and that's it. History rows need real
// evaluations, so those fixtures live in `alert-history.ts`, which
// extends this module — a spec importing from there gets both sets.
//
// Scopes, and why:
// `alertChannel` — worker. Every rule payload has to reference a channel by
// name, and one channel serves the whole worker.
// `alertList` — worker. Read-only rule list the `tests/alerts/list` specs
// page, search and sort through. Names and label values are stamped per
// worker so parallel batches never count each other's rules.
// `ownedRules` — test. Scenarios that rename/toggle/clone/delete a rule seed
// their own and have it removed when they finish; mutating a shared seed
// would break every scenario scheduled after it.
export interface AlertChannel {
id: string;
name: string;
}
export interface AlertListSeed {
channelName: string;
/** Rules are named `<namePrefix>-NN` — unique to this worker's batch. */
namePrefix: string;
/** Rules seeded ⇒ the `of N` total once the list is scoped to the prefix. */
count: number;
/** `team` label on the odd-indexed half of the batch, i.e. `count / 2` rules. */
paymentsLabel: string;
ruleIds: string[];
}
export interface OwnedRules {
/** Seed a metric threshold rule this test owns. */
threshold(
name: string,
overrides?: Partial<Omit<ThresholdAlertSeed, 'name'>>,
): Promise<string>;
/**
* Seed a logs rule this test owns. No telemetry is seeded for its marker, so
* it never fires — enough for anything about the details shell.
*
* `schema: 'v1'` posts the legacy payload; the condition overrides exist so
* a v1 *prefill* assertion can be made against values the create form would
* not have produced by itself.
*/
logs(
options: {
name: string;
schema?: AlertSchema;
marker?: string;
} & Partial<
Pick<
LogsAlertSeed,
'severity' | 'extraLabels' | 'evalWindow' | 'target' | 'op' | 'matchType'
>
>,
): Promise<string>;
/**
* Track a rule the *app* created (Clone / Duplicate) so teardown removes it
* too. Lives here because the id may legitimately be missing and a
* conditional inside a test body is a lint error.
*/
register(response: { json: () => Promise<unknown> }): Promise<void>;
}
/** alertList size. 12 over page size 10 = short second page for pagination tests. */
const LIST_SEED_COUNT = 12;
/**
* Run `body` on a throwaway admin page. Worker hooks can't use the test-scoped
* `authedPage`, and every API helper needs a page whose context carries the
* admin storage state.
*/
export async function withAdminPage<T>(
browser: Browser,
body: (page: Page) => Promise<T>,
): Promise<T> {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
return await body(page);
} finally {
await ctx.close();
}
}
async function deleteRules(browser: Browser, ids: string[]): Promise<void> {
if (ids.length === 0) {
return;
}
await withAdminPage(browser, async (page) => {
for (const id of ids) {
// eslint-disable-next-line no-await-in-loop
await deleteAlertViaApi(page, id);
}
});
}
// --- Fixture setup/teardown functions ---
async function createAlertChannel(
browser: Browser,
workerIndex: number,
): Promise<AlertChannel> {
return withAdminPage(browser, (page) =>
createEmailChannelViaApi(page, `e2e-alerts-ch-w${workerIndex}-${Date.now()}`),
);
}
async function deleteAlertChannel(
browser: Browser,
channelId: string,
): Promise<void> {
await withAdminPage(browser, (page) => deleteChannelViaApi(page, channelId));
}
async function createAlertList(
browser: Browser,
channelName: string,
workerIndex: number,
): Promise<AlertListSeed> {
const stamp = `w${workerIndex}-${Date.now()}`;
const namePrefix = `e2e-alert-list-${stamp}`;
const teamSuffix = `-${stamp}`;
const ruleIds = await withAdminPage(browser, (page) =>
seedAlertRules(page, {
count: LIST_SEED_COUNT,
channelName,
namePrefix,
teamSuffix,
}),
);
return {
channelName,
namePrefix,
count: LIST_SEED_COUNT,
paymentsLabel: `payments${teamSuffix}`,
ruleIds,
};
}
function createOwnedRulesFactory(
browser: Browser,
channelName: string,
ids: Set<string>,
): OwnedRules {
const seed = async (
create: (page: Page) => Promise<string>,
): Promise<string> => {
const id = await withAdminPage(browser, create);
ids.add(id);
return id;
};
return {
threshold: (name, overrides = {}) =>
seed((page) =>
createThresholdAlertViaApi(page, {
name,
target: 42,
channels: [channelName],
labels: { severity: 'critical' },
...overrides,
}),
),
logs: ({ name, schema = 'v2', marker, ...overrides }) =>
seed((page) =>
createLogsAlertViaApi(page, {
name,
marker: marker ?? `e2e alert never seeded ${name}`,
channels: [channelName],
schema,
...overrides,
}),
),
register: async (response) => {
const body = (await response.json()) as { data?: { id?: string } };
const id = body.data?.id;
if (id) {
ids.add(String(id));
}
},
};
}
// --- Fixture definitions ---
export const test = base.extend<
{ ownedRules: OwnedRules },
{ alertChannel: AlertChannel; alertList: AlertListSeed }
>({
alertChannel: [
async ({ browser }, use, workerInfo) => {
const channel = await createAlertChannel(browser, workerInfo.workerIndex);
await use(channel);
await deleteAlertChannel(browser, channel.id);
},
{ scope: 'worker' },
],
alertList: [
async ({ browser, alertChannel }, use, workerInfo) => {
const seed = await createAlertList(
browser,
alertChannel.name,
workerInfo.workerIndex,
);
await use(seed);
await deleteRules(browser, seed.ruleIds);
},
{ scope: 'worker', timeout: FIXTURE_ALERT_LIST_TIMEOUT },
],
ownedRules: async ({ browser, alertChannel }, use) => {
const ids = new Set<string>();
const factory = createOwnedRulesFactory(browser, alertChannel.name, ids);
await use(factory);
await deleteRules(browser, [...ids]);
},
});
export { expect };

View File

@@ -0,0 +1,96 @@
/**
* Centralized timeout constants for alert fixtures.
*
* **Ruler**: SigNoz's alert evaluation engine. Runs on ~15s cycles, checks each
* rule's query against ClickHouse, writes results to `rule_state_history_v0`.
* There's no API to force-evaluate or seed history directly, so fixtures must
* poll the timeline endpoint until the ruler writes rows.
*
* Fixture timeouts are set generously because:
* 1. CI environments are slower than local dev machines
* 2. Ruler evaluation depends on Kafka/ClickHouse latency
* 3. A timeout should mean "something is broken", not "it's just slow today"
*
* Typical measured times (local):
* - API call (create/delete rule/channel): ~1-2s
* - waitForTimelineEntries (logs, 25 services): ~20-35s
* - waitForTimelineEntries (metrics, 2 hosts): ~10s
* - waitForTimelineStates (firing→resolved): ~105s
* - waitForTimelineEntries (nodata state): ~60-120s
*/
// ─── Fixture-specific wait overrides (ms) ──────────────────────────────
/**
* Metrics history wait override. Metrics push faster than logs, but ruler still
* needs 2 evaluation windows to confirm state. 10s typical, 120s defensive.
*/
export const WAIT_METRICS_TIMELINE_TIMEOUT = 120_000;
/**
* Nodata state detection. Ruler must evaluate twice with empty result set.
* Takes longer than firing detection because it's an absence check.
*/
export const WAIT_NODATA_TIMELINE_TIMEOUT = 180_000;
// ─── Fixture timeouts (ms) ─────────────────────────────────────────────
/**
* alertList: seeds 12 rules via API.
*
* Breakdown: createChannel(2s) + 12×createRule(24s) = ~26s.
* Timeout: 120s (~5x headroom for CI).
*/
export const FIXTURE_ALERT_LIST_TIMEOUT = 120_000;
/**
* alertHistory: seeds 25 logs + 2 rules, waits for ruler evaluation.
*
* Breakdown: createChannel(2s) + seedLogs(10s) + 2×createRule(4s) +
* 2×waitForEntries(70s) + 2×disableRule(4s) = ~90s.
* Timeout: 240s (~2.5x headroom).
*/
export const FIXTURE_ALERT_HISTORY_TIMEOUT = 240_000;
/**
* metricsHistory: seeds 2 hosts, waits for metrics ruler cycle.
*
* Breakdown: createChannel(2s) + seedMetrics(5s) + createRule(2s) +
* waitForEntries(10s actual, 120s budget) + disableRule(2s) = ~21s.
* Timeout: 240s (matches alertHistory for consistency).
*/
export const FIXTURE_METRICS_HISTORY_TIMEOUT = 240_000;
/**
* tracesHistory: seeds 3 trace services, waits for ruler.
*
* Breakdown: similar to alertHistory but fewer services = ~45s.
* Timeout: 240s (~5x headroom).
*/
export const FIXTURE_TRACES_HISTORY_TIMEOUT = 240_000;
/**
* resolvedHistory: waits for firing→resolved transition.
*
* Breakdown: setup(30s) + waitForStates(105s) = ~135s.
* Timeout: 300s (~2x headroom). Longest because resolved requires
* evalWindow expiry after data stops matching.
*/
export const FIXTURE_RESOLVED_HISTORY_TIMEOUT = 300_000;
/**
* noDataHistory: waits for nodata state to appear.
*
* Breakdown: createChannel(2s) + createRule(2s) + waitForEntries(60-120s).
* Timeout: 300s. Nodata detection is slowest because ruler must confirm
* absence across multiple evaluation cycles.
*/
export const FIXTURE_NODATA_HISTORY_TIMEOUT = 300_000;
/**
* emptyHistory: creates rule then immediately disables it (no ruler wait).
*
* Breakdown: createChannel(2s) + createRule(2s) + disableRule(2s) = ~6s.
* Timeout: 120s (generous for slow CI, no ruler dependency).
*/
export const FIXTURE_EMPTY_HISTORY_TIMEOUT = 120_000;

View File

@@ -1,81 +1,11 @@
import {
test as base,
expect,
type Browser,
type BrowserContext,
type Page,
} from '@playwright/test';
import { test as base, expect, type Page } from '@playwright/test';
export type User = { email: string; password: string };
import { ADMIN, storageStateFor, type User } from '../helpers/auth';
// Default user — admin from the pytest bootstrap (.env.local) or staging .env.
export const ADMIN: User = {
email: process.env.SIGNOZ_E2E_USERNAME!,
password: process.env.SIGNOZ_E2E_PASSWORD!,
};
// Per-worker storageState cache. One login per unique user per worker.
// Promise-valued so concurrent requests share the same in-flight work.
// Held in memory only — no .auth/ dir, no JSON on disk.
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
const storageByUser = new Map<string, Promise<StorageState>>();
async function storageFor(browser: Browser, user: User): Promise<StorageState> {
const cached = storageByUser.get(user.email);
if (cached) {
return cached;
}
const task = (async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, user);
await pinSidenav(page);
const state = await ctx.storageState();
await ctx.close();
return state;
})();
storageByUser.set(user.email, task);
return task;
}
async function login(page: Page, user: User): Promise<void> {
if (!user.email || !user.password) {
throw new Error(
'User credentials missing. Set SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD ' +
'(pytest bootstrap writes them to .env.local), or pass a User via test.use({ user: ... }).',
);
}
await page.goto('/login?password=Y');
await page.getByTestId('email').fill(user.email);
await page.getByTestId('initiate_login').click();
await page.getByTestId('password').fill(user.password);
await page.getByRole('button', { name: 'Sign in with Password' }).click();
// Post-login lands somewhere different depending on whether the org is
// licensed (onboarding flow on ENTERPRISE) or not (legacy "Hello there"
// welcome). Wait for URL to move off /login — whichever page follows
// is fine, each spec navigates to the feature under test anyway.
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
}
// Pin the nav suite-wide: unpinned it flies out on hover and overlays content.
// Server-side pref, so set once per user at login.
async function pinSidenav(page: Page): Promise<void> {
const token = await page.evaluate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
data: { value: true },
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`PUT /api/v1/user/preferences/sidenav_pinned ${res.status()}: ${await res.text()}`,
);
}
}
// The login flow and the per-worker session cache live in `helpers/auth.ts` so
// worker-scoped fixtures and suite hooks share one login with this fixture.
export { ADMIN };
export type { User };
export const test = base.extend<{
/**
@@ -95,7 +25,7 @@ export const test = base.extend<{
user: [ADMIN, { option: true }],
authedPage: async ({ browser, user }, use) => {
const storageState = await storageFor(browser, user);
const storageState = await storageStateFor(browser, user);
const ctx = await browser.newContext({ storageState });
const page = await ctx.newPage();
// Opt-in CPU throttling to reproduce GitHub-Linux-runner conditions on

View File

@@ -0,0 +1,105 @@
// ─── Routes ────────────────────────────────────────────────────────────────
export const ALERTS_NEW_PATH = '/alerts/new';
/**
* The standalone edit route. Distinct from `/alerts/overview`, which renders the
* *same* editor inside the details shell. The two are not interchangeable for v2
* rules — see `edit/v2.spec.ts` EV2-12.
*/
export const ALERT_EDIT_PATH = '/alerts/edit';
// ─── Enums mirrored from the frontend ──────────────────────────────────────
/**
* URL values of `AlertTypes` (`frontend/src/types/api/alerts/alertTypes.ts`).
* Note `METRICS` maps to the *singular* `METRIC_BASED_ALERT` — the enum key and
* its value disagree in the source, and the URL carries the value.
*/
export const AlertType = {
METRICS: 'METRIC_BASED_ALERT',
LOGS: 'LOGS_BASED_ALERT',
TRACES: 'TRACES_BASED_ALERT',
EXCEPTIONS: 'EXCEPTIONS_BASED_ALERT',
ANOMALY: 'ANOMALY_BASED_ALERT',
} as const;
export type AlertTypeValue = (typeof AlertType)[keyof typeof AlertType];
/** `AlertDetectionTypes` (`frontend/src/container/FormAlertRules/index.tsx:78-81`). */
export const RuleType = {
THRESHOLD: 'threshold_rule',
ANOMALY: 'anomaly_rule',
} as const;
/**
* `AlertThresholdOperator` (`CreateAlertV2/context/types.ts:97-105`) and its
* dropdown labels (`context/constants.ts:123-137`).
*
* Threshold-alert operators only. Anomaly alerts render a different, shorter
* list (`ANOMALY_THRESHOLD_OPERATOR_OPTIONS`) with relabelled entries.
*/
export const ThresholdOperator = {
ABOVE: { value: 'above', label: 'ABOVE' },
BELOW: { value: 'below', label: 'BELOW' },
EQUAL_TO: { value: 'equal', label: 'EQUAL TO' },
NOT_EQUAL_TO: { value: 'not_equal', label: 'NOT EQUAL TO' },
ABOVE_OR_EQUAL_TO: { value: 'above_or_equal', label: 'ABOVE OR EQUAL TO' },
BELOW_OR_EQUAL_TO: { value: 'below_or_equal', label: 'BELOW OR EQUAL TO' },
} as const;
/**
* `AlertThresholdMatchType` (`CreateAlertV2/context/types.ts:105-111`) and its
* dropdown labels (`context/constants.ts:136-142`).
*
* Watch the plural: the enum *key* is `ALL_THE_TIME` but the wire value is
* `all_the_times`, and the API rejects the singular outright — the same
* key/value mismatch as `METRICS_BASED_ALERT` → `METRIC_BASED_ALERT`.
*/
export const ThresholdMatchType = {
AT_LEAST_ONCE: { value: 'at_least_once', label: 'AT LEAST ONCE' },
ALL_THE_TIME: { value: 'all_the_times', label: 'ALL THE TIME' },
ON_AVERAGE: { value: 'on_average', label: 'ON AVERAGE' },
IN_TOTAL: { value: 'in_total', label: 'IN TOTAL' },
LAST: { value: 'last', label: 'LAST' },
} as const;
/**
* `AlertListTabs` (`frontend/src/pages/AlertList/types.ts:7-9`). The values are
* space-less — the tab *labels* read "Triggered Alerts" but the `tab` URL param
* is `TriggeredAlerts`, and asserting the label form silently fails.
*/
export const AlertListTab = {
TRIGGERED_ALERTS: 'TriggeredAlerts',
ALERT_RULES: 'AlertRules',
CONFIGURATION: 'Configuration',
} as const;
/**
* The four cards a stock stack shows, in render order
* (`CreateAlertRule/SelectAlertType/config.ts:10-31`). Anomaly is `unshift`ed to
* the **front** of this list when the `ANOMALY_DETECTION` feature flag is active,
* so both the count and the order change when it is enabled.
*/
export const STOCK_ALERT_TYPE_CARDS: AlertTypeValue[] = [
AlertType.METRICS,
AlertType.LOGS,
AlertType.TRACES,
AlertType.EXCEPTIONS,
];
/**
* Rolling-window presets (`EvaluationSettings/constants.ts:9-18`) paired with the
* button label each one produces. A value *outside* this set collapses to `custom`
* on load (`utils.tsx:86-96`), which is what makes it a prefill assertion worth
* having: `10m0s` proves the seed was read, `7m0s` proves the fallback fired.
*/
export const EVALUATION_WINDOW_PRESETS = {
'5m0s': 'Last 5 minutes',
'10m0s': 'Last 10 minutes',
'15m0s': 'Last 15 minutes',
'30m0s': 'Last 30 minutes',
'1h0m0s': 'Last 1 hour',
'2h0m0s': 'Last 2 hours',
'4h0m0s': 'Last 4 hours',
} as const;

View File

@@ -0,0 +1,118 @@
import { expect, type Locator, type Page } from '@playwright/test';
import {
ALERTS_NEW_PATH,
AlertType,
type AlertTypeValue,
RuleType,
STOCK_ALERT_TYPE_CARDS,
} from './constants';
import { v1SaveButton } from './v1';
// ─── Navigation ────────────────────────────────────────────────────────────
/**
* Open the bare type-selection page. `isTypeSelectionMode` is
* `!alertType && !ruleType && !compositeQuery`
* (`container/CreateAlertRule/index.tsx:39-41`), so *any* of those three params
* skips this page — including a stale `compositeQuery` left in the URL.
*/
export async function gotoAlertTypeSelection(page: Page): Promise<void> {
await page.goto(ALERTS_NEW_PATH);
await expect(alertTypeCard(page, AlertType.METRICS)).toBeVisible();
}
export function alertTypeCard(page: Page, type: AlertTypeValue): Locator {
return page.getByTestId(`alert-type-card-${type}`);
}
function alertTypeCards(page: Page): Locator {
return page.locator('[data-testid^="alert-type-card-"]');
}
/**
* Whether the anomaly card is on the page, i.e. whether `ANOMALY_DETECTION` is
* active for this stack. It **is** active on the pytest-bootstrapped integration
* stack, so every card-count assertion has to branch on it rather than hard-code
* 4.
*/
export async function hasAnomalyAlertTypeCard(page: Page): Promise<boolean> {
return (await alertTypeCard(page, AlertType.ANOMALY).count()) > 0;
}
/**
* Assert the type-selection page shows exactly the expected set of cards: the
* four stock ones, plus anomaly *first* when the flag is on (`getOptionList`
* `unshift`s it, `SelectAlertType/config.ts:33-40`).
*
* Written as an exact set rather than "at least four" so that adding a fifth
* signal still fails this assertion — the flag branch is the only slack.
*/
export async function expectAlertTypeCardSet(page: Page): Promise<void> {
const anomaly = await hasAnomalyAlertTypeCard(page);
const expected = anomaly
? [AlertType.ANOMALY, ...STOCK_ALERT_TYPE_CARDS]
: STOCK_ALERT_TYPE_CARDS;
const cards = alertTypeCards(page);
await expect(cards).toHaveCount(expected.length);
// Read the testids positionally so order is asserted too — anomaly being
// unshifted rather than appended is the behaviour worth pinning.
const rendered: (string | null)[] = [];
for (let i = 0; i < expected.length; i += 1) {
// eslint-disable-next-line no-await-in-loop
rendered.push(await cards.nth(i).getAttribute('data-testid'));
}
expect(rendered).toEqual(expected.map((type) => `alert-type-card-${type}`));
}
export interface CreateAlertUrlOptions {
alertType?: AlertTypeValue;
ruleType?: string;
/** Sets `showClassicCreateAlertsPage=true` ⇒ the v1 classic form. */
classic?: boolean;
/** Merged in last, so it can override anything above. */
params?: Record<string, string>;
}
export function createAlertUrl({
alertType = AlertType.LOGS,
ruleType = RuleType.THRESHOLD,
classic = false,
params = {},
}: CreateAlertUrlOptions = {}): string {
const search = new URLSearchParams({ alertType, ruleType });
if (classic) {
search.set('showClassicCreateAlertsPage', 'true');
}
for (const [key, value] of Object.entries(params)) {
search.set(key, value);
}
return `${ALERTS_NEW_PATH}?${search.toString()}`;
}
/**
* Open the **v2** builder and wait until it has settled. The wait is two-part on
* purpose: the header proves the builder mounted, and the `compositeQuery` in the
* URL proves `useShareBuilderUrl` has finished serialising the default query —
* without the second half, an assertion on the URL races the builder's own
* rewrite (the same trap `gotoAlertOverview` documents).
*/
export async function gotoCreateAlertV2(
page: Page,
options: Omit<CreateAlertUrlOptions, 'classic'> = {},
): Promise<void> {
await page.goto(createAlertUrl({ ...options, classic: false }));
await expect(page.getByTestId('alert-name-input')).toBeVisible();
await page.waitForURL(/compositeQuery=/, { timeout: 15_000 });
}
/** Open the **v1** classic create form and wait for its primary action. */
export async function gotoCreateAlertV1(
page: Page,
options: Omit<CreateAlertUrlOptions, 'classic'> = {},
): Promise<void> {
await page.goto(createAlertUrl({ ...options, classic: true }));
await expect(v1SaveButton(page)).toBeVisible();
}

View File

@@ -0,0 +1,154 @@
import { expect, type Locator, type Page } from '@playwright/test';
// ─── Antd select helpers ───────────────────────────────────────────────────
/**
* Read an antd multi-select's chosen values, for asserting what a threshold row
* ended up pointing at.
*/
export function selectedTags(scope: Locator): Locator {
return scope.locator('.ant-select-selection-item-content');
}
/**
* The currently-open antd dropdown. Scoping option lookups to it matters because
* antd keeps previously-opened dropdowns in the DOM with
* `.ant-select-dropdown-hidden`, so an unscoped `.ant-select-item-option` can
* resolve into a stale list.
*
* Adequate when only one select is ever open on the page. When several selects of
* the *same kind* exist — the per-threshold channel selects — use
* {@link ownDropdown} instead: `-hidden` is applied only after the close
* transition, so "the open dropdown" is briefly ambiguous.
*/
export function openDropdown(page: Page): Locator {
return page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
}
/**
* The dropdown belonging to one specific antd select, resolved through the
* combobox's `aria-controls` → the listbox id it owns.
*
* This is the only unambiguous way to address one of several sibling selects'
* option lists. Filtering on "the visible dropdown" is not enough: with four
* threshold rows, row N's list is still mid-close while row N+1's opens, so the
* option lookup lands in the wrong list and the click fails with "element is not
* stable" and then "element is not visible".
*/
export async function ownDropdown(
page: Page,
select: Locator,
): Promise<Locator> {
const listId = await select
.locator('input[role="combobox"]')
.getAttribute('aria-controls');
if (!listId) {
throw new Error(
'select has no aria-controls — not an antd combobox, or not yet opened',
);
}
return page
.locator('.ant-select-dropdown')
.filter({ has: page.locator(`[id="${listId}"]`) });
}
/**
* An option in the open dropdown, matched on its **exact** label. Substring
* matching is wrong here: `hasText: 'EQUAL TO'` also matches `NOT EQUAL TO`.
*/
export function dropdownOption(page: Page, label: string): Locator {
return openDropdown(page)
.locator('.ant-select-item-option')
.filter({ has: page.getByText(label, { exact: true }) });
}
/**
* Pick a notification channel by exact name in one of the two channel selects —
* v2's per-threshold one and v1's single `alert-channel-select`. Both are
* `mode="multiple"` antd selects over the *same* global channel list, so both need
* exactly this sequence; the shared body is why this is one function rather than
* two near-copies.
*
* The list must be **searched**, not scrolled. Channels are global while the
* `alertChannel` fixture is worker-scoped, so a shared stack accumulates one
* channel per worker (plus anything a killed run leaked) and antd virtualises the
* dropdown: measured on this stack, 31 channels render **10** options into the DOM,
* and the wanted one is simply not there. Clicking by name without filtering first
* is therefore not a slow path, it is a missing element — and it was the single
* biggest source of flake in this suite. It fails as a plain click timeout
* ("waiting for locator … .ant-select-item-option …"), which reads like a renamed
* testid rather than a virtualised list.
*/
export async function pickChannelByName(
page: Page,
select: Locator,
channelName: string,
): Promise<void> {
const tagsBefore = await selectedTags(select).count();
await select.click();
await expect(select).toHaveClass(/ant-select-open/);
// `fill` on the combobox input rather than `keyboard.type`: the query is a ~30
// character channel name and every keystroke re-runs antd's filter, so typing it
// costs ~2.5 s per pick — CV2-09 makes four of them, which was a quarter of that
// test's 30 s budget. `fill` sets the value in one input event, which is all
// rc-select's search needs.
await select.locator('input[role="combobox"]').fill(channelName);
const dropdown = await ownDropdown(page, select);
await dropdown
.locator('.ant-select-item-option')
.filter({ hasText: channelName })
.first()
.click();
// A multi-select stays open after a pick and its dropdown overlays the controls
// below, which the next interaction would otherwise hit instead.
await page.keyboard.press('Escape');
// Wait for *this* select to report itself closed before returning. antd removes
// `.ant-select-dropdown-hidden` only after the close transition, so a caller that
// immediately opens the next row's select races a still-visible stale list: the
// option lookup then resolves inside the previous row's dropdown and the click
// fails with "element is not stable" followed by "element is not visible".
await expect(select).not.toHaveClass(/ant-select-open/);
// Fail here rather than three assertions later: a silently-missed pick shows up
// as "Save is still disabled", which points at the validator instead of at this.
//
// Counted, not name-matched: v2's select sets `maxTagTextLength={10}`
// (`ThresholdItem.tsx:140`) so its tag reads `e2e-alerts…`, and v1's passes
// `optionLabelProp="label"` to options that carry no `label` prop, so its tag
// renders empty. Neither can ever contain the full channel name. The name itself
// is verified where it actually matters — in the request body (CV2-20, CV1-08).
await expect(selectedTags(select)).toHaveCount(tagsBefore + 1);
}
// ─── SEED-CH1: a stack with no notification channels ───────────────────────
/**
* Route-stub `GET /api/v1/channels` to an empty list for this page only.
*
* This is the **one** place the alerts suite mocks the network, and it is a
* deliberate exception to the standing no-stubbing rule. The justification: zero
* channels is a real product state — every fresh install has it — and it is the
* only state that reaches the `disabled` broadcast switch and
* the empty-channel dropdown content. It cannot be produced server-side, because
* `alertChannel` is worker-scoped and parallel workers share one stack, so
* deleting the channel would break every other scenario running at that moment.
*
* Both forms read the same endpoint through `api/channels/getAll`, so one stub
* covers v1 and v2.
*/
export async function stubNoChannels(page: Page): Promise<void> {
await page.route('**/api/v1/channels', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'success', data: [] }),
});
});
}

View File

@@ -0,0 +1,10 @@
import { type Locator, type Page } from '@playwright/test';
/**
* The v1 primary action. Its *label* is mode-dependent — *Create Rule* when
* `isNewRule`, *Save Rule* when editing (`FormAlertRules/index.tsx:970`) — so
* scenarios that care about the mode assert the text; the locator itself does not.
*/
export function v1SaveButton(page: Page): Locator {
return page.getByTestId('alert-save-button');
}

View File

@@ -0,0 +1,226 @@
import { expect, type Locator, type Page } from '@playwright/test';
import { EVALUATION_WINDOW_PRESETS } from './constants';
import { pickChannelByName } from './shared';
// ─── v2 builder ────────────────────────────────────────────────────────────
/**
* Footer buttons. The disabled Save/Test buttons are wrapped in a `<span>` inside
* an antd `Tooltip` (`CreateAlertV2/Footer/Footer.tsx:198-204`) — the wrapper is
* why {@link v2SaveTooltip} exists instead of reading a `title` attribute, and
* why these are testids rather than accessible names: the name lookup also
* matched the wrapper in some states.
*/
export function v2SaveButton(page: Page): Locator {
return page.getByTestId('save-alert-rule-button');
}
export function v2TestButton(page: Page): Locator {
return page.getByTestId('test-notification-button');
}
export function v2DiscardButton(page: Page): Locator {
return page.getByTestId('discard-alert-rule-button');
}
/**
* Click the v2 Discard button — via `dispatchEvent`, because a real click cannot
* reach it.
*
* The footer is `position: fixed; left: 63px` (the *collapsed* nav rail width) and
* Discard is its left-most control, so the button occupies roughly x 75-170 at the
* bottom of the viewport. The side navigation occupies x 0-240 whenever it is
* 240px wide, which is: always when pinned — the default — and transiently when
* not pinned, because a mouse travelling toward the button crosses the rail and
* triggers `:not(.pinned).is-hovered`. Either way `document.elementFromPoint` at
* the button's centre returns the nav's `.nav-item-data`, so the nav swallows the
* click.
*
* `{ force: true }` does **not** help: it skips Playwright's actionability wait
* but still delivers a real mouse event at those coordinates, which the nav
* receives. `dispatchEvent('click')` bypasses hit-testing entirely and React's
* delegated handler fires normally — verified: the page navigates to `/alerts`.
*
* This is a workaround for a **product** bug, not for a flaky test.
* `create/edge.spec.ts` CE-09 is the skipped scenario that asserts the fixed
* behaviour; unskipping it and reverting this helper to `.click()` belong in the
* same commit as the fix.
*/
export async function v2ClickDiscard(page: Page): Promise<void> {
await v2DiscardButton(page).dispatchEvent('click');
}
/**
* Whether the side navigation currently overlaps a point — the mechanism behind
* {@link v2ClickDiscard}. Used by CE-09, which asserts the *absence* of that
* overlap and is skipped until the footer is fixed.
*/
export async function elementAtPointClassName(
page: Page,
x: number,
y: number,
): Promise<string> {
return page.evaluate(
([px, py]) => {
const el = document.elementFromPoint(px as number, py as number);
return el ? String(el.className) : '';
},
[x, y],
);
}
/**
* Hover the (disabled) Save button and return the antd tooltip's text — this is
* the only way to read `validateCreateAlertState`'s message, since the button
* cannot be clicked while a message exists.
*/
export async function v2SaveTooltip(page: Page): Promise<string> {
// The tooltip anchors to the wrapper span, not the disabled button: a disabled
// button emits no pointer events, so hovering it directly never opens.
await v2SaveButton(page).locator('xpath=..').hover();
const tooltip = page.locator('.ant-tooltip-inner').first();
await expect(tooltip).toBeVisible();
return (await tooltip.innerText()).trim();
}
/**
* Threshold rows. There is **no** `threshold-item-<id>` testid — the row is a bare
* `className="threshold-item"` (`AlertCondition/ThresholdItem.tsx`), so rows are
* addressed positionally.
*/
export function thresholdRows(page: Page): Locator {
return page.locator('.threshold-item');
}
export function thresholdRow(page: Page, index: number): Locator {
return thresholdRows(page).nth(index);
}
/** Assign a notification channel to the Nth v2 threshold. */
export async function selectThresholdChannel(
page: Page,
index: number,
channelName: string,
): Promise<void> {
await pickChannelByName(
page,
page.getByTestId('threshold-notification-channel-select').nth(index),
channelName,
);
}
/**
* Add a label through the v2 header editor. The input is a single field with two
* phases — key, then value, each committed with Enter
* (`CreateAlertHeader/LabelsInput.tsx:25-93`) — and a `key:value` string in the
* first phase is accepted as a shortcut. This helper drives the two-phase path
* because that is what a user does.
*/
export async function addAlertLabel(
page: Page,
key: string,
value: string,
): Promise<void> {
await page.getByTestId('alert-add-label-button').click();
const input = page.getByTestId('alert-add-label-input');
await input.fill(key);
await input.press('Enter');
await input.fill(value);
await input.press('Enter');
// Committing a label does *not* close the editor — `isAdding` stays true so a
// user can type several in a row, which means `alert-add-label-button` is still
// unmounted. Escape (with both fields empty) is what closes it, and without this
// a second call to this helper waits forever for the add button.
await input.press('Escape');
await expect(page.getByTestId('alert-add-label-button')).toBeVisible();
}
/**
* The toggle inside an `AdvancedOptionItem` (repeat notifications, send-if-missing,
* enforce-minimum-datapoints). The `Switch` there carries no testid of its own, so
* it is reached through the container's — hence the container testid being the
* documented handle rather than the switch.
*/
export function advancedOptionToggle(
page: Page,
containerTestId: string,
): Locator {
return page.getByTestId(containerTestId).locator('[role="switch"]');
}
// ─── Evaluation window + cadence ───────────────────────────────────────────
export function evaluationSettingsButton(page: Page): Locator {
return page.getByTestId('evaluation-settings-button');
}
/**
* Open the evaluation-window popover. It is an antd `Popover`, so its content is
* only in the DOM while open — every option lookup has to come after this.
*/
export async function openEvaluationSettings(page: Page): Promise<void> {
await evaluationSettingsButton(page).click();
await expect(page.locator('.evaluation-window-popover')).toBeVisible();
}
/**
* A popover option. The popover renders two lists from one component, keyed by
* `data-section-id` — `window-type` (Rolling / Cumulative) and `timeframe` — and
* the testid carries both, so `timeframe-option-10m0s` cannot collide with a
* window-type value.
*/
export function evaluationWindowOption(
page: Page,
section: 'window-type' | 'timeframe',
value: string,
): Locator {
return page.getByTestId(`${section}-option-${value}`);
}
/**
* Pick a rolling timeframe and wait for the trigger button to reflect it. The wait
* matters: the popover closes on its own animation, and a spec that immediately
* clicks Save can otherwise post the previous window.
*/
export async function selectEvaluationTimeframe(
page: Page,
value: keyof typeof EVALUATION_WINDOW_PRESETS,
): Promise<void> {
await openEvaluationSettings(page);
await evaluationWindowOption(page, 'timeframe', value).click();
await expect(evaluationSettingsButton(page)).toContainText(
EVALUATION_WINDOW_PRESETS[value],
);
await page.keyboard.press('Escape');
}
/**
* Expand the ADVANCED OPTIONS panel inside the alert-condition section.
*
* antd's `Collapse` renders its panel children lazily, so `evaluation-cadence-*`
* and the two `AdvancedOptionItem` containers do not exist in the DOM at all until
* this runs — an assertion on them without it fails as "not found" rather than as
* "not visible", which reads like a missing testid.
*/
export async function expandAdvancedOptions(page: Page): Promise<void> {
const header = page.getByRole('button', { name: /ADVANCED OPTIONS/i });
if ((await header.getAttribute('aria-expanded')) !== 'true') {
await header.click();
}
await expect(page.getByTestId('evaluation-cadence-input-group')).toBeVisible();
}
/** The cadence duration field — `evaluation.spec.frequency`'s UI half. */
export function evaluationCadenceInput(page: Page): Locator {
return page.getByTestId('evaluation-cadence-duration-input');
}
export function evaluationCadenceUnitSelect(page: Page): Locator {
return page.getByTestId('evaluation-cadence-unit-select');
}
export function labelPill(page: Page, key: string, value: string): Locator {
return page.getByTestId(`label-pill-${key}-${value}`);
}

View File

@@ -1,188 +0,0 @@
import { expect, type Page } from '@playwright/test';
import { authToken } from './common';
// ─── Constants ───────────────────────────────────────────────────────────
export const ALERTS_LIST_PATH = '/alerts';
export const ALERT_OVERVIEW_PATH = '/alerts/overview';
// ─── Types ─────────────────────────────────────────────────────────────────
export interface ThresholdAlertSeed {
/** Alert rule name. Keep unique per test to avoid collisions. */
name: string;
/** The critical-threshold target value to persist and later assert. */
target: number;
/**
* Notification channel names for the critical threshold. At least one is
* required by the API — seed one with {@link createEmailChannelViaApi}.
*/
channels: string[];
}
// ─── Payload ─────────────────────────────────────────────────────────────
// A minimal but valid v2 (schemaVersion v2alpha1 / version v5) threshold rule
// on the always-present `signoz_calls_total` metric. Mirrors the shape the
// CreateAlertV2 UI posts to POST /api/v2/rules.
function buildThresholdRulePayload({
name,
target,
channels,
}: ThresholdAlertSeed): Record<string, unknown> {
return {
alert: name,
alertType: 'METRIC_BASED_ALERT',
ruleType: 'threshold_rule',
schemaVersion: 'v2alpha1',
version: 'v5',
disabled: false,
source: '',
annotations: {
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
summary:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
},
evaluation: {
kind: 'rolling',
spec: { evalWindow: '5m0s', frequency: '1m' },
},
notificationSettings: {
groupBy: [],
renotify: { enabled: false, interval: '30m', alertStates: [] },
usePolicy: false,
},
condition: {
selectedQueryName: 'A',
compositeQuery: {
panelType: 'graph',
queryType: 'builder',
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'metrics',
source: '',
aggregations: [
{
metricName: 'signoz_calls_total',
temporality: '',
timeAggregation: 'rate',
spaceAggregation: 'sum',
},
],
disabled: false,
filter: { expression: '' },
having: { expression: '' },
legend: '',
},
},
],
},
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target,
targetUnit: '',
recoveryTarget: null,
matchType: 'at_least_once',
op: 'above',
channels,
},
],
},
},
};
}
// ─── API helpers ─────────────────────────────────────────────────────────
/**
* Seed an email notification channel via API. Returns its `{ id, name }`;
* thresholds reference channels by name, cleanup deletes by id. `to` is never
* delivered — the channel only needs to exist to satisfy rule validation.
*/
export async function createEmailChannelViaApi(
page: Page,
name: string,
): Promise<{ id: string; name: string }> {
const token = await authToken(page);
const res = await page.request.post('/api/v1/channels', {
data: {
name,
email_configs: [
{ send_resolved: true, to: 'e2e@signoz.test', html: '', headers: {} },
],
},
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(`POST /api/v1/channels ${res.status()}: ${await res.text()}`);
}
const json = (await res.json()) as { data: { id: string } };
return { id: String(json.data.id), name };
}
/** Delete a notification channel by ID (best-effort cleanup). */
export async function deleteChannelViaApi(
page: Page,
id: string,
): Promise<void> {
const token = await authToken(page);
await page.request.delete(`/api/v1/channels/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
}
/**
* Seed a v2 threshold alert via API. Returns the new rule ID. Pair with
* {@link deleteAlertViaApi} in an `afterAll`/`afterEach` for cleanup.
*/
export async function createThresholdAlertViaApi(
page: Page,
seed: ThresholdAlertSeed,
): Promise<string> {
const token = await authToken(page);
const res = await page.request.post('/api/v2/rules', {
data: buildThresholdRulePayload(seed),
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(`POST /api/v2/rules ${res.status()}: ${await res.text()}`);
}
const json = (await res.json()) as { data: { id: string } };
return json.data.id;
}
/** Delete a rule by ID. Tolerates an already-deleted rule (best-effort cleanup). */
export async function deleteAlertViaApi(page: Page, id: string): Promise<void> {
const token = await authToken(page);
await page.request.delete(`/api/v2/rules/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
}
// ─── Navigation ────────────────────────────────────────────────────────────
/**
* Open the alert overview (edit) page for `ruleId` and wait until it has fully
* settled: the condition editor is visible and the query builder has finished
* serializing the loaded query into the URL.
*/
export async function gotoAlertOverview(
page: Page,
ruleId: string,
): Promise<void> {
await page.goto(`${ALERT_OVERVIEW_PATH}?ruleId=${ruleId}`);
await expect(page.getByTestId('threshold-value-input')).toBeVisible();
// The builder rewrites location.search shortly after load (adds compositeQuery).
await page.waitForURL(/compositeQuery=/, { timeout: 15_000 });
// Let post-load state updates flush so callers read the settled value.
// eslint-disable-next-line playwright/no-wait-for-timeout -- no DOM signal for the async settle
await page.waitForTimeout(500);
}

View File

@@ -0,0 +1,257 @@
import type { Page } from '@playwright/test';
import { authToken } from '../common';
import {
buildThresholdRulePayload,
logsCompositeQuery,
metricsCompositeQuery,
tracesCompositeQuery,
v1RulePayload,
v2RulePayload,
} from './payloads';
import type {
AlertSchema,
LogsAlertSeed,
MetricAlertSeed,
ThresholdAlertSeed,
TracesAlertSeed,
} from './types';
// ─── API helpers ─────────────────────────────────────────────────────────
/**
* Seed an email notification channel via API. Returns its `{ id, name }`;
* thresholds reference channels by name, cleanup deletes by id. `to` is never
* delivered — the channel only needs to exist to satisfy rule validation.
*/
export async function createEmailChannelViaApi(
page: Page,
name: string,
): Promise<{ id: string; name: string }> {
const token = await authToken(page);
const res = await page.request.post('/api/v1/channels', {
data: {
name,
email_configs: [
{ send_resolved: true, to: 'e2e@signoz.test', html: '', headers: {} },
],
},
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(`POST /api/v1/channels ${res.status()}: ${await res.text()}`);
}
const json = (await res.json()) as { data: { id: string } };
return { id: String(json.data.id), name };
}
/** Delete a notification channel by ID (best-effort cleanup). */
export async function deleteChannelViaApi(
page: Page,
id: string,
): Promise<void> {
const token = await authToken(page);
await page.request.delete(`/api/v1/channels/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
}
/**
* Seed a v2 threshold alert via API. Returns the new rule ID. Pair with
* {@link deleteAlertViaApi} in an `afterAll`/`afterEach` for cleanup.
*/
export async function createThresholdAlertViaApi(
page: Page,
seed: ThresholdAlertSeed,
): Promise<string> {
const token = await authToken(page);
const res = await page.request.post('/api/v2/rules', {
data: buildThresholdRulePayload(seed),
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(`POST /api/v2/rules ${res.status()}: ${await res.text()}`);
}
const json = (await res.json()) as { data: { id: string } };
return json.data.id;
}
/** Delete a rule by ID. Tolerates an already-deleted rule (best-effort cleanup). */
export async function deleteAlertViaApi(page: Page, id: string): Promise<void> {
const token = await authToken(page);
await page.request.delete(`/api/v2/rules/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
}
async function postRule(
page: Page,
schema: AlertSchema,
payload: Record<string, unknown>,
): Promise<string> {
const token = await authToken(page);
const path = schema === 'v1' ? '/api/v1/rules' : '/api/v2/rules';
const res = await page.request.post(path, {
data: payload,
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(`POST ${path} ${res.status()}: ${await res.text()}`);
}
const json = (await res.json()) as { data: { id: string } };
return String(json.data.id);
}
/**
* Create a logs threshold rule grouped by `service.name`. `schema: 'v1'` posts
* the legacy payload to `/api/v1/rules`, which the UI then renders through the
* v1 branch of `AlertHeader` / `ActionButtons` — both schemas serve the *same*
* history APIs, so history scenarios can be parameterised over them.
*/
export async function createLogsAlertViaApi(
page: Page,
{
name,
marker,
channels,
schema = 'v2',
evalWindow = '5m0s',
frequency = '15s',
severity = schema === 'v1' ? 'warning' : 'critical',
extraLabels,
alertOnAbsent,
absentFor,
target,
op,
matchType,
}: LogsAlertSeed,
): Promise<string> {
const extraCondition =
alertOnAbsent === undefined
? undefined
: { alertOnAbsent, absentFor: absentFor ?? 1 };
const args = {
name,
alertType: 'LOGS_BASED_ALERT',
compositeQuery: logsCompositeQuery(marker),
channels,
severity,
extraLabels,
evalWindow,
frequency,
extraCondition,
target,
op,
matchType,
};
return postRule(
page,
schema,
schema === 'v1' ? v1RulePayload(args) : v2RulePayload(args),
);
}
/**
* SEED-H's rule: traces-based over the seeded spans, grouped by `service.name`.
* Its history rows carry `relatedTracesLink` and an empty `relatedLogsLink`, so
* the popover offers "View Traces" only.
*/
export async function createTracesAlertViaApi(
page: Page,
{
name,
marker,
channels,
evalWindow = '5m0s',
frequency = '15s',
}: TracesAlertSeed,
): Promise<string> {
return postRule(
page,
'v2',
v2RulePayload({
name,
alertType: 'TRACES_BASED_ALERT',
compositeQuery: tracesCompositeQuery(marker),
channels,
severity: 'critical',
evalWindow,
frequency,
}),
);
}
/** SEED-E's rule: metrics-based, so its history rows carry no related links. */
export async function createMetricAlertViaApi(
page: Page,
{
name,
metricName,
channels,
groupByKey = 'host',
evalWindow = '5m0s',
frequency = '15s',
}: MetricAlertSeed,
): Promise<string> {
return postRule(
page,
'v2',
v2RulePayload({
name,
alertType: 'METRIC_BASED_ALERT',
compositeQuery: metricsCompositeQuery(metricName, groupByKey),
channels,
severity: 'critical',
evalWindow,
frequency,
}),
);
}
/**
* SEED-G's rule: a logs rule whose filter matches nothing, with
* `alertOnAbsent` set — the only cheap way to get a `nodata` history row.
* Seed no telemetry for its marker.
*/
export async function createNoDataAlertViaApi(
page: Page,
{
name,
marker,
channels,
}: { name: string; marker: string; channels: string[] },
): Promise<string> {
return createLogsAlertViaApi(page, {
name,
marker,
channels,
evalWindow: '5m0s',
frequency: '15s',
alertOnAbsent: true,
absentFor: 1,
});
}
/**
* Freeze a rule's history. Rows are written on *state change* only, so the
* firing wave lands once — but once the eval window rolls past the seeded
* records the rule resolves and writes a second row per fingerprint, doubling
* `total` mid-suite. Disable the rule as soon as the firing wave is confirmed.
*/
export async function setRuleDisabledViaApi(
page: Page,
id: string,
disabled: boolean,
): Promise<void> {
const token = await authToken(page);
const res = await page.request.patch(`/api/v2/rules/${id}`, {
data: { disabled },
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`PATCH /api/v2/rules/${id} ${res.status()}: ${await res.text()}`,
);
}
}

View File

@@ -0,0 +1,45 @@
// ─── Constants ───────────────────────────────────────────────────────────
export const ALERTS_LIST_PATH = '/alerts';
export const ALERT_OVERVIEW_PATH = '/alerts/overview';
export const ALERT_HISTORY_PATH = '/alerts/history';
/**
* Mirrors `TIMELINE_TABLE_PAGE_SIZE` in
* `frontend/src/container/AlertHistory/constants.ts`. This 20 is what makes the
* page-2 cursor `base64url({"offset":20,"limit":20})`, so the two must not drift.
*/
export const TIMELINE_PAGE_SIZE = 20;
/** The `relativeTime` the history page falls back to (`DEFAULT_TIME_RANGE`). */
export const DEFAULT_RELATIVE_TIME = '30m';
/**
* Page size the list specs pin in the URL, so the number of rendered rows never
* depends on the viewport height.
*/
export const ALERT_LIST_PAGE_SIZE = 10;
/** Severities the list seed cycles through, so search/sort tests have more than one value. */
export const SEED_B_SEVERITIES = ['critical', 'warning', 'info'] as const;
// ─── Wait timeouts (ms) ──────────────────────────────────────────────────
// These timeouts gate on the "ruler" — SigNoz's alert evaluation engine that
// runs on ~15s cycles and writes history rows to ClickHouse. No way to force
// evaluation or seed history directly, so we poll until rows appear.
/**
* Default timeout for waitForTimelineEntries.
*
* Logs rules need 2+ ruler cycles (~15s each) to see 25 services fire.
* 90s = 6 cycles worst-case. Actual time: 20-35s for logs, ~10s for metrics.
*/
export const WAIT_TIMELINE_ENTRIES_DEFAULT = 90_000;
/**
* Default timeout for waitForTimelineStates (firing + resolved).
*
* Resolved state appears after evalWindow expires with no matching data.
* 1m window + 2 ruler cycles = ~105s observed. 180s = safe margin.
*/
export const WAIT_TIMELINE_STATES_DEFAULT = 180_000;

View File

@@ -0,0 +1,359 @@
import {
expect,
type Locator,
type Page,
type Request,
type Response,
} from '@playwright/test';
import { authToken, requestUrl } from '../common';
import { typeExpression } from '../query-builder';
import {
ALERT_HISTORY_PATH,
DEFAULT_RELATIVE_TIME,
TIMELINE_PAGE_SIZE,
WAIT_TIMELINE_ENTRIES_DEFAULT,
WAIT_TIMELINE_STATES_DEFAULT,
} from './constants';
import type { TimelineItem, TimelineResponse } from './types';
// ─── History API probes ──────────────────────────────────────────────────
/**
* Read the timeline straight from the API. Used to gate on the ruler having
* produced rows *before* a spec opens the UI — polling through the browser
* would conflate "no rows yet" with "the table failed to render".
*/
export async function fetchTimeline(
page: Page,
ruleId: string,
params: Record<string, string | number> = {},
): Promise<TimelineResponse> {
const token = await authToken(page);
const now = Date.now();
const query = new URLSearchParams({
start: String(now - 30 * 60 * 1000),
end: String(now),
limit: '100',
order: 'asc',
...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
});
const res = await page.request.get(
`/api/v2/rules/${ruleId}/history/timeline?${query.toString()}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok()) {
throw new Error(
`GET /api/v2/rules/${ruleId}/history/timeline ${res.status()}: ${await res.text()}`,
);
}
const json = (await res.json()) as { data: TimelineResponse | null };
return {
items: json.data?.items ?? [],
total: json.data?.total ?? 0,
nextCursor: json.data?.nextCursor,
};
}
function countStates(items: TimelineItem[]): Record<string, number> {
return items.reduce<Record<string, number>>((acc, item) => {
acc[item.state] = (acc[item.state] ?? 0) + 1;
return acc;
}, {});
}
/**
* Poll until at least `min` rows in state `state` exist. Takes ~20-35s for the
* logs fixture (the rule fires on the first evaluation that sees the data) and
* ~10s for the metrics one, so budget generously — a timeout here means the
* marker aged out of the eval window, not that the assertion is wrong.
*/
export async function waitForTimelineEntries(
page: Page,
ruleId: string,
{
min,
state = 'firing',
timeoutMs = WAIT_TIMELINE_ENTRIES_DEFAULT,
}: { min: number; state?: string; timeoutMs?: number },
): Promise<TimelineResponse> {
const deadline = Date.now() + timeoutMs;
let last: TimelineResponse = { items: [], total: 0 };
while (Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop
last = await fetchTimeline(page, ruleId);
if (last.items.filter((item) => item.state === state).length >= min) {
return last;
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 2_000);
});
}
throw new Error(
`timeline for rule ${ruleId} never reached ${min} '${state}' rows within ${timeoutMs}ms ` +
`(last: total=${last.total}, states=${JSON.stringify(countStates(last.items))})`,
);
}
/**
* Poll until every requested state has at least the requested row count.
* SEED-F's firing→resolved wave and SEED-G's `nodata` row both gate on this.
*/
export async function waitForTimelineStates(
page: Page,
ruleId: string,
{
states,
timeoutMs = WAIT_TIMELINE_STATES_DEFAULT,
}: { states: Record<string, number>; timeoutMs?: number },
): Promise<TimelineResponse> {
const deadline = Date.now() + timeoutMs;
let last: TimelineResponse = { items: [], total: 0 };
while (Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop
last = await fetchTimeline(page, ruleId);
const seen = countStates(last.items);
if (
Object.entries(states).every(([state, min]) => (seen[state] ?? 0) >= min)
) {
return last;
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 3_000);
});
}
throw new Error(
`timeline for rule ${ruleId} never reached ${JSON.stringify(states)} within ${timeoutMs}ms ` +
`(last states: ${JSON.stringify(countStates(last.items))})`,
);
}
/** The filtered row count the timeline reports. Ignores `limit`. */
export async function readTimelineTotal(
page: Page,
ruleId: string,
): Promise<number> {
return (await fetchTimeline(page, ruleId, { limit: 1 })).total;
}
/**
* Mirror of `encodeCursor` in
* `container/AlertHistory/Timeline/Table/useTimelineTableCursor.ts`, so specs
* can assert the *exact* cursor the UI sends. Verified byte-identical to the
* server's `nextCursor`.
*/
export function encodeTimelineCursor(
page_: number,
limit = TIMELINE_PAGE_SIZE,
): string | undefined {
if (page_ <= 1) {
return undefined;
}
const offset = (page_ - 1) * limit;
return Buffer.from(JSON.stringify({ offset, limit }))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
/** Labels on a timeline row, flattened to a plain object. */
export function timelineLabelsToObject(
item: TimelineItem,
): Record<string, string> {
return (item.labels ?? []).reduce<Record<string, string>>((acc, label) => {
const name = label.key?.name;
if (name) {
acc[name] = String(label.value ?? '');
}
return acc;
}, {});
}
// ─── Navigation ────────────────────────────────────────────────────────────
/**
* Open the history tab for `ruleId` and wait until the timeline table has
* mounted. `params` is merged into the query string, so scenarios can deep-link
* `page`, `order`, `timelineFilter`, … in one call.
*/
export async function gotoAlertHistory(
page: Page,
ruleId: string,
params: Record<string, string> = {},
): Promise<void> {
// An absolute window and `relativeTime` are mutually exclusive in practice:
// with both present the time picker normalises back to the relative range and
// **drops** `startTime`/`endTime` from the URL, so the absolute window never
// takes effect. Only send the default relative range when no absolute one was
// asked for.
const hasAbsoluteRange = !!params.startTime && !!params.endTime;
const query = new URLSearchParams({
ruleId,
...(hasAbsoluteRange ? {} : { relativeTime: DEFAULT_RELATIVE_TIME }),
...params,
});
await page.goto(`${ALERT_HISTORY_PATH}?${query.toString()}`);
// Race the table against the app's error boundary. The history page has been
// observed crashing into it intermittently on load; without this the failure
// reads as a 15s "timeline-table not found", which says nothing about why.
const table = page.getByTestId('timeline-table');
const crashed = page.getByText('Something went wrong :/');
await expect(table.or(crashed)).toBeVisible();
if (await crashed.isVisible()) {
throw new Error(
`alert history crashed into the app error boundary at ${page.url()}` +
'a component threw during render; check the captured console output',
);
}
await expect(table).toBeVisible();
// The `timeline-table` node is rendered by the first paint, *before* the
// timeline request settles — antd only overlays a spinner on it. Returning
// here would leave that request in flight, and the next
// `waitForHistoryResponse` in the spec would resolve with the page's own
// load instead of the response its interaction produced. Wait the spinner
// out so every caller starts from a quiet page.
await expect(page.locator('.timeline-table .ant-spin-spinning')).toHaveCount(
0,
);
}
// ─── Locators ──────────────────────────────────────────────────────────────
/**
* Assert the table is back on page 1. Both the list and the timeline use nuqs
* with `parseAsInteger.withDefault(1)`, which **removes** the `page` param when
* it is reset rather than writing `page=1` — so "absent" and "1" are the same
* state and a naive `?page=1` regex never matches.
*/
export async function expectFirstPage(page: Page): Promise<void> {
await expect
.poll(() => new URL(page.url()).searchParams.get('page') ?? '1')
.toBe('1');
}
export function timelineRows(page: Page): Locator {
return page.getByTestId('timeline-row');
}
export function timelineFooterRange(page: Page): Locator {
return page.getByTestId('timeline-footer-range');
}
export function statsCard(page: Page, title: string): Locator {
return page.locator(`[data-testid="stats-card"][data-stats-title="${title}"]`);
}
/** Open the ACTIONS popover on timeline row `index` (0-based). */
export async function openTimelineRowActions(
page: Page,
index: number,
): Promise<void> {
await timelineRows(page)
.nth(index)
.getByTestId('timeline-row-actions')
.click();
}
// ─── History request matchers ──────────────────────────────────────────────
/** The four v2 endpoints one history page load hits. */
export const HISTORY_ENDPOINTS = [
'stats',
'timeline',
'top_contributors',
'overall_status',
] as const;
export type HistoryEndpoint = (typeof HISTORY_ENDPOINTS)[number];
/** Match a request against one history endpoint, whatever the rule id. */
export function isHistoryRequest(
request: Request,
endpoint: HistoryEndpoint,
): boolean {
return new RegExp(`/api/v2/rules/[^/]+/history/${endpoint}`).test(
request.url(),
);
}
/**
* Wait for a history API response. Common pattern across history specs.
*
* Optionally narrow by HTTP status code or by the `filterExpression` the
* request carried. The latter matters whenever a scenario reacts to *its own*
* request: the page's own load is still in flight when the spec starts typing,
* so an unqualified matcher happily resolves with that earlier response.
*/
export function waitForHistoryResponse(
page: Page,
endpoint: HistoryEndpoint,
options?: { status?: number; filterExpression?: string },
): Promise<Response> {
return page.waitForResponse((res) => {
if (!isHistoryRequest(res.request(), endpoint)) return false;
if (options?.status !== undefined && res.status() !== options.status)
return false;
if (
options?.filterExpression !== undefined &&
(requestUrl(res.request()).searchParams.get('filterExpression') ?? '') !==
options.filterExpression
)
return false;
return true;
});
}
// ─── History interactions ──────────────────────────────────────────────────
/** Apply a filter expression through the real editor + Run button. */
export async function runFilterExpression(
page: Page,
expression: string,
): Promise<void> {
await typeExpression(page, expression);
await page.getByRole('button', { name: /run query/i }).click();
}
/**
* Sort the timeline descending through the STATE header.
*
* The antd table is *uncontrolled* — it has `sorter: true` but no `sortOrder`,
* so its internal cycle is none → ascend → descend regardless of the `order`
* the hook already sends. Reaching `desc` therefore takes two clicks, and the
* first one only resets the page (asc is nuqs's default, so it writes no param).
*/
export async function sortTimelineDescending(page: Page): Promise<void> {
const header = page.getByRole('columnheader', { name: 'STATE' });
const descRequest = page.waitForRequest(
(req) =>
isHistoryRequest(req, 'timeline') &&
requestUrl(req).searchParams.get('order') === 'desc',
);
await header.click();
await header.click();
await descRequest;
}
/**
* Snapshot the LABELS cell of every rendered row. Scenarios that compare two
* snapshots taken at different times (page 1 vs page 2, one timezone vs
* another) cannot express that as a web-first assertion, so the read lives in a
* helper rather than inline in the test.
*/
export async function timelineRowLabels(page: Page): Promise<string[]> {
return timelineRows(page).getByTestId('timeline-row-labels').allInnerTexts();
}
/** Snapshot the first row's CREATED AT cell. See {@link timelineRowLabels}. */
export async function firstTimelineRowCreatedAt(page: Page): Promise<string> {
return timelineRows(page)
.first()
.getByTestId('timeline-row-created-at')
.innerText();
}

View File

@@ -0,0 +1,77 @@
import { expect, type Locator, type Page } from '@playwright/test';
import {
ALERT_LIST_PAGE_SIZE,
ALERT_OVERVIEW_PATH,
ALERTS_LIST_PATH,
DEFAULT_RELATIVE_TIME,
} from './constants';
// ─── Navigation ────────────────────────────────────────────────────────────
/**
* Open the alert overview (edit) page for `ruleId` and wait until it has fully
* settled: the condition editor is visible and the query builder has finished
* serializing the loaded query into the URL.
*/
export async function gotoAlertOverview(
page: Page,
ruleId: string,
): Promise<void> {
await page.goto(`${ALERT_OVERVIEW_PATH}?ruleId=${ruleId}`);
// `.first()` because a rule may have several thresholds, and the editor renders
// one input per threshold. Without it this is a strict-mode violation that only
// appears once the *second* row has rendered — i.e. a timing-dependent failure
// for multi-threshold rules.
await expect(page.getByTestId('threshold-value-input').first()).toBeVisible();
// The builder rewrites location.search shortly after load (adds compositeQuery).
await page.waitForURL(/compositeQuery=/, { timeout: 15_000 });
// Let post-load state updates flush so callers read the settled value.
// eslint-disable-next-line playwright/no-wait-for-timeout -- no DOM signal for the async settle
await page.waitForTimeout(500);
}
/**
* Open the alert details shell (Overview tab) for `ruleId` and wait until it has
* mounted. Unlike {@link gotoAlertOverview} this does **not** wait for the
* condition editor or the serialised query — use it for scenarios about the
* shell itself (header, tabs, actions menu) rather than the rule's contents.
*/
export async function gotoAlertDetails(
page: Page,
ruleId: string,
): Promise<void> {
await page.goto(
`${ALERT_OVERVIEW_PATH}?ruleId=${ruleId}&relativeTime=${DEFAULT_RELATIVE_TIME}`,
);
await expect(page.getByTestId('alert-details-root')).toBeVisible();
}
/** Rows currently rendered in the alert-rules table body. */
export function alertRuleRows(page: Page): Locator {
return page.locator('tbody tr');
}
/**
* Open the alert-rules list and wait until it has rows. `params` is merged into
* the query string (`search`, `page`, `orderBy`, …); `limit` defaults to
* {@link ALERT_LIST_PAGE_SIZE} so row counts are viewport-independent.
*
* Pass `expectRows: false` for scenarios whose filters are *meant* to match
* nothing — the row wait would otherwise fail before the assertion runs.
*/
export async function gotoAlertList(
page: Page,
params: Record<string, string> = {},
{ expectRows = true }: { expectRows?: boolean } = {},
): Promise<void> {
const query = new URLSearchParams({
limit: String(ALERT_LIST_PAGE_SIZE),
...params,
});
await page.goto(`${ALERTS_LIST_PATH}?${query.toString()}`);
await expect(page.getByTestId('list-alerts-search-input')).toBeVisible();
if (expectRows) {
await expect(alertRuleRows(page).first()).toBeVisible();
}
}

View File

@@ -0,0 +1,324 @@
import type { ThresholdAlertSeed, ThresholdSeedSpec } from './types';
// ─── Payload builders ────────────────────────────────────────────────────
const ANNOTATIONS = {
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
summary:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
};
// A minimal but valid v2 (schemaVersion v2alpha1 / version v5) threshold rule
// on the always-present `signoz_calls_total` metric. Mirrors the shape the
// CreateAlertV2 UI posts to POST /api/v2/rules.
export function buildThresholdRulePayload({
name,
target,
channels,
labels,
thresholds,
evalWindow = '5m0s',
frequency = '1m',
groupBy = [],
queryGroupBy = [],
renotify = { enabled: false, interval: '30m', alertStates: [] },
alertOnAbsent,
recoveryTarget = null,
}: ThresholdAlertSeed): Record<string, unknown> {
const thresholdSpec = (
thresholds ?? [{ name: 'critical', target, channels, recoveryTarget }]
).map((spec: ThresholdSeedSpec) => ({
name: spec.name,
target: spec.target,
targetUnit: spec.targetUnit ?? '',
recoveryTarget: spec.recoveryTarget ?? null,
matchType: spec.matchType ?? 'at_least_once',
op: spec.op ?? 'above',
channels: spec.channels,
}));
return {
alert: name,
alertType: 'METRIC_BASED_ALERT',
ruleType: 'threshold_rule',
schemaVersion: 'v2alpha1',
version: 'v5',
disabled: false,
source: '',
...(labels ? { labels } : {}),
annotations: ANNOTATIONS,
evaluation: {
kind: 'rolling',
spec: { evalWindow, frequency },
},
notificationSettings: {
groupBy,
renotify,
usePolicy: false,
},
condition: {
selectedQueryName: 'A',
...(alertOnAbsent
? { alertOnAbsent: true, absentFor: alertOnAbsent.absentFor }
: {}),
compositeQuery: {
panelType: 'graph',
queryType: 'builder',
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'metrics',
source: '',
aggregations: [
{
metricName: 'signoz_calls_total',
temporality: '',
timeAggregation: 'rate',
spaceAggregation: 'sum',
},
],
disabled: false,
filter: { expression: '' },
...(queryGroupBy.length > 0
? {
groupBy: queryGroupBy.map((key) => ({
name: key,
fieldContext: 'attribute',
fieldDataType: 'string',
})),
}
: {}),
having: { expression: '' },
legend: '',
},
},
],
},
thresholds: {
kind: 'basic',
spec: thresholdSpec,
},
},
};
}
// The v5 `queries[]` envelope is identical for both schema versions
// (`AlertCompositeQuery` in pkg/types/ruletypes/alerting.go) — only the
// threshold / evaluation / channel envelopes differ. That keeps one builder
// per signal and a thin branch over the wrapper.
export function logsCompositeQuery(marker: string): Record<string, unknown> {
return {
panelType: 'graph',
queryType: 'builder',
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'logs',
source: '',
disabled: false,
filter: { expression: `body CONTAINS '${marker}'` },
groupBy: [
{
name: 'service.name',
fieldContext: 'resource',
fieldDataType: 'string',
},
],
aggregations: [{ expression: 'count()' }],
having: { expression: '' },
legend: '',
},
},
],
};
}
// Same shape as the logs query, one signal over: the rule's signal is what
// decides which related link the history rows carry (`links()` in
// `pkg/modules/rulestatehistory/implrulestatehistory/links.go` returns *either*
// a logs link *or* a traces link, never both).
export function tracesCompositeQuery(marker: string): Record<string, unknown> {
return {
panelType: 'graph',
queryType: 'builder',
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'traces',
source: '',
disabled: false,
filter: { expression: `name = '${marker}'` },
groupBy: [
{
name: 'service.name',
fieldContext: 'resource',
fieldDataType: 'string',
},
],
aggregations: [{ expression: 'count()' }],
having: { expression: '' },
legend: '',
},
},
],
};
}
export function metricsCompositeQuery(
metricName: string,
groupByKey: string,
): Record<string, unknown> {
return {
panelType: 'graph',
queryType: 'builder',
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'metrics',
source: '',
disabled: false,
filter: { expression: '' },
groupBy: [
{ name: groupByKey, fieldContext: 'attribute', fieldDataType: 'string' },
],
aggregations: [
{
metricName,
temporality: '',
timeAggregation: 'avg',
spaceAggregation: 'max',
},
],
having: { expression: '' },
legend: '',
},
},
],
};
}
// `target 0 / op above / matchType at_least_once` fires on the first evaluation
// that sees any matching record, which is what keeps the ruler wait to ~20-35s.
export function v2RulePayload({
name,
alertType,
compositeQuery,
channels,
severity,
extraLabels,
evalWindow,
frequency,
extraCondition,
}: {
name: string;
alertType: string;
compositeQuery: Record<string, unknown>;
channels: string[];
severity: string;
extraLabels?: Record<string, string>;
evalWindow: string;
frequency: string;
extraCondition?: Record<string, unknown>;
}): Record<string, unknown> {
return {
alert: name,
alertType,
ruleType: 'threshold_rule',
schemaVersion: 'v2alpha1',
version: 'v5',
disabled: false,
source: '',
labels: { severity, ...extraLabels },
annotations: ANNOTATIONS,
evaluation: { kind: 'rolling', spec: { evalWindow, frequency } },
notificationSettings: {
groupBy: [],
renotify: { enabled: false, interval: '30m', alertStates: [] },
usePolicy: false,
},
condition: {
selectedQueryName: 'A',
compositeQuery,
thresholds: {
kind: 'basic',
spec: [
{
name: severity,
target: 0,
targetUnit: '',
recoveryTarget: null,
matchType: 'at_least_once',
op: 'above',
channels,
},
],
},
...extraCondition,
},
};
}
// Legacy schema: `evalWindow`/`frequency` sit at the top level, channels are
// `preferredChannels`, and `condition.{op,target,matchType}` are the numeric
// enum forms the v1 validator requires. `labels.severity` becomes the history
// `threshold.name`.
export function v1RulePayload({
name,
alertType,
compositeQuery,
channels,
severity,
extraLabels,
evalWindow,
frequency,
extraCondition,
target = 0,
op = '1',
matchType = '1',
}: {
name: string;
alertType: string;
compositeQuery: Record<string, unknown>;
channels: string[];
severity: string;
extraLabels?: Record<string, string>;
evalWindow: string;
frequency: string;
extraCondition?: Record<string, unknown>;
target?: number;
op?: string;
matchType?: string;
}): Record<string, unknown> {
return {
alert: name,
alertType,
ruleType: 'threshold_rule',
disabled: false,
source: '',
evalWindow,
frequency,
preferredChannels: channels,
labels: { severity, ...extraLabels },
annotations: ANNOTATIONS,
condition: {
selectedQueryName: 'A',
// Defaults match the history seeds' original shape — `target 0 / op above /
// matchType at_least_once` fires on the first evaluation that sees data — so
// overriding them is opt-in and cannot change what those seeds do.
op,
target,
matchType,
compositeQuery,
...extraCondition,
},
};
}

View File

@@ -0,0 +1,207 @@
import { randomBytes } from 'crypto';
import type { Page } from '@playwright/test';
import { seederUrl } from '../common';
import { createThresholdAlertViaApi } from './api';
import { SEED_B_SEVERITIES } from './constants';
import type {
AlertRulesSeedOptions,
LogsSeedOptions,
MetricsSeedOptions,
TracesSeedOptions,
} from './types';
// ─── Seeding telemetry ───────────────────────────────────────────────────
async function postToSeeder(
page: Page,
path: string,
data: unknown,
): Promise<void> {
const url = `${seederUrl()}${path}`;
// The seeder shares one ClickHouse client, so concurrent POSTs from parallel
// workers collide with a transient 500 "concurrent queries within the same
// session". Retry those; anything else is real.
const maxAttempts = 6;
let lastStatus = 0;
let lastText = '';
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
// eslint-disable-next-line no-await-in-loop
const res = await page.request.post(url, {
data,
headers: { 'Content-Type': 'application/json' },
});
if (res.ok()) {
return;
}
lastStatus = res.status();
// eslint-disable-next-line no-await-in-loop
lastText = await res.text();
if (!(lastStatus === 500 && lastText.includes('concurrent'))) {
break;
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 150 * (attempt + 1) + Math.floor(Math.random() * 100));
});
}
throw new Error(`seeder POST ${path} ${lastStatus}: ${lastText}`);
}
/**
* Seed log records the history rules match on. Returns the generated
* `service.name` values, which become the timeline rows' `groupBy` labels
* (N distinct services ⇒ N distinct fingerprints ⇒ N timeline rows).
*
* Seed these **immediately** before creating the rule: the rule only fires
* while the records are still inside its eval window, and a stale marker
* silently never fires.
*/
export async function seedAlertHistoryLogs(
page: Page,
{
marker,
services,
recordsPerService = 2,
ageSeconds = 150,
minAgeSeconds = 30,
servicePrefix = 'e2e-ah-svc',
}: LogsSeedOptions,
): Promise<string[]> {
const now = Date.now();
const span = Math.max(ageSeconds - minAgeSeconds, 1);
const serviceNames: string[] = [];
const records: Record<string, unknown>[] = [];
for (let i = 0; i < services; i += 1) {
const service = `${servicePrefix}-${i}`;
serviceNames.push(service);
for (let r = 0; r < recordsPerService; r += 1) {
const fraction =
(i * recordsPerService + r) / (services * recordsPerService);
const offset = ageSeconds - Math.floor(fraction * span);
records.push({
timestamp: new Date(now - offset * 1000).toISOString(),
body: marker,
resources: { 'service.name': service },
});
}
}
await postToSeeder(page, '/telemetry/logs', records);
return serviceNames;
}
/**
* Seed spans the traces history rule matches on — one root span per
* `service.name`, all sharing the span `name` marker. Returns the generated
* service names (⇒ one timeline row each), same contract as
* {@link seedAlertHistoryLogs}, and the same "seed immediately before creating
* the rule" rule applies.
*/
export async function seedAlertHistoryTraces(
page: Page,
{
marker,
services,
spansPerService = 2,
ageSeconds = 150,
minAgeSeconds = 30,
servicePrefix = 'e2e-aht-svc',
}: TracesSeedOptions,
): Promise<string[]> {
const now = Date.now();
const span = Math.max(ageSeconds - minAgeSeconds, 1);
const serviceNames: string[] = [];
const spans: Record<string, unknown>[] = [];
for (let i = 0; i < services; i += 1) {
const service = `${servicePrefix}-${i}`;
serviceNames.push(service);
for (let s = 0; s < spansPerService; s += 1) {
const fraction = (i * spansPerService + s) / (services * spansPerService);
const offset = ageSeconds - Math.floor(fraction * span);
spans.push({
timestamp: new Date(now - offset * 1000).toISOString(),
trace_id: randomBytes(16).toString('hex'),
span_id: randomBytes(8).toString('hex'),
name: marker,
kind: 2,
duration: 'PT0.05S',
resources: { 'service.name': service },
});
}
}
await postToSeeder(page, '/telemetry/traces', spans);
return serviceNames;
}
/**
* Seed a throwaway gauge the metrics rule alerts on. Cheaper than the logs
* fixture (~10s to fire) and its history rows carry neither `relatedLogsLink`
* nor `relatedTracesLink` — the "no links available" case.
*/
export async function seedAlertHistoryMetrics(
page: Page,
{
metricName,
hosts,
pointsPerHost = 3,
groupByKey = 'host',
}: MetricsSeedOptions,
): Promise<void> {
const now = Date.now();
const points: Record<string, unknown>[] = [];
for (const host of hosts) {
for (let p = 0; p < pointsPerHost; p += 1) {
points.push({
metric_name: metricName,
labels: { [groupByKey]: host },
timestamp: new Date(now - (pointsPerHost - p) * 20 * 1000).toISOString(),
value: 10 + p,
type_: 'Gauge',
temporality: 'Unspecified',
is_monotonic: false,
});
}
}
await postToSeeder(page, '/telemetry/metrics', points);
}
/**
* SEED-B: `count` metric threshold rules sharing one channel. Severities cycle
* through {@link SEED_B_SEVERITIES} and every rule carries a `team` label, so
* the list's "Alert Name, Severity and Labels" search has hits *and* misses for
* all three. Even-indexed rules are `platform`, odd ones `payments` — i.e. half
* the batch each. Returns the ids in creation order.
*/
export async function seedAlertRules(
page: Page,
{
count,
channelName,
namePrefix = 'e2e-alert-list',
teamSuffix = '',
}: AlertRulesSeedOptions,
): Promise<string[]> {
const ids: string[] = [];
for (let i = 0; i < count; i += 1) {
// Sequential on purpose: the rules API is not the thing under test and
// parallel POSTs make failures harder to attribute.
// eslint-disable-next-line no-await-in-loop
const id = await createThresholdAlertViaApi(page, {
name: `${namePrefix}-${String(i).padStart(2, '0')}`,
target: 100 + i,
channels: [channelName],
labels: {
severity: SEED_B_SEVERITIES[i % SEED_B_SEVERITIES.length],
team: `${i % 2 === 0 ? 'platform' : 'payments'}${teamSuffix}`,
},
});
ids.push(id);
}
return ids;
}

View File

@@ -0,0 +1,191 @@
// ─── Types ─────────────────────────────────────────────────────────────────
export interface ThresholdAlertSeed {
/** Alert rule name. Keep unique per test to avoid collisions. */
name: string;
/** The critical-threshold target value to persist and later assert. */
target: number;
/**
* Notification channel names for the critical threshold. At least one is
* required by the API — seed one with {@link createEmailChannelViaApi}.
*/
channels: string[];
/**
* Rule labels. `severity` drives the list's Severity column and is one of
* the things its search box matches on, so list specs set it explicitly.
*/
labels?: Record<string, string>;
// ── SEED-RV2 extras ─────────────────────────────────────────────────────
// Everything below exists so an *edit* spec can prove the form prefilled from
// the rule rather than from its own defaults. A prefill assertion against a
// value that equals `INITIAL_CREATE_ALERT_STATE` proves nothing, so each of
// these deliberately differs from the corresponding UI default.
/**
* Replaces the single `critical` threshold. Use two or more to exercise the
* multi-threshold prefill — and note the UI only reads `op`/`matchType` back
* from `spec[0]`, so entries after the first should keep them identical unless
* the test is *about* that defect.
*/
thresholds?: ThresholdSeedSpec[];
/** Go duration; UI default is `5m0s`, so pass something else. */
evalWindow?: string;
/** Go duration; UI default is `1m`, so pass something else. */
frequency?: string;
/**
* `notificationSettings.groupBy`. The UI's group-by select only offers keys
* that the *query* groups by (`MultipleNotifications.tsx:20-48`), so set
* {@link ThresholdAlertSeed.queryGroupBy} to the same keys or the prefilled
* value has no matching option.
*/
groupBy?: string[];
/** Attribute keys the query groups by. Also what unlocks the group-by select. */
queryGroupBy?: string[];
/** `notificationSettings.renotify`. UI default is `{enabled: false}`. */
renotify?: {
enabled: boolean;
/** Go duration; UI default is `30m`. */
interval: string;
alertStates: ('firing' | 'nodata')[];
};
/** `condition.alertOnAbsent` + `condition.absentFor` (minutes). */
alertOnAbsent?: { absentFor: number };
/** `condition.recoveryTarget` on the first threshold — the UI never renders it. */
recoveryTarget?: number | null;
}
export interface ThresholdSeedSpec {
name: string;
target: number;
targetUnit?: string;
matchType?: string;
op?: string;
channels: string[];
recoveryTarget?: number | null;
}
/** Rule schema flavour. `v1` is the legacy payload posted to `/api/v1/rules`. */
export type AlertSchema = 'v1' | 'v2';
export interface LogsAlertSeed {
name: string;
/** Substring the rule matches on (`body CONTAINS '<marker>'`). */
marker: string;
/** Channel *names* (not ids) — the API validates the reference. */
channels: string[];
schema?: AlertSchema;
/** Go duration, e.g. `5m0s`. Shrink it to make the rule resolve fast. */
evalWindow?: string;
frequency?: string;
/** Becomes the history `threshold.name` for v1 rules (`processRuleDefaults`). */
severity?: string;
/**
* Extra rule labels merged alongside `severity`. They show up in the details
* header's labels row (which renders `labels` minus `severity`) *and* as extra
* history `filter_keys`, so add them only where a scenario needs them.
*/
extraLabels?: Record<string, string>;
/** `condition.alertOnAbsent` — the only route to a `nodata` row. */
alertOnAbsent?: boolean;
/** `condition.absentFor`, in minutes. */
absentFor?: number;
// ── SEED-RV1 extras ─────────────────────────────────────────────────────
// v1 only. Same reasoning as SEED-RV2's block: an `EV1-*` prefill assertion
// against the value the create form would have produced anyway proves nothing,
// so each of these exists to differ from `alertDefaults`
// (`container/CreateAlertRule/defaults.ts`).
/** `condition.target`. The v1 default is *absent*, so any number differs. */
target?: number;
/** `condition.op` as the legacy numeric string. `1` above, `2` below, … */
op?: string;
/** `condition.matchType`, same encoding. `1` at-least-once, `2` all-the-times. */
matchType?: string;
}
export interface TracesAlertSeed {
name: string;
/** Span name the rule matches on (`name = '<marker>'`). */
marker: string;
channels: string[];
evalWindow?: string;
frequency?: string;
}
export interface MetricAlertSeed {
name: string;
metricName: string;
channels: string[];
/** Attribute the history rows group by. Defaults to `host`. */
groupByKey?: string;
evalWindow?: string;
frequency?: string;
}
export interface LogsSeedOptions {
marker: string;
/** Number of distinct `service.name` values ⇒ number of timeline rows. */
services: number;
recordsPerService?: number;
/** Oldest record age in seconds; records spread from here up to `minAgeSeconds`. */
ageSeconds?: number;
minAgeSeconds?: number;
/** Prefix for the generated `service.name` values. */
servicePrefix?: string;
}
export interface MetricsSeedOptions {
metricName: string;
/** Distinct attribute values ⇒ number of timeline rows. */
hosts: string[];
pointsPerHost?: number;
groupByKey?: string;
}
export interface TracesSeedOptions {
/** Span `name` the rule matches on. */
marker: string;
/** Number of distinct `service.name` values ⇒ number of timeline rows. */
services: number;
spansPerService?: number;
/** Oldest span age in seconds; spans spread from here up to `minAgeSeconds`. */
ageSeconds?: number;
minAgeSeconds?: number;
servicePrefix?: string;
}
/** One row of `GET /api/v2/rules/{id}/history/timeline`. */
export interface TimelineItem {
state: string;
unixMilli: number;
fingerprint: string;
value: number;
labels: {
key?: { name?: string };
value?: string | number | boolean | null;
}[];
relatedLogsLink?: string;
relatedTracesLink?: string;
}
export interface TimelineResponse {
items: TimelineItem[];
total: number;
nextCursor?: string;
}
export interface AlertRulesSeedOptions {
count: number;
channelName: string;
/** Rules are named `<namePrefix>-NN`. Keep it unique per batch. */
namePrefix?: string;
/**
* Appended to both `team` label values. Every list spec seeds its own batch
* and they run in parallel, so a bare `team: payments` would also match the
* neighbouring batches — which is exactly what the label-search scenario
* counts. Leave it empty only when nothing asserts an exact label count.
*/
teamSuffix?: string;
}

View File

@@ -1,34 +1,123 @@
import type { Browser, BrowserContext } from '@playwright/test';
import type { Browser, BrowserContext, Page } from '@playwright/test';
export type User = { email: string; password: string };
/** Default user — admin from the pytest bootstrap (.env.local) or staging .env. */
export const ADMIN: User = {
email: process.env.SIGNOZ_E2E_USERNAME!,
password: process.env.SIGNOZ_E2E_PASSWORD!,
};
/**
* Build a fresh authenticated `BrowserContext` via UI login. Used by suite
* hooks (`test.beforeAll` / `test.afterAll`), where the test-scoped
* `authedPage` fixture from `fixtures/auth.ts` is not reachable.
* `browser.newContext()` only inherits `use.baseURL` while a *test* is in
* scope. Worker-scoped fixtures (and their teardown) run outside that, where a
* relative `page.goto('/login')` fails with "Cannot navigate to invalid URL" —
* so pass it explicitly whenever we know it. Left empty when the var is unset
* so the config's staging default still applies inside a test.
*/
const contextDefaults: { baseURL?: string } = process.env.SIGNOZ_E2E_BASE_URL
? { baseURL: process.env.SIGNOZ_E2E_BASE_URL }
: {};
// Per-worker storageState cache. One UI login per unique user per worker
// process, shared by everything in that worker: the `authedPage` fixture, the
// worker-scoped seed fixtures, and their teardown. Promise-valued so concurrent
// callers await the same in-flight login rather than racing several of their
// own. Held in memory only — no .auth/ dir, no JSON on disk.
//
// This cache is why `newAdminContext` is cheap. It used to log in through the
// UI on every call, and the alerts fixtures call it a dozen-plus times per
// worker (channel, rule list, five history seeds, one per owned rule, plus a
// teardown for each) — a couple of seconds each, paid over and over for a
// session that never changes.
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
const storageByUser = new Map<string, Promise<StorageState>>();
async function login(page: Page, user: User): Promise<void> {
if (!user.email || !user.password) {
throw new Error(
'User credentials missing. Set SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD ' +
'(pytest bootstrap writes them to .env.local), or pass a User via test.use({ user: ... }).',
);
}
await page.goto('/login?password=Y');
await page.getByTestId('email').fill(user.email);
await page.getByTestId('initiate_login').click();
await page.getByTestId('password').fill(user.password);
await page.getByRole('button', { name: 'Sign in with Password' }).click();
// Post-login lands somewhere different depending on whether the org is
// licensed (onboarding flow on ENTERPRISE) or not (legacy "Hello there"
// welcome). Wait for URL to move off /login — whichever page follows
// is fine, each spec navigates to the feature under test anyway.
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
}
// Pin the nav suite-wide: unpinned it flies out on hover and overlays content.
// Server-side pref, so set once per user at login.
async function pinSidenav(page: Page): Promise<void> {
const token = await page.evaluate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
data: { value: true },
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
const text = await res.text();
// Two workers logging in at the same moment both insert the preference and
// the loser gets a 500 on `uq_user_preference_name_user_id`. The write it
// lost to set the same value, so the preference *is* pinned — treat the
// duplicate as success rather than failing an unrelated test.
if (text.includes('uq_user_preference_name_user_id')) {
return;
}
throw new Error(
`PUT /api/v1/user/preferences/sidenav_pinned ${res.status()}: ${text}`,
);
}
}
/**
* Authenticated storage state for `user`, logging in once per worker. Callers
* hand the result to `browser.newContext({ storageState })`.
*/
export function storageStateFor(
browser: Browser,
user: User = ADMIN,
): Promise<StorageState> {
const cached = storageByUser.get(user.email);
if (cached) {
return cached;
}
const task = (async () => {
const ctx = await browser.newContext(contextDefaults);
const page = await ctx.newPage();
await login(page, user);
await pinSidenav(page);
const state = await ctx.storageState();
await ctx.close();
return state;
})();
storageByUser.set(user.email, task);
return task;
}
/**
* Build an authenticated admin `BrowserContext`. Used by suite hooks
* (`test.beforeAll` / `test.afterAll`) and worker-scoped fixtures, where the
* test-scoped `authedPage` fixture from `fixtures/auth.ts` is not reachable.
*
* Each call performs one fresh login (~1s). The per-worker storageState
* cache in `fixtures/auth.ts` is intentionally not shared here — keeping
* this helper standalone avoids coupling suite hooks to the fixture's
* private cache.
* Reuses this worker's cached session, so only the first call in a worker pays
* for a login. The caller owns the context and must close it.
*/
export async function newAdminContext(
browser: Browser,
): Promise<BrowserContext> {
const email = process.env.SIGNOZ_E2E_USERNAME;
const password = process.env.SIGNOZ_E2E_PASSWORD;
if (!email || !password) {
throw new Error(
'SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD must be set ' +
'(pytest bootstrap writes them to .env.local).',
);
}
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto('/login?password=Y');
await page.getByTestId('email').fill(email);
await page.getByTestId('initiate_login').click();
await page.getByTestId('password').fill(password);
await page.getByRole('button', { name: 'Sign in with Password' }).click();
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
await page.close();
return ctx;
return browser.newContext({
...contextDefaults,
storageState: await storageStateFor(browser, ADMIN),
});
}

View File

@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test';
import type { Page, Request } from '@playwright/test';
// Shared helpers used across feature-specific helper modules (dashboards,
// trace-details, …). Keep this to genuinely cross-feature utilities.
@@ -18,6 +18,108 @@ export function seederUrl(): string {
return url;
}
// ─── Console / network noise ──────────────────────────────────────────────
// Requests the bootstrap stack always fails, on every page, for reasons that
// have nothing to do with the feature under test. Keep this list tiny and give
// every entry a reason — it is a deny-list of *environment* noise, never of real
// application errors.
const HARNESS_FAILING_REQUESTS = [
// Zeus is a WireMock stub with no /api/v2/zeus/hosts mapping, so the app
// shell's workspace-URL lookup 404s on every page load. It reaches the console
// three ways: the resource-load error, the AxiosError, and the literal `any`
// that `api/ErrorResponseHandler.ts`'s fallback branch logs.
'/api/v2/zeus/hosts',
// The app shell polls GitHub for the latest release. Unauthenticated calls
// from CI/dev machines get rate-limited (403), which has nothing to do with
// the page under test.
'api.github.com',
];
// The console side of {@link HARNESS_FAILING_REQUESTS}. Browsers log a
// resource-load error without the URL, so these have to be matched on text —
// which is why the URL list above is the precise half of the check.
const HARNESS_CONSOLE_NOISE = [
'Failed to load resource: the server responded with a status of 404 (Not Found)',
'Failed to load resource: the server responded with a status of 403',
'Request failed with status code 404',
'client never received a response, or request never left',
'ErrorResponseHandler: unclassified error',
];
export interface ConsoleWatch {
/** Console `error` entries and uncaught page errors, harness noise removed. */
errors: string[];
/** `"<status> <method> <url>"` for every 4xx/5xx, harness noise removed. */
failedResponses: string[];
}
/**
* Watch a page for console errors and failed requests. Call **before** the first
* navigation; the returned object fills in as the page runs, so assert on it at
* the end of the scenario.
*
* Console text alone is a weak signal (the harness's Zeus 404 produces three
* generic-looking entries), so the failed-response list is the precise half:
* text matching is deliberately loose while the URL check stays strict.
*/
export function watchConsole(
page: Page,
/**
* Extra substrings to ignore. Use this — with a comment naming the defect —
* for a *known application* bug that is out of the spec's scope, so the rest
* of the console assertion keeps its value instead of being deleted.
*/
options: { ignore?: string[] } = {},
): ConsoleWatch {
const watch: ConsoleWatch = { errors: [], failedResponses: [] };
const noise = [...HARNESS_CONSOLE_NOISE, ...(options.ignore ?? [])];
const isNoise = (text: string): boolean =>
noise.some((entry) => text.includes(entry));
page.on('console', (msg) => {
if (msg.type() === 'error' && !isNoise(msg.text())) {
watch.errors.push(msg.text());
}
});
page.on('pageerror', (err) => {
if (!isNoise(String(err))) {
watch.errors.push(String(err));
}
});
page.on('response', (res) => {
if (res.status() < 400) {
return;
}
const url = res.url();
if (HARNESS_FAILING_REQUESTS.some((entry) => url.includes(entry))) {
return;
}
watch.failedResponses.push(
`${res.status()} ${res.request().method()} ${url}`,
);
});
return watch;
}
// ─── Network capture ──────────────────────────────────────────────────────
/**
* Every request the page issues from now on. Call **before** the first
* navigation — the returned array fills in as the page runs, so filter it at the
* end of the scenario ("endpoint called exactly once", "no legacy route used").
*/
export function collectRequests(page: Page): Request[] {
const requests: Request[] = [];
page.on('request', (request) => requests.push(request));
return requests;
}
/** A request's URL, parsed — the readable way to reach `searchParams`. */
export function requestUrl(request: Request): URL {
return new URL(request.url());
}
// ─── Auth ────────────────────────────────────────────────────────────────
// Read the app JWT from the context's stored auth state. No navigation needed:

View File

@@ -5,7 +5,11 @@
"main": "index.js",
"scripts": {
"preinstall": "npx only-allow pnpm",
"env:start": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no --with-web e2e/bootstrap/setup.py::test_setup",
"env:stop": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no e2e/bootstrap/setup.py::test_teardown",
"env:clean": "rm -rf ../tmp ../.pytest_cache .env.local artifacts && echo 'Cleaned. Run docker container prune if needed.'",
"test": "playwright test",
"test:local": "pnpm env:start && pnpm test",
"test:staging": "SIGNOZ_E2E_BASE_URL=https://app.us.staging.signoz.cloud playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed",

View File

@@ -1,15 +1,37 @@
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import fs from 'fs';
import os from 'os';
import path from 'path';
// .env holds user-provided defaults (staging creds).
// .env.local is written by tests/e2e/bootstrap/setup.py when the pytest
// lifecycle brings the backend up locally; override=true so local-backend
// coordinates win over any stale .env values. Subprocess-injected env
// (e.g. when pytest shells out to `pnpm test`) still takes priority —
// dotenv doesn't touch vars that are already set in process.env.
dotenv.config({ path: path.resolve(__dirname, '.env') });
dotenv.config({ path: path.resolve(__dirname, '.env.local'), override: true });
// Precedence, lowest to highest:
// .env — user-provided defaults (staging creds)
// .env.local — written by tests/e2e/bootstrap/setup.py when the pytest
// lifecycle brings the backend up locally, so it must win over
// any stale .env value
// the real environment — anything the caller exported on purpose, e.g.
// `SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test` to run
// against a locally served frontend, or the vars pytest injects
// when it shells out to `pnpm test`.
//
// This is deliberately *not* `dotenv.config({ override: true })`: that flag
// makes the file beat process.env, so an exported SIGNOZ_E2E_BASE_URL was
// silently discarded and every run went to whatever .env.local pointed at.
// Parsing by hand is the only way to get ".env.local beats .env" without also
// getting ".env.local beats the caller".
const exported = new Set(Object.keys(process.env));
for (const file of ['.env', '.env.local']) {
const filePath = path.resolve(__dirname, file);
if (!fs.existsSync(filePath)) {
continue;
}
const parsed = dotenv.parse(fs.readFileSync(filePath));
for (const [key, value] of Object.entries(parsed)) {
if (!exported.has(key)) {
process.env[key] = value;
}
}
}
export default defineConfig({
testDir: './tests',
@@ -33,8 +55,17 @@ export default defineConfig({
// Retry on CI only
retries: process.env.CI ? 2 : 0,
// Workers
workers: process.env.CI ? 2 : undefined,
// Workers. Playwright's local default is `cpus / 2`, which on a 32-core box is
// 16 — and 16 is strictly worse than 6 here, because every worker's browser
// shares one SigNoz container: measured on `tests/alerts/{create,edit}` at
// `--repeat-each=3` (224 tests), 16 workers took 128 s with 3 failures while 6
// took 119 s with none. Past ~6 the extra workers only add queueing, which shows
// up as 4-6 s app mounts and save requests that outlive the test timeout — i.e.
// as flakes that look like product bugs. Capped rather than fixed at 6 so a
// 4-core laptop still gets `cpus / 2`.
workers: process.env.CI
? 2
: Math.max(1, Math.min(6, Math.floor(os.cpus().length / 2))),
// The SPA hydrates slowly on CI, so the 5s expect default fires mid-load.
expect: { timeout: 15_000 },

View File

@@ -1,67 +0,0 @@
import { expect, test } from '../../fixtures/auth';
import {
createEmailChannelViaApi,
createThresholdAlertViaApi,
deleteAlertViaApi,
deleteChannelViaApi,
gotoAlertOverview,
} from '../../helpers/alerts';
import { newAdminContext } from '../../helpers/auth';
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
await page.goto('/alerts');
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
await expect(page.getByRole('tab', { name: /configuration/i })).toBeVisible();
});
test.describe('alerts — threshold persists on edit-page load', () => {
const TARGET = 245;
let ruleId: string;
let channelId: string;
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const stamp = Date.now();
const channel = await createEmailChannelViaApi(
page,
`e2e-threshold-persistence-ch-${stamp}`,
);
channelId = channel.id;
ruleId = await createThresholdAlertViaApi(page, {
name: `e2e-threshold-persistence-${stamp}`,
target: TARGET,
channels: [channel.name],
});
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
if (ruleId) {
await deleteAlertViaApi(page, ruleId);
}
if (channelId) {
await deleteChannelViaApi(page, channelId);
}
} finally {
await ctx.close();
}
});
test('TC-02 edit page shows the saved threshold value', async ({
authedPage: page,
}) => {
await gotoAlertOverview(page, ruleId);
// The condition editor should show the persisted target once loaded.
await expect(page.getByTestId('threshold-value-input')).toHaveValue(
String(TARGET),
);
});
});

View File

@@ -0,0 +1,45 @@
import { expect, test } from '../../../fixtures/auth';
import {
createEmailChannelViaApi,
deleteChannelViaApi,
} from '../../../helpers/alerts/api';
test.describe('Notification channels — edit', () => {
// Regression guard for engineering-pod#5509: after channels moved from
// /settings/channels to /alerts/channels, the edit container still parsed the
// channel id out of the old pathname, so every save PUT went to an empty id
// and no edit ever persisted. Nothing in the suite navigated into the edit
// page, so the whole class of "edits silently do nothing" was invisible.
test('TC-01 an edited recipient persists after reload', async ({
authedPage: page,
}) => {
// The channel *name* is read-only on the edit page, so the editable field
// this exercises is the email recipient.
const name = `e2e-nc-${Date.now()}`;
const updatedTo = 'e2e-updated@signoz.test';
const { id } = await createEmailChannelViaApi(page, name);
try {
await page.goto(`/alerts/channels/edit/${id}`);
const toBox = page.getByRole('textbox', { name: 'To' });
await expect(toBox).toHaveValue('e2e@signoz.test');
await toBox.fill(updatedTo);
await Promise.all([
page.waitForResponse(
(r) =>
r.url().includes('/api/v1/channels') && r.request().method() === 'PUT',
),
page.getByTestId('save-channel-button').click(),
]);
await page.goto(`/alerts/channels/edit/${id}`);
await expect(page.getByRole('textbox', { name: 'To' })).toHaveValue(
updatedTo,
);
} finally {
await deleteChannelViaApi(page, id);
}
});
});

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