Compare commits

...

11 Commits

Author SHA1 Message Date
Nityananda Gohain
58a52529c3 Merge branch 'main' into issue_4501 2026-08-26 18:02:20 +05:30
Nityananda Gohain
02c5555a48 fix: add ai_observability to saved views (#12675)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
build-staging / go-build (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
#### Description
* adds `ai_observability` to saved view for ai explorer

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/engineering-pod/issues/5955
2026-08-26 11:28:54 +00:00
Naman Verma
0bdc7bf6a1 fix: resolve aggregate column for exp histograms before samples table (#12640)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

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

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

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

---

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

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

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

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

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


#### Alerts page shell (7)

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

#### Alert rules list (19)

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

#### Create alert (52)

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

#### Edit alert (22)

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

#### Alert details (15)

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

#### Alert history (75)

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

#### Notification channels (1)

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

#### Skipped tests

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


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

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

---

###  Change Type
_Select all that apply_

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

---

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

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

---

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

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

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [ ] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-26 06:37:32 +00:00
nityanandagohain
bb57adcdee fix: address comments 2026-08-21 12:21:44 +05:30
nityanandagohain
71dc06bc7e Merge remote-tracking branch 'origin/main' into issue_4501 2026-08-20 14:32:41 +05:30
nityanandagohain
435471a18d fix: address comments 2026-08-19 19:17:04 +05:30
nityanandagohain
816905f4cf fix: remove root user 2026-08-19 14:48:38 +05:30
nityanandagohain
691f724480 fix: more cleanup 2026-08-19 14:12:09 +05:30
nityanandagohain
e04f26f5b7 fix: remove update endpoint 2026-08-19 12:33:21 +05:30
nityanandagohain
4a72aab47c feat: system dashboards 2026-08-19 12:19:10 +05:30
127 changed files with 9436 additions and 573 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

@@ -8010,6 +8010,7 @@ components:
- logs
- metrics
- meter
- ai_observability
type: string
SavedviewtypesUpdatableSavedView:
properties:
@@ -22949,6 +22950,73 @@ paths:
summary: Rotate session
tags:
- sessions
/api/v2/system/dashboards/{name}:
get:
deprecated: false
description: Returns a dashboard SigNoz ships and owns, addressed by its stable
definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards
are read-only and upgraded through releases. The dashboard's own `name` field
carries a reserved prefix that the path segment must not include.
operationId: GetSystemDashboard
parameters:
- in: path
name: name
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
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:
- dashboard:read
- tokenizer:
- dashboard:read
summary: Get system dashboard
tags:
- dashboard
/api/v2/user_roles:
post:
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

@@ -276,6 +276,10 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
func (module *module) GetByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.GetByNameV2(ctx, orgID, name)
}
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.MigrateV2(ctx, orgID, id)
}
@@ -284,6 +288,10 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
}
func (module *module) UpdateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.UpdateUnsafeV2(ctx, orgID, id, updatedBy, updatable)
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
}

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

@@ -46,6 +46,8 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
GetSystemDashboard200,
GetSystemDashboardPathParameters,
ListDashboardViews200,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
@@ -2111,6 +2113,108 @@ export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
return queryClient;
};
/**
* Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.
* @summary Get system dashboard
*/
export const getSystemDashboard = (
{ name }: GetSystemDashboardPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetSystemDashboard200>({
url: `/api/v2/system/dashboards/${name}`,
method: 'GET',
signal,
});
};
export const getGetSystemDashboardQueryKey = ({
name,
}: GetSystemDashboardPathParameters) => {
return [`/api/v2/system/dashboards/${name}`] as const;
};
export const getGetSystemDashboardQueryOptions = <
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ name }: GetSystemDashboardPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetSystemDashboardQueryKey({ name });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getSystemDashboard>>
> = ({ signal }) => getSystemDashboard({ name }, signal);
return {
queryKey,
queryFn,
enabled: !!name,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetSystemDashboardQueryResult = NonNullable<
Awaited<ReturnType<typeof getSystemDashboard>>
>;
export type GetSystemDashboardQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get system dashboard
*/
export function useGetSystemDashboard<
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ name }: GetSystemDashboardPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetSystemDashboardQueryOptions({ name }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get system dashboard
*/
export const invalidateGetSystemDashboard = async (
queryClient: QueryClient,
{ name }: GetSystemDashboardPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetSystemDashboardQueryKey({ name }) },
options,
);
return queryClient;
};
/**
* Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.
* @summary List dashboards for the current user (v2)

View File

@@ -9021,6 +9021,7 @@ export enum SavedviewtypesSourceDTO {
logs = 'logs',
metrics = 'metrics',
meter = 'meter',
ai_observability = 'ai_observability',
}
export interface SavedviewtypesSavedViewSpecDTO {
display?: SavedviewtypesDisplayDTO;
@@ -12249,6 +12250,17 @@ export type RotateSession200 = {
status: string;
};
export type GetSystemDashboardPathParameters = {
name: string;
};
export type GetSystemDashboard200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type CreateUserRole201 = {
data: TypesIdentifiableDTO;
/**

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

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

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

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

@@ -30,6 +30,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
@@ -80,6 +81,8 @@ type provider struct {
llmPricingRuleHandler llmpricingrule.Handler
statsHandler statsreporter.Handler
savedViewHandler savedview.Handler
systemDashboardModule systemdashboard.Module
systemDashboardHandler systemdashboard.Handler
}
func NewFactory(
@@ -118,6 +121,8 @@ func NewFactory(
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
systemDashboardModule systemdashboard.Module,
systemDashboardHandler systemdashboard.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
return newProvider(
@@ -159,6 +164,8 @@ func NewFactory(
rulerHandler,
statsHandler,
savedViewHandler,
systemDashboardModule,
systemDashboardHandler,
)
})
}
@@ -202,6 +209,8 @@ func newProvider(
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
systemDashboardModule systemdashboard.Module,
systemDashboardHandler systemdashboard.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
router := mux.NewRouter().UseEncodedPath()
@@ -244,6 +253,8 @@ func newProvider(
llmPricingRuleHandler: llmPricingRuleHandler,
statsHandler: statsHandler,
savedViewHandler: savedViewHandler,
systemDashboardModule: systemDashboardModule,
systemDashboardHandler: systemDashboardHandler,
}
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
@@ -296,6 +307,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addSystemDashboardRoutes(router); err != nil {
return err
}
if err := provider.addMetricsExplorerRoutes(router); err != nil {
return err
}

View File

@@ -0,0 +1,63 @@
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/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (provider *provider) addSystemDashboardRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/system/dashboards/{name}", handler.New(
provider.authzMiddleware.CheckResources(provider.systemDashboardHandler.Get, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetSystemDashboard",
Tags: []string{"dashboard"},
Summary: "Get system dashboard",
Description: "Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: provider.systemDashboardID(),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}
// systemDashboardID resolves the {name} path param to the dashboard's id. Authz
// tuples and audit records are written against ids, so the name has to be
// resolved before either runs.
func (provider *provider) systemDashboardID() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
ctx := ec.Request.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return "", err
}
id, err := provider.systemDashboardModule.ResolveID(ctx, valuer.MustNewUUID(claims.OrgID), mux.Vars(ec.Request)["name"])
if err != nil {
return "", err
}
return id.StringValue(), nil
})
}

View File

@@ -63,6 +63,8 @@ type Module interface {
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
GetByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error)
// MigrateV2 retries the v1→v2 migration on a dashboard still stored in the v1 schema.
MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
@@ -72,6 +74,9 @@ type Module interface {
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error)
// UpdateUnsafeV2 updates a dashboard bypassing the guards. Intended for internal system callers.
UpdateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error)
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)

View File

@@ -64,6 +64,23 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
return storableDashboard, nil
}
func (store *store) GetByName(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.StorableDashboard, error) {
storableDashboard := new(dashboardtypes.StorableDashboard)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(storableDashboard).
Where("name = ?", name).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with name %s doesn't exist", name)
}
return storableDashboard, nil
}
// ListForUser emits the joined dashboard ⨝ user_dashboard_preference query the
// spec calls for. Aliases:
//

View File

@@ -19,9 +19,12 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
return nil, err
}
dashboard := postable.NewDashboardV2(orgID, createdBy, source)
dashboard, err := postable.NewDashboardV2(orgID, createdBy, source)
if err != nil {
return nil, err
}
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
err = m.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := m.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Tags)
if err != nil {
return err
@@ -120,6 +123,20 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return storable.ToDashboardV2(tags)
}
func (module *module) GetByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
storable, err := module.store.GetByName(ctx, orgID, name)
if err != nil {
return nil, err
}
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, storable.ID)
if err != nil {
return nil, err
}
return storable.ToDashboardV2(tags)
}
// MigrateV2 retries the v1→v2 migration on a dashboard still stored as v1 (one the
// bulk 103 migration skipped or failed). Idempotent: an already-v2 one is unchanged.
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
@@ -179,13 +196,32 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updatable.Tags)
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.Update)
}
func (module *module) UpdateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := updatable.Validate(); err != nil {
return nil, err
}
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.UpdateUnsafe)
}
// apply is existing.Update or existing.UpdateUnsafe, so the gated path keeps its
// in-transaction checks and only UpdateUnsafeV2 skips them.
func (module *module) updateV2(ctx context.Context, orgID valuer.UUID, existing *dashboardtypes.DashboardV2, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2, apply func(dashboardtypes.UpdatableDashboardV2, string, []*tagtypes.Tag) error) (*dashboardtypes.DashboardV2, error) {
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, existing.ID, updatable.Tags)
if err != nil {
return err
}
err = existing.Update(updatable, updatedBy, resolvedTags)
err = apply(updatable, updatedBy, resolvedTags)
if err != nil {
return err
}

View File

@@ -6,18 +6,20 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
type setter struct {
store types.OrganizationStore
alertmanager alertmanager.Alertmanager
quickfilter quickfilter.Module
store types.OrganizationStore
alertmanager alertmanager.Alertmanager
quickfilter quickfilter.Module
systemDashboard systemdashboard.Module
}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, systemDashboard systemdashboard.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, systemDashboard: systemDashboard}
}
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
@@ -37,6 +39,10 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
return err
}
if err := module.systemDashboard.Reconcile(ctx, organization.ID); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,45 @@
package implsystemdashboard
import (
"embed"
"io/fs"
"path"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
)
const definitionsRoot = "fs/definitions"
//go:embed fs/definitions/*.json
var definitionFiles embed.FS
// NewRegistry parses every embedded definition. Definitions are build-time assets
// validated by a test, so a failure here means the binary shipped broken JSON.
func NewRegistry() (systemdashboardtypes.Registry, error) {
entries, err := fs.ReadDir(definitionFiles, definitionsRoot)
if err != nil {
return systemdashboardtypes.Registry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read system dashboard definitions")
}
definitions := make([]systemdashboardtypes.Definition, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
file := path.Join(definitionsRoot, entry.Name())
raw, err := definitionFiles.ReadFile(file)
if err != nil {
return systemdashboardtypes.Registry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", file)
}
definition, err := systemdashboardtypes.NewDefinition(raw)
if err != nil {
return systemdashboardtypes.Registry{}, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't parse %s", file)
}
definitions = append(definitions, definition)
}
return systemdashboardtypes.NewRegistry(definitions)
}

View File

@@ -0,0 +1,20 @@
package implsystemdashboard
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// A schema migration cannot ship without updating the definitions: parsing them
// runs the same validation a create goes through, at the current schemaVersion.
func TestEmbeddedDefinitionsParseAtCurrentSchemaVersion(t *testing.T) {
registry, err := NewRegistry()
require.NoError(t, err)
// The frontend addresses the overview dashboard by this name.
_, ok := registry.Get(dashboardtypes.SystemDashboardNamePrefix + "ai-o11y-overview")
assert.True(t, ok)
}

View File

@@ -0,0 +1,17 @@
{
"version": 1,
"definition": {
"schemaVersion": "v6",
"name": "signoz---ai-o11y-overview",
"tags": [],
"spec": {
"display": {
"name": "AI Observability Overview",
"description": "Overview of LLM traffic. Panels ship in an upcoming release."
},
"variables": [],
"panels": {},
"layouts": []
}
}
}

View File

@@ -0,0 +1,48 @@
package implsystemdashboard
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
type handler struct {
module systemdashboard.Module
}
func NewHandler(module systemdashboard.Module) systemdashboard.Handler {
return &handler{module: module}
}
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
name := mux.Vars(r)["name"]
if name == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "name is missing in the path"))
return
}
systemDashboard, err := handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), name)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, systemDashboard.ToGettableDashboardV2())
}

View File

@@ -0,0 +1,151 @@
package implsystemdashboard
import (
"context"
"log/slog"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct {
settings factory.ScopedProviderSettings
store systemdashboardtypes.Store
registry systemdashboardtypes.Registry
dashboardModule dashboard.Module
}
func NewModule(
providerSettings factory.ProviderSettings,
store systemdashboardtypes.Store,
registry systemdashboardtypes.Registry,
dashboardModule dashboard.Module,
) systemdashboard.Module {
return &module{
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"),
store: store,
registry: registry,
dashboardModule: dashboardModule,
}
}
func (module *module) Reconcile(ctx context.Context, orgID valuer.UUID) error {
for _, definition := range module.registry.List() {
if err := module.reconcile(ctx, orgID, definition); err != nil {
return err
}
}
return nil
}
func (module *module) reconcile(ctx context.Context, orgID valuer.UUID, definition systemdashboardtypes.Definition) error {
existing, err := module.dashboardModule.GetByNameV2(ctx, orgID, definition.Name())
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return err
}
return module.provision(ctx, orgID, definition)
}
// Anything but the provisioner in updated_by means a foreign write. Leave the
// row alone — never overwriting is the safe direction.
if existing.UpdatedBy != systemdashboardtypes.ProvisionerIdentity {
module.settings.Logger().WarnContext(ctx, "skipping system dashboard reconcile: last write was not by the provisioner", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()), slog.String("updated_by", existing.UpdatedBy))
return nil
}
state, err := module.store.Get(ctx, orgID, definition.Name())
if err != nil {
return err
}
// Only ever move forward: a downgrade must not rewrite the newer content.
if state.Version >= definition.Version {
return nil
}
return module.upgrade(ctx, orgID, existing.ID, definition)
}
// provision creates the dashboard and its state row in one transaction, so a
// system dashboard can never exist without the version it was provisioned at.
// A concurrent provisioner (another replica, or the org-creation hook racing the
// startup sweep) loses on the state row's unique (org_id, name) index and rolls back.
func (module *module) provision(ctx context.Context, orgID valuer.UUID, definition systemdashboardtypes.Definition) error {
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
created, err := module.dashboardModule.CreateV2(
ctx,
orgID,
systemdashboardtypes.ProvisionerIdentity,
valuer.UUID{},
dashboardtypes.SourceSystem,
definition.Dashboard,
)
if err != nil {
return err
}
return module.store.Create(ctx, systemdashboardtypes.NewStorableSystemDashboard(orgID, created.ID, definition.Name(), definition.Version))
})
if err != nil {
if errors.Ast(err, errors.TypeAlreadyExists) {
module.settings.Logger().DebugContext(ctx, "system dashboard already provisioned concurrently", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
return nil
}
return err
}
module.settings.Logger().InfoContext(ctx, "provisioned system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
return nil
}
func (module *module) upgrade(ctx context.Context, orgID valuer.UUID, id valuer.UUID, definition systemdashboardtypes.Definition) error {
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if _, err := module.dashboardModule.UpdateUnsafeV2(ctx, orgID, id, systemdashboardtypes.ProvisionerIdentity, definition.ToUpdatable()); err != nil {
return err
}
return module.store.UpdateVersion(ctx, orgID, definition.Name(), definition.Version)
})
if err != nil {
return err
}
module.settings.Logger().InfoContext(ctx, "upgraded system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
return nil
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
return module.get(ctx, orgID, name)
}
func (module *module) ResolveID(ctx context.Context, orgID valuer.UUID, name string) (valuer.UUID, error) {
existing, err := module.get(ctx, orgID, name)
if err != nil {
return valuer.UUID{}, err
}
return existing.ID, nil
}
func (module *module) get(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
if strings.HasPrefix(name, dashboardtypes.SystemDashboardNamePrefix) {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "name must not carry the %q prefix", dashboardtypes.SystemDashboardNamePrefix)
}
existing, err := module.dashboardModule.GetByNameV2(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+name)
if err != nil {
return nil, err
}
if err := existing.ErrIfNotSystem(); err != nil {
return nil, err
}
return existing, nil
}

View File

@@ -0,0 +1,209 @@
package implsystemdashboard
import (
"context"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testDashboardName = "test-overview"
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10},
Sqlite: sqlstore.SqliteConfig{
Path: filepath.Join(t.TempDir(), "test.db"),
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
for _, model := range []any{
(*dashboardtypes.StorableDashboard)(nil),
(*tagtypes.Tag)(nil),
(*tagtypes.TagRelation)(nil),
(*systemdashboardtypes.StorableSystemDashboard)(nil),
} {
_, err := store.BunDB().NewCreateTable().Model(model).IfNotExists().Exec(context.Background())
require.NoError(t, err)
}
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_system_dashboard_org_name ON system_dashboard (org_id, name)`)
require.NoError(t, err)
return store
}
func newTestModule(t *testing.T, sqlStore sqlstore.SQLStore, definitions ...systemdashboardtypes.Definition) (*module, dashboard.Module) {
t.Helper()
providerSettings := factorytest.NewSettings()
dashboardModule := impldashboard.NewModule(
impldashboard.NewStore(sqlStore),
providerSettings,
analyticstest.New(),
nil,
queryparser.New(providerSettings),
impltag.NewModule(impltag.NewStore(sqlStore)),
)
registry, err := systemdashboardtypes.NewRegistry(definitions)
require.NoError(t, err)
return NewModule(providerSettings, NewStore(sqlStore), registry, dashboardModule).(*module), dashboardModule
}
func newTestDefinition(t *testing.T, version int, displayName string) systemdashboardtypes.Definition {
t.Helper()
raw := `{
"version": ` + strconv.Itoa(version) + `,
"definition": {
"schemaVersion": "` + dashboardtypes.SchemaVersion + `",
"name": "` + dashboardtypes.SystemDashboardNamePrefix + testDashboardName + `",
"tags": [],
"spec": {"display": {"name": "` + displayName + `"}, "variables": [], "panels": {}, "layouts": []}
}
}`
definition, err := systemdashboardtypes.NewDefinition([]byte(raw))
require.NoError(t, err)
return definition
}
func TestReconcileProvisionsThenUpgradesUntilTheRowIsModified(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
systemDashboardModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
require.NoError(t, systemDashboardModule.Reconcile(ctx, orgID))
provisioned, err := systemDashboardModule.Get(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, dashboardtypes.SourceSystem, provisioned.Source)
assert.Equal(t, systemdashboardtypes.ProvisionerIdentity, provisioned.CreatedBy)
assert.Equal(t, "v1", provisioned.Spec.Display.Name)
assert.Equal(t, 1, stateVersion(t, systemDashboardModule, ctx, orgID))
// Reconciling the same version again is a no-op.
require.NoError(t, systemDashboardModule.Reconcile(ctx, orgID))
unchanged, err := systemDashboardModule.Get(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, provisioned.UpdatedAt, unchanged.UpdatedAt)
// An unmodified copy is upgraded in place, keeping its id.
upgradingModule, dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
require.NoError(t, upgradingModule.Reconcile(ctx, orgID))
upgraded, err := upgradingModule.Get(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, provisioned.ID, upgraded.ID)
assert.Equal(t, "v2", upgraded.Spec.Display.Name)
assert.Equal(t, 2, stateVersion(t, upgradingModule, ctx, orgID))
// Once anything but the provisioner writes the row, later releases leave it alone.
updatable := newTestDefinition(t, 2, "edited out of band").ToUpdatable()
_, err = dashboardModule.UpdateUnsafeV2(ctx, orgID, upgraded.ID, "user@signoz.io", updatable)
require.NoError(t, err)
shippingModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 3, "v3"))
require.NoError(t, shippingModule.Reconcile(ctx, orgID))
untouched, err := shippingModule.Get(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, "user@signoz.io", untouched.UpdatedBy)
assert.Equal(t, "edited out of band", untouched.Spec.Display.Name)
assert.Equal(t, 2, stateVersion(t, shippingModule, ctx, orgID))
}
func stateVersion(t *testing.T, module *module, ctx context.Context, orgID valuer.UUID) int {
t.Helper()
state, err := module.store.Get(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
require.NoError(t, err)
return state.Version
}
func TestSystemDashboardsAreImmutableToUsers(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
systemDashboardModule, dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
require.NoError(t, systemDashboardModule.Reconcile(ctx, orgID))
provisioned, err := systemDashboardModule.Get(ctx, orgID, testDashboardName)
require.NoError(t, err)
_, err = dashboardModule.UpdateV2(ctx, orgID, provisioned.ID, "user@signoz.io", newTestDefinition(t, 1, "edited").ToUpdatable())
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot be modified")
}
func TestReconcileDoesNotDowngrade(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
newerModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 3, "v3"))
require.NoError(t, newerModule.Reconcile(ctx, orgID))
olderModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
require.NoError(t, olderModule.Reconcile(ctx, orgID))
got, err := newerModule.Get(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, "v3", got.Spec.Display.Name)
assert.Equal(t, 3, stateVersion(t, newerModule, ctx, orgID))
}
func TestGetRejectsANonSystemDashboard(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
systemDashboardModule, dashboardModule := newTestModule(t, sqlStore)
var postable dashboardtypes.PostableDashboardV2
require.NoError(t, postable.UnmarshalJSON([]byte(`{
"schemaVersion": "`+dashboardtypes.SchemaVersion+`",
"name": "a-user-dashboard",
"tags": [],
"spec": {"display": {"name": "user"}, "variables": [], "panels": {}, "layouts": []}
}`)))
_, err := dashboardModule.CreateV2(ctx, orgID, "user@signoz.io", valuer.GenerateUUID(), dashboardtypes.SourceUser, postable)
require.NoError(t, err)
// The server-side prefix makes user names structurally unreachable here.
_, err = systemDashboardModule.Get(ctx, orgID, "a-user-dashboard")
require.Error(t, err)
_, err = systemDashboardModule.Get(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
require.Error(t, err)
assert.Contains(t, err.Error(), "must not carry")
}

View File

@@ -0,0 +1,81 @@
package implsystemdashboard
import (
"context"
"log/slog"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
)
const reconcileRetryInterval = 30 * time.Second
type service struct {
settings factory.ScopedProviderSettings
module systemdashboard.Module
orgGetter organization.Getter
stopC chan struct{}
healthyC chan struct{}
}
// NewService reconciles every org's system dashboards once at startup. Orgs
// created later are reconciled by the organization setter instead.
func NewService(providerSettings factory.ProviderSettings, module systemdashboard.Module, orgGetter organization.Getter) factory.Service {
return &service{
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"),
module: module,
orgGetter: orgGetter,
stopC: make(chan struct{}),
healthyC: make(chan struct{}),
}
}
func (service *service) Start(ctx context.Context) error {
ticker := time.NewTicker(reconcileRetryInterval)
defer ticker.Stop()
for {
err := service.reconcile(ctx)
if err == nil {
close(service.healthyC)
<-service.stopC
return nil
}
service.settings.Logger().WarnContext(ctx, "system dashboard reconciliation failed, retrying", errors.Attr(err))
select {
case <-service.stopC:
return nil
case <-ticker.C:
}
}
}
func (service *service) Healthy() <-chan struct{} {
return service.healthyC
}
func (service *service) Stop(_ context.Context) error {
close(service.stopC)
return nil
}
func (service *service) reconcile(ctx context.Context) error {
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
if err != nil {
return err
}
for _, org := range orgs {
if err := service.module.Reconcile(ctx, org.ID); err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't reconcile system dashboards for org %s", org.ID.StringValue())
}
}
service.settings.Logger().InfoContext(ctx, "system dashboard reconciliation completed", slog.Int("orgs", len(orgs)))
return nil
}

View File

@@ -0,0 +1,80 @@
package implsystemdashboard
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type store struct {
sqlstore sqlstore.SQLStore
}
func NewStore(sqlstore sqlstore.SQLStore) systemdashboardtypes.Store {
return &store{sqlstore: sqlstore}
}
func (store *store) Create(ctx context.Context, storable *systemdashboardtypes.StorableSystemDashboard) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(storable).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, systemdashboardtypes.ErrCodeSystemDashboardAlreadyProvisioned, "system dashboard %s is already provisioned", storable.Name)
}
return nil
}
func (store *store) Get(ctx context.Context, orgID valuer.UUID, name string) (*systemdashboardtypes.StorableSystemDashboard, error) {
storable := new(systemdashboardtypes.StorableSystemDashboard)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("org_id = ?", orgID).
Where("name = ?", name).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, systemdashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
}
return storable, nil
}
func (store *store) UpdateVersion(ctx context.Context, orgID valuer.UUID, name string, version int) error {
result, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model(new(systemdashboardtypes.StorableSystemDashboard)).
Set("version = ?", version).
Set("updated_at = ?", time.Now()).
Where("org_id = ?", orgID).
Where("name = ?", name).
Exec(ctx)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.Newf(errors.TypeNotFound, systemdashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
}
return nil
}
func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return store.sqlstore.RunInTxCtx(ctx, nil, cb)
}

View File

@@ -0,0 +1,28 @@
package systemdashboard
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
// Reconcile provisions the org's missing system dashboards and upgrades the
// unmodified ones to the shipped version. It never touches a dashboard whose
// row carries a foreign write and it never deletes.
Reconcile(ctx context.Context, orgID valuer.UUID) error
// Get addresses the dashboard by its bare definition name; the reserved
// prefix is a storage concern the API never exposes.
Get(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error)
// ResolveID maps a system dashboard's name to its id, so routes addressed by
// name can be authz-checked and audited against the id tuples carry.
ResolveID(ctx context.Context, orgID valuer.UUID, name string) (valuer.UUID, error)
}
type Handler interface {
Get(http.ResponseWriter, *http.Request)
}

View File

@@ -470,7 +470,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
continue
}
// Type is resolved now; validate aggregation compatibility against it.
if err := spec.Aggregations[i].ValidateForType(); err != nil {
if err := spec.Aggregations[i].ValidateForTypeAndTemporality(); err != nil {
return nil, nil, err
}
if reducedMetricsSet[spec.Aggregations[i].MetricName] {

View File

@@ -46,6 +46,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
@@ -88,6 +90,7 @@ type Handlers struct {
RulerHandler ruler.Handler
LLMPricingRuleHandler llmpricingrule.Handler
StatsHandler statsreporter.Handler
SystemDashboard systemdashboard.Handler
}
func NewHandlers(
@@ -137,5 +140,6 @@ func NewHandlers(
RulerHandler: signozruler.NewHandler(rulerService),
LLMPricingRuleHandler: impllmpricingrule.NewHandler(modules.LLMPricingRule),
StatsHandler: statsreporter.NewHandler(statsAggregator),
SystemDashboard: implsystemdashboard.NewHandler(modules.SystemDashboard),
}
}

View File

@@ -59,7 +59,7 @@ func TestNewHandlers(t *testing.T) {
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil, nil)
querierHandler := querier.NewHandler(providerSettings, nil, nil)
registryHandler := factory.NewHandler(nil)

View File

@@ -48,6 +48,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
@@ -67,35 +68,36 @@ import (
)
type Modules struct {
OrgGetter organization.Getter
OrgSetter organization.Setter
Preference preference.Module
UserSetter user.Setter
UserGetter user.Getter
RetentionGetter retention.Getter
SavedView savedview.Module
Apdex apdex.Module
Dashboard dashboard.Module
QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module
AuthDomain authdomain.Module
Session session.Module
Services services.Module
SpanPercentile spanpercentile.Module
MetricsExplorer metricsexplorer.Module
MetricReductionRule metricreductionrule.Module
InfraMonitoring inframonitoring.Module
OrgGetter organization.Getter
OrgSetter organization.Setter
Preference preference.Module
UserSetter user.Setter
UserGetter user.Getter
RetentionGetter retention.Getter
SavedView savedview.Module
Apdex apdex.Module
Dashboard dashboard.Module
QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module
AuthDomain authdomain.Module
Session session.Module
Services services.Module
SpanPercentile spanpercentile.Module
MetricsExplorer metricsexplorer.Module
MetricReductionRule metricreductionrule.Module
InfraMonitoring inframonitoring.Module
Promote promote.Module
ServiceAccount serviceaccount.Module
ServiceAccountGetter serviceaccount.Getter
CloudIntegration cloudintegration.Module
LogsPipeline logspipeline.Module
RuleStateHistory rulestatehistory.Module
TraceDetail tracedetail.Module
SpanMapper spanmapper.Module
LLMPricingRule llmpricingrule.Module
Tag tag.Module
LogsPipeline logspipeline.Module
RuleStateHistory rulestatehistory.Module
TraceDetail tracedetail.Module
SpanMapper spanmapper.Module
LLMPricingRule llmpricingrule.Module
Tag tag.Module
SystemDashboard systemdashboard.Module
}
func NewModules(
@@ -124,9 +126,10 @@ func NewModules(
fl flagger.Flagger,
tagModule tag.Module,
metricReductionRule metricreductionrule.Module,
systemDashboard systemdashboard.Module,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, systemDashboard)
// Cleanup callbacks from other modules, invoked when a user is deleted.
onDeleteUser := []user.OnDeleteUser{
dashboard.DeletePreferencesForUser,
@@ -136,34 +139,35 @@ func NewModules(
authDomainModule := implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs, authz)
return Modules{
OrgGetter: orgGetter,
OrgSetter: orgSetter,
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: dashboard,
UserSetter: userSetter,
UserGetter: userGetter,
RetentionGetter: retentionGetter,
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: authDomainModule,
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
MetricReductionRule: metricReductionRule,
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
OrgGetter: orgGetter,
OrgSetter: orgSetter,
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: dashboard,
UserSetter: userSetter,
UserGetter: userGetter,
RetentionGetter: retentionGetter,
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: authDomainModule,
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
MetricReductionRule: metricReductionRule,
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
ServiceAccount: serviceAccount,
ServiceAccountGetter: serviceAccountGetter,
LogsPipeline: impllogspipeline.NewModule(sqlstore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
Tag: tagModule,
LogsPipeline: impllogspipeline.NewModule(sqlstore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
Tag: tagModule,
SystemDashboard: systemDashboard,
}
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/queryparser"
@@ -66,7 +67,12 @@ func TestNewModules(t *testing.T) {
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
systemDashboardRegistry, err := implsystemdashboard.NewRegistry()
require.NoError(t, err)
systemDashboard := implsystemdashboard.NewModule(providerSettings, implsystemdashboard.NewStore(sqlstore), systemDashboardRegistry, dashboardModule)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule(), systemDashboard)
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -35,6 +35,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
@@ -93,6 +94,8 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ ruler.Handler }{},
struct{ statsreporter.Handler }{},
struct{ savedview.Handler }{},
struct{ systemdashboard.Module }{},
struct{ systemdashboard.Handler }{},
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
if err != nil {
return nil, err

View File

@@ -244,6 +244,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewDeleteOrphanUserRolesFactory(),
sqlmigration.NewMigrateLambdaDashboardsFactory(),
sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore),
sqlmigration.NewAddSystemDashboardFactory(sqlstore, sqlschema),
)
}
@@ -347,6 +348,8 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.RulerHandler,
handlers.StatsHandler,
handlers.SavedView,
modules.SystemDashboard,
handlers.SystemDashboard,
),
)
}

View File

@@ -36,6 +36,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
@@ -540,8 +541,16 @@ func New(
metricReductionRuleModule := metricReductionRuleModuleCallback(sqlstore, telemetrystore, dashboard, queryParser, licensing, flagger, telemetryMetadataStore, providerSettings, config.MetricsExplorer.TelemetryStore.Threads)
// Initialize the system dashboard module. The registry is parsed here so a
// malformed embedded definition fails startup instead of a request.
systemDashboardRegistry, err := implsystemdashboard.NewRegistry()
if err != nil {
return nil, err
}
systemDashboardModule := implsystemdashboard.NewModule(providerSettings, implsystemdashboard.NewStore(sqlstore), systemDashboardRegistry, dashboard)
// Initialize all modules
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule, systemDashboardModule)
// Initialize ruler from the variant-specific provider factories
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")
@@ -610,6 +619,7 @@ func New(
factory.NewNamedService(factory.MustNewName("auditor"), auditor),
factory.NewNamedService(factory.MustNewName("meterreporter"), meterReporter, factory.MustNewName("licensing")),
factory.NewNamedService(factory.MustNewName("ruler"), rulerInstance),
factory.NewNamedService(factory.MustNewName("systemdashboard"), implsystemdashboard.NewService(providerSettings, systemDashboardModule, orgGetter)),
)
if err != nil {
return nil, err

View File

@@ -0,0 +1,93 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addSystemDashboard struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddSystemDashboardFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("add_system_dashboard"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addSystemDashboard{sqlstore: sqlstore, sqlschema: sqlschema}, nil
},
)
}
func (migration *addSystemDashboard) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addSystemDashboard) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "system_dashboard",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "version", DataType: sqlschema.DataTypeBigInt, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
{
ReferencingColumnName: sqlschema.ColumnName("dashboard_id"),
ReferencedTableName: sqlschema.TableName("dashboard"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
// (org_id, name) is what makes provisioning safe across replicas: the state
// row is written in the same transaction as the dashboard, so a losing racer
// rolls back its dashboard too.
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndex{
TableName: "system_dashboard",
ColumnNames: []sqlschema.ColumnName{"org_id", "name"},
},
)...)
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndex{
TableName: "system_dashboard",
ColumnNames: []sqlschema.ColumnName{"dashboard_id"},
},
)...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addSystemDashboard) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -428,20 +428,24 @@ func (b *StatementBuilder) buildTemporalAggDeltaFastPath(
sb.SelectMore(fmt.Sprintf("`%s`", GroupByColumnAlias(i, g.Name)))
}
aggCol, err := metricstelemetryschema.AggregationColumnForSamplesTable(
samplesTable, query.Aggregations[0].Temporality, query.Aggregations[0].TimeAggregation,
)
if err != nil {
return "", nil, err
}
if query.Aggregations[0].TimeAggregation == metrictypes.TimeAggregationRate {
// TODO(srikanthccv): should it be step interval or use [start_time_unix_nano](https://github.com/open-telemetry/opentelemetry-proto/blob/d3fb76d70deb0874692bd0ebe03148580d85f3bb/opentelemetry/proto/metrics/v1/metrics.proto#L400C11-L400C31)?
aggCol = fmt.Sprintf("%s/%d", aggCol, stepSec)
}
var aggCol string
if query.Aggregations[0].SpaceAggregation.IsPercentile() &&
query.Aggregations[0].Type == metrictypes.ExpHistogramType {
// merging sketches already spans every series in the step, so neither a
// samples-table value column nor the rate divisor applies
aggCol = fmt.Sprintf("quantilesDDMerge(0.01, %f)(sketch)[1]", query.Aggregations[0].SpaceAggregation.Percentile())
} else {
col, err := metricstelemetryschema.AggregationColumnForSamplesTable(
samplesTable, query.Aggregations[0].Temporality, query.Aggregations[0].TimeAggregation,
)
if err != nil {
return "", nil, err
}
aggCol = col
if query.Aggregations[0].TimeAggregation == metrictypes.TimeAggregationRate {
// TODO(srikanthccv): should it be step interval or use [start_time_unix_nano](https://github.com/open-telemetry/opentelemetry-proto/blob/d3fb76d70deb0874692bd0ebe03148580d85f3bb/opentelemetry/proto/metrics/v1/metrics.proto#L400C11-L400C31)?
aggCol = fmt.Sprintf("%s/%d", aggCol, stepSec)
}
}
sb.SelectMore(fmt.Sprintf("%s AS value", aggCol))

View File

@@ -126,6 +126,64 @@ func TestStatementBuilder(t *testing.T) {
},
expectedErr: nil,
},
{
name: "test_exp_histogram_percentile_delta",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "signoz_latency",
Type: metrictypes.ExpHistogramType,
Temporality: metrictypes.Delta,
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
},
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, quantilesDDMerge(0.01, 0.950000)(sketch)[1] AS value FROM signoz_metrics.distributed_exp_hist AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency", uint64(1747947390000), uint64(1747983420000)},
},
expectedErr: nil,
},
{
// the sketch merge spans the whole step, so `rate` must not add a /step divisor
name: "test_exp_histogram_percentile_delta_rate_time_aggregation",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "signoz_latency",
Type: metrictypes.ExpHistogramType,
Temporality: metrictypes.Delta,
TimeAggregation: metrictypes.TimeAggregationRate,
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
},
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, quantilesDDMerge(0.01, 0.950000)(sketch)[1] AS value FROM signoz_metrics.distributed_exp_hist AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency", uint64(1747947390000), uint64(1747983420000)},
},
expectedErr: nil,
},
{
name: "test_histogram_percentile1",
requestType: qbtypes.RequestTypeTimeSeries,

View File

@@ -25,6 +25,10 @@ const (
dashboardNameSuffixLen = 8
)
// SystemDashboardNamePrefix is reserved for dashboards SigNoz ships and owns. Generated
// names never contain consecutive hyphens, so only a typed name can carry it — create rejects that.
const SystemDashboardNamePrefix = "signoz---"
const (
dashboardIconPathPrefix = "/assets/Icons/"
dashboardLogoPathPrefix = "/assets/Logos/"
@@ -75,8 +79,8 @@ type DashboardV2 struct {
}
func (d *DashboardV2) ErrIfNotMutable() error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
if d.Source != SourceUser {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be modified", d.Source)
}
return nil
}
@@ -95,6 +99,11 @@ func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, r
if err := d.ErrIfNotUpdatable(); err != nil {
return err
}
return d.UpdateUnsafe(updatable, updatedBy, resolvedTags)
}
// UpdateUnsafe applies the update without the source/lock gate. Intended for internal system callers.
func (d *DashboardV2) UpdateUnsafe(updatable UpdatableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
if updatable.Name != d.Name {
return errors.NewInvalidInputf(ErrCodeDashboardImmutable, "name is immutable; cannot change from %q to %q", d.Name, updatable.Name)
}
@@ -129,6 +138,13 @@ func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) erro
return nil
}
func (d *DashboardV2) ErrIfNotSystem() error {
if d.Source != SourceSystem {
return errors.Newf(errors.TypeNotFound, ErrCodeDashboardNotFound, "dashboard %q is not a system dashboard", d.Name)
}
return nil
}
func (d *DashboardV2) ErrIfNotClonable() error {
if !d.Source.isClonable() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be cloned", d.Source)
@@ -205,13 +221,21 @@ type PostableDashboardV2 struct {
Spec DashboardSpec `json:"spec" required:"true"`
}
func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy string, source Source) *DashboardV2 {
func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy string, source Source) (*DashboardV2, error) {
now := time.Now()
name := postable.Name
if postable.GenerateName {
name = generateDashboardName(postable.Spec.Display.Name)
}
// Checked on the final name, here rather than in validateName, because only
// the constructor knows the source.
if source != SourceSystem && strings.HasPrefix(name, SystemDashboardNamePrefix) {
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "name %q is invalid: the %q prefix is reserved for system dashboards", name, SystemDashboardNamePrefix)
}
if source == SourceSystem && !strings.HasPrefix(name, SystemDashboardNamePrefix) {
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "name %q is invalid: system dashboard names must start with the %q prefix", name, SystemDashboardNamePrefix)
}
return &DashboardV2{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
@@ -224,7 +248,7 @@ func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy
Name: name,
Tags: tagtypes.NewTagsFromPostableTags(orgID, coretypes.KindDashboard, postable.Tags),
Spec: postable.Spec,
}
}, nil
}
func (p *PostableDashboardV2) UnmarshalJSON(data []byte) error {

View File

@@ -89,21 +89,25 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
cases := []struct {
scenario string
source Source
name string
expectedLocked bool
}{
{
scenario: "user source is not locked",
source: SourceUser,
name: "my-dashboard",
expectedLocked: false,
},
{
scenario: "system source is not locked",
source: SourceSystem,
name: SystemDashboardNamePrefix + "my-dashboard",
expectedLocked: false,
},
{
scenario: "integration source is locked",
source: SourceIntegration,
name: "my-dashboard",
expectedLocked: true,
},
}
@@ -115,7 +119,7 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
SchemaVersion: SchemaVersion,
Image: "img",
},
Name: "my-dashboard",
Name: tc.name,
Tags: []tagtypes.PostableTag{
{Key: "team", Value: "platform"},
{Key: "env", Value: "prod"},
@@ -124,7 +128,8 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
}
before := time.Now()
dashboard := postable.NewDashboardV2(orgID, "alice", tc.source)
dashboard, err := postable.NewDashboardV2(orgID, "alice", tc.source)
require.NoError(t, err)
after := time.Now()
require.NotNil(t, dashboard)
@@ -160,8 +165,10 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
Spec: DashboardSpec{},
}
first := postable.NewDashboardV2(orgID, "alice", SourceUser)
second := postable.NewDashboardV2(orgID, "alice", SourceUser)
first, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
require.NoError(t, err)
second, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
require.NoError(t, err)
assert.NotEqual(t, first.ID, second.ID, "expected distinct UUIDs across invocations")
})
@@ -174,7 +181,8 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
},
}
dashboard := postable.NewDashboardV2(orgID, "alice", SourceUser)
dashboard, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
require.NoError(t, err)
assert.True(t, strings.HasPrefix(dashboard.Name, "my-dashboard-"), "expected slug prefix, got %q", dashboard.Name)
assert.Len(t, dashboard.Name, len("my-dashboard-")+dashboardNameSuffixLen)
})

View File

@@ -109,7 +109,8 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
var p PostableDashboardV2
require.NoError(t, json.Unmarshal([]byte(basePostableJSON), &p), "base postable JSON must validate")
testOrgID := valuer.GenerateUUID()
base := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
base, err := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
require.NoError(t, err)
base.Tags = []*tagtypes.Tag{
{Key: "team", Value: "alpha"},
{Key: "env", Value: "prod"},

View File

@@ -8,6 +8,7 @@ import (
"testing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/dashboard"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -1928,3 +1929,37 @@ func TestEnsureSingleExpressionAggregation(t *testing.T) {
})
}
}
// Guards the constant: a prefixed name must stay a valid DNS-1123 label.
func TestSystemDashboardNamePrefix(t *testing.T) {
require.NoError(t, validateDashboardName(SystemDashboardNamePrefix+"ai-o11y-overview"))
}
func TestNewDashboardV2RejectsReservedName(t *testing.T) {
testCases := []struct {
description string
name string
source Source
errContains string
}{
{description: "reserved name for a system dashboard", name: SystemDashboardNamePrefix + "overview", source: SourceSystem},
{description: "reserved name for a user dashboard", name: SystemDashboardNamePrefix + "overview", source: SourceUser, errContains: "reserved for system dashboards"},
{description: "reserved name for an integration dashboard", name: SystemDashboardNamePrefix + "overview", source: SourceIntegration, errContains: "reserved for system dashboards"},
{description: "unprefixed name for a system dashboard", name: "overview", source: SourceSystem, errContains: "must start with"},
{description: "ordinary name for a user dashboard", name: "overview", source: SourceUser},
{description: "fewer hyphens than the prefix for a user dashboard", name: "signoz--overview", source: SourceUser},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
postable := PostableDashboardV2{Name: testCase.name}
_, err := postable.NewDashboardV2(valuer.GenerateUUID(), "user@signoz.io", testCase.source)
if testCase.errContains != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), testCase.errContains)
return
}
require.NoError(t, err)
})
}
}

View File

@@ -13,6 +13,9 @@ type Store interface {
Get(context.Context, valuer.UUID, valuer.UUID) (*StorableDashboard, error)
// GetByName resolves a dashboard by its per-org unique name.
GetByName(ctx context.Context, orgID valuer.UUID, name string) (*StorableDashboard, error)
GetPublic(context.Context, string) (*StorablePublicDashboard, error)
GetDashboardByOrgsAndPublicID(context.Context, []string, string) (*StorableDashboard, error)

View File

@@ -374,7 +374,7 @@ func (q *QueryBuilderQuery[T]) validateAggregations(cfg validationConfig) error
return nil
}
func (m MetricAggregation) ValidateForType() error {
func (m MetricAggregation) ValidateForTypeAndTemporality() error {
if m.SpaceAggregation.IsPercentile() && !m.Type.IsPercentileSpaceAggregationAllowed() {
return errors.Newf(
errors.TypeInvalidInput,
@@ -384,6 +384,17 @@ func (m MetricAggregation) ValidateForType() error {
m.Type.StringValue(),
)
}
// reading a step's distribution out of a cumulative sketch would mean
// subtracting the previous point's sketch, which ClickHouse cannot do
if m.Type == metrictypes.ExpHistogramType && m.Temporality != metrictypes.Delta {
return errors.Newf(
errors.TypeUnsupported,
errors.CodeUnsupported,
"metric `%s` is an exponential histogram recorded with `%s` temporality, which cannot be queried; only `delta` exponential histograms are supported",
m.MetricName,
m.Temporality.StringValue(),
)
}
return nil
}

View File

@@ -1517,10 +1517,11 @@ func TestNonAggregationFieldsSkipped(t *testing.T) {
})
}
func TestMetricAggregationValidateForType(t *testing.T) {
func TestMetricAggregationValidateForTypeAndTemporality(t *testing.T) {
cases := []struct {
name string
metricType metrictypes.Type
temporality metrictypes.Temporality
spaceAggregation metrictypes.SpaceAggregation
comparisonParam *metrictypes.ComparisonSpaceAggregationParam
wantErr bool
@@ -1532,11 +1533,33 @@ func TestMetricAggregationValidateForType(t *testing.T) {
wantErr: false,
},
{
name: "percentile on exponential histogram is allowed",
name: "percentile on delta exponential histogram is allowed",
metricType: metrictypes.ExpHistogramType,
temporality: metrictypes.Delta,
spaceAggregation: metrictypes.SpaceAggregationPercentile99,
wantErr: false,
},
{
name: "cumulative exponential histogram is not allowed",
metricType: metrictypes.ExpHistogramType,
temporality: metrictypes.Cumulative,
spaceAggregation: metrictypes.SpaceAggregationPercentile99,
wantErr: true,
},
{
name: "exponential histogram with unresolved temporality is not allowed",
metricType: metrictypes.ExpHistogramType,
temporality: metrictypes.Unknown,
spaceAggregation: metrictypes.SpaceAggregationPercentile99,
wantErr: true,
},
{
name: "cumulative histogram is unaffected by the exponential histogram rule",
metricType: metrictypes.HistogramType,
temporality: metrictypes.Cumulative,
spaceAggregation: metrictypes.SpaceAggregationPercentile95,
wantErr: false,
},
{
name: "percentile on summary is not allowed",
metricType: metrictypes.SummaryType,
@@ -1562,10 +1585,11 @@ func TestMetricAggregationValidateForType(t *testing.T) {
agg := MetricAggregation{
MetricName: "test_metric",
Type: tc.metricType,
Temporality: tc.temporality,
SpaceAggregation: tc.spaceAggregation,
ComparisonSpaceAggregationParam: tc.comparisonParam,
}
err := agg.ValidateForType()
err := agg.ValidateForTypeAndTemporality()
if tc.wantErr && err == nil {
t.Errorf("expected error, got nil")
}

View File

@@ -23,10 +23,11 @@ var (
const savedViewNameSuffixLen = 8
var (
SourceTraces = Source{valuer.NewString("traces")}
SourceLogs = Source{valuer.NewString("logs")}
SourceMetrics = Source{valuer.NewString("metrics")}
SourceMeter = Source{valuer.NewString("meter")}
SourceTraces = Source{valuer.NewString("traces")}
SourceLogs = Source{valuer.NewString("logs")}
SourceMetrics = Source{valuer.NewString("metrics")}
SourceMeter = Source{valuer.NewString("meter")}
SourceAIObservability = Source{valuer.NewString("ai_observability")}
)
type SavedView struct {
@@ -117,12 +118,13 @@ func (Source) Enum() []any {
SourceLogs,
SourceMetrics,
SourceMeter,
SourceAIObservability,
}
}
func (s Source) Validate() error {
switch s {
case SourceTraces, SourceLogs, SourceMetrics, SourceMeter:
case SourceTraces, SourceLogs, SourceMetrics, SourceMeter, SourceAIObservability:
return nil
default:
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "invalid source: %s", s.StringValue())

View File

@@ -39,6 +39,7 @@ func TestSourceValidate(t *testing.T) {
{name: "logs", source: SourceLogs},
{name: "metrics", source: SourceMetrics},
{name: "meter", source: SourceMeter},
{name: "ai_observability", source: SourceAIObservability},
{name: "unknown is rejected", source: Source{valuer.NewString("bogus")}, expectError: true},
}

View File

@@ -173,6 +173,21 @@ func TestSavedViewSpecValidate(t *testing.T) {
},
expectError: false,
},
{
name: "builder_ai_query is valid",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeList,
RequestType: qbtypes.RequestTypeRaw,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
},
}},
},
expectError: false,
},
{
name: "graph panel query with no aggregation is still rejected",
spec: SavedViewSpec{

View File

@@ -0,0 +1,95 @@
package systemdashboardtypes
import (
"bytes"
"encoding/json"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
)
// Definition is one shipped system dashboard. Version is bumped on every content
// change and drives upgrade detection; the name is the stable key and never changes.
type Definition struct {
Version int `json:"version"`
Dashboard dashboardtypes.PostableDashboardV2 `json:"definition"`
}
func (definition Definition) Name() string {
return definition.Dashboard.Name
}
func NewDefinition(raw []byte) (Definition, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var definition Definition
if err := decoder.Decode(&definition); err != nil {
return Definition{}, errors.WrapInvalidInputf(err, ErrCodeSystemDashboardDefinitionInvalid, "%s", err.Error())
}
if err := definition.validate(); err != nil {
return Definition{}, err
}
return definition, nil
}
func (definition Definition) validate() error {
if definition.Version < 1 {
return errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "version must be at least 1, got %d", definition.Version)
}
if !strings.HasPrefix(definition.Name(), dashboardtypes.SystemDashboardNamePrefix) {
return errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "name %q must start with %q", definition.Name(), dashboardtypes.SystemDashboardNamePrefix)
}
if definition.Dashboard.GenerateName {
return errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "%s: generateName is not allowed, the name is the stable key", definition.Name())
}
return nil
}
// ToUpdatable is how an upgrade re-applies a definition onto an existing row:
// everything but the dashboard's identity comes from the shipped definition.
func (definition Definition) ToUpdatable() dashboardtypes.UpdatableDashboardV2 {
return dashboardtypes.UpdatableDashboardV2{
DashboardV2MetadataBase: definition.Dashboard.DashboardV2MetadataBase,
Name: definition.Dashboard.Name,
Tags: definition.Dashboard.Tags,
Spec: definition.Dashboard.Spec,
}
}
// Registry holds every definition embedded in the binary, keyed by name.
type Registry struct {
definitions map[string]Definition
}
func NewRegistry(definitions []Definition) (Registry, error) {
byName := make(map[string]Definition, len(definitions))
for _, definition := range definitions {
if _, duplicate := byName[definition.Name()]; duplicate {
return Registry{}, errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "duplicate system dashboard name %q", definition.Name())
}
byName[definition.Name()] = definition
}
return Registry{definitions: byName}, nil
}
func (registry Registry) Get(name string) (Definition, bool) {
definition, ok := registry.definitions[name]
return definition, ok
}
// List returns the definitions sorted by name so provisioning order is stable.
func (registry Registry) List() []Definition {
definitions := make([]Definition, 0, len(registry.definitions))
for _, definition := range registry.definitions {
definitions = append(definitions, definition)
}
slices.SortFunc(definitions, func(a, b Definition) int { return strings.Compare(a.Name(), b.Name()) })
return definitions
}

View File

@@ -0,0 +1,58 @@
package systemdashboardtypes
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
var (
ErrCodeSystemDashboardNotFound = errors.MustNewCode("system_dashboard_not_found")
ErrCodeSystemDashboardDefinitionInvalid = errors.MustNewCode("system_dashboard_definition_invalid")
ErrCodeSystemDashboardAlreadyProvisioned = errors.MustNewCode("system_dashboard_already_provisioned")
)
// ProvisionerIdentity is stamped into created_by/updated_by by the reconciler. It
// is deliberately not a valid email, so it can never collide with a real account:
// any other value in updated_by means a foreign write.
const ProvisionerIdentity = "signoz"
type Store interface {
Create(ctx context.Context, storable *StorableSystemDashboard) error
Get(ctx context.Context, orgID valuer.UUID, name string) (*StorableSystemDashboard, error)
UpdateVersion(ctx context.Context, orgID valuer.UUID, name string, version int) error
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
}
// StorableSystemDashboard records the shipped version each org's copy of a system
// dashboard was last provisioned at. That version is the only thing the dashboard
// row cannot answer, since the binary only embeds the latest definition.
type StorableSystemDashboard struct {
bun.BaseModel `bun:"table:system_dashboard"`
types.Identifiable
types.TimeAuditable
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
DashboardID valuer.UUID `bun:"dashboard_id,type:text,notnull"`
Name string `bun:"name,type:text,notnull"`
Version int `bun:"version,notnull"`
}
func NewStorableSystemDashboard(orgID valuer.UUID, dashboardID valuer.UUID, name string, version int) *StorableSystemDashboard {
now := time.Now()
return &StorableSystemDashboard{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
OrgID: orgID,
DashboardID: dashboardID,
Name: name,
Version: version,
}
}

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);
}
});
});

View File

@@ -0,0 +1,139 @@
import { expect, test } from '../../../fixtures/alerts/alert-rules';
import { AlertType } from '../../../helpers/alert-forms/constants';
import {
gotoCreateAlertV1,
gotoCreateAlertV2,
} from '../../../helpers/alert-forms/navigation';
import { v1SaveButton } from '../../../helpers/alert-forms/v1';
import {
elementAtPointClassName,
selectThresholdChannel,
v2DiscardButton,
v2SaveButton,
} from '../../../helpers/alert-forms/v2';
import {
createEmailChannelViaApi,
deleteChannelViaApi,
} from '../../../helpers/alerts/api';
import {
gotoAlertDetails,
gotoAlertOverview,
} from '../../../helpers/alerts/navigation';
import { watchConsole } from '../../../helpers/common';
// TC-* — errors and edges that are not specific to one form.
test.describe('Alert create — errors and edges', () => {
test('TC-01 a server-side rejection opens the error modal and keeps the draft', async ({
authedPage: page,
}) => {
// A duplicate rule name is *not* rejected — the API happily creates two rules
// with the same `alert`. A missing channel is, with
// `400 invalid_input: channels: the following channels do not exist`.
//
// So the 4xx comes from a real race rather than a stub: the form is filled with
// a channel that exists, and the channel is deleted behind its back before the
// save. Nothing about the response is faked.
const channel = await createEmailChannelViaApi(
page,
`e2e-ce04-ch-${Date.now()}`,
);
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
const name = `e2e-ce04-${Date.now()}`;
await page.getByTestId('alert-name-input').fill(name);
await selectThresholdChannel(page, 0, channel.name);
await deleteChannelViaApi(page, channel.id);
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
expect(response.status()).toBe(400);
// Both forms funnel every save error into the shared error modal, which is
// antd's wrapped in `.error-modal__wrap`.
await expect(page.locator('.error-modal__wrap')).toBeVisible();
await expect(page.getByText(/do not exist/)).toBeVisible();
// A rejected save must not navigate, and must not lose what the user typed.
expect(new URL(page.url()).pathname).toBe('/alerts/new');
await page.getByTestId('close-button').click();
await expect(page.locator('.error-modal__wrap')).toBeHidden();
await expect(page.getByTestId('alert-name-input')).toHaveValue(name);
});
test('TC-02 none of the four builder mounts logs a console error', async ({
authedPage: page,
ownedRules,
}) => {
const watch = watchConsole(page);
// v2 create.
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// v1 create. Metrics-based on purpose: it is the only alert type whose classic
// form renders the detection-method step and the PromQL tab, i.e. the most code.
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
// v2 edit.
const v2Rule = await ownedRules.threshold(`e2e-ce07-v2-${Date.now()}`);
await gotoAlertOverview(page, v2Rule);
// v1 edit. `gotoAlertOverview` is wrong here — it waits for
// `threshold-value-input`, which only the v2 builder renders — so the shell-level
// wait is used and the classic form is asserted directly.
const v1Rule = await ownedRules.logs({
name: `e2e-ce07-v1-${Date.now()}`,
schema: 'v1',
});
await gotoAlertDetails(page, v1Rule);
await expect(v1SaveButton(page)).toBeVisible();
expect(watch.errors).toEqual([]);
});
// TODO: enable once the covered-Discard bug is fixed, and revert
// `v2ClickDiscard` (`helpers/alert-forms.ts`) to a plain `.click()` in the same
// commit — CV2-22 and EV2-11 both go through it.
//
// 🐞 **A user cannot discard an alert draft.** The footer is
// `position: fixed; left: 63px` — the *collapsed* nav rail width — while the side
// navigation is 240px wide whenever expanded, which is the default (pinned for a
// fresh admin) and also happens transiently on hover when unpinned. Discard is the
// footer's left-most control, so the nav sits on top of it and wins the stacking
// contest despite the footer's `z-index: 1000`.
//
// Observed live: `document.elementFromPoint` at the button's centre returns the
// nav's `.nav-item-data`, and `page.click()` fails with
// *"div.nav-item-data … intercepts pointer events"*. `{ force: true }` does not
// help — it skips the actionability wait but still delivers a real mouse event at
// those coordinates. Only `dispatchEvent('click')` gets through, which proves the
// handler is fine and the defect is purely pointer delivery.
//
// Fix is one of: make the footer's `left` follow the nav's actual width, move
// Discard to the right-hand group, or lift the footer out of the nav's stacking
// context.
test.skip('TC-03 the v2 Discard button is clickable', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// Nothing from the side navigation may sit over the button's centre.
const box = await v2DiscardButton(page).boundingBox();
expect(box).not.toBeNull();
const covering = await elementAtPointClassName(
page,
box!.x + box!.width / 2,
box!.y + box!.height / 2,
);
expect(covering).not.toMatch(/nav-item/);
// And the consequence that matters: a real click lands and leaves the form.
await v2DiscardButton(page).click({ timeout: 3_000 });
await page.waitForURL(/\/alerts(\?|$)/);
expect(new URL(page.url()).pathname).toBe('/alerts');
});
});

View File

@@ -0,0 +1,266 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/alerts/alert-rules';
import {
ALERTS_NEW_PATH,
AlertType,
type AlertTypeValue,
RuleType,
ThresholdMatchType,
ThresholdOperator,
} from '../../../helpers/alert-forms/constants';
import { gotoCreateAlertV2 } from '../../../helpers/alert-forms/navigation';
import {
evaluationSettingsButton,
thresholdRows,
} from '../../../helpers/alert-forms/v2';
import { gotoAlertOverview } from '../../../helpers/alerts/navigation';
// TC-* — deep-link prefill.
//
// The contract is producer-agnostic (`context/resolveUrlAlertPrefill.ts`), but the
// three producers do **not** write the same params: dashboards
// (`buildAlertUrl`) and the explorer only ever emit query/panel params, while
// metering (`MultiIngestionSettings`) is the sole producer of `ruleName`,
// `yAxisUnit` and `evaluationWindowPreset`. CD-04 and CD-05 therefore drive the
// *metering* URL shape — aiming them at a dashboard URL would test a link nobody
// generates.
/**
* A `compositeQuery` param harvested from the app itself.
*
* Hand-writing the v5 envelope would be a second, drifting copy of the query
* builder's serialiser — the thing these scenarios are *reading*, not testing. So
* the builder is opened once, allowed to serialise its own default query into the
* URL, and that exact value is reused as the deep link.
*/
async function harvestCompositeQuery(
page: Page,
alertType: AlertTypeValue,
): Promise<string> {
await gotoCreateAlertV2(page, { alertType });
const value = new URL(page.url()).searchParams.get('compositeQuery');
if (!value) {
throw new Error(
'the builder did not serialise a compositeQuery into the URL',
);
}
return value;
}
/** `Threshold` as `context/types.ts` declares it — the shape the URL param carries. */
function urlThreshold(
overrides: Record<string, unknown>,
): Record<string, unknown> {
return {
id: 'e2e-url-threshold',
label: 'from-url',
thresholdValue: 0,
recoveryThresholdValue: null,
unit: '',
channels: [],
color: '#e5484d',
...overrides,
};
}
function prefillUrl(params: Record<string, string>): string {
return `${ALERTS_NEW_PATH}?${new URLSearchParams(params).toString()}`;
}
test.describe('Alert create — deep-link prefill', () => {
test('TC-01 a compositeQuery alone selects the alert type', async ({
authedPage: page,
}) => {
const compositeQuery = await harvestCompositeQuery(page, AlertType.LOGS);
// No `alertType` and no `ruleType` in this URL: both come from the query's data
// source through `ALERT_TYPE_VS_SOURCE_MAPPING`. The presence of
// `compositeQuery` is also what skips the type-selection page, so this one
// param decides two things at once.
await page.goto(prefillUrl({ compositeQuery }));
await expect(page.getByTestId('alert-name-input')).toBeVisible();
// Asserted on the *rendered* signal tab, not on the URL: the mapping only feeds
// the memo that picks the form — it does **not** write `alertType` back into the
// query string. So a spec waiting for `alertType=LOGS_BASED_ALERT` in the URL
// waits forever.
await expect(
page.locator('.list-view-tab.active-tab', {
has: page.getByTestId('logs-view'),
}),
).toHaveCount(1);
expect(new URL(page.url()).searchParams.get('alertType')).toBeNull();
// A stale `compositeQuery` silently bypasses card selection, so the cards must
// not be on screen.
await expect(page.locator('[data-testid^="alert-type-card-"]')).toHaveCount(
0,
);
});
test('TC-02 thresholds prefill from JSON, and a malformed value falls back', async ({
authedPage: page,
}) => {
const base = {
alertType: AlertType.LOGS,
ruleType: RuleType.THRESHOLD,
};
await page.goto(
prefillUrl({
...base,
thresholds: JSON.stringify([
urlThreshold({ label: 'page-me', thresholdValue: 42 }),
urlThreshold({
id: 'e2e-url-threshold-2',
label: 'warn-me',
thresholdValue: 7,
}),
]),
}),
);
await expect(thresholdRows(page)).toHaveCount(2);
const names = page.getByTestId('threshold-name-input');
await expect(names.nth(0)).toHaveValue('page-me');
await expect(names.nth(1)).toHaveValue('warn-me');
await expect(page.getByTestId('threshold-value-input').nth(0)).toHaveValue(
'42',
);
// A malformed value is swallowed by `parseThresholds` and the form falls back to
// its own single `critical` row. That path also writes
// `console.error('Error parsing thresholds from URL:', …)`, which is why this
// scenario must never be paired with CE-07's clean-console assertion.
await page.goto(prefillUrl({ ...base, thresholds: 'not-json-at-all' }));
await expect(thresholdRows(page)).toHaveCount(1);
await expect(page.getByTestId('threshold-name-input')).toHaveValue(
'critical',
);
});
test('TC-03 matchType and compareOp aliases normalise to the enum', async ({
authedPage: page,
}) => {
// `avg` and `<` are aliases the *backend* accepts (`normalizeMatchType` /
// `normalizeOperator` mirror `pkg/types/ruletypes/{match,compare}.go`), not values
// the UI ever writes — so a producer or a hand-edited link can carry them.
await page.goto(
prefillUrl({
alertType: AlertType.LOGS,
ruleType: RuleType.THRESHOLD,
matchType: 'avg',
compareOp: '<',
}),
);
await expect(
page.getByTestId('alert-threshold-match-type-select'),
).toContainText(ThresholdMatchType.ON_AVERAGE.label);
await expect(
page.getByTestId('alert-threshold-operator-select'),
).toContainText(ThresholdOperator.BELOW.label);
});
test('TC-04 ruleName and yAxisUnit apply once and never stomp an edit', async ({
authedPage: page,
}) => {
const compositeQuery = await harvestCompositeQuery(page, AlertType.METRICS);
const ruleName =
'[ingestion][logs] e2e key has exceeded daily ingestion limit';
// The metering URL shape, verbatim from `MultiIngestionSettings.tsx`.
await page.goto(
prefillUrl({
compositeQuery,
thresholds: JSON.stringify([
urlThreshold({ label: 'critical', thresholdValue: 100, unit: 'bytes' }),
]),
ruleName,
yAxisUnit: 'bytes',
matchType: ThresholdMatchType.IN_TOTAL.value,
evaluationWindowPreset: 'meter',
}),
);
await expect(page.getByTestId('alert-name-input')).toHaveValue(ruleName);
// `yAxisUnit` is what makes the per-threshold unit select usable at all — with no
// unit the control is permanently disabled (CV2-12).
await expect(
page.getByTestId('threshold-unit-select').first(),
).not.toHaveClass(/ant-select-disabled/);
// Now the half the `ruleNameAppliedRef` / `yAxisUnitAppliedRef` guards exist for.
// The prefill effect re-runs on *every* change to location.search, and the query
// builder rewrites it constantly — without the refs, a hand-edited name would be
// silently reverted to the URL's the next time that happened.
const edited = 'e2e-cd-04-renamed-by-hand';
await page.getByTestId('alert-name-input').fill(edited);
// Switching the signal tab is a real user action that rewrites the URL *and*
// changes `alertType`, which is also in the effect's dependency list.
await page.getByTestId('logs-view').click();
await page.waitForURL(/alertType=LOGS_BASED_ALERT/);
await expect(page.getByTestId('alert-name-input')).toHaveValue(edited);
});
test('TC-05 evaluationWindowPreset=meter switches to the cumulative daily window', async ({
authedPage: page,
}) => {
const compositeQuery = await harvestCompositeQuery(page, AlertType.METRICS);
await page.goto(
prefillUrl({
compositeQuery,
matchType: ThresholdMatchType.IN_TOTAL.value,
evaluationWindowPreset: 'meter',
}),
);
// `SET_INITIAL_STATE_FOR_METER` is a *cumulative* window starting at midnight
// UTC — not one of the rolling presets — so the trigger button's whole text
// changes shape, type included.
await expect(evaluationSettingsButton(page)).toContainText('Cumulative');
await expect(evaluationSettingsButton(page)).toContainText(
'Current day, starting from 00:00:00 (UTC)',
);
});
test('TC-06 URL prefill is ignored in edit mode', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(`e2e-cd-06-${Date.now()}`, {
target: 42,
});
await gotoAlertOverview(page, ruleId);
// Append a prefill param to the *edit* URL, which is what a stale link or a copied
// query string produces in practice.
await page.goto(
`${new URL(page.url()).pathname}?${new URLSearchParams({
ruleId,
thresholds: JSON.stringify([
urlThreshold({ label: 'from-url', thresholdValue: 999 }),
]),
}).toString()}`,
);
await expect(page.getByTestId('threshold-value-input').first()).toBeVisible();
// The effect early-returns in edit mode. Without that return the `RESET` at the
// top of the block would wipe the loaded rule's thresholds every time the query
// builder rewrote location.search.
await expect(thresholdRows(page)).toHaveCount(1);
await expect(page.getByTestId('threshold-name-input')).toHaveValue(
'critical',
);
await expect(page.getByTestId('threshold-value-input')).toHaveValue('42');
await expect(page.getByTestId('alert-name-input')).not.toHaveValue(
'from-url',
);
});
});

View File

@@ -0,0 +1,170 @@
import { expect, test } from '../../../fixtures/alerts/alert-rules';
import {
AlertListTab,
AlertType,
RuleType,
STOCK_ALERT_TYPE_CARDS,
} from '../../../helpers/alert-forms/constants';
import {
alertTypeCard,
createAlertUrl,
expectAlertTypeCardSet,
gotoAlertTypeSelection,
gotoCreateAlertV1,
gotoCreateAlertV2,
hasAnomalyAlertTypeCard,
} from '../../../helpers/alert-forms/navigation';
import { v1SaveButton } from '../../../helpers/alert-forms/v1';
// TC-* — the create *shell*: type selection, how a card click writes the
// URL, the breadcrumb, the surrounding alerts tab bar, and the two ways to reach
// the classic form. Nothing here saves a rule, so no scenario needs a channel.
test.describe('Alert create — shell & type selection', () => {
test('TC-01 bare /alerts/new lists exactly the expected alert-type cards', async ({
authedPage: page,
}) => {
await gotoAlertTypeSelection(page);
await expect(page.getByText('Choose a type for the alert')).toBeVisible();
// The four stock signals are unconditional; anomaly is added only when
// ANOMALY_DETECTION is active. `expectAlertTypeCardSet` pins the exact set *and
// order* for whichever branch applies, so adding a sixth signal still fails.
for (const type of STOCK_ALERT_TYPE_CARDS) {
await expect(alertTypeCard(page, type)).toBeVisible();
}
await expectAlertTypeCardSet(page);
});
test('TC-02 picking a card writes both params and mounts the v2 builder', async ({
authedPage: page,
}) => {
await gotoAlertTypeSelection(page);
await alertTypeCard(page, AlertType.METRICS).click();
await expect(page.getByTestId('alert-name-input')).toBeVisible();
const params = new URL(page.url()).searchParams;
expect(params.get('ruleType')).toBe(RuleType.THRESHOLD);
expect(params.get('alertType')).toBe(AlertType.METRICS);
});
test('TC-03 the anomaly card rewrites the rule type, not the alert type', async ({
authedPage: page,
}) => {
await gotoAlertTypeSelection(page);
test.skip(
!(await hasAnomalyAlertTypeCard(page)),
'ANOMALY_DETECTION feature flag is inactive on this stack (see CS-01)',
);
await alertTypeCard(page, AlertType.ANOMALY).click();
const params = new URL(page.url()).searchParams;
expect(params.get('ruleType')).toBe(RuleType.ANOMALY);
// The card's own value is deliberately *not* written: `handleSelectType`
// forces the metrics alert type for anomaly rules, and the rendered form
// resolves back to anomaly from `ruleType` alone.
expect(params.get('alertType')).toBe(AlertType.METRICS);
});
test('TC-04 modifier-clicking a card opens the builder in a new tab', async ({
authedPage: page,
}) => {
await gotoAlertTypeSelection(page);
const [newTab] = await Promise.all([
page.context().waitForEvent('page'),
alertTypeCard(page, AlertType.METRICS).click({
modifiers: ['ControlOrMeta'],
}),
]);
await newTab.waitForLoadState();
const params = new URL(newTab.url()).searchParams;
expect(params.get('ruleType')).toBe(RuleType.THRESHOLD);
expect(params.get('alertType')).toBe(AlertType.METRICS);
// A modifier click that *also* navigates in place is the regression this half
// guards.
await expect(alertTypeCard(page, AlertType.METRICS)).toBeVisible();
await newTab.close();
});
test('TC-05 breadcrumb gains a third crumb after a type is picked', async ({
authedPage: page,
}) => {
await gotoAlertTypeSelection(page);
const breadcrumb = page.locator('.ant-breadcrumb');
await expect(breadcrumb.getByText('Alert Rules')).toBeVisible();
await expect(breadcrumb.getByText('Select Alert Type')).toBeVisible();
await alertTypeCard(page, AlertType.METRICS).click();
await expect(page.getByTestId('alert-name-input')).toBeVisible();
await expect(breadcrumb.getByText('Metric-Based Alert')).toBeVisible();
// The middle crumb is now navigable and goes back to bare /alerts/new.
await breadcrumb.getByRole('button', { name: 'Select Alert Type' }).click();
await expect(alertTypeCard(page, AlertType.METRICS)).toBeVisible();
expect(new URL(page.url()).searchParams.get('alertType')).toBeNull();
});
test('TC-06 create renders inside the Alert Rules tab and leaving drops subTab/search', async ({
authedPage: page,
}) => {
// `subTab` and `search` are seeded here precisely so their removal is
// observable — `handleTabChange` deletes them while keeping everything else.
await page.goto(
createAlertUrl({
alertType: AlertType.LOGS,
params: { subTab: 'Alert Rules', search: 'stale' },
}),
);
await expect(page.getByTestId('alert-name-input')).toBeVisible();
await expect(page.getByRole('tab', { name: /Alert Rules/ })).toBeVisible();
await page.getByRole('tab', { name: /Triggered Alerts/ }).click();
await page.waitForURL(/\/alerts\?/);
const params = new URL(page.url()).searchParams;
// The param carries the space-less enum value, not the tab's visible label.
expect(params.get('tab')).toBe(AlertListTab.TRIGGERED_ALERTS);
expect(params.get('subTab')).toBeNull();
expect(params.get('search')).toBeNull();
});
test('TC-07 showClassicCreateAlertsPage=true renders the v1 form instead', async ({
authedPage: page,
}) => {
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
await expect(v1SaveButton(page)).toBeVisible();
// The clearest v1/v2 discriminator: the v2 header input simply is not there.
await expect(page.getByTestId('alert-name-input')).toBeHidden();
});
test('TC-08 Switch to Classic Experience replaces history, so Back does not return to v2', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.METRICS });
await page
.getByRole('button', { name: 'Switch to Classic Experience' })
.click();
await expect(v1SaveButton(page)).toBeVisible();
expect(
new URL(page.url()).searchParams.get('showClassicCreateAlertsPage'),
).toBe('true');
// `safeNavigate(url, { replace: true })` — going back must skip the v2 entry
// entirely rather than bouncing between the two experiences.
await page.goBack();
await expect(page.getByTestId('alert-name-input')).toBeHidden();
});
});

View File

@@ -0,0 +1,568 @@
import { expect, test } from '../../../fixtures/alerts/alert-rules';
import {
AlertType,
ThresholdMatchType,
ThresholdOperator,
} from '../../../helpers/alert-forms/constants';
import { gotoCreateAlertV2 } from '../../../helpers/alert-forms/navigation';
import {
dropdownOption,
openDropdown,
ownDropdown,
stubNoChannels,
} from '../../../helpers/alert-forms/shared';
import {
addAlertLabel,
advancedOptionToggle,
evaluationCadenceInput,
expandAdvancedOptions,
labelPill,
selectEvaluationTimeframe,
selectThresholdChannel,
thresholdRows,
v2ClickDiscard,
v2DiscardButton,
v2SaveButton,
v2SaveTooltip,
v2TestButton,
} from '../../../helpers/alert-forms/v2';
// CV2-* — the v2 create builder.
//
// Every scenario uses a **logs**-based alert unless it says otherwise: its default
// query is valid with no seeded metrics. CV2-12 (unit select) and CV2-16 (group-by
// select) are about what that choice costs — both are gated on query state a
// default logs query does not provide, and both assert the gate.
const VALIDATION = {
name: 'Please enter an alert name',
thresholdLabel: 'Please enter a label for each threshold',
channels:
'Please select at least one channel for each threshold or enable routing policies',
} as const;
test.describe('Alert create — v2 builder', () => {
test('CV2-01 initial state: one critical threshold, both actions gated', async ({
authedPage: page,
alertChannel,
}) => {
expect(alertChannel.name).toBeTruthy();
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await expect(page.getByTestId('alert-name-input')).toHaveValue('');
await expect(page.getByTestId('alert-name-input')).toHaveAttribute(
'placeholder',
'Enter alert rule name',
);
// `INITIAL_CRITICAL_THRESHOLD` — label `critical`, value 0, `channels: []`.
// The empty channel list is what makes the save gate reachable at all.
await expect(thresholdRows(page)).toHaveCount(1);
await expect(page.getByTestId('threshold-name-input')).toHaveValue(
'critical',
);
await expect(page.getByTestId('threshold-value-input')).toHaveValue('0');
await expect(v2SaveButton(page)).toBeDisabled();
await expect(v2TestButton(page)).toBeDisabled();
});
test('CV2-02 the save tooltip walks from the name gate to the channel gate', async ({
authedPage: page,
alertChannel,
}) => {
expect(alertChannel.name).toBeTruthy();
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// `validateCreateAlertState` returns the *first* failure, so the message order
// encodes the validation order: name, then per-threshold label, then channels.
expect(await v2SaveTooltip(page)).toBe(VALIDATION.name);
await page.getByTestId('alert-name-input').fill(`e2e-cv2-02-${Date.now()}`);
expect(await v2SaveTooltip(page)).toBe(VALIDATION.channels);
});
test('CV2-03 clearing a threshold label re-gates the save', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-03-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
// With a name and a channel the only remaining gate is the label.
await expect(v2SaveButton(page)).toBeEnabled();
await page.getByTestId('threshold-name-input').fill('');
expect(await v2SaveTooltip(page)).toBe(VALIDATION.thresholdLabel);
});
test('CV2-04 a label added in the header survives the save round-trip', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
const name = `e2e-cv2-04-${Date.now()}`;
await page.getByTestId('alert-name-input').fill(name);
await selectThresholdChannel(page, 0, alertChannel.name);
await addAlertLabel(page, 'team', 'payments');
await expect(labelPill(page, 'team', 'payments')).toBeVisible();
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
await ownedRules.register(response);
// Asserted on the request body rather than on the pill: the pill only proves
// local state, and the defect worth guarding is a label that renders but is
// never posted.
const body = response.request().postDataJSON();
expect(body.labels).toMatchObject({ team: 'payments' });
});
test('CV2-05 a rejected label key surfaces as a notification, not an inline message', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// Both rejection branches in `LabelsInput` — duplicate key and group-by key —
// raise an antd **notification**, so a spec looking for inline text fails. Only
// the duplicate branch is reachable here: the group-by branch needs a query that
// already groups by something, which the default logs query does not.
await addAlertLabel(page, 'team', 'payments');
await expect(labelPill(page, 'team', 'payments')).toBeVisible();
await page.getByTestId('alert-add-label-button').click();
const input = page.getByTestId('alert-add-label-input');
await input.fill('team');
await input.press('Enter');
await expect(
page.getByText('Label with this key already exists'),
).toBeVisible();
// Rejected, so no second pill appeared.
await expect(page.locator('[data-testid^="label-pill-team-"]')).toHaveCount(
1,
);
});
test('CV2-06 CV2-07 the operator and match-type selects offer the documented options', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-threshold-operator-select').click();
// Exact-text matching, because `hasText: 'EQUAL TO'` also matches the
// `NOT EQUAL TO` option and the count assertion then reads 2.
for (const operator of Object.values(ThresholdOperator)) {
await expect(dropdownOption(page, operator.label)).toHaveCount(1);
}
await expect(
openDropdown(page).locator('.ant-select-item-option'),
).toHaveCount(Object.keys(ThresholdOperator).length);
await page.keyboard.press('Escape');
await page.getByTestId('alert-threshold-match-type-select').click();
for (const matchType of Object.values(ThresholdMatchType)) {
await expect(dropdownOption(page, matchType.label)).toHaveCount(1);
}
await expect(
openDropdown(page).locator('.ant-select-item-option'),
).toHaveCount(Object.keys(ThresholdMatchType).length);
});
test('CV2-08 the operator is rule-wide: one change reaches every threshold', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-08-${Date.now()}`);
await page.getByTestId('add-threshold-button').click();
await expect(thresholdRows(page)).toHaveCount(2);
await selectThresholdChannel(page, 0, alertChannel.name);
await selectThresholdChannel(page, 1, alertChannel.name);
await page.getByTestId('alert-threshold-operator-select').click();
await dropdownOption(page, ThresholdOperator.BELOW.label).click();
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
await ownedRules.register(response);
// The UI models one operator per rule while the schema stores one per
// threshold, so a single change is fanned out across `spec[]`.
const spec = response.request().postDataJSON().condition.thresholds.spec;
expect(spec).toHaveLength(2);
expect(spec.map((entry: { op: string }) => entry.op)).toEqual([
ThresholdOperator.BELOW.value,
ThresholdOperator.BELOW.value,
]);
});
test('CV2-09 CV2-10 added thresholds take preset tiers, and the first cannot be removed', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// `addThreshold` branches on the current count: 2nd ⇒ warning, 3rd ⇒ info,
// 4th and beyond ⇒ an unnamed row with a random colour.
await page.getByTestId('add-threshold-button').click();
await page.getByTestId('add-threshold-button').click();
await page.getByTestId('add-threshold-button').click();
await expect(thresholdRows(page)).toHaveCount(4);
const names = page.getByTestId('threshold-name-input');
await expect(names.nth(0)).toHaveValue('critical');
await expect(names.nth(1)).toHaveValue('warning');
await expect(names.nth(2)).toHaveValue('info');
await expect(names.nth(3)).toHaveValue('');
// `showRemoveButton` is `index !== 0 && length > 1`, so there are three remove
// buttons for four rows and the first row can never be removed.
await expect(page.getByTestId('remove-threshold-button')).toHaveCount(3);
// To see the unnamed row's own gate, the earlier gates have to be satisfied
// first: `validateCreateAlertState` loops thresholds and returns on the first
// failure, checking label *then* channels **per threshold** — so with row 0
// lacking a channel the channel message wins before row 3 is ever examined.
await page.getByTestId('alert-name-input').fill(`e2e-cv2-09-${Date.now()}`);
for (const index of [0, 1, 2, 3]) {
// eslint-disable-next-line no-await-in-loop
await selectThresholdChannel(page, index, alertChannel.name);
}
expect(await v2SaveTooltip(page)).toBe(VALIDATION.thresholdLabel);
await names.nth(3).fill('page-me');
await expect(v2SaveButton(page)).toBeEnabled();
});
test('CV2-11 a channel on one threshold is not enough — the validator loops all of them', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-11-${Date.now()}`);
await page.getByTestId('add-threshold-button').click();
await selectThresholdChannel(page, 0, alertChannel.name);
expect(await v2SaveTooltip(page)).toBe(VALIDATION.channels);
await selectThresholdChannel(page, 1, alertChannel.name);
await expect(v2SaveButton(page)).toBeEnabled();
});
test('CV2-12 the unit select is disabled while the query has no y-axis unit', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// `disabled={units.length === 0}`, and `units` is derived from
// `alertState.yAxisUnit`. A logs alert carries no unit, so the control is dead on
// this path — CD-04 covers a URL that does supply one.
const unitSelect = page.getByTestId('threshold-unit-select').first();
await expect(unitSelect).toHaveClass(/ant-select-disabled/);
});
test('CV2-13 the recovery threshold control is never rendered', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// `showRecoveryThreshold` starts false and the only setter is commented out, so
// neither the input nor its remove button can appear.
await expect(page.getByTestId('recovery-threshold-value-input')).toHaveCount(
0,
);
await expect(
page.getByTestId('remove-recovery-threshold-button'),
).toHaveCount(0);
});
test('CV2-14 CV2-15 the evaluation window and cadence reach the payload', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-14-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
// Written as one test because the two settings share a payload branch:
// `getEvaluationProps` emits `evalWindow` and `frequency` together, and a
// scenario that changed only one would still pass with the other hardcoded.
await selectEvaluationTimeframe(page, '30m0s');
await expandAdvancedOptions(page);
await evaluationCadenceInput(page).fill('5');
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
await ownedRules.register(response);
const { evaluation } = response.request().postDataJSON();
expect(evaluation.kind).toBe('rolling');
expect(evaluation.spec.evalWindow).toBe('30m0s');
// `getFormattedTimeValue` maps value + unit onto a Go duration; the unit is
// left at its default Minutes, which is what makes `5m` the expected string.
expect(evaluation.spec.frequency).toBe('5m');
});
test('CV2-18 with no channels the dropdown offers only a way to create one', async ({
authedPage: page,
}) => {
// The single deliberate network stub in the alerts suite — its justification is
// in `stubNoChannels`, and the state it produces is the one every fresh install
// starts in.
await stubNoChannels(page);
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-18-${Date.now()}`);
const select = page
.getByTestId('threshold-notification-channel-select')
.first();
await select.click();
const dropdown = await ownDropdown(page, select);
// `NotificationChannelsNotFoundContent` branches on the user's role. The harness
// user is an admin, so this is the "create one here" half — asserting the
// non-admin string instead would fail for the wrong reason.
await expect(dropdown.getByText('No channels yet.')).toBeVisible();
await expect(dropdown.getByRole('button', { name: 'here.' })).toBeVisible();
await expect(dropdown.getByRole('button', { name: 'Refresh' })).toBeVisible();
await expect(
dropdown.getByText('Please ask your admin to create one.'),
).toBeHidden();
await page.keyboard.press('Escape');
// With a name filled and no channel selectable, the channel gate is the only
// thing left — and there is no way to satisfy it from this page.
expect(await v2SaveTooltip(page)).toBe(VALIDATION.channels);
await expect(v2SaveButton(page)).toBeDisabled();
});
test('CV2-19 routing policies unlock the save with zero channels', async ({
authedPage: page,
}) => {
await stubNoChannels(page);
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-19-${Date.now()}`);
await expect(v2SaveButton(page)).toBeDisabled();
await page.getByTestId('routing-policies-switch').click();
// The validator skips the channel check when `routingPolicies` is on, and the
// threshold row *removes* its channel select rather than disabling it — so this
// is the one route to a saveable rule on a stack with no channels at all.
await expect(
page.getByTestId('threshold-notification-channel-select'),
).toHaveCount(0);
await expect(v2SaveButton(page)).toBeEnabled();
await page.getByTestId('view-routing-policies-button').click();
await page.waitForURL(/subTab=routing-policies/);
const url = new URL(page.url());
expect(url.pathname).toBe('/alerts');
expect(url.searchParams.get('tab')).toBe('Configuration');
});
test('CV2-16 the group-by select is disabled until the query groups by something', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// `isMultipleNotificationsEnabled` is `spaceAggregationOptions.length > 0`, and
// the options come from the query's `groupBy` keys. The default logs query has
// none, so notification grouping is unreachable without editing the query first.
const groupBy = page.getByTestId('multiple-notifications-select');
await expect(groupBy).toHaveAttribute('aria-disabled', 'true');
await expect(page.getByText('No grouping fields available')).toBeVisible();
});
test('CV2-17 repeat notifications enable their inputs and reach the payload', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-17-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
const interval = page.getByTestId('repeat-notifications-time-input');
await expect(interval).toBeDisabled();
await advancedOptionToggle(page, 'repeat-notifications-container').click();
await expect(interval).toBeEnabled();
await interval.fill('45');
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
await ownedRules.register(response);
// `getFormattedTimeValue` turns value+unit into a Go duration.
const renotify = response.request().postDataJSON()
.notificationSettings.renotify;
expect(renotify.enabled).toBe(true);
expect(renotify.interval).toBe('45m');
});
test('CV2-20 happy-path save posts the v2 shape and lands on the list', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
const name = `e2e-cv2-20-${Date.now()}`;
await page.getByTestId('alert-name-input').fill(name);
await selectThresholdChannel(page, 0, alertChannel.name);
// One click — v2 has no confirm dialog, unlike v1.
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
await ownedRules.register(response);
expect(response.status()).toBe(201);
expect(new URL(response.url()).pathname).toBe('/api/v2/rules');
const body = response.request().postDataJSON();
expect(body.schemaVersion).toBe('v2alpha1');
expect(body.version).toBe('v5');
expect(body.alert).toBe(name);
expect(body.condition.thresholds.kind).toBe('basic');
expect(body.condition.thresholds.spec[0]).toMatchObject({
name: 'critical',
target: 0,
matchType: ThresholdMatchType.AT_LEAST_ONCE.value,
op: ThresholdOperator.ABOVE.value,
channels: [alertChannel.name],
targetUnit: '',
});
// The payload carries no recovery field at all — see CV2-13.
expect(body.condition.thresholds.spec[0]).not.toHaveProperty(
'recoveryTarget',
);
await expect(page.getByText('Alert rule created successfully')).toBeVisible();
// `safeNavigate('/alerts')`; the list page then appends its own defaults, so
// only the pathname is asserted.
await page.waitForURL(/\/alerts(\?|$)/);
expect(new URL(page.url()).pathname).toBe('/alerts');
});
test('CV2-21 test notification reports that a non-firing rule matched nothing', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-21-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
const [response] = await Promise.all([
page.waitForResponse(
(r) =>
r.url().includes('/api/v2/rules/test') && r.request().method() === 'POST',
),
v2TestButton(page).click(),
]);
expect(response.ok()).toBe(true);
// `alertCount === 0` is an *error* toast, not a success one — the rule evaluated
// fine, it just did not fire. Asserted permissively so a stack that happens to
// have matching data does not flip it.
await expect(
page.getByText(/No alerts found during the evaluation|sent successfully/),
).toBeVisible();
});
test('CV2-22 discard leaves without posting and resets the form', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-22-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
let sawPost = false;
page.on('request', (request) => {
if (request.method() === 'POST' && request.url().includes('/api/v2/rules')) {
sawPost = true;
}
});
// dispatchEvent, not click — the side navigation covers the button (CE-09).
await v2ClickDiscard(page);
await page.waitForURL(/\/alerts(\?|$)/);
expect(sawPost).toBe(false);
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await expect(page.getByTestId('alert-name-input')).toHaveValue('');
await expect(thresholdRows(page)).toHaveCount(1);
});
test('CV2-23 every footer button is disabled while the save is in flight', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-23-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
// The in-flight window is a few milliseconds against a local stack, so it is
// widened by *delaying* the request — `route.continue()` still sends it to the
// real backend and the real 201 comes back, so nothing about the response is
// faked.
await page.route('**/api/v2/rules', async (route) => {
if (route.request().method() !== 'POST') {
await route.fallback();
return;
}
await new Promise((resolve) => {
setTimeout(resolve, 2_000);
});
await route.continue();
});
const responsePromise = page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
);
await v2SaveButton(page).click();
// `disableButtons` is one flag shared by all three, so Discard going disabled is
// what proves a user cannot abandon a half-created rule mid-request.
await expect(page.getByTestId('save-alert-rule-loader-icon')).toBeVisible();
await expect(page.getByTestId('save-alert-rule-check-icon')).toHaveCount(0);
await expect(v2SaveButton(page)).toBeDisabled();
await expect(v2TestButton(page)).toBeDisabled();
await expect(v2DiscardButton(page)).toBeDisabled();
const response = await responsePromise;
await ownedRules.register(response);
expect(response.status()).toBe(201);
await expect(page.getByText('Alert rule created successfully')).toBeVisible();
});
});

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