Compare commits

..

27 Commits

Author SHA1 Message Date
Nikhil Soni
210cda03ec fix(savedview): distinct error message for UpdateView's RowsAffected check
Matches the same distinction just added to DeleteView, so the exec error
and the RowsAffected-read error can be told apart in logs.
2026-07-31 13:30:22 +05:30
Nikhil Soni
fe3314853d test(savedview): add v2 integration tests, fix DeleteView not-found error
- tests/integration/tests/savedview/01_saved_view.py: v2 saved-view
  integration suite (validation failures, not-found, lifecycle), plus
  round-trip tests guarding the update-corrupts-zero-values failure mode --
  overwriting a previously non-zero maxLines/selectedFields/etc. down to its
  zero value must actually take effect on GET, not silently retain the old
  value or drop/null the field.
- module.go: DeleteView now checks RowsAffected and returns TypeNotFound
  when nothing matched, matching UpdateView and impldashboard's
  DeleteDashboardView convention. Distinct error messages for the delete
  and the RowsAffected check so logs can tell them apart.
- module_test.go: unit coverage for the not-found and org-scoping cases.
2026-07-31 13:07:11 +05:30
Nikhil Soni
8111224728 test(savedview): add types and module test coverage, fix GetView not-found error
- pkg/types/savedviewtypes/{spec,savedview}_test.go: Validate() coverage for
  PanelType/SourcePage/SavedViewSpec/SavedViewData/PostableSavedView, plus
  the storable<->gettable conversion helpers.
- pkg/modules/savedview/implsavedview/module_test.go: real-sqlite CRUD
  coverage (create/get/update/delete/list/collect), including org scoping.
- module.go: GetView now wraps sql.ErrNoRows via WrapNotFoundErrf instead of
  WrapInternalf, matching UpdateView and impldashboard's convention, so a
  missing view surfaces as TypeNotFound instead of TypeInternal.
2026-07-30 20:18:18 +05:30
Nikhil Soni
f1f0e743f1 Merge remote-tracking branch 'origin/main' into ns/saved-views-2
# Conflicts:
#	pkg/signoz/provider.go
2026-07-30 19:22:15 +05:30
Nikhil Soni
37746dc52c refactor(savedview): rename saved_views table to saved_view
This codebase's table-naming convention is overwhelmingly singular
(dashboard, dashboard_view, rule, tag, role, quick_filter, ...);
saved_views was a pre-existing plural exception alongside
organizations/users/pipelines. Since migration 102 already touches
this table, fold the rename in now rather than as a separate future
migration.

The rename and column drops (both take an exclusive table lock) run
last, right before commit -- after the row-by-row data transform,
which only needs row-level locks under the old name. This keeps the
exclusive-lock window as short as possible for any concurrent access
from other replicas during a rolling deploy.

Verified live: migration runs cleanly, table renamed with all rows
and data intact, and CRUD against the renamed table (via
StorableSavedView's updated bun table tag) works end-to-end.
2026-07-30 19:04:30 +05:30
Nikhil Soni
69bdf01a1c refactor(savedview): revert legacy List to raw query params
The ListSavedViewsParams/binding.Query.BindQuery/Validate() machinery
in the legacy handler.List wasn't required by this PR's restructuring
-- it predates this branch's spec work (from affb94031d/33fdde9939,
already on ns/saved-views) and was only carried over into this file
during the handler.go/handler_v2.go split. Revert it to main's raw
r.URL.Query().Get(...) reads, matching Create/Update in the same file
which also don't validate sourcePage for the legacy path. The v2
ListV2 handler is untouched and keeps using ListSavedViewsParams.

Only remaining diff from main: wrapping the query string into
savedviewtypes.SourcePage (Module.GetViewsForFilters requires the
typed value) and converting the result to []*v3.SavedView (Module's
return type changed).

Verified live: valid sourcePage filters correctly, invalid sourcePage
now returns an empty match (not a validation error) -- matches main.
2026-07-30 19:00:51 +05:30
Nikhil Soni
29375bb5fc chore: generate api spec 2026-07-30 18:37:10 +05:30
Nikhil Soni
6eae37409c fix(savedview): drop omitempty to prevent response round-trip bugs
Display's fields (maxLines/fontSize/format/color) and
SavedViewSpec.SelectedFields used omitempty, which silently drops a
zero-valued field from the response (e.g. an explicit maxLines:0 would
vanish from a subsequent GET). Matches dashboardtypes v2's own
convention: required fields never carry an omit option, so a response
value can never be confused with an omitted key.

SelectedFields/Queries also gain nullable:false since they're now
always present -- NewGettableSavedViewFromStorable normalizes a nil
SelectedFields to an empty slice so the response never emits `null`
for it (Queries needs no such guard: CompositeQuery.Validate() already
rejects an empty query list before a row can be persisted).

Verified live: creating a view with explicit maxLines:0/empty
selectedFields, and a legacy v1-created view with no extraData at all,
both round-trip with selectedFields:[] and the zero-valued display
fields intact, never omitted.
2026-07-30 18:37:02 +05:30
Nikhil Soni
ae522552f5 chore: remove unnecessary comments 2026-07-30 18:24:09 +05:30
Nikhil Soni
092468d6c8 chore: generate api spec
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 18:23:20 +05:30
Nikhil Soni
0e4d5c87d0 feat(savedview): migrate saved_views data to new spec envelope
One-time migration (mirrors 046's tx-based read/transform/write-back
structure): rewrites every existing saved_views.data row from the bare
CompositeQuery blob into the new {schemaVersion, spec} envelope,
best-effort folding legacy extra_data content into
spec.selectedFields/spec.display. Then drops the now-unused extra_data,
category, and tags columns (confirmed via earlier research that
category/tags were never actually populated by any caller).
2026-07-30 18:23:20 +05:30
Nikhil Soni
dc387e1efe feat(savedview): restructure storage around typed schemaVersion+spec
Mirrors the dashboardtypes v2 spec pattern: StorableSavedView.Data is
now a typed SavedViewData{SchemaVersion, Spec} (bun auto-marshals it
like DashboardView.Data), and the canonical GettableSavedView/
PostableSavedView embed it directly, replacing the old flat
CompositeQuery+ExtraData shape. SavedViewSpec adds SelectedFields and
Display{MaxLines,FontSize,Format,Color}, formalizing what the frontend
previously packed into an opaque, backend-unaware extraData JSON
string.

Handler splits into canonical (Create/Get/Update/List, backing
/api/v2/saved_views) and legacy (CreateV1/GetV1/UpdateV1/ListV1,
backing /api/v1/explorer/views) methods; Delete is shared since it has
no request/response body to reshape. The legacy methods decode/encode
v3.SavedView directly and convert to/from the canonical spec via small
converters in implsavedview -- extraData is best-effort parsed into
selectedFields/display on write and re-synthesized on read, so the
still-live frontend keeps working unchanged. CreateV1/UpdateV1 validate
via v3.SavedView.Validate() (no sourcePage enum enforcement), matching
production main's behavior exactly rather than the stricter canonical
validation.

Module stays singular (CreateView/GetView/UpdateView/
GetViewsForFilters/DeleteView) -- only the wire-facing handler differs
per API generation, so the two generations always converge on the same
storage shape, unlike dashboard's v1/v2 split where the DB can hold two
different JSON shapes depending on which API wrote a row.
2026-07-30 18:23:19 +05:30
Nikhil Soni
26b8d7d590 revert(v3): restore SavedView type for legacy /api/v1 compat
v3.SavedView was removed from this package earlier in this branch when
the saved-view domain type moved to savedviewtypes. Restoring it here
so /api/v1/explorer/views can decode/encode the exact legacy wire shape
via a thin converter, instead of duplicating an equivalent struct.
2026-07-30 16:14:42 +05:30
Nikhil Soni
ef6ac791e8 chore: generate api spec
Regenerated via: go run cmd/enterprise/*.go generate openapi && cd frontend && pnpm generate:api
Picks up UpdateSavedView's response becoming void (no body).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb5R7Eo19HUukBMVCgKPjS
2026-07-24 16:46:57 +05:30
Nikhil Soni
b3ce3a5d1f refactor(savedview): drop redundant GetView after UpdateView
No consumer needs the full view back from Update: neither active
frontend caller of useUpdateView (ExplorerOptions.tsx, pages/SaveView/
index.tsx) reads the mutation's response data -- both just check
success and separately call refetchAllView(). The signoz-mcp-server
does its own GetView before calling Update for its own validation, so
it doesn't depend on Update's response either.

The GetView call was, however, the only thing making PUT on a
nonexistent view id return an error -- bun's raw UPDATE with a
non-matching WHERE clause doesn't error on its own, so removing the
extra fetch without replacement would have silently turned "update a
bogus id" into a fake success. Replaced it with a RowsAffected() check
on the UPDATE result instead (same pattern already used in
llmpricingrule/spanmapper stores) -- no extra DB round trip, and it's
strictly more correct: a bogus id now returns a proper "not found"
error (matching the ErrorStatusCodes: 404 already declared on
UpdateSavedView's OpenAPIDef, which wasn't actually enforced before).

Response for Update is now nil (matching Delete's convention), since
nothing consumes the body.

Verified with go build, golangci-lint (0 issues), and a live smoke
test: a normal update still persists (confirmed via a follow-up GET),
and updating a nonexistent id now returns saved_view_not_found instead
of a silent success. Also re-verified /api/v1/explorer/views (same
shared handler) is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb5R7Eo19HUukBMVCgKPjS
2026-07-24 16:44:00 +05:30
Nikhil Soni
8cddda3ed6 chore: generate api spec
Regenerated via: go run cmd/enterprise/*.go generate openapi && cd frontend && pnpm generate:api
docs/api/openapi.yml had no diff (already up to date from prior commits
this session); the frontend generated schemas pick up the savedview
type changes (SourcePage/PanelType/QueryType enums, dropped category/
tags).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb5R7Eo19HUukBMVCgKPjS
2026-07-24 16:29:54 +05:30
Nikhil Soni
33fdde9939 refactor(savedview): keep Module.GetViewsForFilters on individual args
Passing the whole *ListSavedViewsParams through to the module ties the
module's signature to the handler's request-binding shape. Revert to
individual args (sourcePage, name) -- the handler still binds/validates
via ListSavedViewsParams, it just unpacks before calling into the
module.

Verified with go build, golangci-lint (0 issues), no openapi.yml diff
(pure module-boundary change), and a live List smoke test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb5R7Eo19HUukBMVCgKPjS
2026-07-24 16:19:23 +05:30
Nikhil Soni
affb94031d refactor(savedview): drop unused category filter, bind List params via struct
category was never passed by the frontend on any List call -- confirmed
via grep, no call site sets it. Drop it from ListSavedViewsParams and
the category-branching query in GetViewsForFilters.

Also switch List's handler to the binding.Query.BindQuery(...) +
params.Validate() pattern already used by dashboard v2's list handler
(pkg/modules/dashboard/impldashboard/v2_handler.go), instead of manually
pulling each field off r.URL.Query(). GetViewsForFilters now takes the
whole *ListSavedViewsParams instead of separate sourcePage/name/category
strings, matching Module.ListV2's params-struct signature.

ListSavedViewsParams.Validate() skips the SourcePage check when it's
zero (unset), consistent with how ListSort/ListOrder handle optional
enum query params in ListFilter.Validate() -- but validates it strictly
otherwise, so an invalid sourcePage on List now returns a clear error
instead of silently matching zero rows.

Verified with go build, golangci-lint (0 issues), an openapi.yml
regeneration (category query param removed, clean diff), and a live
List smoke test: valid sourcePage filters correctly, no sourcePage
returns empty (unchanged from before), invalid sourcePage is now
rejected with a validation error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb5R7Eo19HUukBMVCgKPjS
2026-07-24 16:15:59 +05:30
Nikhil Soni
54787a63d8 feat(savedview): drop category/tags from Postable/GettableSavedView
Both are unused: category is set nowhere in the frontend and read
nowhere either (never in SaveViewProps/UpdateViewProps, only appears
in the response-only ViewProps type); tags has no write path at all
(no UI to add them) -- the one place it's read (a homepage widget
badge list) already guards against the empty-string artifact this
produces, confirming nothing ever populates it.

Keeping StorableSavedView.Category/.Tags as-is (still NOT NULL text
columns) to avoid a migration in this PR -- marked `// TODO:
deprecated, remove it` for a follow-up that drops the columns.
NewStorableSavedView/NewGettableSavedViewFromStorable no longer
read/write them, so new rows get empty-string defaults same as before
tags was ever set, and existing rows' values are simply never
surfaced.

Verified with go build, golangci-lint (0 issues), an openapi.yml
regeneration (clean field removal from both request/response schemas),
and a live create/get/update/list/delete smoke test with no category/
tags in the payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb5R7Eo19HUukBMVCgKPjS
2026-07-24 16:06:28 +05:30
Nikhil Soni
06ab96984f refactor(savedview): separate createdBy/updatedBy in NewStorableSavedView
NewStorableSavedView took a single createdBy param and used it for both
CreatedBy and UpdatedBy, which is only correct for a fresh create. Split
it into separate createdBy/updatedBy args so a caller can vary them
independently (e.g. an update path that only needs to bump updatedBy).
Both CreateView and UpdateView currently pass claims.Email for both --
UpdateView's result is used to patch update_at/update_by plus the
content columns via Set(), so the throwaway id/createdAt/createdBy it
computes stay unused there, same as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb5R7Eo19HUukBMVCgKPjS
2026-07-24 16:02:01 +05:30
Nikhil Soni
104bcc55b9 refactor(savedview): move type construction/conversion into savedviewtypes
implsavedview/module.go was hand-building StorableSavedView/GettableSavedView
struct literals and doing json.Marshal/Unmarshal + strings.Join/Split inline
in every method. Mirrors the tagtypes convention (NewTag,
NewGettableTagFromTag/NewGettableTagsFromTags) instead:

- NewStorableSavedView(orgID, createdBy, PostableSavedView) (*StorableSavedView, error)
  builds the DB row from a request, generating id/timestamps.
- NewGettableSavedViewFromStorable(*StorableSavedView) (*GettableSavedView, error)
  and NewGettableSavedViewsFromStorable (batch) build the API response,
  unmarshalling the JSON-encoded query blob.

UpdateView reuses NewStorableSavedView too -- it only pulls Name/Category/
SourcePage/Tags/Data/ExtraData/UpdatedAt/UpdatedBy out of the result for its
column-level Set(), so the throwaway id/createdAt/createdBy it also computes
are harmless.

No behavior change: verified with go build, golangci-lint (0 issues), a
no-op openapi.yml regeneration (pure internal refactor, no type-shape
change), and a live create/get/update/list/delete smoke test confirming
createdAt/createdBy survive an update while updatedAt changes, and tags/
category/compositeQuery all round-trip correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb5R7Eo19HUukBMVCgKPjS
2026-07-24 15:42:50 +05:30
Nikhil Soni
ffbdf26ed7 feat(savedview): add SourcePage enum with validation
sourcePage was a bare string. The frontend only ever sends one of 4
values -- traces/logs/metrics (its own DataSource enum) plus a
special-cased "meter" string literal for the meter explorer -- matching
the legacy v3.DataSource enum exactly. Category has no such fixed set
(unused by the frontend entirely, always empty) so it stays a string.

SourcePage is local to savedviewtypes (valuer.String-backed, same
pattern as PanelType/QueryType), validated on PostableSavedView.Validate(),
and used directly as the StorableSavedView bun column type -- valuer.String
already implements driver.Valuer/sql.Scanner so no extra plumbing is
needed for it to round-trip through the DB.

Verified with go build, golangci-lint (0 issues), an openapi.yml
regeneration (sourcePage now has a proper enum schema instead of a bare
string, in both the request/response bodies and the List query param),
and a live smoke test: valid sourcePage round-trips through create/get/
list, invalid values are rejected on create.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb5R7Eo19HUukBMVCgKPjS
2026-07-24 15:39:14 +05:30
Nikhil Soni
97ee59636b refactor(savedview): drop legacy v3 dependency for panel/query type
savedviewtypes.CompositeQuery used pkg/query-service/model/v3.PanelType
and v3.QueryType, pulling in the legacy package purely for two small
enums. Alerting already solved this exact problem for its own
qbtypes.QueryEnvelope-based composite query (ruletypes.PanelType/
QueryType in pkg/types/ruletypes/alerting.go) -- mirror that pattern
here instead:

- savedviewtypes.PanelType: local enum with 5 values (value/graph/
  table/list/trace). ruletypes.PanelType only has 3 (value/table/
  graph), which isn't enough for saved views -- log/trace explorer
  views need list/trace too.
- savedviewtypes.QueryType: local enum (builder/clickhouse_sql/
  promql). This is a UI-tab-selector concept (which query-builder mode
  the view was last edited in), distinct from qbtypes.QueryEnvelope.Type
  (the per-query envelope discriminator, e.g. builder_query/
  builder_formula/clickhouse_sql/promql, which can differ across
  entries in the same Queries array). Keeping it, just re-typed
  locally instead of importing v3 for it.

Bonus: unlike v3.PanelType (a bare Go string with no schema enum),
these implement jsonschema.Enum, so the generated OpenAPI spec now
lists the acceptable values instead of a bare `type: string`.

Verified with go build, golangci-lint (0 issues), an openapi.yml
regeneration (diff is just the two new enum schemas plus $ref swaps),
and a live smoke test confirming the wire format is unchanged and
invalid panelType/queryType values are rejected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb5R7Eo19HUukBMVCgKPjS
2026-07-24 12:53:44 +05:30
Nikhil Soni
6e87eff704 chore: generate api spec 2026-07-23 17:05:37 +05:30
Nikhil Soni
21ac0f44e0 feat(savedview): make SavedView.CompositeQuery v5-only
Splits the domain type into request/response shapes so create/update
don't require id/createdAt/createdBy/updatedAt/updatedBy from the
client:

- PostableSavedView: request body for create/update (no id or
  server-populated audit fields).
- UpdatableSavedView: alias of PostableSavedView (a saved view is
  always replaced in full).
- GettableSavedView: response shape for get/list/create's-echo,
  carrying id and audit fields.

Module and Handler interfaces updated accordingly (CreateView/
UpdateView take PostableSavedView/UpdatableSavedView, GetView/
GetViewsForFilters return *GettableSavedView). The Update handler now
re-fetches and returns the persisted view instead of echoing the
request body, since callers (including the existing frontend type,
UpdateViewPayloadProps.data: ViewProps) expect id/timestamps back.

Also, per the v5-only typing work this continues:
- CompositeQuery is the new name for the saved-view query type
  (matches the established qbtypes.CompositeQuery naming), replacing
  the legacy v3.CompositeQuery for this domain.
- pkg/query-service/model/v3/v3.go now only differs from origin/main
  by the SavedView struct removal (and its now-unused valuer import)
  -- no stray struct tags left over from earlier iterations.
- Validate() uses errors.NewInvalidInputf with a package error code
  instead of fmt.Errorf, matching the forbidigo-clean pattern used
  elsewhere (e.g. dashboardtypes).
- pkg/types/savedviewtypes consolidated down to query.go and
  savedview.go; the separate list.go/domain.go files are gone.

Verified with go build, golangci-lint (0 issues), an openapi.yml
regeneration (clean diff), and a live create/get/update/list/delete
smoke test against /api/v2/saved_views.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb5R7Eo19HUukBMVCgKPjS
2026-07-22 16:39:02 +05:30
Nikhil Soni
735b9e7d68 refactor(savedview): move SavedView domain type into savedviewtypes
Relocates the saved-view domain type from pkg/query-service/model/v3
(v3.SavedView) to pkg/types/savedviewtypes.SavedView, the conventional
home for domain types, following the same Handler/Module signatures.
The existing bun-persisted row type in that package is renamed from
SavedView to StorableSavedView to avoid a name collision (matching the
Storable*/domain-type naming convention used elsewhere, e.g.
dashboardtypes.StorableDashboard).

v3.SavedView was only referenced by the savedview module/handler
(verified via grep), so this is a mechanical move -- CompositeQuery
itself stays in model/v3 since ~25 other files depend on it.

No new handler methods or request/response types: the v2 routes added
in the previous commit keep using the same Create/Get/Update/Delete/
List methods as /api/v1/explorer/views. CompositeQuery already has
omitempty legacy (builderQueries/chQueries/promQueries) and v5
(queries) fields side by side, and Validate() already accepts a
v5-only payload, so one type/handler pair genuinely serves both API
versions -- no conversion layer needed.

Refs SigNoz/engineering-pod#4651
2026-07-21 15:31:13 +05:30
Nikhil Soni
29315d8c89 refactor: register savedview handler in signozapiserver
feat(savedview): register v2 saved view routes via handler.New()

Registers List/Create/Get/Update/Delete for saved views at
/api/v2/saved_views(/{viewId}) in signozapiserver using handler.New()
with OpenAPIDef, mirroring the alertmanager migration (#10941). This
unblocks audit-log instrumentation and Terraform resource generation,
which the legacy router.HandleFunc registrations in http_handler.go
can't support.

/api/v1/explorer/views keeps working unchanged. Wires the previously
dead addSavedViewRoutes into provider.AddToRouter, adds the missing
authz middleware (ViewAccess/EditAccess, matching the v1 access split),
adds required/nullable OpenAPI tags to v3.SavedView/CompositeQuery, and
regenerates docs/api/openapi.yml.

Refs SigNoz/engineering-pod#4651
2026-07-21 15:19:57 +05:30
87 changed files with 3607 additions and 5227 deletions

View File

@@ -38,8 +38,7 @@ COPY ./pkg/ ./pkg/
COPY ./templates /root/templates
COPY Makefile Makefile
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
COPY --from=build /opt/build ./web/

View File

@@ -7745,6 +7745,101 @@ components:
enum:
- basic
type: string
SavedviewtypesDisplay:
properties:
color:
type: string
fontSize:
type: string
format:
type: string
maxLines:
type: integer
type: object
SavedviewtypesGettableSavedView:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
id:
type: string
name:
type: string
schemaVersion:
type: string
sourcePage:
$ref: '#/components/schemas/SavedviewtypesSourcePage'
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
- name
- createdAt
- createdBy
- updatedAt
- updatedBy
- sourcePage
- schemaVersion
- spec
type: object
SavedviewtypesPanelType:
enum:
- value
- graph
- table
- list
- trace
type: string
SavedviewtypesPostableSavedView:
properties:
name:
type: string
schemaVersion:
type: string
sourcePage:
$ref: '#/components/schemas/SavedviewtypesSourcePage'
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
required:
- name
- sourcePage
- schemaVersion
- spec
type: object
SavedviewtypesSavedViewSpec:
properties:
display:
$ref: '#/components/schemas/SavedviewtypesDisplay'
panelType:
$ref: '#/components/schemas/SavedviewtypesPanelType'
queries:
items:
$ref: '#/components/schemas/Querybuildertypesv5QueryEnvelope'
type: array
selectedFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
required:
- panelType
- queries
- selectedFields
- display
type: object
SavedviewtypesSourcePage:
enum:
- traces
- logs
- metrics
- meter
type: string
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
properties:
id:
@@ -22577,6 +22672,299 @@ paths:
summary: Test alert rule
tags:
- rules
/api/v2/saved_views:
get:
deprecated: false
description: Returns saved views, optionally filtered by source page and name.
operationId: ListSavedViews
parameters:
- in: query
name: sourcePage
schema:
$ref: '#/components/schemas/SavedviewtypesSourcePage'
- in: query
name: name
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/SavedviewtypesGettableSavedView'
nullable: true
type: array
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
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List saved views
tags:
- saved_view
post:
deprecated: false
description: Persists a saved view for the explore page. Returns the id of the
created view.
operationId: CreateSavedView
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SavedviewtypesPostableSavedView'
responses:
"200":
content:
application/json:
schema:
properties:
data:
nullable: true
type: string
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
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Create saved view
tags:
- saved_view
/api/v2/saved_views/{viewId}:
delete:
deprecated: false
description: Deletes a saved view by id.
operationId: DeleteSavedView
parameters:
- in: path
name: viewId
required: true
schema:
type: string
responses:
"200":
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:
- EDITOR
- tokenizer:
- EDITOR
summary: Delete saved view
tags:
- saved_view
get:
deprecated: false
description: Returns a saved view by id.
operationId: GetSavedView
parameters:
- in: path
name: viewId
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SavedviewtypesGettableSavedView'
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:
- VIEWER
- tokenizer:
- VIEWER
summary: Get saved view
tags:
- saved_view
put:
deprecated: false
description: Replaces a saved view's name and query.
operationId: UpdateSavedView
parameters:
- in: path
name: viewId
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SavedviewtypesPostableSavedView'
responses:
"200":
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:
- EDITOR
- tokenizer:
- EDITOR
summary: Update saved view
tags:
- saved_view
/api/v2/sessions:
delete:
deprecated: false

View File

@@ -227,14 +227,11 @@ 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/page.spec.ts --project=chromium
npx playwright test tests/alerts/alerts.spec.ts --project=chromium
# Single test by title grep
npx playwright test --project=chromium -g "AL-01"
npx playwright test --project=chromium -g "TC-01"
```
### Iterative modes
@@ -268,14 +265,7 @@ 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. |
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.
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.
### Playwright options

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleStatsPayload } from 'types/api/alerts/def';
import { RuleStatsProps } from 'types/api/alerts/ruleStats';
const ruleStats = async (
props: RuleStatsProps,
): Promise<SuccessResponse<AlertRuleStatsPayload> | ErrorResponse> => {
try {
const response = await axios.post(`/rules/${props.id}/history/stats`, {
start: props.start,
end: props.end,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default ruleStats;

View File

@@ -0,0 +1,33 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleTimelineGraphResponsePayload } from 'types/api/alerts/def';
import { GetTimelineGraphRequestProps } from 'types/api/alerts/timelineGraph';
const timelineGraph = async (
props: GetTimelineGraphRequestProps,
): Promise<
SuccessResponse<AlertRuleTimelineGraphResponsePayload> | ErrorResponse
> => {
try {
const response = await axios.post(
`/rules/${props.id}/history/overall_status`,
{
start: props.start,
end: props.end,
},
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default timelineGraph;

View File

@@ -0,0 +1,36 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleTimelineTableResponsePayload } from 'types/api/alerts/def';
import { GetTimelineTableRequestProps } from 'types/api/alerts/timelineTable';
const timelineTable = async (
props: GetTimelineTableRequestProps,
): Promise<
SuccessResponse<AlertRuleTimelineTableResponsePayload> | ErrorResponse
> => {
try {
const response = await axios.post(`/rules/${props.id}/history/timeline`, {
start: props.start,
end: props.end,
offset: props.offset,
limit: props.limit,
order: props.order,
state: props.state,
// TODO(shaheer): implement filters
filters: props.filters,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default timelineTable;

View File

@@ -0,0 +1,33 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleTopContributorsPayload } from 'types/api/alerts/def';
import { TopContributorsProps } from 'types/api/alerts/topContributors';
const topContributors = async (
props: TopContributorsProps,
): Promise<
SuccessResponse<AlertRuleTopContributorsPayload> | ErrorResponse
> => {
try {
const response = await axios.post(
`/rules/${props.id}/history/top_contributors`,
{
start: props.start,
end: props.end,
},
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default topContributors;

View File

@@ -0,0 +1,491 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
CreateSavedView200,
DeleteSavedViewPathParameters,
GetSavedView200,
GetSavedViewPathParameters,
ListSavedViews200,
ListSavedViewsParams,
RenderErrorResponseDTO,
SavedviewtypesPostableSavedViewDTO,
UpdateSavedViewPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Returns saved views, optionally filtered by source page and name.
* @summary List saved views
*/
export const listSavedViews = (
params?: ListSavedViewsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListSavedViews200>({
url: `/api/v2/saved_views`,
method: 'GET',
params,
signal,
});
};
export const getListSavedViewsQueryKey = (params?: ListSavedViewsParams) => {
return [`/api/v2/saved_views`, ...(params ? [params] : [])] as const;
};
export const getListSavedViewsQueryOptions = <
TData = Awaited<ReturnType<typeof listSavedViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListSavedViewsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listSavedViews>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListSavedViewsQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listSavedViews>>> = ({
signal,
}) => listSavedViews(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listSavedViews>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListSavedViewsQueryResult = NonNullable<
Awaited<ReturnType<typeof listSavedViews>>
>;
export type ListSavedViewsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List saved views
*/
export function useListSavedViews<
TData = Awaited<ReturnType<typeof listSavedViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListSavedViewsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listSavedViews>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListSavedViewsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List saved views
*/
export const invalidateListSavedViews = async (
queryClient: QueryClient,
params?: ListSavedViewsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListSavedViewsQueryKey(params) },
options,
);
return queryClient;
};
/**
* Persists a saved view for the explore page. Returns the id of the created view.
* @summary Create saved view
*/
export const createSavedView = (
savedviewtypesPostableSavedViewDTO?: BodyType<SavedviewtypesPostableSavedViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateSavedView200>({
url: `/api/v2/saved_views`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: savedviewtypesPostableSavedViewDTO,
signal,
});
};
export const getCreateSavedViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createSavedView>>,
TError,
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createSavedView>>,
TError,
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
TContext
> => {
const mutationKey = ['createSavedView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createSavedView>>,
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> }
> = (props) => {
const { data } = props ?? {};
return createSavedView(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateSavedViewMutationResult = NonNullable<
Awaited<ReturnType<typeof createSavedView>>
>;
export type CreateSavedViewMutationBody =
| BodyType<SavedviewtypesPostableSavedViewDTO>
| undefined;
export type CreateSavedViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create saved view
*/
export const useCreateSavedView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createSavedView>>,
TError,
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createSavedView>>,
TError,
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
TContext
> => {
return useMutation(getCreateSavedViewMutationOptions(options));
};
/**
* Deletes a saved view by id.
* @summary Delete saved view
*/
export const deleteSavedView = (
{ viewId }: DeleteSavedViewPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/saved_views/${viewId}`,
method: 'DELETE',
signal,
});
};
export const getDeleteSavedViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteSavedView>>,
TError,
{ pathParams: DeleteSavedViewPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteSavedView>>,
TError,
{ pathParams: DeleteSavedViewPathParameters },
TContext
> => {
const mutationKey = ['deleteSavedView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteSavedView>>,
{ pathParams: DeleteSavedViewPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteSavedView(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteSavedViewMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteSavedView>>
>;
export type DeleteSavedViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete saved view
*/
export const useDeleteSavedView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteSavedView>>,
TError,
{ pathParams: DeleteSavedViewPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteSavedView>>,
TError,
{ pathParams: DeleteSavedViewPathParameters },
TContext
> => {
return useMutation(getDeleteSavedViewMutationOptions(options));
};
/**
* Returns a saved view by id.
* @summary Get saved view
*/
export const getSavedView = (
{ viewId }: GetSavedViewPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetSavedView200>({
url: `/api/v2/saved_views/${viewId}`,
method: 'GET',
signal,
});
};
export const getGetSavedViewQueryKey = ({
viewId,
}: GetSavedViewPathParameters) => {
return [`/api/v2/saved_views/${viewId}`] as const;
};
export const getGetSavedViewQueryOptions = <
TData = Awaited<ReturnType<typeof getSavedView>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ viewId }: GetSavedViewPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSavedView>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetSavedViewQueryKey({ viewId });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSavedView>>> = ({
signal,
}) => getSavedView({ viewId }, signal);
return {
queryKey,
queryFn,
enabled: !!viewId,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getSavedView>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetSavedViewQueryResult = NonNullable<
Awaited<ReturnType<typeof getSavedView>>
>;
export type GetSavedViewQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get saved view
*/
export function useGetSavedView<
TData = Awaited<ReturnType<typeof getSavedView>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ viewId }: GetSavedViewPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSavedView>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetSavedViewQueryOptions({ viewId }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get saved view
*/
export const invalidateGetSavedView = async (
queryClient: QueryClient,
{ viewId }: GetSavedViewPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetSavedViewQueryKey({ viewId }) },
options,
);
return queryClient;
};
/**
* Replaces a saved view's name and query.
* @summary Update saved view
*/
export const updateSavedView = (
{ viewId }: UpdateSavedViewPathParameters,
savedviewtypesPostableSavedViewDTO?: BodyType<SavedviewtypesPostableSavedViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/saved_views/${viewId}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: savedviewtypesPostableSavedViewDTO,
signal,
});
};
export const getUpdateSavedViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateSavedView>>,
TError,
{
pathParams: UpdateSavedViewPathParameters;
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateSavedView>>,
TError,
{
pathParams: UpdateSavedViewPathParameters;
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
},
TContext
> => {
const mutationKey = ['updateSavedView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateSavedView>>,
{
pathParams: UpdateSavedViewPathParameters;
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateSavedView(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateSavedViewMutationResult = NonNullable<
Awaited<ReturnType<typeof updateSavedView>>
>;
export type UpdateSavedViewMutationBody =
| BodyType<SavedviewtypesPostableSavedViewDTO>
| undefined;
export type UpdateSavedViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update saved view
*/
export const useUpdateSavedView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateSavedView>>,
TError,
{
pathParams: UpdateSavedViewPathParameters;
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateSavedView>>,
TError,
{
pathParams: UpdateSavedViewPathParameters;
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
},
TContext
> => {
return useMutation(getUpdateSavedViewMutationOptions(options));
};

View File

@@ -8842,6 +8842,99 @@ export interface RuletypesRuleDTO {
export enum RuletypesThresholdKindDTO {
basic = 'basic',
}
export interface SavedviewtypesDisplayDTO {
/**
* @type string
*/
color?: string;
/**
* @type string
*/
fontSize?: string;
/**
* @type string
*/
format?: string;
/**
* @type integer
*/
maxLines?: number;
}
export enum SavedviewtypesSourcePageDTO {
traces = 'traces',
logs = 'logs',
metrics = 'metrics',
meter = 'meter',
}
export enum SavedviewtypesPanelTypeDTO {
value = 'value',
graph = 'graph',
table = 'table',
list = 'list',
trace = 'trace',
}
export interface SavedviewtypesSavedViewSpecDTO {
display: SavedviewtypesDisplayDTO;
panelType: SavedviewtypesPanelTypeDTO;
/**
* @type array
*/
queries: Querybuildertypesv5QueryEnvelopeDTO[];
/**
* @type array
*/
selectedFields: TelemetrytypesTelemetryFieldKeyDTO[];
}
export interface SavedviewtypesGettableSavedViewDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
createdBy: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
schemaVersion: string;
sourcePage: SavedviewtypesSourcePageDTO;
spec: SavedviewtypesSavedViewSpecDTO;
/**
* @type string
* @format date-time
*/
updatedAt: string;
/**
* @type string
*/
updatedBy: string;
}
export interface SavedviewtypesPostableSavedViewDTO {
/**
* @type string
*/
name: string;
/**
* @type string
*/
schemaVersion: string;
sourcePage: SavedviewtypesSourcePageDTO;
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
/**
* @type string
@@ -12029,6 +12122,57 @@ export type TestRule200 = {
status: string;
};
export type ListSavedViewsParams = {
/**
* @description undefined
*/
sourcePage?: SavedviewtypesSourcePageDTO;
/**
* @type string
* @description undefined
*/
name?: string;
};
export type ListSavedViews200 = {
/**
* @type array,null
*/
data: SavedviewtypesGettableSavedViewDTO[] | null;
/**
* @type string
*/
status: string;
};
export type CreateSavedView200 = {
/**
* @type string,null
*/
data: string | null;
/**
* @type string
*/
status: string;
};
export type DeleteSavedViewPathParameters = {
viewId: string;
};
export type GetSavedViewPathParameters = {
viewId: string;
};
export type GetSavedView200 = {
data: SavedviewtypesGettableSavedViewDTO;
/**
* @type string
*/
status: string;
};
export type UpdateSavedViewPathParameters = {
viewId: string;
};
export type GetSessionContext200 = {
data: AuthtypesSessionContextDTO;
/**

View File

@@ -102,15 +102,6 @@ interface QuerySearchProps {
showFilterSuggestionsWithoutMetric?: boolean;
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
initialExpression?: string;
/** When set, replaces the generic value-suggestion API with a custom fetcher. */
valueSuggestionsOverride?: (
key: string,
searchText: string,
) => Promise<{
stringValues: string[];
numberValues: number[];
complete: boolean;
}>;
}
function QuerySearch({
@@ -124,7 +115,6 @@ function QuerySearch({
showFilterSuggestionsWithoutMetric,
initialExpression,
metricNamespace,
valueSuggestionsOverride,
}: QuerySearchProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
@@ -494,24 +484,13 @@ function QuerySearch({
const sanitizedSearchText = searchText ? searchText?.trim() : '';
try {
const values = valueSuggestionsOverride
? await valueSuggestionsOverride(key, sanitizedSearchText)
: await getValueSuggestions({
key,
searchText: sanitizedSearchText,
signal: dataSource,
signalSource: signalSource as 'meter' | '',
metricName: debouncedMetricName ?? undefined,
}).then((response) => {
const responseData = response.data as any;
const data = responseData.data || {};
const values = data.values || {};
return {
stringValues: values.stringValues || [],
numberValues: values.numberValues || [],
complete: data.complete ?? false,
};
});
const response = await getValueSuggestions({
key,
searchText: sanitizedSearchText,
signal: dataSource,
signalSource: signalSource as 'meter' | '',
metricName: debouncedMetricName ?? undefined,
});
// Skip updates if component unmounted or key changed
if (
@@ -523,6 +502,8 @@ function QuerySearch({
}
// Process the response data
const responseData = response.data as any;
const values = responseData.data?.values || {};
const stringValues = values.stringValues || [];
const numberValues = values.numberValues || [];
@@ -603,7 +584,6 @@ function QuerySearch({
debouncedMetricName,
signalSource,
toggleSuggestions,
valueSuggestionsOverride,
],
);

View File

@@ -20,6 +20,10 @@ export const REACT_QUERY_KEY = {
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',
ALERT_RULE_DETAILS: 'ALERT_RULE_DETAILS',
ALERT_RULE_STATS: 'ALERT_RULE_STATS',
ALERT_RULE_TOP_CONTRIBUTORS: 'ALERT_RULE_TOP_CONTRIBUTORS',
ALERT_RULE_TIMELINE_TABLE: 'ALERT_RULE_TIMELINE_TABLE',
ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH',
GET_CONSUMER_LAG_DETAILS: 'GET_CONSUMER_LAG_DETAILS',
TOGGLE_ALERT_STATE: 'TOGGLE_ALERT_STATE',
GET_ALL_ALERTS: 'GET_ALL_ALERTS',

View File

@@ -29,7 +29,6 @@ function PopoverContent({
<Link
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
className="contributor-row-popover-buttons__button"
data-testid="alert-popover-view-logs"
>
<div className="icon">
<LogsIcon />
@@ -41,7 +40,6 @@ 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,10 +26,7 @@ function ChangePercentage({
}: ChangePercentageProps): JSX.Element {
if (direction > 0) {
return (
<div
className="change-percentage change-percentage--success"
data-testid="stats-card-change"
>
<div className="change-percentage change-percentage--success">
<div className="change-percentage__icon">
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
</div>
@@ -41,10 +38,7 @@ function ChangePercentage({
}
if (direction < 0) {
return (
<div
className="change-percentage change-percentage--error"
data-testid="stats-card-change"
>
<div className="change-percentage change-percentage--error">
<div className="change-percentage__icon">
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
</div>
@@ -56,10 +50,7 @@ function ChangePercentage({
}
return (
<div
className="change-percentage change-percentage--no-previous-data"
data-testid="stats-card-change"
>
<div className="change-percentage change-percentage--no-previous-data">
<div className="change-percentage__label">no previous data</div>
</div>
);
@@ -112,12 +103,7 @@ function StatsCard({
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
return (
<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 ${isEmpty ? 'stats-card--empty' : ''}`}>
<div className="stats-card__title-wrapper">
<div className="title">{title}</div>
<div className="duration-indicator">
@@ -137,7 +123,7 @@ function StatsCard({
</div>
<div className="stats-card__stats">
<div className="count-label" data-testid="stats-card-value">
<div className="count-label">
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
</div>

View File

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

View File

@@ -1,7 +1,6 @@
import { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import { StatsTimeSeriesItem } from 'types/api/alerts/def';
import AverageResolutionCard from '../AverageResolutionCard/AverageResolutionCard';
import StatsCard from '../StatsCard/StatsCard';
@@ -26,59 +25,24 @@ type StatsCardsRendererProps = {
};
// TODO(shaheer): render the DataStateRenderer inside the TotalTriggeredCard/AverageResolutionCard, it should display the title
type AdaptedStatsData = {
totalCurrentTriggers: number;
totalPastTriggers: number;
currentAvgResolutionTime: string;
pastAvgResolutionTime: string;
currentTriggersSeries: StatsTimeSeriesItem[];
currentAvgResolutionTimeSeries: StatsTimeSeriesItem[];
};
function StatsCardsRenderer({
setTotalCurrentTriggers,
}: StatsCardsRendererProps): JSX.Element {
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
useGetAlertRuleDetailsStats();
const adaptedData = useMemo((): AdaptedStatsData | null => {
if (!data?.data) {
return null;
}
const statsData = data.data;
const adaptTimeSeries = (
series: typeof statsData.currentTriggersSeries,
): StatsTimeSeriesItem[] =>
series?.values?.map((item) => ({
timestamp: item.timestamp ?? 0,
value: String(item.value ?? 0),
})) ?? [];
return {
totalCurrentTriggers: statsData.totalCurrentTriggers,
totalPastTriggers: statsData.totalPastTriggers,
currentAvgResolutionTime: String(statsData.currentAvgResolutionTime),
pastAvgResolutionTime: String(statsData.pastAvgResolutionTime),
currentTriggersSeries: adaptTimeSeries(statsData.currentTriggersSeries),
currentAvgResolutionTimeSeries: adaptTimeSeries(
statsData.currentAvgResolutionTimeSeries,
),
};
}, [data?.data]);
useEffect(() => {
if (adaptedData?.totalCurrentTriggers !== undefined) {
setTotalCurrentTriggers(adaptedData.totalCurrentTriggers);
if (data?.payload?.data?.totalCurrentTriggers !== undefined) {
setTotalCurrentTriggers(data.payload.data.totalCurrentTriggers);
}
}, [adaptedData, setTotalCurrentTriggers]);
}, [data, setTotalCurrentTriggers]);
return (
<DataStateRenderer
isLoading={isLoading}
isRefetching={isRefetching}
isError={isError || !isValidRuleId || !ruleId}
data={adaptedData}
data={data?.payload?.data || null}
>
{(data): JSX.Element => {
const {
@@ -96,7 +60,7 @@ function StatsCardsRenderer({
<TotalTriggeredCard
totalCurrentTriggers={totalCurrentTriggers}
totalPastTriggers={totalPastTriggers}
timeSeries={currentTriggersSeries}
timeSeries={currentTriggersSeries?.values}
/>
) : (
<StatsCard
@@ -113,7 +77,7 @@ function StatsCardsRenderer({
<AverageResolutionCard
currentAvgResolutionTime={currentAvgResolutionTime}
pastAvgResolutionTime={pastAvgResolutionTime}
timeSeries={currentAvgResolutionTimeSeries}
timeSeries={currentAvgResolutionTimeSeries?.values}
/>
) : (
<StatsCard

View File

@@ -48,16 +48,11 @@ function TopContributorsCard({
return (
<>
<div className="top-contributors-card" data-testid="top-contributors-card">
<div className="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}
data-testid="top-contributors-view-all"
>
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
<div className="label">View all</div>
<div className="icon">
<ArrowRight

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import { useMemo } from 'react';
import { useGetAlertRuleDetailsTopContributors } from 'pages/AlertDetails/hooks';
import { labelsArrayToObject } from 'container/AlertHistory/utils/labelAdapters';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def';
import { AlertRuleStats } from 'types/api/alerts/def';
import TopContributorsCard from '../TopContributorsCard/TopContributorsCard';
@@ -15,27 +13,15 @@ function TopContributorsRenderer({
}: TopContributorsRendererProps): JSX.Element {
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
useGetAlertRuleDetailsTopContributors();
const response = data?.payload?.data;
// TODO(shaheer): render the DataStateRenderer inside the TopContributorsCard, it should display the title and view all
const adaptedData = useMemo((): AlertRuleTopContributors[] | null => {
if (!data?.data) {
return null;
}
return data.data.map((contributor) => ({
fingerprint: contributor.fingerprint,
count: contributor.count,
labels: labelsArrayToObject(contributor.labels),
relatedLogsLink: contributor.relatedLogsLink ?? '',
relatedTracesLink: contributor.relatedTracesLink ?? '',
}));
}, [data?.data]);
return (
<DataStateRenderer
isLoading={isLoading}
isRefetching={isRefetching}
isError={isError || !isValidRuleId || !ruleId}
data={adaptedData}
data={response || null}
>
{(topContributorsData): JSX.Element => (
<TopContributorsCard

View File

@@ -1,18 +1,12 @@
import { Color } from '@signozhq/design-tokens';
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
export const ALERT_STATUS: Record<RuletypesAlertStateDTO, number> & {
[key: string]: number;
} = {
[RuletypesAlertStateDTO.firing]: 0,
[RuletypesAlertStateDTO.inactive]: 1,
export const ALERT_STATUS: { [key: string]: number } = {
firing: 0,
inactive: 1,
normal: 1,
[RuletypesAlertStateDTO.pending]: 2,
[RuletypesAlertStateDTO.recovering]: 2,
'no-data': 3,
[RuletypesAlertStateDTO.nodata]: 3,
[RuletypesAlertStateDTO.disabled]: 4,
muted: 5,
'no-data': 2,
disabled: 3,
muted: 4,
};
export const STATE_VS_COLOR: {
@@ -22,10 +16,9 @@ export const STATE_VS_COLOR: {
{
0: { stroke: Color.BG_CHERRY_500, fill: Color.BG_CHERRY_500 },
1: { stroke: Color.BG_FOREST_500, fill: Color.BG_FOREST_500 },
2: { stroke: Color.BG_AMBER_500, fill: Color.BG_AMBER_500 },
3: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 },
4: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 },
5: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 },
2: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 },
3: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 },
4: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 },
},
];

View File

@@ -1,8 +1,6 @@
import { useMemo } from 'react';
import useUrlQuery from 'hooks/useUrlQuery';
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
import Graph from '../Graph/Graph';
@@ -20,20 +18,30 @@ function GraphWrapper({
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
useGetAlertRuleDetailsTimelineGraphData();
const adaptedData = useMemo((): AlertRuleTimelineGraphResponse[] | null => {
if (!data?.data) {
return null;
}
return data.data.map((item) => ({
start: item.start,
end: item.end,
state: item.state as AlertRuleTimelineGraphResponse['state'],
}));
}, [data?.data]);
// TODO(shaheer): uncomment when the API is ready for
// const { startTime } = useAlertHistoryQueryParams();
// const [isVerticalGraph, setIsVerticalGraph] = useState(false);
// useEffect(() => {
// const checkVerticalGraph = (): void => {
// if (startTime) {
// const startTimeDate = dayjs(Number(startTime));
// const twentyFourHoursAgo = dayjs().subtract(
// HORIZONTAL_GRAPH_HOURS_THRESHOLD,
// DAYJS_MANIPULATE_TYPES.HOUR,
// );
// setIsVerticalGraph(startTimeDate.isBefore(twentyFourHoursAgo));
// }
// };
// checkVerticalGraph();
// }, [startTime]);
return (
<div className="timeline-graph" data-testid="timeline-graph">
<div className="timeline-graph__title" data-testid="timeline-graph-title">
<div className="timeline-graph">
<div className="timeline-graph__title">
{totalCurrentTriggers} triggers in {relativeTime}
</div>
<div className="timeline-graph__chart">
@@ -41,7 +49,7 @@ function GraphWrapper({
isLoading={isLoading}
isError={isError || !isValidRuleId || !ruleId}
isRefetching={isRefetching}
data={adaptedData}
data={data?.payload?.data || null}
>
{(data): JSX.Element => <Graph type="horizontal" data={data} />}
</DataStateRenderer>

View File

@@ -1,35 +1,11 @@
.timeline-table {
border-top: 1px solid var(--l1-border);
border-radius: 6px;
overflow: hidden;
margin-top: 4px;
min-height: 600px;
&__filter {
padding: 12px 16px;
background: var(--l1-background);
border-bottom: 1px solid var(--l1-border);
}
&__filter-row {
display: flex;
align-items: center;
gap: 8px;
}
&__filter-search {
flex: 1;
}
&__filter--loading,
&__filter--loading-skeleton {
width: 100% !important;
}
.ant-table {
background: var(--l1-background);
&-placeholder {
background: var(--l1-background) !important;
}
&-cell {
padding: 12px 16px !important;
vertical-align: baseline;
@@ -47,9 +23,6 @@
&-tbody > tr > td {
border: none;
}
&-footer {
background-color: var(--l1-background);
}
}
.label-filter {
@@ -113,38 +86,4 @@
background: var(--l2-background);
}
}
&__error {
display: flex;
align-self: flex-start;
justify-content: flex-start;
text-align: left;
}
&__pagination {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-6) var(--spacing-8);
background: var(--l1-background);
.pagination-controls {
display: flex;
gap: 4px;
.ant-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
min-width: 24px;
height: 24px;
&:disabled {
opacity: 0.4;
}
}
}
}
}

View File

@@ -1,129 +1,52 @@
import { HTMLAttributes, useCallback, useMemo } from 'react';
import { Button, Skeleton, Table } from 'antd';
import { ChevronLeft, ChevronRight } from '@signozhq/icons';
import { HTMLAttributes, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Table } from 'antd';
import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import {
QuerySearchV2Provider,
useExpression,
useInputExpression,
useQuerySearchOnChange,
useQuerySearchOnRun,
} from 'components/QueryBuilderV2';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { initialFilters } from 'constants/queryBuilder';
import {
useGetAlertRuleDetailsTimelineTable,
useTimelineTable,
} from 'pages/AlertDetails/hooks';
import { labelsArrayToObject } from 'container/AlertHistory/utils/labelAdapters';
import { useTimezone } from 'providers/Timezone';
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
import { DataSource } from 'types/common/queryBuilder';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { useAlertHistoryFilterSuggestions } from './useAlertHistoryFilterSuggestions';
import { timelineTableColumns } from './useTimelineTable';
import './Table.styles.scss';
export const ALERT_HISTORY_EXPRESSION_KEY = 'alertHistoryExpression';
function TimelineTable(): JSX.Element {
const [filters, setFilters] = useState<TagFilter>(initialFilters);
function TimelineTableContent(): JSX.Element {
const expression = useExpression();
const inputExpression = useInputExpression();
const querySearchOnChange = useQuerySearchOnChange();
const querySearchOnRun = useQuerySearchOnRun();
const {
isLoading,
isRefetching,
isError,
data,
error,
ruleId,
refetch,
cancel,
} = useGetAlertRuleDetailsTimelineTable({ filterExpression: expression });
const apiError = useMemo(() => convertToApiError(error), [error]);
const { hardcodedAttributeKeys, valueSuggestionsOverride, isLoadingKeys } =
useAlertHistoryFilterSuggestions(ruleId ?? null);
const { timelineData, totalItems, nextCursor } = useMemo(() => {
const response = data?.data;
const items: AlertRuleTimelineTableResponse[] | undefined =
response?.items?.map((item) => {
return {
ruleID: item.ruleId,
ruleName: item.ruleName,
overallState: item.overallState as string,
overallStateChanged: item.overallStateChanged,
state: item.state as string,
stateChanged: item.stateChanged,
unixMilli: item.unixMilli,
fingerprint: item.fingerprint,
value: item.value,
labels: labelsArrayToObject(item.labels),
relatedLogsLink: item.relatedLogsLink,
relatedTracesLink: item.relatedTracesLink,
};
});
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
useGetAlertRuleDetailsTimelineTable({ filters });
const { timelineData, totalItems, labels } = useMemo(() => {
const response = data?.payload?.data;
return {
timelineData: items,
totalItems: response?.total ?? 0,
nextCursor: response?.nextCursor,
timelineData: response?.items,
totalItems: response?.total,
labels: response?.labels,
};
}, [data?.data]);
}, [data?.payload?.data]);
const {
paginationConfig,
onChangeHandler,
handleNextPage,
handlePrevPage,
hasNextPage,
hasPrevPage,
} = useTimelineTable({
const { paginationConfig, onChangeHandler } = useTimelineTable({
totalItems: totalItems ?? 0,
nextCursor,
});
const { t } = useTranslation('common');
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const handleRunQuery = useCallback(
(updatedExpression?: string): void => {
const nextExpression = updatedExpression ?? inputExpression;
querySearchOnRun(nextExpression);
if (nextExpression === expression) {
refetch();
}
},
[querySearchOnRun, refetch, inputExpression, expression],
);
const queryData = useMemo(
() => ({
...initialQueryBuilderFormValuesMap.logs,
queryName: 'A',
dataSource: DataSource.LOGS,
filter: { expression },
expression,
}),
[expression],
);
if (isError || !isValidRuleId || !ruleId) {
return <div>{t('something_went_wrong')}</div>;
}
const handleRowClick = (
record: AlertRuleTimelineTableResponse,
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
'data-testid': string;
} => ({
'data-testid': 'timeline-row',
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
onClick: (): void => {
void logEvent('Alert history: Timeline table row: Clicked', {
logEvent('Alert history: Timeline table row: Clicked', {
ruleId: record.ruleID,
labels: record.labels,
});
@@ -131,114 +54,24 @@ function TimelineTableContent(): JSX.Element {
});
return (
<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"
data-testid="timeline-filter-search"
>
<QuerySearch
onChange={querySearchOnChange}
queryData={queryData}
dataSource={DataSource.LOGS}
onRun={handleRunQuery}
hardcodedAttributeKeys={hardcodedAttributeKeys}
valueSuggestionsOverride={valueSuggestionsOverride}
/>
</div>
<RunQueryBtn
isLoadingQueries={isLoading || isRefetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
/>
</div>
</div>
) : (
<div className="timeline-table__filter timeline-table__filter--loading">
<Skeleton.Input
className="timeline-table__filter--loading-skeleton"
active
data-testid="timeline-filter-skeleton"
/>
</div>
)}
<div className="timeline-table">
<Table
rowKey={(row): string => `${row.fingerprint}-${row.value}-${row.unixMilli}`}
columns={timelineTableColumns({
filters,
labels: labels ?? {},
setFilters,
formatTimezoneAdjustedTimestamp,
})}
onRow={handleRowClick}
dataSource={timelineData}
pagination={false}
pagination={paginationConfig}
size="middle"
onChange={onChangeHandler}
loading={isLoading || isRefetching}
locale={{
emptyText:
isError && apiError ? (
<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"
data-testid="timeline-footer-range"
>
{paginationConfig.showTotal?.(totalItems, [
totalItems === 0
? 0
: ((paginationConfig.current ?? 1) - 1) *
(paginationConfig.pageSize ?? 10) +
1,
Math.min(
(paginationConfig.current ?? 1) * (paginationConfig.pageSize ?? 10),
totalItems,
),
])}
</div>
<div className="pagination-controls">
<Button
type="text"
size="small"
disabled={!hasPrevPage}
onClick={handlePrevPage}
data-testid="timeline-prev-page"
>
<ChevronLeft size={14} />
</Button>
<Button
type="text"
size="small"
disabled={!hasNextPage}
onClick={handleNextPage}
data-testid="timeline-next-page"
>
<ChevronRight size={14} />
</Button>
</div>
</div>
)}
/>
</div>
);
}
function TimelineTable(): JSX.Element {
return (
<QuerySearchV2Provider
queryParamKey={ALERT_HISTORY_EXPRESSION_KEY}
initialExpression=""
persistOnUnmount
>
<TimelineTableContent />
</QuerySearchV2Provider>
);
}
export default TimelineTable;

View File

@@ -1,100 +0,0 @@
import { useCallback, useMemo } from 'react';
import {
getRuleHistoryFilterValues,
useGetRuleHistoryFilterKeys,
} from 'api/generated/services/rules';
import { useAlertHistoryQueryParams } from 'pages/AlertDetails/hooks';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { fieldContextToSuggestionContext } from 'container/AlertHistory/Timeline/Table/utils';
export interface AlertHistoryFilterSuggestions {
hardcodedAttributeKeys: QueryKeyDataSuggestionsProps[];
valueSuggestionsOverride: (
key: string,
searchText: string,
) => Promise<{
stringValues: string[];
numberValues: number[];
complete: boolean;
}>;
isLoadingKeys: boolean;
}
export function useAlertHistoryFilterSuggestions(
ruleId: string | null,
): AlertHistoryFilterSuggestions {
const { startTime, endTime } = useAlertHistoryQueryParams();
const { data: filterKeysData, isLoading: isLoadingKeys } =
useGetRuleHistoryFilterKeys(
{ id: ruleId ?? '' },
{ startUnixMilli: startTime, endUnixMilli: endTime },
{
query: {
enabled: !!ruleId,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
);
const hardcodedAttributeKeys = useMemo((): QueryKeyDataSuggestionsProps[] => {
const keys = filterKeysData?.data?.keys;
if (!keys) {
// by default, when QuerySearch keys fails, we don't render fallback keys
// we just return empty to let user write whatever they want with no
// key suggestion
return [];
}
return Object.values(keys).flatMap((items) =>
items.map(
(item) =>
({
label: item.name,
name: item.name,
type: item.fieldDataType || 'string',
signal: 'logs' as const,
fieldDataType: item.fieldDataType,
fieldContext: fieldContextToSuggestionContext(item.fieldContext),
}) satisfies QueryKeyDataSuggestionsProps,
),
);
}, [filterKeysData]);
const valueSuggestionsOverride = useCallback(
async (
key: string,
searchText: string,
): Promise<{
stringValues: string[];
numberValues: number[];
complete: boolean;
}> => {
if (!ruleId) {
return {
stringValues: [],
numberValues: [],
complete: true,
};
}
const response = await getRuleHistoryFilterValues(
{ id: ruleId },
{
name: key,
searchText,
startUnixMilli: startTime,
endUnixMilli: endTime,
},
);
const values = response.data?.values;
return {
stringValues: values?.stringValues ?? [],
numberValues: values?.numberValues ?? [],
complete: response.data?.complete ?? false,
};
},
[ruleId, startTime, endTime],
);
return { hardcodedAttributeKeys, valueSuggestionsOverride, isLoadingKeys };
}

View File

@@ -1,15 +1,83 @@
import { Ellipsis } from '@signozhq/icons';
import { Button, TableColumnsType as ColumnsType, Tooltip } from 'antd';
import { useMemo } from 'react';
import { Ellipsis, Search } from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { Button, TableColumnsType as ColumnsType } from 'antd';
import ClientSideQBSearch, {
AttributeKey,
} from 'components/ClientSideQBSearch/ClientSideQBSearch';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
import { transformKeyValuesToAttributeValuesMap } from 'container/QueryBuilder/filters/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
import AlertLabels, {
AlertLabelsProps,
} from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState';
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
const transformLabelsToQbKeys = (
labels: AlertRuleTimelineTableResponse['labels'],
): AttributeKey[] => Object.keys(labels).flatMap((key) => [{ key }]);
function LabelFilter({
filters,
setFilters,
labels,
}: {
setFilters: (filters: TagFilter) => void;
filters: TagFilter;
labels: AlertLabelsProps['labels'];
}): JSX.Element | null {
const isDarkMode = useIsDarkMode();
const { transformedKeys, attributesMap } = useMemo(
() => ({
transformedKeys: transformLabelsToQbKeys(labels || {}),
attributesMap: transformKeyValuesToAttributeValuesMap(labels),
}),
[labels],
);
const handleSearch = (tagFilters: TagFilter): void => {
const tagFiltersLength = tagFilters.items.length;
if (
(!tagFiltersLength && (!filters || !filters.items.length)) ||
tagFiltersLength === filters?.items.length
) {
return;
}
setFilters(tagFilters);
};
return (
<ClientSideQBSearch
onChange={handleSearch}
filters={filters}
className="alert-history-label-search"
attributeKeys={transformedKeys}
attributeValuesMap={attributesMap}
suffixIcon={
<Search
size={14}
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
/>
}
/>
);
}
export const timelineTableColumns = ({
filters,
labels,
setFilters,
formatTimezoneAdjustedTimestamp,
}: {
filters: TagFilter;
labels: AlertLabelsProps['labels'];
setFilters: (filters: TagFilter) => void;
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
@@ -21,16 +89,18 @@ export const timelineTableColumns = ({
sorter: true,
width: 140,
render: (value): JSX.Element => (
<div className="alert-rule-state" data-testid="timeline-row-state">
<div className="alert-rule-state">
<AlertState state={value} showLabel />
</div>
),
},
{
title: 'LABELS',
title: (
<LabelFilter setFilters={setFilters} filters={filters} labels={labels} />
),
dataIndex: 'labels',
render: (labels): JSX.Element => (
<div className="alert-rule-labels" data-testid="timeline-row-labels">
<div className="alert-rule-labels">
<AlertLabels labels={labels} />
</div>
),
@@ -40,10 +110,7 @@ export const timelineTableColumns = ({
dataIndex: 'unixMilli',
width: 200,
render: (value): JSX.Element => (
<div
className="alert-rule__created-at"
data-testid="timeline-row-created-at"
>
<div className="alert-rule__created-at">
{formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.DASH_DATETIME)}
</div>
),
@@ -52,27 +119,15 @@ export const timelineTableColumns = ({
title: 'ACTIONS',
width: 140,
align: 'right',
render: (_, record): JSX.Element => {
if (!record.relatedTracesLink && !record.relatedLogsLink) {
return (
<Tooltip title="No links available for this item">
<Button type="text" ghost disabled data-testid="timeline-row-actions">
<Ellipsis className="dropdown-icon" size="md" />
</Button>
</Tooltip>
);
}
return (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink ?? ''}
relatedLogsLink={record.relatedLogsLink ?? ''}
>
<Button type="text" ghost data-testid="timeline-row-actions">
<Ellipsis className="dropdown-icon" size="md" />
</Button>
</ConditionalAlertPopover>
);
},
render: (record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<Button type="text" ghost>
<Ellipsis className="dropdown-icon" size="md" />
</Button>
</ConditionalAlertPopover>
),
},
];

View File

@@ -1,53 +0,0 @@
import {
Options,
parseAsInteger,
parseAsStringLiteral,
useQueryState,
UseQueryStateReturn,
} from 'nuqs';
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
const defaultNuqsOptions: Options = {
history: 'push',
};
export const TIMELINE_TABLE_PARAMS = {
PAGE: 'page',
ORDER: 'order',
} as const;
const ORDER_VALUES = ['asc', 'desc'] as const;
export type OrderDirection = (typeof ORDER_VALUES)[number];
export const useTimelineTablePage = (): UseQueryStateReturn<number, number> =>
useQueryState(
TIMELINE_TABLE_PARAMS.PAGE,
parseAsInteger.withDefault(1).withOptions(defaultNuqsOptions),
);
export const useTimelineTableOrder = (): UseQueryStateReturn<
OrderDirection,
OrderDirection
> =>
useQueryState(
TIMELINE_TABLE_PARAMS.ORDER,
parseAsStringLiteral(ORDER_VALUES)
.withDefault('asc')
.withOptions(defaultNuqsOptions),
);
export function encodeCursor(page: number, limit: number): string | undefined {
if (page <= 1) {
return undefined;
}
const offset = (page - 1) * limit;
// Backend uses base64.RawURLEncoding (URL-safe, no padding)
return btoa(JSON.stringify({ offset, limit }))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
export function computeCursorForPage(page: number): string | undefined {
return encodeCursor(page, TIMELINE_TABLE_PAGE_SIZE);
}

View File

@@ -1,26 +0,0 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
const fieldContextToSuggestionMap: Record<
TelemetrytypesFieldContextDTO,
QueryKeyDataSuggestionsProps['fieldContext']
> = {
[TelemetrytypesFieldContextDTO.resource]: 'resource',
[TelemetrytypesFieldContextDTO.span]: 'span',
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
// no maps for the following values on suggestion context
[TelemetrytypesFieldContextDTO.body]: undefined,
[TelemetrytypesFieldContextDTO.metric]: undefined,
[TelemetrytypesFieldContextDTO.log]: undefined,
[TelemetrytypesFieldContextDTO['']]: undefined,
};
export function fieldContextToSuggestionContext(
fc: TelemetrytypesFieldContextDTO | undefined,
): QueryKeyDataSuggestionsProps['fieldContext'] {
if (fc === undefined) {
return undefined;
}
return fieldContextToSuggestionMap[fc];
}

View File

@@ -23,7 +23,6 @@ function TimelineTabs(): JSX.Element {
{
value: TimelineTab.OVERALL_STATUS,
label: 'Overall Status',
testId: 'timeline-tab-overall-status',
},
{
value: TimelineTab.TOP_5_CONTRIBUTORS,
@@ -34,7 +33,6 @@ function TimelineTabs(): JSX.Element {
</div>
),
disabled: true,
testId: 'timeline-tab-top-contributors',
},
];
@@ -59,17 +57,14 @@ 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

@@ -1,19 +0,0 @@
import type { Querybuildertypesv5LabelDTO } from 'api/generated/services/sigNoz.schemas';
import type { Labels } from 'types/api/alerts/def';
export function labelsArrayToObject(
labels: Querybuildertypesv5LabelDTO[] | null | undefined,
): Labels {
if (!labels) {
return {};
}
return labels.reduce<Labels>((acc, label) => {
const key = label.key?.name ?? '';
const value = String(label.value ?? '');
if (key) {
acc[key] = value;
}
return acc;
}, {});
}

View File

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

View File

@@ -47,29 +47,21 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
<div className="alert-info__info-wrapper">
<div className="top-section">
<div className="alert-title-wrapper">
<div data-testid="alert-header-state">
<AlertState state={alertRuleState ?? state ?? ''} />
</div>
<div className="alert-title" data-testid="alert-header-title">
<AlertState state={alertRuleState ?? state ?? ''} />
<div className="alert-title">
<LineClampedText text={displayName || ''} />
</div>
</div>
</div>
<div className="bottom-section">
{labels?.severity && (
<div data-testid="alert-header-severity">
<AlertSeverity severity={labels.severity} />
</div>
)}
{labels?.severity && <AlertSeverity severity={labels.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()}
/> */}
<div data-testid="alert-header-labels">
<AlertLabels labels={labelsWithoutSeverity} />
</div>
<AlertLabels labels={labelsWithoutSeverity} />
</div>
</div>
);

View File

@@ -1,12 +1,11 @@
import { Color } from '@signozhq/design-tokens';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { BellOff, CircleCheck, CircleOff, Flame } from '@signozhq/icons';
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
import './AlertState.styles.scss';
type AlertStateProps = {
state: RuletypesAlertStateDTO | string;
state: string;
showLabel?: boolean;
};
@@ -18,7 +17,7 @@ export default function AlertState({
let label;
const isDarkMode = useIsDarkMode();
switch (state) {
case RuletypesAlertStateDTO.nodata:
case 'nodata':
icon = (
<CircleOff
size={18}
@@ -29,7 +28,7 @@ export default function AlertState({
label = <span style={{ color: Color.BG_SIENNA_400 }}>No Data</span>;
break;
case RuletypesAlertStateDTO.disabled:
case 'disabled':
icon = (
<BellOff
size={18}
@@ -39,16 +38,15 @@ export default function AlertState({
);
label = <span style={{ color: Color.BG_VANILLA_400 }}>Muted</span>;
break;
case RuletypesAlertStateDTO.firing:
case 'firing':
icon = (
<Flame size={18} fill={Color.BG_CHERRY_500} color={Color.BG_CHERRY_500} />
);
label = <span style={{ color: Color.BG_CHERRY_500 }}>Firing</span>;
break;
case RuletypesAlertStateDTO.inactive:
case 'normal': // legacy
case 'normal':
case 'inactive':
icon = (
<CircleCheck
size={18}

View File

@@ -1,34 +1,28 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from 'react-query';
import { generatePath } from 'react-router-dom';
import { useCallback, useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { generatePath, useLocation } from 'react-router-dom';
import { TablePaginationConfig, TableProps } from 'antd';
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import { patchRulePartial } from 'api/alerts/patchRulePartial';
import ruleStats from 'api/alerts/ruleStats';
import timelineGraph from 'api/alerts/timelineGraph';
import timelineTable from 'api/alerts/timelineTable';
import topContributors from 'api/alerts/topContributors';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
deleteRuleByID,
getGetRuleByIDQueryKey,
getGetRuleHistoryTimelineQueryOptions,
invalidateGetRuleByID,
invalidateListRules,
updateRuleByID,
useGetRuleByID,
useGetRuleHistoryOverallStatus,
useGetRuleHistoryStats,
useGetRuleHistoryTopContributors,
useListRules,
} from 'api/generated/services/rules';
import {
Querybuildertypesv5OrderDirectionDTO,
RuletypesAlertStateDTO,
type GetRuleByID200,
type GetRuleHistoryOverallStatus200,
type GetRuleHistoryStats200,
type GetRuleHistoryTimeline200,
type GetRuleHistoryTopContributors200,
type RenderErrorResponseDTO,
type RuletypesPostableRuleDTO,
import type {
GetRuleByID200,
RenderErrorResponseDTO,
RuletypesPostableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { TabRoutes } from 'components/RouteTab/types';
@@ -37,27 +31,35 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import AlertHistory from 'container/AlertHistory';
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
import {
computeCursorForPage,
useTimelineTableOrder,
useTimelineTablePage,
} from 'container/AlertHistory/Timeline/Table/useTimelineTableCursor';
import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
import { urlKey } from 'container/AllError/utils';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import GetMinMax from 'lib/getMinMax';
import history from 'lib/history';
import { History, Table } from '@signozhq/icons';
import EditRules from 'pages/EditRules';
import { OrderPreferenceItems } from 'pages/Logs/config';
import BetaTag from 'periscope/components/BetaTag/BetaTag';
import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText';
import { useAlertRule } from 'providers/Alert';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
import { AlertDef, AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
import {
AlertDef,
AlertRuleStatsPayload,
AlertRuleTimelineGraphResponsePayload,
AlertRuleTimelineTableResponse,
AlertRuleTimelineTableResponsePayload,
AlertRuleTopContributorsPayload,
} from 'types/api/alerts/def';
import APIError from 'types/api/error';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { nanoToMilli } from 'utils/timeUtils';
import { Typography } from '@signozhq/ui/typography';
export const useAlertHistoryQueryParams = (): {
ruleId: string | null;
@@ -127,7 +129,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
{
Component: EditRules,
name: (
<div className="tab-item" data-testid="alert-details-tab-overview">
<div className="tab-item">
<Table size={14} />
Overview
</div>
@@ -138,7 +140,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
{
Component: AlertHistory,
name: (
<div className="tab-item" data-testid="alert-details-tab-history">
<div className="tab-item">
<History size={14} />
History
<BetaTag />
@@ -199,7 +201,10 @@ type GetAlertRuleDetailsApiProps = {
};
type GetAlertRuleDetailsStatsProps = GetAlertRuleDetailsApiProps & {
data: GetRuleHistoryStats200 | undefined;
data:
| SuccessResponse<AlertRuleStatsPayload, unknown>
| ErrorResponse
| undefined;
};
export const useGetAlertRuleDetailsStats =
@@ -208,15 +213,18 @@ export const useGetAlertRuleDetailsStats =
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const { isLoading, isRefetching, isError, data } = useGetRuleHistoryStats(
{ id: ruleId || '' },
{ start: startTime, end: endTime },
const { isLoading, isRefetching, isError, data } = useQuery(
[REACT_QUERY_KEY.ALERT_RULE_STATS, ruleId, startTime, endTime],
{
query: {
enabled: isValidRuleId && !!startTime && !!endTime,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
queryFn: () =>
ruleStats({
id: ruleId || '',
start: startTime,
end: endTime,
}),
enabled: isValidRuleId && !!startTime && !!endTime,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
@@ -224,7 +232,10 @@ export const useGetAlertRuleDetailsStats =
};
type GetAlertRuleDetailsTopContributorsProps = GetAlertRuleDetailsApiProps & {
data: GetRuleHistoryTopContributors200 | undefined;
data:
| SuccessResponse<AlertRuleTopContributorsPayload, unknown>
| ErrorResponse
| undefined;
};
export const useGetAlertRuleDetailsTopContributors =
@@ -233,128 +244,90 @@ export const useGetAlertRuleDetailsTopContributors =
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const { isLoading, isRefetching, isError, data } =
useGetRuleHistoryTopContributors(
{ id: ruleId || '' },
{ start: startTime, end: endTime },
{
query: {
enabled: isValidRuleId && !!startTime && !!endTime,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
);
const { isLoading, isRefetching, isError, data } = useQuery(
[REACT_QUERY_KEY.ALERT_RULE_TOP_CONTRIBUTORS, ruleId, startTime, endTime],
{
queryFn: () =>
topContributors({
id: ruleId || '',
start: startTime,
end: endTime,
}),
enabled: isValidRuleId,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
};
type GetAlertRuleDetailsTimelineTableProps = GetAlertRuleDetailsApiProps & {
data: GetRuleHistoryTimeline200 | undefined;
error: AxiosError<RenderErrorResponseDTO> | null;
refetch: () => void;
cancel: () => void;
data:
| SuccessResponse<AlertRuleTimelineTableResponsePayload, unknown>
| ErrorResponse
| undefined;
};
export const useGetAlertRuleDetailsTimelineTable = ({
filterExpression,
filters,
}: {
filterExpression: string;
filters: TagFilter;
}): GetAlertRuleDetailsTimelineTableProps => {
const queryClient = useQueryClient();
const { ruleId, startTime, endTime, params } = useAlertHistoryQueryParams();
const [page, setPage] = useTimelineTablePage();
const [order] = useTimelineTableOrder();
const updatedOrder = useMemo(
() =>
order === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
[order],
const { updatedOrder, offset } = useMemo(
() => ({
updatedOrder: params.get(urlKey.order) ?? OrderPreferenceItems.ASC,
offset: parseInt(params.get(urlKey.offset) ?? '0', 10),
}),
[params],
);
const timelineFilter = params.get('timelineFilter');
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const hasStartAndEnd = startTime !== null && endTime !== null;
const stateFilter = useMemo(() => {
if (!timelineFilter || timelineFilter === TimelineFilter.ALL) {
return undefined;
}
return timelineFilter === TimelineFilter.FIRED
? RuletypesAlertStateDTO.firing
: RuletypesAlertStateDTO.inactive;
}, [timelineFilter]);
const filtersKey = `${filterExpression}|${stateFilter ?? ''}|${startTime}|${endTime}`;
const prevFiltersKeyRef = useRef(filtersKey);
const filtersChanged = prevFiltersKeyRef.current !== filtersKey;
const cursor = computeCursorForPage(filtersChanged ? 1 : page);
useEffect(() => {
if (prevFiltersKeyRef.current !== filtersKey) {
prevFiltersKeyRef.current = filtersKey;
if (page > 1) {
void setPage(1);
}
}
}, [filtersKey, page, setPage]);
const queryParams = useMemo(
() => ({
start: startTime,
end: endTime,
limit: TIMELINE_TABLE_PAGE_SIZE,
order: updatedOrder,
cursor,
filterExpression: filterExpression || undefined,
state: stateFilter,
}),
[startTime, endTime, updatedOrder, cursor, filterExpression, stateFilter],
);
const queryOptions = getGetRuleHistoryTimelineQueryOptions(
{ id: ruleId || '' },
queryParams,
const { isLoading, isRefetching, isError, data } = useQuery(
[
REACT_QUERY_KEY.ALERT_RULE_TIMELINE_TABLE,
ruleId,
startTime,
endTime,
timelineFilter,
updatedOrder,
offset,
JSON.stringify(filters.items),
],
{
query: {
enabled: isValidRuleId,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
queryFn: () =>
timelineTable({
id: ruleId || '',
start: startTime,
end: endTime,
limit: TIMELINE_TABLE_PAGE_SIZE,
order: updatedOrder,
offset,
filters,
...(timelineFilter && timelineFilter !== TimelineFilter.ALL
? {
state: timelineFilter === TimelineFilter.FIRED ? 'firing' : 'normal',
}
: {}),
}),
enabled: isValidRuleId && hasStartAndEnd,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
const { isLoading, isRefetching, isError, data, error, refetch } =
useQuery(queryOptions);
const queryKeyRef = useRef(queryOptions.queryKey);
queryKeyRef.current = queryOptions.queryKey;
const cancel = useCallback(() => {
void queryClient.cancelQueries({ queryKey: queryKeyRef.current });
}, [queryClient]);
return {
isLoading,
isRefetching,
isError,
data,
error: error as AxiosError<RenderErrorResponseDTO> | null,
isValidRuleId,
ruleId,
refetch,
cancel,
};
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
};
export const useTimelineTable = ({
totalItems,
nextCursor,
}: {
totalItems: number;
nextCursor?: string;
}): {
paginationConfig: TablePaginationConfig;
onChangeHandler: (
@@ -363,13 +336,16 @@ export const useTimelineTable = ({
filters: any,
extra: any,
) => void;
handleNextPage: () => void;
handlePrevPage: () => void;
hasNextPage: boolean;
hasPrevPage: boolean;
} => {
const [page, setPage] = useTimelineTablePage();
const [, setOrder] = useTimelineTableOrder();
const { safeNavigate } = useSafeNavigate();
const { pathname } = useLocation();
const { search } = useLocation();
const params = useMemo(() => new URLSearchParams(search), [search]);
const offset = params.get('offset') ?? '0';
const onChangeHandler: TableProps<AlertRuleTimelineTableResponse>['onChange'] =
useCallback(
@@ -381,52 +357,38 @@ export const useTimelineTable = ({
| SorterResult<AlertRuleTimelineTableResponse>,
) => {
if (!Array.isArray(sorter)) {
const { pageSize = 0, current = 0 } = pagination;
const { order } = sorter;
const updatedOrder = order === 'ascend' ? 'asc' : 'desc';
void Promise.all([setOrder(updatedOrder), setPage(1)]);
const params = new URLSearchParams(window.location.search);
safeNavigate(
`${pathname}?${createQueryParams({
...Object.fromEntries(params),
order: updatedOrder,
offset: current * TIMELINE_TABLE_PAGE_SIZE - TIMELINE_TABLE_PAGE_SIZE,
pageSize,
})}`,
);
}
},
[setOrder, setPage],
[pathname, safeNavigate],
);
const handleNextPage = useCallback(() => {
if (!nextCursor) {
return;
}
void setPage(page + 1);
}, [nextCursor, page, setPage]);
const handlePrevPage = useCallback(() => {
if (page <= 1) {
return;
}
void setPage(page - 1);
}, [page, setPage]);
const offsetInt = parseInt(offset, 10);
const pageSize = params.get('pageSize') ?? String(TIMELINE_TABLE_PAGE_SIZE);
const pageSizeInt = parseInt(pageSize, 10);
const paginationConfig: TablePaginationConfig = {
pageSize: TIMELINE_TABLE_PAGE_SIZE,
showTotal: (total, [start, end]) => (
<span>
<Typography.Text size="small">
{start} &#8212; {end}
</Typography.Text>
<Typography.Text size="small"> of {total}</Typography.Text>
</span>
),
current: page,
pageSize: pageSizeInt,
showTotal: PaginationInfoText,
current: offsetInt / TIMELINE_TABLE_PAGE_SIZE + 1,
showSizeChanger: false,
hideOnSinglePage: true,
total: totalItems,
};
return {
paginationConfig,
onChangeHandler,
handleNextPage,
handlePrevPage,
hasNextPage: !!nextCursor,
hasPrevPage: page > 1,
};
return { paginationConfig, onChangeHandler };
};
export const useAlertRuleStatusToggle = ({
@@ -619,7 +581,10 @@ export const useAlertRuleDelete = ({
};
type GetAlertRuleDetailsTimelineGraphProps = GetAlertRuleDetailsApiProps & {
data: GetRuleHistoryOverallStatus200 | undefined;
data:
| SuccessResponse<AlertRuleTimelineGraphResponsePayload, unknown>
| ErrorResponse
| undefined;
};
export const useGetAlertRuleDetailsTimelineGraphData =
@@ -629,18 +594,20 @@ export const useGetAlertRuleDetailsTimelineGraphData =
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const hasStartAndEnd = startTime !== null && endTime !== null;
const { isLoading, isRefetching, isError, data } =
useGetRuleHistoryOverallStatus(
{ id: ruleId || '' },
{ start: startTime, end: endTime },
{
query: {
enabled: isValidRuleId && hasStartAndEnd,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
);
const { isLoading, isRefetching, isError, data } = useQuery(
[REACT_QUERY_KEY.ALERT_RULE_TIMELINE_GRAPH, ruleId, startTime, endTime],
{
queryFn: () =>
timelineGraph({
id: ruleId || '',
start: startTime,
end: endTime,
}),
enabled: isValidRuleId && hasStartAndEnd,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
};

View File

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

View File

@@ -1,4 +1,4 @@
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
import { AlertLabelsProps } from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
// default match type for threshold
@@ -73,6 +73,10 @@ export interface StatsTimeSeriesItem {
value: string;
}
export type AlertRuleStatsPayload = {
data: AlertRuleStats;
};
export interface AlertRuleTopContributors {
fingerprint: number;
labels: Labels;
@@ -80,6 +84,9 @@ export interface AlertRuleTopContributors {
relatedLogsLink: string;
relatedTracesLink: string;
}
export type AlertRuleTopContributorsPayload = {
data: AlertRuleTopContributors[];
};
export interface AlertRuleTimelineTableResponse {
ruleID: string;
@@ -92,12 +99,24 @@ export interface AlertRuleTimelineTableResponse {
labels: Labels;
fingerprint: number;
value: number;
relatedLogsLink?: string;
relatedTracesLink?: string;
relatedTracesLink: string;
relatedLogsLink: string;
}
export type AlertRuleTimelineTableResponsePayload = {
data: {
items: AlertRuleTimelineTableResponse[];
total: number;
labels: AlertLabelsProps['labels'];
};
};
type AlertState = 'firing' | 'normal' | 'nodata' | 'muted';
export interface AlertRuleTimelineGraphResponse {
start: number;
end: number;
state: RuletypesAlertStateDTO;
state: AlertState;
}
export type AlertRuleTimelineGraphResponsePayload = {
data: AlertRuleTimelineGraphResponse[];
};

View File

@@ -0,0 +1,7 @@
import { AlertDef } from './def';
export interface RuleStatsProps {
id: AlertDef['id'];
start: number;
end: number;
}

View File

@@ -0,0 +1,7 @@
import { AlertDef } from './def';
export interface GetTimelineGraphRequestProps {
id: AlertDef['id'];
start: number;
end: number;
}

View File

@@ -0,0 +1,13 @@
import { TagFilter } from '../queryBuilder/queryBuilderData';
import { AlertDef } from './def';
export interface GetTimelineTableRequestProps {
id: AlertDef['id'];
start: number;
end: number;
offset: number;
limit: number;
order: string;
filters?: TagFilter;
state?: string;
}

View File

@@ -0,0 +1,7 @@
import { AlertDef } from './def';
export interface TopContributorsProps {
id: AlertDef['id'];
start: number;
end: number;
}

View File

@@ -25,6 +25,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/promote"
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/savedview"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
@@ -75,6 +76,7 @@ type provider struct {
rulerHandler ruler.Handler
llmPricingRuleHandler llmpricingrule.Handler
statsHandler statsreporter.Handler
savedViewHandler savedview.Handler
}
func NewFactory(
@@ -110,6 +112,7 @@ func NewFactory(
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.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(
@@ -148,6 +151,7 @@ func NewFactory(
traceDetailHandler,
rulerHandler,
statsHandler,
savedViewHandler,
)
})
}
@@ -188,6 +192,7 @@ func newProvider(
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
router := mux.NewRouter().UseEncodedPath()
@@ -227,6 +232,7 @@ func newProvider(
rulerHandler: rulerHandler,
llmPricingRuleHandler: llmPricingRuleHandler,
statsHandler: statsHandler,
savedViewHandler: savedViewHandler,
}
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
@@ -359,6 +365,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addSavedViewRoutes(router); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,101 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (provider *provider) addSavedViewRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/saved_views", handler.New(provider.authzMiddleware.ViewAccess(provider.savedViewHandler.ListV2), handler.OpenAPIDef{
ID: "ListSavedViews",
Tags: []string{"saved_view"},
Summary: "List saved views",
Description: "Returns saved views, optionally filtered by source page and name.",
Request: nil,
RequestQuery: new(savedviewtypes.ListSavedViewsParams),
RequestContentType: "",
Response: new([]*savedviewtypes.GettableSavedView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/saved_views", handler.New(provider.authzMiddleware.EditAccess(provider.savedViewHandler.CreateV2), handler.OpenAPIDef{
ID: "CreateSavedView",
Tags: []string{"saved_view"},
Summary: "Create saved view",
Description: "Persists a saved view for the explore page. Returns the id of the created view.",
Request: new(savedviewtypes.PostableSavedView),
RequestContentType: "application/json",
Response: new(valuer.UUID),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/saved_views/{viewId}", handler.New(provider.authzMiddleware.ViewAccess(provider.savedViewHandler.GetV2), handler.OpenAPIDef{
ID: "GetSavedView",
Tags: []string{"saved_view"},
Summary: "Get saved view",
Description: "Returns a saved view by id.",
Request: nil,
RequestContentType: "",
Response: new(savedviewtypes.GettableSavedView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/saved_views/{viewId}", handler.New(provider.authzMiddleware.EditAccess(provider.savedViewHandler.UpdateV2), handler.OpenAPIDef{
ID: "UpdateSavedView",
Tags: []string{"saved_view"},
Summary: "Update saved view",
Description: "Replaces a saved view's name and query.",
Request: new(savedviewtypes.UpdatableSavedView),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/saved_views/{viewId}", handler.New(provider.authzMiddleware.EditAccess(provider.savedViewHandler.Delete), handler.OpenAPIDef{
ID: "DeleteSavedView",
Tags: []string{"saved_view"},
Summary: "Delete saved view",
Description: "Deletes a saved view by id.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -485,11 +485,9 @@ func (s *store) buildFilterClause(ctx context.Context, orgID valuer.UUID, filter
}
fieldKeys, _, err := s.telemetryMetadataStore.GetKeysMulti(ctx, orgID, selectors)
if err != nil || fieldKeys == nil {
if err != nil || len(fieldKeys) == 0 {
fieldKeys = map[string][]*telemetrytypes.TelemetryFieldKey{}
}
for _, sel := range selectors {
if len(fieldKeys[sel.Name]) == 0 {
for _, sel := range selectors {
fieldKeys[sel.Name] = []*telemetrytypes.TelemetryFieldKey{{
Name: sel.Name,
Signal: sel.Signal,

View File

@@ -11,6 +11,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/savedview"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -23,6 +25,85 @@ func NewHandler(module savedview.Module) savedview.Handler {
return &handler{module: module}
}
// legacyExtraData mirrors the frontend's extraData JSON shape so /api/v1
// responses can synthesize the same shape back for the legacy frontend.
type legacyExtraData struct {
Color string `json:"color,omitempty"`
SelectColumns []telemetrytypes.TelemetryFieldKey `json:"selectColumns,omitempty"`
Format string `json:"format,omitempty"`
MaxLines int `json:"maxLines,omitempty"`
FontSize string `json:"fontSize,omitempty"`
}
func newPostableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.PostableSavedView {
var legacy legacyExtraData
if v.ExtraData != "" {
// Best-effort: malformed/older extraData shapes never fail the request
_ = json.Unmarshal([]byte(v.ExtraData), &legacy)
}
return savedviewtypes.PostableSavedView{
Name: v.Name,
SourcePage: savedviewtypes.SourcePage{String: valuer.NewString(v.SourcePage)},
SavedViewData: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
PanelType: savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))},
Queries: v.CompositeQuery.Queries,
SelectedFields: legacy.SelectColumns,
Display: savedviewtypes.Display{
MaxLines: legacy.MaxLines,
FontSize: legacy.FontSize,
Format: legacy.Format,
Color: legacy.Color,
},
},
},
}
}
func newLegacyViewFromGettable(v *savedviewtypes.GettableSavedView) (*v3.SavedView, error) {
extraData, err := json.Marshal(legacyExtraData{
Color: v.Spec.Display.Color,
SelectColumns: v.Spec.SelectedFields,
Format: v.Spec.Display.Format,
MaxLines: v.Spec.Display.MaxLines,
FontSize: v.Spec.Display.FontSize,
})
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling extra data")
}
return &v3.SavedView{
ID: v.ID,
Name: v.Name,
CreatedAt: v.CreatedAt,
CreatedBy: v.CreatedBy,
UpdatedAt: v.UpdatedAt,
UpdatedBy: v.UpdatedBy,
SourcePage: v.SourcePage.StringValue(),
CompositeQuery: &v3.CompositeQuery{
PanelType: v3.PanelType(v.Spec.PanelType.StringValue()),
// Saved views are only ever created from the explorer's builder mode.
QueryType: v3.QueryTypeBuilder,
Queries: v.Spec.Queries,
},
ExtraData: string(extraData),
}, nil
}
func newLegacyViewsFromGettable(views []*savedviewtypes.GettableSavedView) ([]*v3.SavedView, error) {
out := make([]*v3.SavedView, 0, len(views))
for _, view := range views {
legacyView, err := newLegacyViewFromGettable(view)
if err != nil {
return nil, err
}
out = append(out, legacyView)
}
return out, nil
}
func (handler *handler) Create(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -44,7 +125,7 @@ func (handler *handler) Create(w http.ResponseWriter, r *http.Request) {
return
}
uuid, err := handler.module.CreateView(ctx, claims.OrgID, view)
uuid, err := handler.module.CreateView(ctx, claims.OrgID, newPostableSavedViewFromLegacyView(&view))
if err != nil {
render.Error(w, err)
return
@@ -76,7 +157,13 @@ func (handler *handler) Get(w http.ResponseWriter, r *http.Request) {
return
}
render.Success(w, http.StatusOK, view)
legacyView, err := newLegacyViewFromGettable(view)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, legacyView)
}
func (handler *handler) Update(w http.ResponseWriter, r *http.Request) {
@@ -106,7 +193,7 @@ func (handler *handler) Update(w http.ResponseWriter, r *http.Request) {
return
}
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, view)
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, newPostableSavedViewFromLegacyView(&view))
if err != nil {
render.Error(w, err)
return
@@ -153,13 +240,18 @@ func (handler *handler) List(w http.ResponseWriter, r *http.Request) {
sourcePage := r.URL.Query().Get("sourcePage")
name := r.URL.Query().Get("name")
category := r.URL.Query().Get("category")
queries, err := handler.module.GetViewsForFilters(r.Context(), claims.OrgID, sourcePage, name, category)
views, err := handler.module.GetViewsForFilters(r.Context(), claims.OrgID, savedviewtypes.SourcePage{String: valuer.NewString(sourcePage)}, name)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, queries)
legacyViews, err := newLegacyViewsFromGettable(views)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, legacyViews)
}

View File

@@ -0,0 +1,134 @@
package implsavedview
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (handler *handler) CreateV2(w 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(w, err)
return
}
var view savedviewtypes.PostableSavedView
if err := binding.JSON.BindBody(r.Body, &view, binding.WithDisallowUnknownFields(true)); err != nil {
render.Error(w, err)
return
}
if err := view.Validate(); err != nil {
render.Error(w, err)
return
}
uuid, err := handler.module.CreateView(ctx, claims.OrgID, view)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, uuid)
}
func (handler *handler) GetV2(w 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(w, err)
return
}
viewID := mux.Vars(r)["viewId"]
viewUUID, err := valuer.NewUUID(viewID)
if err != nil {
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse view id"))
return
}
view, err := handler.module.GetView(ctx, claims.OrgID, viewUUID)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, view)
}
func (handler *handler) UpdateV2(w 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(w, err)
return
}
viewID := mux.Vars(r)["viewId"]
viewUUID, err := valuer.NewUUID(viewID)
if err != nil {
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse view id"))
return
}
var view savedviewtypes.UpdatableSavedView
if err := binding.JSON.BindBody(r.Body, &view, binding.WithDisallowUnknownFields(true)); err != nil {
render.Error(w, err)
return
}
if err := view.Validate(); err != nil {
render.Error(w, err)
return
}
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, view)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, nil)
}
func (handler *handler) ListV2(w 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(w, err)
return
}
params := new(savedviewtypes.ListSavedViewsParams)
if err := binding.Query.BindQuery(r.URL.Query(), params); err != nil {
render.Error(w, err)
return
}
if err := params.Validate(); err != nil {
render.Error(w, err)
return
}
queries, err := handler.module.GetViewsForFilters(r.Context(), claims.OrgID, params.SourcePage, params.Name)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, queries)
}

View File

@@ -2,15 +2,10 @@ package implsavedview
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/savedview"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -24,155 +19,96 @@ func NewModule(sqlstore sqlstore.SQLStore) savedview.Module {
return &module{sqlstore: sqlstore}
}
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, name string, category string) ([]*v3.SavedView, error) {
var views []savedviewtypes.SavedView
var err error
if len(category) == 0 {
err = module.sqlstore.BunDB().NewSelect().Model(&views).Where("org_id = ? AND source_page = ? AND name LIKE ?", orgID, sourcePage, "%"+name+"%").Scan(ctx)
} else {
err = module.sqlstore.BunDB().NewSelect().Model(&views).Where("org_id = ? AND source_page = ? AND category LIKE ? AND name LIKE ?", orgID, sourcePage, "%"+category+"%", "%"+name+"%").Scan(ctx)
}
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, sourcePage savedviewtypes.SourcePage, name string) ([]*savedviewtypes.GettableSavedView, error) {
var views []*savedviewtypes.StorableSavedView
err := module.sqlstore.BunDB().NewSelect().Model(&views).
Where("org_id = ? AND source_page = ? AND name LIKE ?", orgID, sourcePage, "%"+name+"%").
Scan(ctx)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved views")
}
var savedViews []*v3.SavedView
for _, view := range views {
var compositeQuery v3.CompositeQuery
err = json.Unmarshal([]byte(view.Data), &compositeQuery)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in unmarshalling explorer query data: %s", err.Error())
}
savedViews = append(savedViews, &v3.SavedView{
ID: view.ID,
Name: view.Name,
CreatedAt: view.CreatedAt,
CreatedBy: view.CreatedBy,
UpdatedAt: view.UpdatedAt,
UpdatedBy: view.UpdatedBy,
Tags: strings.Split(view.Tags, ","),
SourcePage: view.SourcePage,
CompositeQuery: &compositeQuery,
ExtraData: view.ExtraData,
})
}
return savedViews, nil
return savedviewtypes.NewGettableSavedViewsFromStorable(views), nil
}
func (module *module) CreateView(ctx context.Context, orgID string, view v3.SavedView) (valuer.UUID, error) {
data, err := json.Marshal(view.CompositeQuery)
func (module *module) CreateView(ctx context.Context, orgID string, view savedviewtypes.PostableSavedView) (valuer.UUID, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return valuer.UUID{}, errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling explorer query data")
}
uuid := valuer.GenerateUUID()
createdAt := time.Now()
updatedAt := time.Now()
claims, errv2 := authtypes.ClaimsFromContext(ctx)
if errv2 != nil {
return valuer.UUID{}, errors.NewInternalf(errors.CodeInternal, "error in getting email from context")
}
createBy := claims.Email
updatedBy := claims.Email
dbView := savedviewtypes.NewStorableSavedView(orgID, claims.Email, claims.Email, view)
dbView := savedviewtypes.SavedView{
TimeAuditable: types.TimeAuditable{
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
UserAuditable: types.UserAuditable{
CreatedBy: createBy,
UpdatedBy: updatedBy,
},
OrgID: orgID,
Identifiable: types.Identifiable{
ID: uuid,
},
Name: view.Name,
Category: view.Category,
SourcePage: view.SourcePage,
Tags: strings.Join(view.Tags, ","),
Data: string(data),
ExtraData: view.ExtraData,
}
_, err = module.sqlstore.BunDB().NewInsert().Model(&dbView).Exec(ctx)
_, err = module.sqlstore.BunDB().NewInsert().Model(dbView).Exec(ctx)
if err != nil {
return valuer.UUID{}, errors.WrapInternalf(err, errors.CodeInternal, "error in creating saved view")
}
return uuid, nil
return dbView.ID, nil
}
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView, error) {
var view savedviewtypes.SavedView
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*savedviewtypes.GettableSavedView, error) {
var view savedviewtypes.StorableSavedView
err := module.sqlstore.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, uuid.StringValue()).Scan(ctx)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved view")
return nil, module.sqlstore.WrapNotFoundErrf(err, savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", uuid.StringValue())
}
var compositeQuery v3.CompositeQuery
err = json.Unmarshal([]byte(view.Data), &compositeQuery)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in unmarshalling explorer query data")
}
return &v3.SavedView{
ID: view.ID,
Name: view.Name,
Category: view.Category,
CreatedAt: view.CreatedAt,
CreatedBy: view.CreatedBy,
UpdatedAt: view.UpdatedAt,
UpdatedBy: view.UpdatedBy,
SourcePage: view.SourcePage,
Tags: strings.Split(view.Tags, ","),
CompositeQuery: &compositeQuery,
ExtraData: view.ExtraData,
}, nil
return savedviewtypes.NewGettableSavedViewFromStorable(&view), nil
}
func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view v3.SavedView) error {
data, err := json.Marshal(view.CompositeQuery)
func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view savedviewtypes.UpdatableSavedView) error {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling explorer query data")
}
claims, errv2 := authtypes.ClaimsFromContext(ctx)
if errv2 != nil {
return errors.NewInternalf(errors.CodeInternal, "error in getting email from context")
}
updatedAt := time.Now()
updatedBy := claims.Email
dbView := savedviewtypes.NewStorableSavedView(orgID, claims.Email, claims.Email, view)
_, err = module.sqlstore.BunDB().NewUpdate().
Model(&savedviewtypes.SavedView{}).
Set("updated_at = ?, updated_by = ?, name = ?, category = ?, source_page = ?, tags = ?, data = ?, extra_data = ?",
updatedAt, updatedBy, view.Name, view.Category, view.SourcePage, strings.Join(view.Tags, ","), data, view.ExtraData).
res, err := module.sqlstore.BunDB().NewUpdate().
Model(&savedviewtypes.StorableSavedView{}).
Set("updated_at = ?, updated_by = ?, name = ?, source_page = ?, data = ?",
dbView.UpdatedAt, dbView.UpdatedBy, dbView.Name, dbView.SourcePage, dbView.Data).
Where("id = ?", uuid.StringValue()).
Where("org_id = ?", orgID).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "error in updating saved view")
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "error in verifying the updated saved view")
}
if rowsAffected == 0 {
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", uuid.StringValue())
}
return nil
}
func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error {
_, err := module.sqlstore.BunDB().NewDelete().
Model(&savedviewtypes.SavedView{}).
res, err := module.sqlstore.BunDB().NewDelete().
Model(&savedviewtypes.StorableSavedView{}).
Where("id = ?", uuid.StringValue()).
Where("org_id = ?", orgID).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "error in deleting explorer query")
return errors.WrapInternalf(err, errors.CodeInternal, "error in deleting saved view")
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "error in verifying the deleted saved view")
}
if rowsAffected == 0 {
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", uuid.StringValue())
}
return nil
}
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
savedViews := []*savedviewtypes.SavedView{}
savedViews := []*savedviewtypes.StorableSavedView{}
err := module.
sqlstore.

View File

@@ -0,0 +1,270 @@
package implsavedview
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{
MaxOpenConns: 1,
MaxConnLifetime: 0,
},
Sqlite: sqlstore.SqliteConfig{
Path: dbPath,
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
_, err = store.BunDB().NewCreateTable().
Model((*savedviewtypes.StorableSavedView)(nil)).
IfNotExists().
Exec(context.Background())
require.NoError(t, err)
return store
}
func testPostableSavedView(name string, sourcePage savedviewtypes.SourcePage) savedviewtypes.PostableSavedView {
return savedviewtypes.PostableSavedView{
Name: name,
SourcePage: sourcePage,
SavedViewData: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
PanelType: savedviewtypes.PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
},
},
},
},
},
}
}
func contextWithClaims(orgID, email string) context.Context {
return authtypes.NewContextWithClaims(context.Background(), authtypes.Claims{
OrgID: orgID,
Email: email,
})
}
func TestModule_CreateAndGetView(t *testing.T) {
store := newTestStore(t)
m := NewModule(store)
orgID := valuer.GenerateUUID().StringValue()
ctx := contextWithClaims(orgID, "creator@signoz.io")
id, err := m.CreateView(ctx, orgID, testPostableSavedView("my view", savedviewtypes.SourcePageLogs))
require.NoError(t, err)
require.False(t, id.IsZero())
got, err := m.GetView(ctx, orgID, id)
require.NoError(t, err)
assert.Equal(t, id, got.ID)
assert.Equal(t, "my view", got.Name)
assert.Equal(t, savedviewtypes.SourcePageLogs, got.SourcePage)
assert.Equal(t, "creator@signoz.io", got.CreatedBy)
assert.Equal(t, "creator@signoz.io", got.UpdatedBy)
assert.Equal(t, savedviewtypes.PanelTypeGraph, got.Spec.PanelType)
}
func TestModule_GetView_ScopedToOrg(t *testing.T) {
store := newTestStore(t)
m := NewModule(store)
orgA := valuer.GenerateUUID().StringValue()
orgB := valuer.GenerateUUID().StringValue()
id, err := m.CreateView(contextWithClaims(orgA, "a@signoz.io"), orgA, testPostableSavedView("org a's view", savedviewtypes.SourcePageLogs))
require.NoError(t, err)
_, err = m.GetView(contextWithClaims(orgB, "b@signoz.io"), orgB, id)
require.Error(t, err, "a view created under org A must not be visible to org B")
assert.True(t, errors.Ast(err, errors.TypeNotFound), "expected a not-found error, got %v", err)
}
func TestModule_UpdateView(t *testing.T) {
store := newTestStore(t)
m := NewModule(store)
orgID := valuer.GenerateUUID().StringValue()
ctx := contextWithClaims(orgID, "creator@signoz.io")
id, err := m.CreateView(ctx, orgID, testPostableSavedView("original", savedviewtypes.SourcePageLogs))
require.NoError(t, err)
updated := testPostableSavedView("renamed", savedviewtypes.SourcePageTraces)
updated.Spec.PanelType = savedviewtypes.PanelTypeTable
updateCtx := contextWithClaims(orgID, "updater@signoz.io")
require.NoError(t, m.UpdateView(updateCtx, orgID, id, updated))
got, err := m.GetView(ctx, orgID, id)
require.NoError(t, err)
assert.Equal(t, "renamed", got.Name)
assert.Equal(t, savedviewtypes.SourcePageTraces, got.SourcePage)
assert.Equal(t, savedviewtypes.PanelTypeTable, got.Spec.PanelType)
assert.Equal(t, "updater@signoz.io", got.UpdatedBy)
assert.Equal(t, "creator@signoz.io", got.CreatedBy, "creator is untouched by an update")
}
func TestModule_UpdateView_NotFound(t *testing.T) {
store := newTestStore(t)
m := NewModule(store)
orgID := valuer.GenerateUUID().StringValue()
ctx := contextWithClaims(orgID, "someone@signoz.io")
err := m.UpdateView(ctx, orgID, valuer.GenerateUUID(), testPostableSavedView("does not exist", savedviewtypes.SourcePageLogs))
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeNotFound), "expected a not-found error, got %v", err)
}
func TestModule_UpdateView_ScopedToOrg(t *testing.T) {
store := newTestStore(t)
m := NewModule(store)
orgA := valuer.GenerateUUID().StringValue()
orgB := valuer.GenerateUUID().StringValue()
id, err := m.CreateView(contextWithClaims(orgA, "a@signoz.io"), orgA, testPostableSavedView("org a's view", savedviewtypes.SourcePageLogs))
require.NoError(t, err)
err = m.UpdateView(contextWithClaims(orgB, "b@signoz.io"), orgB, id, testPostableSavedView("hijacked", savedviewtypes.SourcePageLogs))
require.Error(t, err, "org B must not be able to update org A's view")
}
func TestModule_DeleteView(t *testing.T) {
store := newTestStore(t)
m := NewModule(store)
orgID := valuer.GenerateUUID().StringValue()
ctx := contextWithClaims(orgID, "creator@signoz.io")
id, err := m.CreateView(ctx, orgID, testPostableSavedView("my view", savedviewtypes.SourcePageLogs))
require.NoError(t, err)
require.NoError(t, m.DeleteView(ctx, orgID, id))
_, err = m.GetView(ctx, orgID, id)
require.Error(t, err, "deleted view should no longer be gettable")
}
func TestModule_DeleteView_NotFound(t *testing.T) {
store := newTestStore(t)
m := NewModule(store)
orgID := valuer.GenerateUUID().StringValue()
ctx := contextWithClaims(orgID, "someone@signoz.io")
err := m.DeleteView(ctx, orgID, valuer.GenerateUUID())
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeNotFound), "expected a not-found error, got %v", err)
}
func TestModule_DeleteView_ScopedToOrg(t *testing.T) {
store := newTestStore(t)
m := NewModule(store)
orgA := valuer.GenerateUUID().StringValue()
orgB := valuer.GenerateUUID().StringValue()
id, err := m.CreateView(contextWithClaims(orgA, "a@signoz.io"), orgA, testPostableSavedView("org a's view", savedviewtypes.SourcePageLogs))
require.NoError(t, err)
err = m.DeleteView(contextWithClaims(orgB, "b@signoz.io"), orgB, id)
require.Error(t, err, "org B must not be able to delete org A's view")
assert.True(t, errors.Ast(err, errors.TypeNotFound))
}
func TestModule_GetViewsForFilters(t *testing.T) {
store := newTestStore(t)
m := NewModule(store)
orgID := valuer.GenerateUUID().StringValue()
ctx := contextWithClaims(orgID, "creator@signoz.io")
_, err := m.CreateView(ctx, orgID, testPostableSavedView("logs overview", savedviewtypes.SourcePageLogs))
require.NoError(t, err)
_, err = m.CreateView(ctx, orgID, testPostableSavedView("logs errors", savedviewtypes.SourcePageLogs))
require.NoError(t, err)
_, err = m.CreateView(ctx, orgID, testPostableSavedView("traces overview", savedviewtypes.SourcePageTraces))
require.NoError(t, err)
t.Run("filters by source page", func(t *testing.T) {
views, err := m.GetViewsForFilters(ctx, orgID, savedviewtypes.SourcePageLogs, "")
require.NoError(t, err)
assert.Len(t, views, 2)
})
t.Run("filters by name substring", func(t *testing.T) {
views, err := m.GetViewsForFilters(ctx, orgID, savedviewtypes.SourcePageLogs, "errors")
require.NoError(t, err)
require.Len(t, views, 1)
assert.Equal(t, "logs errors", views[0].Name)
})
t.Run("source page filter is an exact match, not a wildcard", func(t *testing.T) {
// source_page is matched with =, not LIKE, so a zero-value
// SourcePage doesn't mean "any" -- it matches nothing.
views, err := m.GetViewsForFilters(ctx, orgID, savedviewtypes.SourcePage{}, "")
require.NoError(t, err)
assert.Empty(t, views)
})
t.Run("scoped to org", func(t *testing.T) {
otherOrgID := valuer.GenerateUUID().StringValue()
views, err := m.GetViewsForFilters(ctx, otherOrgID, savedviewtypes.SourcePageLogs, "")
require.NoError(t, err)
assert.Empty(t, views)
})
}
func TestModule_Collect(t *testing.T) {
store := newTestStore(t)
m := NewModule(store)
orgID := valuer.GenerateUUID()
ctx := contextWithClaims(orgID.StringValue(), "creator@signoz.io")
_, err := m.CreateView(ctx, orgID.StringValue(), testPostableSavedView("logs a", savedviewtypes.SourcePageLogs))
require.NoError(t, err)
_, err = m.CreateView(ctx, orgID.StringValue(), testPostableSavedView("logs b", savedviewtypes.SourcePageLogs))
require.NoError(t, err)
_, err = m.CreateView(ctx, orgID.StringValue(), testPostableSavedView("traces a", savedviewtypes.SourcePageTraces))
require.NoError(t, err)
stats, err := m.Collect(context.Background(), orgID)
require.NoError(t, err)
assert.Equal(t, int64(3), stats["savedview.count"])
assert.Equal(t, int64(2), stats["savedview.source.logs.count"])
assert.Equal(t, int64(1), stats["savedview.source.traces.count"])
}

View File

@@ -4,19 +4,19 @@ import (
"context"
"net/http"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, name string, category string) ([]*v3.SavedView, error)
GetViewsForFilters(ctx context.Context, orgID string, sourcePage savedviewtypes.SourcePage, name string) ([]*savedviewtypes.GettableSavedView, error)
CreateView(ctx context.Context, orgID string, view v3.SavedView) (valuer.UUID, error)
CreateView(ctx context.Context, orgID string, view savedviewtypes.PostableSavedView) (valuer.UUID, error)
GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView, error)
GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*savedviewtypes.GettableSavedView, error)
UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view v3.SavedView) error
UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view savedviewtypes.UpdatableSavedView) error
DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error
@@ -33,9 +33,22 @@ type Handler interface {
// Updates the saved view
Update(http.ResponseWriter, *http.Request)
// Deletes the saved view
// Deletes the saved view. Shared by both API generations -- delete has no
// request/response body to reshape.
Delete(http.ResponseWriter, *http.Request)
// Lists the saved views
List(http.ResponseWriter, *http.Request)
// CreateV2 is the /api/v2/saved_views typed-spec variant of Create.
CreateV2(http.ResponseWriter, *http.Request)
// GetV2 is the /api/v2/saved_views typed-spec variant of Get.
GetV2(http.ResponseWriter, *http.Request)
// UpdateV2 is the /api/v2/saved_views typed-spec variant of Update.
UpdateV2(http.ResponseWriter, *http.Request)
// ListV2 is the /api/v2/saved_views typed-spec variant of List.
ListV2(http.ResponseWriter, *http.Request)
}

View File

@@ -30,6 +30,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/promote"
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/savedview"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
@@ -88,6 +89,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ tracedetail.Handler }{},
struct{ ruler.Handler }{},
struct{ statsreporter.Handler }{},
struct{ savedview.Handler }{},
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
if err != nil {
return nil, err

View File

@@ -231,6 +231,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateDashboardsV1ToV2Factory(sqlstore, sqlschema, dashboardStore, tagModule),
sqlmigration.NewFillDashboardMeterSourceFactory(sqlstore, dashboardStore),
sqlmigration.NewUpdateRoleTransactionGroupsFactory(),
sqlmigration.NewRestructureSavedViewSpecFactory(sqlstore),
)
}
@@ -330,6 +331,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.TraceDetail,
handlers.RulerHandler,
handlers.StatsHandler,
handlers.SavedView,
),
)
}

View File

@@ -0,0 +1,144 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
type restructureSavedViewSpec struct {
store sqlstore.SQLStore
}
func NewRestructureSavedViewSpecFactory(store sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("restructure_saved_view_spec"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &restructureSavedViewSpec{store: store}, nil
})
}
func (migration *restructureSavedViewSpec) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// legacySavedViewCompositeQuery is the bare shape saved_view.data held
// before this migration -- just the relevant fields of composite query.
// Queries is kept as raw JSON since the migration only needs to relocate it, not interpret it.
type legacySavedViewCompositeQuery struct {
PanelType string `json:"panelType"`
Queries json.RawMessage `json:"queries"`
}
// legacySavedViewExtraData mirrors the frontend defined extraData JSON shape.
type legacySavedViewExtraData struct {
Color string `json:"color,omitempty"`
SelectColumns json.RawMessage `json:"selectColumns,omitempty"`
Format string `json:"format,omitempty"`
MaxLines int `json:"maxLines,omitempty"`
FontSize string `json:"fontSize,omitempty"`
}
type savedViewDisplay struct {
MaxLines int `json:"maxLines"`
FontSize string `json:"fontSize"`
Format string `json:"format"`
Color string `json:"color"`
}
type savedViewSpec struct {
PanelType string `json:"panelType"`
Queries json.RawMessage `json:"queries"`
SelectedFields json.RawMessage `json:"selectedFields"`
Display savedViewDisplay `json:"display"`
}
type savedViewData struct {
SchemaVersion string `json:"schemaVersion"`
Spec savedViewSpec `json:"spec"`
}
func (migration *restructureSavedViewSpec) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var savedViews []struct {
ID string `bun:"id"`
Data string `bun:"data"`
ExtraData string `bun:"extra_data"`
}
err = tx.NewSelect().
Table("saved_views").
Column("id", "data", "extra_data").
Scan(ctx, &savedViews)
if err != nil && err != sql.ErrNoRows {
return err
}
for _, savedView := range savedViews {
var compositeQuery legacySavedViewCompositeQuery
if err := json.Unmarshal([]byte(savedView.Data), &compositeQuery); err != nil {
continue // skip the row on error rather than fail the whole migration
}
var extraData legacySavedViewExtraData
if savedView.ExtraData != "" {
// best-effort: malformed/older extraData shapes never fail the migration,
// they just leave selectedFields/display empty.
_ = json.Unmarshal([]byte(savedView.ExtraData), &extraData)
}
dataJSON, err := json.Marshal(savedViewData{
SchemaVersion: "v2",
Spec: savedViewSpec{
PanelType: compositeQuery.PanelType,
Queries: compositeQuery.Queries,
SelectedFields: extraData.SelectColumns,
Display: savedViewDisplay{
MaxLines: extraData.MaxLines,
FontSize: extraData.FontSize,
Format: extraData.Format,
Color: extraData.Color,
},
},
})
if err != nil {
return err
}
_, err = tx.NewUpdate().
Table("saved_views").
Set("data = ?", string(dataJSON)).
Where("id = ?", savedView.ID).
Exec(ctx)
if err != nil {
return err
}
}
for _, column := range []string{"category", "tags"} {
if err := migration.store.Dialect().DropColumn(ctx, tx, "saved_views", column); err != nil {
return err
}
}
// matching the singular table-name convention.
if _, err := tx.ExecContext(ctx, "ALTER TABLE saved_views RENAME TO saved_view"); err != nil {
return err
}
return tx.Commit()
}
func (migration *restructureSavedViewSpec) Down(context.Context, *bun.DB) error {
// this migration is not reversible as we're transforming the structure
return nil
}

View File

@@ -2,30 +2,145 @@ package savedviewtypes
import (
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
type SavedView struct {
bun.BaseModel `bun:"table:saved_views"`
var (
ErrCodeSavedViewInvalidInput = errors.MustNewCode("saved_view_invalid_input")
ErrCodeSavedViewNotFound = errors.MustNewCode("saved_view_not_found")
)
type GettableSavedView struct {
ID valuer.UUID `json:"id" required:"true"`
Name string `json:"name" required:"true"`
CreatedAt time.Time `json:"createdAt" required:"true"`
CreatedBy string `json:"createdBy" required:"true"`
UpdatedAt time.Time `json:"updatedAt" required:"true"`
UpdatedBy string `json:"updatedBy" required:"true"`
SourcePage SourcePage `json:"sourcePage" required:"true"`
SavedViewData
}
type PostableSavedView struct {
Name string `json:"name" required:"true"`
SourcePage SourcePage `json:"sourcePage" required:"true"`
SavedViewData
}
type UpdatableSavedView = PostableSavedView
type ListSavedViewsParams struct {
SourcePage SourcePage `query:"sourcePage"`
Name string `query:"name"`
}
// StorableSavedView has schemaVersion + spec stored JSON-encoded in Data.
type StorableSavedView struct {
bun.BaseModel `bun:"table:saved_view"`
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID string `json:"orgId" bun:"org_id,notnull"`
Name string `json:"name" bun:"name,type:text,notnull"`
Category string `json:"category" bun:"category,type:text,notnull"`
SourcePage string `json:"sourcePage" bun:"source_page,type:text,notnull"`
Tags string `json:"tags" bun:"tags,type:text"`
Data string `json:"data" bun:"data,type:text,notnull"`
ExtraData string `json:"extraData" bun:"extra_data,type:text"`
OrgID string `json:"orgId" bun:"org_id,notnull"`
Name string `json:"name" bun:"name,type:text,notnull"`
SourcePage SourcePage `json:"sourcePage" bun:"source_page,type:text,notnull"`
Data SavedViewData `json:"data" bun:"data,type:text,notnull"`
}
func NewStatsFromSavedViews(savedViews []*SavedView) map[string]any {
type SourcePage struct {
valuer.String
}
var (
SourcePageTraces = SourcePage{valuer.NewString("traces")}
SourcePageLogs = SourcePage{valuer.NewString("logs")}
SourcePageMetrics = SourcePage{valuer.NewString("metrics")}
SourcePageMeter = SourcePage{valuer.NewString("meter")}
)
func (SourcePage) Enum() []any {
return []any{
SourcePageTraces,
SourcePageLogs,
SourcePageMetrics,
SourcePageMeter,
}
}
func (s SourcePage) Validate() error {
switch s {
case SourcePageTraces, SourcePageLogs, SourcePageMetrics, SourcePageMeter:
return nil
default:
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "invalid source page: %s", s.StringValue())
}
}
func (p *PostableSavedView) Validate() error {
if err := p.SourcePage.Validate(); err != nil {
return err
}
return p.SavedViewData.Validate()
}
func (p *ListSavedViewsParams) Validate() error {
if p.SourcePage.IsZero() {
return nil
}
return p.SourcePage.Validate()
}
func NewStorableSavedView(orgID string, createdBy string, updatedBy string, view PostableSavedView) *StorableSavedView {
now := time.Now()
return &StorableSavedView{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
UserAuditable: types.UserAuditable{CreatedBy: createdBy, UpdatedBy: updatedBy},
OrgID: orgID,
Name: view.Name,
SourcePage: view.SourcePage,
Data: view.SavedViewData,
}
}
func NewGettableSavedViewFromStorable(view *StorableSavedView) *GettableSavedView {
data := view.Data
if data.Spec.SelectedFields == nil {
data.Spec.SelectedFields = []telemetrytypes.TelemetryFieldKey{}
}
return &GettableSavedView{
ID: view.ID,
Name: view.Name,
CreatedAt: view.CreatedAt,
CreatedBy: view.CreatedBy,
UpdatedAt: view.UpdatedAt,
UpdatedBy: view.UpdatedBy,
SourcePage: view.SourcePage,
SavedViewData: data,
}
}
func NewGettableSavedViewsFromStorable(views []*StorableSavedView) []*GettableSavedView {
out := make([]*GettableSavedView, 0, len(views))
for _, view := range views {
out = append(out, NewGettableSavedViewFromStorable(view))
}
return out
}
func NewStatsFromSavedViews(savedViews []*StorableSavedView) map[string]any {
stats := make(map[string]any)
for _, savedView := range savedViews {
key := "savedview.source." + strings.ToLower(string(savedView.SourcePage)) + ".count"
key := "savedview.source." + strings.ToLower(savedView.SourcePage.StringValue()) + ".count"
if _, ok := stats[key]; !ok {
stats[key] = int64(1)
} else {

View File

@@ -0,0 +1,178 @@
package savedviewtypes
import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validPostableSavedView() PostableSavedView {
return PostableSavedView{
Name: "my view",
SourcePage: SourcePageLogs,
SavedViewData: SavedViewData{
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()},
},
}
}
func TestSourcePageValidate(t *testing.T) {
cases := []struct {
name string
sourcePage SourcePage
expectError bool
}{
{name: "traces", sourcePage: SourcePageTraces},
{name: "logs", sourcePage: SourcePageLogs},
{name: "metrics", sourcePage: SourcePageMetrics},
{name: "meter", sourcePage: SourcePageMeter},
{name: "unknown is rejected", sourcePage: SourcePage{valuer.NewString("bogus")}, expectError: true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.sourcePage.Validate()
if c.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestPostableSavedViewValidate(t *testing.T) {
t.Run("valid view", func(t *testing.T) {
view := validPostableSavedView()
assert.NoError(t, view.Validate())
})
t.Run("invalid source page is rejected", func(t *testing.T) {
view := validPostableSavedView()
view.SourcePage = SourcePage{valuer.NewString("bogus")}
assert.Error(t, view.Validate())
})
t.Run("invalid saved view data is rejected", func(t *testing.T) {
view := validPostableSavedView()
view.SchemaVersion = "v1"
assert.Error(t, view.Validate())
})
}
func TestListSavedViewsParamsValidate(t *testing.T) {
t.Run("zero source page is allowed", func(t *testing.T) {
params := ListSavedViewsParams{}
assert.NoError(t, params.Validate())
})
t.Run("valid source page is allowed", func(t *testing.T) {
params := ListSavedViewsParams{SourcePage: SourcePageLogs}
assert.NoError(t, params.Validate())
})
t.Run("invalid source page is rejected", func(t *testing.T) {
params := ListSavedViewsParams{SourcePage: SourcePage{valuer.NewString("bogus")}}
assert.Error(t, params.Validate())
})
}
func TestNewStorableSavedView(t *testing.T) {
orgID := valuer.GenerateUUID().StringValue()
view := validPostableSavedView()
storable := NewStorableSavedView(orgID, "creator@signoz.io", "updater@signoz.io", view)
assert.False(t, storable.ID.IsZero())
assert.Equal(t, orgID, storable.OrgID)
assert.Equal(t, "creator@signoz.io", storable.CreatedBy)
assert.Equal(t, "updater@signoz.io", storable.UpdatedBy)
assert.Equal(t, view.Name, storable.Name)
assert.Equal(t, view.SourcePage, storable.SourcePage)
assert.Equal(t, view.SavedViewData, storable.Data)
assert.False(t, storable.CreatedAt.IsZero())
assert.Equal(t, storable.CreatedAt, storable.UpdatedAt)
}
func TestNewGettableSavedViewFromStorable(t *testing.T) {
t.Run("nil selected fields are normalized to an empty slice", func(t *testing.T) {
storable := &StorableSavedView{
Name: "my view",
SourcePage: SourcePageLogs,
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries(), SelectedFields: nil},
},
}
gettable := NewGettableSavedViewFromStorable(storable)
require.NotNil(t, gettable.Spec.SelectedFields)
assert.Empty(t, gettable.Spec.SelectedFields)
})
t.Run("existing selected fields are preserved", func(t *testing.T) {
fields := []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}}
storable := &StorableSavedView{
Name: "my view",
SourcePage: SourcePageLogs,
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries(), SelectedFields: fields},
},
}
gettable := NewGettableSavedViewFromStorable(storable)
assert.Equal(t, fields, gettable.Spec.SelectedFields)
})
t.Run("all fields carried over", func(t *testing.T) {
now := time.Now()
storable := &StorableSavedView{
Name: "my view",
SourcePage: SourcePageTraces,
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{PanelType: PanelTypeTable, Queries: validQueries()},
},
}
storable.ID = valuer.GenerateUUID()
storable.CreatedAt = now
storable.UpdatedAt = now
storable.CreatedBy = "creator@signoz.io"
storable.UpdatedBy = "updater@signoz.io"
gettable := NewGettableSavedViewFromStorable(storable)
assert.Equal(t, storable.ID, gettable.ID)
assert.Equal(t, storable.Name, gettable.Name)
assert.Equal(t, storable.CreatedAt, gettable.CreatedAt)
assert.Equal(t, storable.CreatedBy, gettable.CreatedBy)
assert.Equal(t, storable.UpdatedAt, gettable.UpdatedAt)
assert.Equal(t, storable.UpdatedBy, gettable.UpdatedBy)
assert.Equal(t, storable.SourcePage, gettable.SourcePage)
assert.Equal(t, storable.Data.SchemaVersion, gettable.SchemaVersion)
assert.Equal(t, storable.Data.Spec.PanelType, gettable.Spec.PanelType)
})
}
func TestNewStatsFromSavedViews(t *testing.T) {
views := []*StorableSavedView{
{SourcePage: SourcePageLogs},
{SourcePage: SourcePageLogs},
{SourcePage: SourcePageTraces},
}
stats := NewStatsFromSavedViews(views)
assert.Equal(t, int64(3), stats["savedview.count"])
assert.Equal(t, int64(2), stats["savedview.source.logs.count"])
assert.Equal(t, int64(1), stats["savedview.source.traces.count"])
assert.NotContains(t, stats, "savedview.source.metrics.count")
}

View File

@@ -0,0 +1,81 @@
package savedviewtypes
import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// SavedViewSchemaVersion is the only schemaVersion currently.
const SavedViewSchemaVersion = "v2"
// Display holds view-rendering preferences.
type Display struct {
MaxLines int `json:"maxLines"`
FontSize string `json:"fontSize"`
Format string `json:"format"`
Color string `json:"color"`
}
// SavedViewSpec is the typed content of a saved view, mirroring the dashboardtypes v2 spec pattern.
type SavedViewSpec struct {
PanelType PanelType `json:"panelType" required:"true"`
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true" nullable:"false"`
SelectedFields []telemetrytypes.TelemetryFieldKey `json:"selectedFields" required:"true" nullable:"false"`
Display Display `json:"display" required:"true"`
}
// SavedViewData is what's persisted as saved view data.
type SavedViewData struct {
SchemaVersion string `json:"schemaVersion" required:"true"`
Spec SavedViewSpec `json:"spec" required:"true"`
}
// PanelType is the explore-page panel a saved view renders as.
type PanelType struct {
valuer.String
}
var (
PanelTypeValue = PanelType{valuer.NewString("value")}
PanelTypeGraph = PanelType{valuer.NewString("graph")}
PanelTypeTable = PanelType{valuer.NewString("table")}
PanelTypeList = PanelType{valuer.NewString("list")}
PanelTypeTrace = PanelType{valuer.NewString("trace")}
)
func (PanelType) Enum() []any {
return []any{
PanelTypeValue,
PanelTypeGraph,
PanelTypeTable,
PanelTypeList,
PanelTypeTrace,
}
}
func (p PanelType) Validate() error {
switch p {
case PanelTypeValue, PanelTypeGraph, PanelTypeTable, PanelTypeList, PanelTypeTrace:
return nil
default:
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "invalid panel type: %s", p.StringValue())
}
}
func (s *SavedViewSpec) Validate() error {
if err := s.PanelType.Validate(); err != nil {
return err
}
return (&qbtypes.CompositeQuery{Queries: s.Queries}).Validate()
}
func (d *SavedViewData) Validate() error {
if d.SchemaVersion != SavedViewSchemaVersion {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "schemaVersion must be %q, got %q", SavedViewSchemaVersion, d.SchemaVersion)
}
return d.Spec.Validate()
}

View File

@@ -0,0 +1,134 @@
package savedviewtypes
import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"testing"
"github.com/stretchr/testify/assert"
)
func validQueries() []qbtypes.QueryEnvelope {
return []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
},
},
}
}
func TestPanelTypeValidate(t *testing.T) {
cases := []struct {
name string
panelType PanelType
expectError bool
}{
{name: "value", panelType: PanelTypeValue},
{name: "graph", panelType: PanelTypeGraph},
{name: "table", panelType: PanelTypeTable},
{name: "list", panelType: PanelTypeList},
{name: "trace", panelType: PanelTypeTrace},
{name: "unknown is rejected", panelType: PanelType{valuer.NewString("bogus")}, expectError: true},
{name: "empty is rejected", panelType: PanelType{}, expectError: true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.panelType.Validate()
if c.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestSavedViewSpecValidate(t *testing.T) {
cases := []struct {
name string
spec SavedViewSpec
expectError bool
}{
{
name: "valid spec",
spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()},
expectError: false,
},
{
name: "invalid panel type is rejected before queries are checked",
spec: SavedViewSpec{PanelType: PanelType{valuer.NewString("bogus")}, Queries: validQueries()},
expectError: true,
},
{
name: "no queries is rejected",
spec: SavedViewSpec{PanelType: PanelTypeGraph},
expectError: true,
},
{
name: "selected fields and display are not required",
spec: SavedViewSpec{
PanelType: PanelTypeTable,
Queries: validQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
Display: Display{MaxLines: 3, FontSize: "small", Format: "table", Color: "blue"},
},
expectError: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.spec.Validate()
if c.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestSavedViewDataValidate(t *testing.T) {
cases := []struct {
name string
data SavedViewData
expectError bool
}{
{
name: "valid data",
data: SavedViewData{SchemaVersion: SavedViewSchemaVersion, Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()}},
expectError: false,
},
{
name: "wrong schema version is rejected",
data: SavedViewData{SchemaVersion: "v1", Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()}},
expectError: true,
},
{
name: "empty schema version is rejected",
data: SavedViewData{Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()}},
expectError: true,
},
{
name: "invalid spec is rejected",
data: SavedViewData{SchemaVersion: SavedViewSchemaVersion, Spec: SavedViewSpec{PanelType: PanelTypeGraph}},
expectError: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.data.Validate()
if c.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}

View File

@@ -1,383 +0,0 @@
import type { Browser } from '@playwright/test';
import {
createEmailChannelViaApi,
createLogsAlertViaApi,
createMetricAlertViaApi,
createNoDataAlertViaApi,
deleteAlertViaApi,
deleteChannelViaApi,
readTimelineTotal,
seedAlertHistoryLogs,
seedAlertHistoryMetrics,
setRuleDisabledViaApi,
waitForTimelineEntries,
waitForTimelineStates,
} from '../helpers/alerts';
import { expect, test as base, withAdminPage } from './alert-rules';
// 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 the logs fixtures, ~10s for metrics, ~105s for
// the firing→resolved wave. 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.
//
// See `tests/e2e/specs/alerts/alerts-e2e-coverage.md` §3 for the recipes and the
// empirically-measured timings each budget here is derived from.
/** Distinct `service.name` values SEED-A seeds ⇒ its timeline row count. */
const SEED_A_SERVICES = 25;
/** SEED-F seeds fewer services and a 1m window so it resolves inside ~105s. */
const SEED_F_SERVICES = 3;
/** SEED-E's group-by values ⇒ a 2-row history that fits on one page. */
const SEED_E_HOSTS = ['host-0', 'host-1'];
/** Non-severity label on SEED-C, so the v1 header's labels row is non-empty. */
export const SEED_C_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 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);
}
});
}
export const test = base.extend<
// eslint-disable-next-line @typescript-eslint/ban-types
{},
{
alertHistory: AlertHistorySeed;
metricsHistory: MetricsHistorySeed;
resolvedHistory: ResolvedHistorySeed;
noDataHistory: NoDataHistorySeed;
emptyHistory: EmptyHistorySeed;
}
>({
/**
* SEED-A (25-row firing history, v2) **plus** SEED-C (the same logs seen
* through a legacy v1 rule). Both rules share one seeded log batch, so the
* two ruler waves overlap and the fixture costs roughly one wait, not two.
*/
alertHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
const marker = `e2e alert history ${stamp}`;
let channelId = '';
let ruleId = '';
let ruleIdV1 = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(page, `e2e-ah-ch-${stamp}`);
channelId = channel.id;
// Seed and create in the same breath: the rules only fire while the
// records are inside the 5m eval window.
const services = await seedAlertHistoryLogs(page, {
marker,
services: SEED_A_SERVICES,
servicePrefix: `e2e-ah-svc`,
});
ruleId = await createLogsAlertViaApi(page, {
name: `e2e-ah-rule-v2-${stamp}`,
marker,
channels: [channel.name],
schema: 'v2',
});
ruleIdV1 = await createLogsAlertViaApi(page, {
name: `e2e-ah-rule-v1-${stamp}`,
marker,
channels: [channel.name],
schema: 'v1',
// The v1 header renders `labels` minus `severity`, so without a
// second label its labels row is present but empty (AD-02).
extraLabels: { team: SEED_C_TEAM_LABEL },
});
await waitForTimelineEntries(page, ruleId, { min: SEED_A_SERVICES });
await waitForTimelineEntries(page, ruleIdV1, { min: SEED_A_SERVICES });
// Freeze both before the eval window rolls past the seeded records —
// otherwise the resolve wave doubles `total` mid-suite.
await setRuleDisabledViaApi(page, ruleId, true);
await setRuleDisabledViaApi(page, ruleIdV1, true);
return {
ruleId,
ruleIdV1,
channelName: channel.name,
marker,
services,
total: await readTimelineTotal(page, ruleId),
totalV1: await readTimelineTotal(page, ruleIdV1),
};
});
if (seed.total !== SEED_A_SERVICES) {
// A different total means the fixture is not what the scenarios were
// written against — most likely the resolve wave landed before the
// PATCH froze the rule. Fail loudly here rather than let every
// downstream count assertion fail with a confusing off-by-N.
throw new Error(
`SEED-A expected ${SEED_A_SERVICES} timeline rows, got ${seed.total}`,
);
}
await use(seed);
await cleanup(browser, { ruleIds: [ruleId, ruleIdV1], channelId });
},
{ scope: 'worker', timeout: 240_000 },
],
/**
* SEED-E — a metrics rule over two hosts. Two things SEED-A can't give:
* history rows with **no** related links (links are derived from the rule's
* signal), and a 2-row history that fits on a single page.
*/
metricsHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
const metricName = `e2e_ah_probe_metric_${stamp}`;
let channelId = '';
let ruleId = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-metrics-ch-${stamp}`,
);
channelId = channel.id;
await seedAlertHistoryMetrics(page, {
metricName,
hosts: SEED_E_HOSTS,
});
ruleId = await createMetricAlertViaApi(page, {
name: `e2e-ah-metrics-rule-${stamp}`,
metricName,
channels: [channel.name],
});
await waitForTimelineEntries(page, ruleId, {
min: SEED_E_HOSTS.length,
timeoutMs: 120_000,
});
await setRuleDisabledViaApi(page, ruleId, true);
return {
ruleId,
channelName: channel.name,
metricName,
hosts: SEED_E_HOSTS,
total: await readTimelineTotal(page, ruleId),
};
});
await use(seed);
await cleanup(browser, { ruleIds: [ruleId], channelId });
},
{ scope: 'worker', timeout: 240_000 },
],
/**
* SEED-F — firing **and** resolved, without touching the seeder: a 1m eval
* window means the seeded records fall out of it fast, so the rule resolves
* on its own in ~105s. This is the only fixture that produces a non-zero
* average resolution time and a 3-segment overall-status graph.
*/
resolvedHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
const marker = `e2e alert resolved ${stamp}`;
let channelId = '';
let ruleId = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-resolved-ch-${stamp}`,
);
channelId = channel.id;
const services = await seedAlertHistoryLogs(page, {
marker,
services: SEED_F_SERVICES,
ageSeconds: 40,
minAgeSeconds: 28,
servicePrefix: 'e2e-ahr-svc',
});
ruleId = await createLogsAlertViaApi(page, {
name: `e2e-ah-resolved-rule-${stamp}`,
marker,
channels: [channel.name],
evalWindow: '1m0s',
});
const timeline = await waitForTimelineStates(page, ruleId, {
states: {
firing: SEED_F_SERVICES,
inactive: SEED_F_SERVICES,
},
});
await setRuleDisabledViaApi(page, ruleId, true);
return {
ruleId,
channelName: channel.name,
marker,
services,
firingCount: timeline.items.filter((i) => i.state === 'firing').length,
resolvedCount: timeline.items.filter((i) => i.state === 'inactive').length,
};
});
await use(seed);
await cleanup(browser, { ruleIds: [ruleId], channelId });
},
{ scope: 'worker', timeout: 300_000 },
],
/**
* SEED-G — a `nodata` row, reached the same way
* `integration/testdata/alerts/test_scenarios/no_data_rule_test` does:
* `alertOnAbsent` on a query that matches nothing.
*/
noDataHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
let channelId = '';
let ruleId = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-nodata-ch-${stamp}`,
);
channelId = channel.id;
ruleId = await createNoDataAlertViaApi(page, {
name: `e2e-ah-nodata-rule-${stamp}`,
// Deliberately unseeded — the query must match nothing.
marker: `e2e alert nodata ${stamp}`,
channels: [channel.name],
});
await waitForTimelineEntries(page, ruleId, {
min: 1,
state: 'nodata',
timeoutMs: 180_000,
});
await setRuleDisabledViaApi(page, ruleId, true);
return { ruleId, channelName: channel.name };
});
await use(seed);
await cleanup(browser, { ruleIds: [ruleId], channelId });
},
{ scope: 'worker', timeout: 300_000 },
],
/**
* A rule that will never have history: its query matches nothing and it is
* disabled immediately. Covers "no history yet" (empty table, zero stats) and
* "no key suggestions" without waiting on the ruler at all.
*/
emptyHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
let channelId = '';
let ruleId = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-empty-ch-${stamp}`,
);
channelId = channel.id;
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 { ruleId, channelName: channel.name };
});
await use(seed);
await cleanup(browser, { ruleIds: [ruleId], channelId });
},
{ scope: 'worker', timeout: 120_000 },
],
});
export { expect };

View File

@@ -1,201 +0,0 @@
import type { Browser, Page } from '@playwright/test';
import {
type AlertSchema,
createEmailChannelViaApi,
createLogsAlertViaApi,
createThresholdAlertViaApi,
deleteAlertViaApi,
deleteChannelViaApi,
seedAlertRules,
type ThresholdAlertSeed,
} from '../helpers/alerts';
import { newAdminContext } from '../helpers/auth';
import { expect, test as base } from './auth';
// 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 `fixtures/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. SEED-B, the 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.
*/
logs(options: {
name: string;
schema?: AlertSchema;
marker?: string;
}): 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>;
}
/** SEED-B size. 12 over a pinned page size of 10 ⇒ a short second page. */
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);
}
});
}
export const test = base.extend<
{ ownedRules: OwnedRules },
{ alertChannel: AlertChannel; alertList: AlertListSeed }
>({
alertChannel: [
async ({ browser }, use, workerInfo) => {
const channel = await withAdminPage(browser, (page) =>
createEmailChannelViaApi(
page,
`e2e-alerts-ch-w${workerInfo.workerIndex}-${Date.now()}`,
),
);
await use(channel);
await withAdminPage(browser, (page) =>
deleteChannelViaApi(page, channel.id),
);
},
{ scope: 'worker' },
],
alertList: [
async ({ browser, alertChannel }, use, workerInfo) => {
const stamp = `w${workerInfo.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: alertChannel.name,
namePrefix,
teamSuffix,
}),
);
await use({
channelName: alertChannel.name,
namePrefix,
count: LIST_SEED_COUNT,
paymentsLabel: `payments${teamSuffix}`,
ruleIds,
});
await deleteRules(browser, ruleIds);
},
{ scope: 'worker', timeout: 120_000 },
],
ownedRules: async ({ browser, alertChannel }, use) => {
const ids = new Set<string>();
const seed = async (
create: (page: Page) => Promise<string>,
): Promise<string> => {
const id = await withAdminPage(browser, create);
ids.add(id);
return id;
};
await use({
threshold: (name, overrides = {}) =>
seed((page) =>
createThresholdAlertViaApi(page, {
name,
target: 42,
channels: [alertChannel.name],
labels: { severity: 'critical' },
...overrides,
}),
),
logs: ({ name, schema = 'v2', marker }) =>
seed((page) =>
createLogsAlertViaApi(page, {
name,
marker: marker ?? `e2e alert never seeded ${name}`,
channels: [alertChannel.name],
schema,
}),
),
register: async (response) => {
const body = (await response.json()) as { data?: { id?: string } };
const id = body.data?.id;
if (id) {
ids.add(String(id));
}
},
});
// Best-effort: `deleteAlertViaApi` tolerates a rule a scenario already
// deleted through the UI.
await deleteRules(browser, [...ids]);
},
});
export { expect };

View File

@@ -1,11 +1,81 @@
import { test as base, expect, type Page } from '@playwright/test';
import {
test as base,
expect,
type Browser,
type BrowserContext,
type Page,
} from '@playwright/test';
import { ADMIN, storageStateFor, type User } from '../helpers/auth';
export type User = { email: string; password: string };
// 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 };
// 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()}`,
);
}
}
export const test = base.extend<{
/**
@@ -25,7 +95,7 @@ export const test = base.extend<{
user: [ADMIN, { option: true }],
authedPage: async ({ browser, user }, use) => {
const storageState = await storageStateFor(browser, user);
const storageState = await storageFor(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

@@ -1,34 +1,11 @@
import {
expect,
type Locator,
type Page,
type Request,
} from '@playwright/test';
import { expect, type Page } from '@playwright/test';
import { authToken, requestUrl, seederUrl } from './common';
import { typeExpression } from './query-builder';
import { authToken } from './common';
// ─── 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;
// ─── Types ─────────────────────────────────────────────────────────────────
@@ -42,11 +19,6 @@ export interface ThresholdAlertSeed {
* 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>;
}
// ─── Payload ─────────────────────────────────────────────────────────────
@@ -58,7 +30,6 @@ function buildThresholdRulePayload({
name,
target,
channels,
labels,
}: ThresholdAlertSeed): Record<string, unknown> {
return {
alert: name,
@@ -68,7 +39,6 @@ function buildThresholdRulePayload({
version: 'v5',
disabled: false,
source: '',
...(labels ? { labels } : {}),
annotations: {
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
@@ -216,926 +186,3 @@ export async function gotoAlertOverview(
// 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();
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Alert history fixtures
//
// There is no seeder endpoint that writes `rule_state_history_v0`, so every
// history row here comes from the ruler actually evaluating a rule: seed
// telemetry, create a rule whose query matches it, then poll the timeline until
// the firing wave lands. See `tests/e2e/specs/alerts/alerts-e2e-coverage.md` §3.
// ═══════════════════════════════════════════════════════════════════════════
// ─── Types ─────────────────────────────────────────────────────────────────
/** 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;
}
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;
}
/** 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;
}
// ─── 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}})',
};
// 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.
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: '',
},
},
],
};
}
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.
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`.
function v1RulePayload({
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',
disabled: false,
source: '',
evalWindow,
frequency,
preferredChannels: channels,
labels: { severity, ...extraLabels },
annotations: ANNOTATIONS,
condition: {
selectedQueryName: 'A',
op: '1',
target: 0,
matchType: '1',
compositeQuery,
...extraCondition,
},
};
}
// ─── 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 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);
}
// ─── Rule creation ───────────────────────────────────────────────────────
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,
}: 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,
};
return postRule(
page,
schema,
schema === 'v1' ? v1RulePayload(args) : v2RulePayload(args),
);
}
/** 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()}`,
);
}
}
/** Severities SEED-B cycles through, so list search/sort has more than one value. */
export const SEED_B_SEVERITIES = ['critical', 'warning', 'info'] as const;
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;
}
/**
* 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;
}
// ─── 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 = 90_000,
}: { 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 = 180_000,
}: { 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();
}
// ─── 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(),
);
}
// ─── 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

@@ -1,123 +1,34 @@
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!,
};
import type { Browser, BrowserContext } from '@playwright/test';
/**
* `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.
* 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.
*
* 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.
* 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.
*/
export async function newAdminContext(
browser: Browser,
): Promise<BrowserContext> {
return browser.newContext({
...contextDefaults,
storageState: await storageStateFor(browser, ADMIN),
});
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;
}

View File

@@ -1,4 +1,4 @@
import type { Page, Request } from '@playwright/test';
import type { Page } from '@playwright/test';
// Shared helpers used across feature-specific helper modules (dashboards,
// trace-details, …). Keep this to genuinely cross-feature utilities.
@@ -18,108 +18,6 @@ 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',
'any',
];
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,12 +5,7 @@
"main": "index.js",
"scripts": {
"preinstall": "npx only-allow pnpm",
"env:start": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --with-web e2e/bootstrap/setup.py::test_setup",
"env:stop": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown 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:all": "playwright 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,36 +1,15 @@
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import fs from 'fs';
import path from 'path';
// 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;
}
}
}
// .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 });
export default defineConfig({
testDir: './tests',

View File

@@ -0,0 +1,67 @@
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

@@ -1,96 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERT_OVERVIEW_PATH,
ALERTS_LIST_PATH,
gotoAlertDetails,
} from '../../../helpers/alerts';
test.describe('Alert details — actions', () => {
test('AD-06 enable/disable toggle changes the rule state', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.logs({
name: `e2e-ad-toggle-${Date.now()}`,
schema: 'v2',
});
await gotoAlertDetails(page, ruleId);
const toggle = page.getByTestId('alert-actions-toggle');
await expect(toggle).toBeVisible();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'PATCH',
),
toggle.click(),
]);
await expect(page.getByText('Alert has been disabled.')).toBeVisible();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'PATCH',
),
toggle.click(),
]);
await expect(page.getByText('Alert has been enabled.')).toBeVisible();
});
test('AD-07 Duplicate creates a copy and navigates to overview', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ad-duplicate-${Date.now()}`;
const ruleId = await ownedRules.logs({ name, schema: 'v2' });
await gotoAlertDetails(page, ruleId);
await page.getByTestId('alert-actions-menu').click();
const [createResponse] = await Promise.all([
page.waitForResponse(
(res) =>
/\/api\/v\d\/rules$/.test(new URL(res.url()).pathname) &&
res.request().method() === 'POST',
),
page.getByRole('menuitem', { name: 'Duplicate' }).click(),
]);
await ownedRules.register(createResponse);
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
await expect(page).toHaveURL(/[?&]ruleId=/);
await page.goto(`${ALERTS_LIST_PATH}?search=${name}`);
await expect(page.getByText(`${name} - Copy`)).toBeVisible();
});
test('AD-08 Delete removes the rule and returns to the list', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ad-delete-${Date.now()}`;
const ruleId = await ownedRules.logs({ name, schema: 'v2' });
await gotoAlertDetails(page, ruleId);
await page.getByTestId('alert-actions-menu').click();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'DELETE',
),
page.getByRole('menuitem', { name: 'Delete' }).click(),
]);
await expect(page).toHaveURL(new RegExp(`${ALERTS_LIST_PATH}$`));
await page.goto(`${ALERTS_LIST_PATH}?search=${name}`);
await expect(page.getByText(name, { exact: true })).toHaveCount(0);
});
});

View File

@@ -1,51 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import { ALERTS_LIST_PATH, gotoAlertHistory } from '../../../helpers/alerts';
test.describe('Alert details — page chrome', () => {
test('AD-09 copy-link button copies the current URL to clipboard', async ({
authedPage: page,
alertHistory,
browserName,
}) => {
test.skip(
browserName !== 'chromium',
'clipboard-read permission is Chromium-only in Playwright',
);
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await gotoAlertHistory(page, alertHistory.ruleId);
const expected = page.url();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Copied')).toBeVisible();
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied).toBe(expected);
});
test('AD-10 breadcrumb navigates back to the alert list', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const breadcrumb = page.locator('.ant-breadcrumb');
await expect(breadcrumb).toContainText('Alert Rules');
await expect(breadcrumb).toContainText(alertHistory.ruleId);
await breadcrumb.getByText('Alert Rules').click();
await expect(page).toHaveURL(new RegExp(`${ALERTS_LIST_PATH}$`));
});
test('AD-13 document title updates to show the rule name', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect
.poll(() => page.title(), { timeout: 15_000 })
.toContain('e2e-ah-rule-v2');
});
});

View File

@@ -1,55 +0,0 @@
import {
expect,
SEED_C_TEAM_LABEL,
test,
} from '../../../fixtures/alert-history';
import { gotoAlertDetails } from '../../../helpers/alerts';
test.describe('Alert details — header', () => {
test('AD-01 v2 header shows editable name input without Rename menu item', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertDetails(page, alertHistory.ruleId);
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
'data-schema-version',
'v2alpha1',
);
const nameInput = page.getByTestId('alert-name-input');
await expect(nameInput).toBeVisible();
await expect(nameInput).not.toHaveValue('');
await expect(nameInput).toBeEditable();
await page.getByTestId('alert-actions-menu').click();
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Rename' })).toHaveCount(0);
});
test('AD-02 v1 header shows static title with state, severity and labels', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertDetails(page, alertHistory.ruleIdV1);
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
'data-schema-version',
'v1',
);
await expect(page.getByTestId('alert-header-title')).toBeVisible();
await expect(page.getByTestId('alert-header-state')).toBeVisible();
await expect(page.getByTestId('alert-header-severity')).toContainText(
'Warning',
);
await expect(page.getByTestId('alert-header-labels')).toContainText(
SEED_C_TEAM_LABEL,
);
await expect(page.getByTestId('alert-name-input')).toHaveCount(0);
await page.getByTestId('alert-actions-menu').click();
await expect(page.getByRole('menuitem', { name: 'Rename' })).toBeVisible();
});
});

View File

@@ -1,33 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERT_HISTORY_PATH,
ALERT_OVERVIEW_PATH,
} from '../../../helpers/alerts';
test.describe('Alert details — not found', () => {
test('AD-11 invalid ruleId shows AlertNotFound page', async ({
authedPage: page,
}) => {
await page.goto(`${ALERT_HISTORY_PATH}?ruleId=not-a-real-rule-id`);
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
await page.goto(
`${ALERT_HISTORY_PATH}?ruleId=01920000-0000-7000-8000-000000000000`,
);
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
});
test('AD-12 missing ruleId on overview shows AlertNotFound page', async ({
authedPage: page,
}) => {
await page.goto(ALERT_OVERVIEW_PATH);
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
});
});

View File

@@ -1,74 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERTS_LIST_PATH,
gotoAlertDetails,
gotoAlertHistory,
} from '../../../helpers/alerts';
test.describe('Alert details — rename', () => {
test('AD-03 v1 rename via modal updates the rule name', async ({
authedPage: page,
ownedRules,
}) => {
const stamp = Date.now();
const original = `e2e-ad-rename-v1-${stamp}`;
const renamed = `${original}-renamed`;
const ruleId = await ownedRules.logs({ name: original, schema: 'v1' });
await gotoAlertDetails(page, ruleId);
await expect(page.getByTestId('alert-header-title')).toContainText(original);
await page.getByTestId('alert-actions-menu').click();
await page.getByRole('menuitem', { name: 'Rename' }).click();
const modalInput = page.getByTestId('alert-name');
await expect(modalInput).toBeVisible();
await modalInput.fill(renamed);
await page.getByRole('button', { name: 'Rename Alert' }).click();
await expect(page.getByText('Alert renamed successfully')).toBeVisible();
await expect(page.getByTestId('alert-header-title')).toContainText(renamed);
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
await expect(page.getByText(renamed)).toBeVisible();
});
test('AD-04 v2 inline rename saves via Overview footer button', async ({
authedPage: page,
ownedRules,
}) => {
const stamp = Date.now();
const original = `e2e-ad-rename-v2-${stamp}`;
const renamed = `${original}-renamed`;
const ruleId = await ownedRules.logs({ name: original, schema: 'v2' });
await gotoAlertDetails(page, ruleId);
const nameInput = page.getByTestId('alert-name-input');
await expect(nameInput).toHaveValue(original);
await nameInput.fill(renamed);
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
['PUT', 'POST', 'PATCH'].includes(res.request().method()),
),
page.getByRole('button', { name: 'Save Alert Rule' }).click(),
]);
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
await expect(page.getByText(renamed)).toBeVisible();
await gotoAlertHistory(page, ruleId);
const historyInput = page.getByTestId('alert-name-input');
await historyInput.fill(`${renamed}-unsaved`);
await expect(
page.getByRole('button', { name: 'Save Alert Rule' }),
).toHaveCount(0);
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
await expect(page.getByText(renamed)).toBeVisible();
await expect(page.getByText(`${renamed}-unsaved`)).toHaveCount(0);
});
});

View File

@@ -1,55 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERT_HISTORY_PATH,
ALERT_OVERVIEW_PATH,
DEFAULT_RELATIVE_TIME,
gotoAlertDetails,
gotoAlertHistory,
} from '../../../helpers/alerts';
test.describe('Alert details — tabs', () => {
test('AD-05 Overview/History tabs preserve ruleId and relativeTime', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertDetails(page, alertHistory.ruleId);
await page.getByTestId('alert-details-tab-history').click();
await expect(page).toHaveURL(new RegExp(ALERT_HISTORY_PATH));
await expect(page).toHaveURL(new RegExp(`ruleId=${alertHistory.ruleId}`));
await expect(page).toHaveURL(
new RegExp(`relativeTime=${DEFAULT_RELATIVE_TIME}`),
);
await expect(
page.getByTestId('alert-details-tab-history').getByText('Beta'),
).toBeVisible();
await page.getByTestId('alert-details-tab-overview').click();
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
await expect(page).toHaveURL(new RegExp(`ruleId=${alertHistory.ruleId}`));
});
test('AD-05b switching to History tab discards other history params', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
page: '2',
order: 'desc',
timelineFilter: 'FIRED',
});
await page.getByTestId('alert-details-tab-overview').click();
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
await expect(page).toHaveURL(/[?&]timelineFilter=FIRED/);
await page.getByTestId('alert-details-tab-history').click();
await expect(page).toHaveURL(new RegExp(ALERT_HISTORY_PATH));
const search = new URL(page.url()).searchParams;
expect([...search.keys()].sort()).toEqual(['relativeTime', 'ruleId']);
expect(search.get('ruleId')).toBe(alertHistory.ruleId);
});
});

View File

@@ -1,22 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import { gotoAlertOverview } from '../../../helpers/alerts';
const TARGET = 245;
test.describe('Alert overview — threshold persistence', () => {
test('TC-02 edit page displays the saved threshold value', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(
`e2e-threshold-persistence-${Date.now()}`,
{ target: TARGET },
);
await gotoAlertOverview(page, ruleId);
await expect(page.getByTestId('threshold-value-input')).toHaveValue(
String(TARGET),
);
});
});

View File

@@ -1,223 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERT_HISTORY_PATH,
DEFAULT_RELATIVE_TIME,
encodeTimelineCursor,
gotoAlertHistory,
HISTORY_ENDPOINTS,
isHistoryRequest,
sortTimelineDescending,
statsCard,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRows,
} from '../../../helpers/alerts';
import {
collectRequests,
requestUrl,
watchConsole,
} from '../../../helpers/common';
test.describe('Alert history — cross-cutting', () => {
test('AX-01 full deep-link with all params is honoured in one load', async ({
authedPage: page,
alertHistory,
}) => {
const service = alertHistory.services[8];
await page.goto(
`${ALERT_HISTORY_PATH}?ruleId=${alertHistory.ruleId}` +
`&relativeTime=${DEFAULT_RELATIVE_TIME}` +
`&timelineFilter=FIRED&page=1&order=desc` +
`&alertHistoryExpression=${encodeURIComponent(`service.name = '${service}'`)}` +
`&viewAllTopContributors=true`,
);
await expect(page.getByTestId('timeline-table')).toBeVisible();
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
await expect(page.getByTestId('timeline-filter-fired')).toHaveClass(
/selected/,
);
await expect(timelineRows(page)).toHaveCount(1);
});
test('AX-02 page reload preserves all history params', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
timelineFilter: 'FIRED',
page: '2',
order: 'desc',
});
const before = new URL(page.url()).searchParams;
await page.reload();
await expect(page.getByTestId('timeline-table')).toBeVisible();
const after = new URL(page.url()).searchParams;
for (const [key, value] of before) {
expect(after.get(key), `param ${key} survived the reload`).toBe(value);
}
});
test('AX-03 browser back/forward restores correct table state', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await page.getByTestId('timeline-filter-fired').click();
await expect(page).toHaveURL(/[?&]timelineFilter=FIRED/);
await page.getByTestId('timeline-next-page').click();
await expect(page).toHaveURL(/[?&]page=2/);
await expect(timelineRows(page)).toHaveCount(
alertHistory.total - TIMELINE_PAGE_SIZE,
);
await page.goBack();
await expect(page).not.toHaveURL(/[?&]page=2/);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await page.goForward();
await expect(page).toHaveURL(/[?&]page=2/);
await expect(timelineRows(page)).toHaveCount(
alertHistory.total - TIMELINE_PAGE_SIZE,
);
});
test('AX-04 no unhandled console errors across full history session', async ({
authedPage: page,
alertHistory,
}) => {
const watch = watchConsole(page);
await gotoAlertHistory(page, alertHistory.ruleId);
await page.getByTestId('timeline-filter-fired').click();
await page.getByTestId('timeline-next-page').click();
await expect(page).toHaveURL(/[?&]page=2/);
await sortTimelineDescending(page);
await expect(page).toHaveURL(/[?&]order=desc/);
await page.getByTestId('top-contributors-view-all').click();
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
expect(watch.errors).toEqual([]);
expect(watch.failedResponses).toEqual([]);
});
test('AX-05 no request storm on mount (exactly one call per endpoint)', async ({
authedPage: page,
alertHistory,
}) => {
const requests = collectRequests(page);
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toBeVisible();
for (const endpoint of HISTORY_ENDPOINTS) {
const calls = requests.filter((req) => isHistoryRequest(req, endpoint));
expect(calls, `${endpoint} called exactly once on mount`).toHaveLength(1);
}
});
test('AX-06 v1 and v2 schema rules both render history correctly', async ({
authedPage: page,
alertHistory,
}) => {
for (const [variant, ruleId, total] of [
['v2', alertHistory.ruleId, alertHistory.total],
['v1', alertHistory.ruleIdV1, alertHistory.totalV1],
] as const) {
await gotoAlertHistory(page, ruleId);
await expect(
page.getByTestId('alert-details-root'),
`${variant} rule renders the details shell`,
).toHaveAttribute(
'data-schema-version',
variant === 'v2' ? 'v2alpha1' : 'v1',
);
await expect(timelineFooterRange(page)).toContainText(`of ${total}`);
}
});
test('AX-07 no legacy v1 history API calls during full session', async ({
authedPage: page,
alertHistory,
}) => {
const requests = collectRequests(page);
await gotoAlertHistory(page, alertHistory.ruleId);
await page.getByTestId('timeline-filter-fired').click();
await page.getByTestId('timeline-next-page').click();
await expect(page).toHaveURL(/[?&]page=2/);
await sortTimelineDescending(page);
await expect(page).toHaveURL(/[?&]order=desc/);
await gotoAlertHistory(page, alertHistory.ruleId, { relativeTime: '6h' });
const legacy = requests.filter((req) =>
/\/api\/v1\/rules\/[^/]+\/history\//.test(req.url()),
);
expect(
legacy.map((req) => req.url()),
'no legacy POST /api/v1/rules/*/history/* calls',
).toEqual([]);
for (const endpoint of HISTORY_ENDPOINTS) {
expect(
requests.filter((req) => isHistoryRequest(req, endpoint)).length,
`v2 ${endpoint} endpoint was used`,
).toBeGreaterThan(0);
}
});
test('AX-08 history API endpoints carry expected params', async ({
authedPage: page,
alertHistory,
}) => {
const requests = collectRequests(page);
await gotoAlertHistory(page, alertHistory.ruleId, {
timelineFilter: 'FIRED',
page: '2',
});
await expect(page.getByTestId('timeline-table')).toBeVisible();
const timeline = requests
.filter((req) => isHistoryRequest(req, 'timeline'))
.pop();
expect(timeline).toBeDefined();
const timelineParams = requestUrl(timeline!).searchParams;
for (const key of ['start', 'end', 'limit', 'order', 'cursor', 'state']) {
expect(timelineParams.get(key), `timeline carries ${key}`).toBeTruthy();
}
expect(timelineParams.get('limit')).toBe(String(TIMELINE_PAGE_SIZE));
expect(timelineParams.get('cursor')).toBe(encodeTimelineCursor(2));
for (const endpoint of [
'stats',
'overall_status',
'top_contributors',
] as const) {
const request = requests
.filter((req) => isHistoryRequest(req, endpoint))
.pop();
expect(request, `${endpoint} was requested`).toBeDefined();
const params = requestUrl(request!).searchParams;
expect(params.get('start'), `${endpoint} carries start`).toBeTruthy();
expect(params.get('end'), `${endpoint} carries end`).toBeTruthy();
}
const keys = requests
.filter((req) => /\/history\/filter_keys/.test(req.url()))
.pop();
expect(keys, 'filter_keys was requested').toBeDefined();
const keyParams = requestUrl(keys!).searchParams;
expect(keyParams.get('startUnixMilli')).toBeTruthy();
expect(keyParams.get('endUnixMilli')).toBeTruthy();
expect(keyParams.get('start')).toBeNull();
});
});

View File

@@ -1,177 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERT_HISTORY_PATH,
createLogsAlertViaApi,
deleteAlertViaApi,
expectFirstPage,
gotoAlertHistory,
isHistoryRequest,
runFilterExpression,
setRuleDisabledViaApi,
statsCard,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRows,
} from '../../../helpers/alerts';
import { collectRequests } from '../../../helpers/common';
import { typeExpression } from '../../../helpers/query-builder';
test.describe('Alert history — error and empty states', () => {
test('AE-01 invalid filter expression shows syntax error and recovers on fix', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const responsePromise = page.waitForResponse((res) =>
isHistoryRequest(res.request(), 'timeline'),
);
await runFilterExpression(page, 'service.name =');
const response = await responsePromise;
expect(response.status()).toBe(400);
const error = page.getByTestId('timeline-error');
await expect(error).toBeVisible();
await expect(error).toContainText(/syntax error/i);
await runFilterExpression(
page,
`service.name = '${alertHistory.services[7]}'`,
);
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
await expect(timelineRows(page)).toHaveCount(1);
});
test('AE-02 empty filter_keys response still mounts editor (no suggestions)', async ({
authedPage: page,
emptyHistory,
}) => {
const keysPromise = page.waitForResponse((res) =>
/\/history\/filter_keys/.test(res.url()),
);
await gotoAlertHistory(page, emptyHistory.ruleId);
const keysResponse = await keysPromise;
const body = (await keysResponse.json()) as {
data: { keys?: Record<string, unknown> } | null;
};
expect(Object.keys(body.data?.keys ?? {})).toHaveLength(0);
await expect(page.getByTestId('timeline-filter-skeleton')).toHaveCount(0);
await expect(page.getByTestId('timeline-filter-search')).toBeVisible();
await typeExpression(page, 'anything.at.all');
await expect(
page.locator('.query-where-clause-editor .cm-content'),
).toContainText('anything.at.all');
});
test('AE-02b bogus ruleId never reaches history APIs (shows AlertNotFound)', async ({
authedPage: page,
}) => {
const requests = collectRequests(page);
await page.goto(`${ALERT_HISTORY_PATH}?ruleId=not-a-real-rule-id`);
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
expect(requests.filter((req) => /\/history\//.test(req.url()))).toHaveLength(
0,
);
});
test('AE-03 rule with no history renders empty state (not error)', async ({
authedPage: page,
emptyHistory,
}) => {
await gotoAlertHistory(page, emptyHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(0);
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toHaveText('None Triggered.');
await expect(
statsCard(page, 'Avg. Resolution Time').getByTestId('stats-card-value'),
).toHaveText('No Resolutions.');
await expect(page.getByTestId('top-contributors-row')).toHaveCount(0);
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
});
test('AE-04 time range with no data renders empty state', async ({
authedPage: page,
alertHistory,
}) => {
const end = Date.now() - 24 * 60 * 60 * 1000;
const start = end - 30 * 60 * 1000;
await gotoAlertHistory(page, alertHistory.ruleId, {
startTime: String(start),
endTime: String(end),
});
await expect(timelineRows(page)).toHaveCount(0);
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toHaveText('None Triggered.');
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
});
test('AE-05 time-range change resets pagination to first page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
await expect(page).toHaveURL(/[?&]page=2/);
await page.locator('.filters input').first().click();
await page.getByText('Last 6 hours', { exact: true }).click();
await expectFirstPage(page);
});
test('AE-06 absurd time range (90d) still renders', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { relativeTime: '90d' });
await expect(page.getByTestId('timeline-table')).toBeVisible();
await expect(timelineRows(page).first()).toBeVisible();
await expect(page.getByTestId('timeline-graph-title')).toContainText(
`${alertHistory.total} triggers in`,
);
});
test('AE-07 disabled rule history is still readable', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.total}`,
);
});
test('AE-08 deleted rule shows AlertNotFound on revisit', async ({
authedPage: page,
alertHistory,
}) => {
const doomedId = await createLogsAlertViaApi(page, {
name: `e2e-ah-doomed-${Date.now()}`,
marker: alertHistory.marker,
channels: [alertHistory.channelName],
});
await setRuleDisabledViaApi(page, doomedId, true);
await gotoAlertHistory(page, doomedId);
await expect(page.getByTestId('timeline-table')).toBeVisible();
await deleteAlertViaApi(page, doomedId);
await page.goto(`${ALERT_HISTORY_PATH}?ruleId=${doomedId}`);
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
});
});

View File

@@ -1,271 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
expectFirstPage,
gotoAlertHistory,
isHistoryRequest,
runFilterExpression,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRows,
} from '../../../helpers/alerts';
import { requestUrl } from '../../../helpers/common';
import { typeExpression } from '../../../helpers/query-builder';
test.describe('Alert history — expression filter', () => {
test('AF-06 key suggestions load on page load', async ({
authedPage: page,
alertHistory,
}) => {
const keysPromise = page.waitForResponse((res) =>
/\/history\/filter_keys/.test(res.url()),
);
await gotoAlertHistory(page, alertHistory.ruleId);
const keysResponse = await keysPromise;
const body = (await keysResponse.json()) as {
data: { keys: Record<string, { name: string }[]> } | null;
};
expect(Object.keys(body.data?.keys ?? {}).sort()).toEqual([
'service.name',
'severity',
'threshold.name',
]);
const params = new URL(keysResponse.url()).searchParams;
expect(params.get('startUnixMilli')).toBeTruthy();
expect(params.get('endUnixMilli')).toBeTruthy();
await expect(page.getByTestId('timeline-filter-search')).toBeVisible();
await expect(page.getByTestId('timeline-filter-skeleton')).toHaveCount(0);
});
test('AF-07 value suggestions fetch from filter_values endpoint', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const valuesPromise = page.waitForResponse((res) =>
/\/history\/filter_values/.test(res.url()),
);
await typeExpression(page, "service.name = '");
const valuesResponse = await valuesPromise;
const params = new URL(valuesResponse.url()).searchParams;
expect(params.get('name')).toBe('service.name');
const body = (await valuesResponse.json()) as {
data: { values?: { stringValues?: string[] }; complete?: boolean } | null;
};
expect(body.data?.values?.stringValues).toEqual(
[...alertHistory.services].sort(),
);
expect(body.data?.complete).toBe(true);
});
test('AF-08 value suggestions filter client-side as user types', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await typeExpression(page, "service.name = 'svc-1");
const options = page.locator('.cm-tooltip-autocomplete li');
await expect(options.first()).toBeVisible();
const texts = await options.allInnerTexts();
expect(texts.length).toBeGreaterThan(0);
expect(texts.length).toBeLessThan(alertHistory.services.length);
for (const text of texts) {
expect(text).toContain('svc-1');
}
});
test('AF-09 running equality expression filters the table', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const service = alertHistory.services[3];
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await runFilterExpression(page, `service.name = '${service}'`);
const request = await requestPromise;
expect(requestUrl(request).searchParams.get('filterExpression')).toBe(
`service.name = '${service}'`,
);
await expect(timelineRows(page)).toHaveCount(1);
await expect(page).toHaveURL(/[?&]alertHistoryExpression=/);
});
test('AF-10 running expression resets pagination to first page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
await runFilterExpression(
page,
`service.name = '${alertHistory.services[0]}'`,
);
await expect(page).not.toHaveURL(/[?&]page=2/);
await expectFirstPage(page);
});
test('AF-11 Run button re-fetches unchanged expression', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const expression = `service.name = '${alertHistory.services[1]}'`;
await runFilterExpression(page, expression);
await expect(timelineRows(page)).toHaveCount(1);
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByRole('button', { name: /run query/i }).click();
await requestPromise;
await expect(timelineRows(page)).toHaveCount(1);
});
test('AF-12 in-flight query can be cancelled', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await page.route(
(url) => /\/history\/timeline/.test(url.pathname),
async (route) => {
await new Promise((resolve) => {
setTimeout(resolve, 3_000);
});
await route.continue();
},
);
await typeExpression(page, `service.name = '${alertHistory.services[2]}'`);
await page.getByRole('button', { name: /run query/i }).click();
const cancel = page.getByRole('button', { name: 'Cancel' });
await expect(cancel).toBeVisible();
await cancel.click();
await page.unrouteAll({ behavior: 'ignoreErrors' });
await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /run query/i })).toBeVisible();
});
test('AF-13 threshold.name and severity keys filter correctly', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await runFilterExpression(page, `threshold.name = 'critical'`);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.total}`,
);
await runFilterExpression(page, `severity = 'critical'`);
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.total}`,
);
await gotoAlertHistory(page, alertHistory.ruleIdV1);
await runFilterExpression(page, `threshold.name = 'warning'`);
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.totalV1}`,
);
await runFilterExpression(page, `threshold.name = 'critical'`);
await expect(timelineRows(page)).toHaveCount(0);
});
test('AF-14 unknown key returns 200 with zero rows (not 500)', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const responsePromise = page.waitForResponse((res) =>
isHistoryRequest(res.request(), 'timeline'),
);
await runFilterExpression(page, `nonexistent.key = 'x'`);
const response = await responsePromise;
expect(response.status()).toBe(200);
await expect(timelineRows(page)).toHaveCount(0);
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
});
test('AF-15 expression is lost on Overview→History round-trip (known bug)', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const expression = `service.name = '${alertHistory.services[4]}'`;
await runFilterExpression(page, expression);
await expect(timelineRows(page)).toHaveCount(1);
await expect(page).toHaveURL(/[?&]alertHistoryExpression=/);
await page.getByTestId('alert-details-tab-overview').click();
await page.getByTestId('alert-details-tab-history').click();
await expect(page.getByTestId('timeline-table')).toBeVisible();
expect(new URL(page.url()).searchParams.has('alertHistoryExpression')).toBe(
false,
);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
});
test('AF-16 expression and state filter compose in request', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
timelineFilter: 'FIRED',
});
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
const service = alertHistory.services[5];
await runFilterExpression(page, `service.name = '${service}'`);
const request = await requestPromise;
const params = requestUrl(request).searchParams;
expect(params.get('state')).toBe('firing');
expect(params.get('filterExpression')).toBe(`service.name = '${service}'`);
await expect(timelineRows(page)).toHaveCount(1);
});
test('AF-17 clearing expression restores full unfiltered list', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await runFilterExpression(
page,
`service.name = '${alertHistory.services[6]}'`,
);
await expect(timelineRows(page)).toHaveCount(1);
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await runFilterExpression(page, '');
const request = await requestPromise;
expect(requestUrl(request).searchParams.get('filterExpression')).toBeNull();
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.total}`,
);
});
});

View File

@@ -1,107 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
expectFirstPage,
gotoAlertHistory,
isHistoryRequest,
timelineRows,
} from '../../../helpers/alerts';
import { requestUrl } from '../../../helpers/common';
test.describe('Alert history — state filter', () => {
test('AF-01 All filter sends no state param in request', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
timelineFilter: 'FIRED',
});
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByTestId('timeline-filter-all').click();
const request = await requestPromise;
await expect(page).toHaveURL(/[?&]timelineFilter=ALL/);
expect(requestUrl(request).searchParams.get('state')).toBeNull();
});
test('AF-02 Fired filter sends state=firing in request', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByTestId('timeline-filter-fired').click();
const request = await requestPromise;
await expect(page).toHaveURL(/[?&]timelineFilter=FIRED/);
expect(requestUrl(request).searchParams.get('state')).toBe('firing');
await expect(timelineRows(page).first()).toBeVisible();
});
test('AF-03 Resolved filter shows empty for rule with no resolutions', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByTestId('timeline-filter-resolved').click();
const request = await requestPromise;
await expect(page).toHaveURL(/[?&]timelineFilter=RESOLVED/);
expect(requestUrl(request).searchParams.get('state')).toBe('inactive');
await expect(timelineRows(page)).toHaveCount(0);
});
test('AF-03b Resolved filter shows rows for rule with resolutions', async ({
authedPage: page,
resolvedHistory,
}) => {
await gotoAlertHistory(page, resolvedHistory.ruleId, {
timelineFilter: 'RESOLVED',
});
await expect(timelineRows(page)).toHaveCount(resolvedHistory.resolvedCount);
for (const text of await timelineRows(page)
.getByTestId('timeline-row-state')
.allInnerTexts()) {
expect(text).toBe('Resolved');
}
await page.getByTestId('timeline-filter-fired').click();
await expect(timelineRows(page)).toHaveCount(resolvedHistory.firingCount);
});
test('AF-04 deep-link ?timelineFilter=FIRED starts on Fired tab', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
timelineFilter: 'FIRED',
});
await expect(page.getByTestId('timeline-filter-fired')).toHaveClass(
/selected/,
);
await expect(timelineRows(page).first()).toBeVisible();
});
test('AF-05 changing state filter resets pagination to first page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
await page.getByTestId('timeline-filter-fired').click();
await expect(page).not.toHaveURL(/[?&]page=2/);
await expectFirstPage(page);
});
});

View File

@@ -1,125 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
gotoAlertHistory,
statsCard,
timelineRows,
} from '../../../helpers/alerts';
test.describe('Alert history — statistics', () => {
test('AS-01 Total Triggered card shows the firing count', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const card = statsCard(page, 'Total Triggered');
await expect(card).toBeVisible();
await expect(card).toHaveAttribute('data-empty', 'false');
await expect(card.getByTestId('stats-card-value')).toHaveText(
String(alertHistory.total),
);
});
test('AS-02 Avg. Resolution Time card shows "No Resolutions." when none exist', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const card = statsCard(page, 'Avg. Resolution Time');
await expect(card).toBeVisible();
await expect(card).toHaveAttribute('data-empty', 'true');
const value = card.getByTestId('stats-card-value');
await expect(value).toHaveText('No Resolutions.');
await expect(value).not.toHaveText(/NaN/);
await expect(card.getByTestId('stats-card-sparkline')).toHaveCount(0);
});
test('AS-03 empty stats card never renders a sparkline', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const empty = statsCard(page, 'Avg. Resolution Time');
await expect(empty).toHaveAttribute('data-empty', 'true');
await expect(empty.getByTestId('stats-card-sparkline')).toHaveCount(0);
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('AS-03b sparkline present with a multi-point series', async () => {
test.skip(
true,
'Not deterministic on SEED-A: whether `currentTriggersSeries` lands in ' +
'one stats bucket or two depends on where the ~2-minute seed falls ' +
'relative to the bucket boundary, so the `timeSeries.length > 1` gate ' +
'flips between runs (observed both ways). Needs a fixture that ' +
'guarantees ≥2 points — see coverage doc §3.5.',
);
});
test('AS-04 change-vs-past indicator shows "no previous data" when unavailable', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const card = statsCard(page, 'Total Triggered');
await expect(card.getByTestId('stats-card-change')).toHaveText(
'no previous data',
);
});
test('AS-09 stats update when time range changes', async ({
authedPage: page,
alertHistory,
}) => {
const end = Date.now() - 24 * 60 * 60 * 1000;
const start = end - 30 * 60 * 1000;
await gotoAlertHistory(page, alertHistory.ruleId, {
startTime: String(start),
endTime: String(end),
});
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toHaveText('None Triggered.');
await expect(
statsCard(page, 'Avg. Resolution Time').getByTestId('stats-card-value'),
).toHaveText('No Resolutions.');
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toHaveText(String(alertHistory.total));
});
test('AS-11 Avg. Resolution Time shows formatted duration when resolutions exist', async ({
authedPage: page,
resolvedHistory,
}) => {
await gotoAlertHistory(page, resolvedHistory.ruleId);
const card = statsCard(page, 'Avg. Resolution Time');
await expect(card).toHaveAttribute('data-empty', 'false');
await expect(card.getByTestId('stats-card-value')).not.toHaveText(
'No Resolutions.',
);
await expect(card.getByTestId('stats-card-value')).not.toHaveText('');
await expect(card.getByTestId('stats-card-sparkline')).toHaveCount(0);
});
test('AS-12 Total Triggered counts only firing rows (not resolved)', async ({
authedPage: page,
resolvedHistory,
}) => {
await gotoAlertHistory(page, resolvedHistory.ruleId);
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toHaveText(String(resolvedHistory.firingCount));
await expect(timelineRows(page)).toHaveCount(
resolvedHistory.firingCount + resolvedHistory.resolvedCount,
);
});
});

View File

@@ -1,99 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
DEFAULT_RELATIVE_TIME,
gotoAlertHistory,
isHistoryRequest,
} from '../../../helpers/alerts';
import { watchConsole } from '../../../helpers/common';
test.describe('Alert history — timeline graph', () => {
test('AT-03 renders canvas with two segments (inactive→firing)', async ({
authedPage: page,
alertHistory,
}) => {
const watch = watchConsole(page);
const statusPromise = page.waitForResponse((res) =>
isHistoryRequest(res.request(), 'overall_status'),
);
await gotoAlertHistory(page, alertHistory.ruleId);
const status = await statusPromise;
const body = (await status.json()) as {
data: { state: string }[] | null;
};
const states = (body.data ?? []).map((segment) => segment.state);
expect(states).toEqual(['inactive', 'firing']);
await expect(page.getByTestId('timeline-graph')).toBeVisible();
await expect(
page.getByTestId('timeline-graph').locator('canvas'),
).toBeVisible();
await expect(page.getByTestId('timeline-graph-title')).toHaveText(
`${alertHistory.total} triggers in ${DEFAULT_RELATIVE_TIME}`,
);
expect(watch.errors).toEqual([]);
expect(watch.failedResponses).toEqual([]);
});
test('AT-03b renders canvas with three segments (inactive→firing→inactive)', async ({
authedPage: page,
resolvedHistory,
}) => {
const watch = watchConsole(page);
const statusPromise = page.waitForResponse((res) =>
isHistoryRequest(res.request(), 'overall_status'),
);
await gotoAlertHistory(page, resolvedHistory.ruleId);
const status = await statusPromise;
const body = (await status.json()) as { data: { state: string }[] | null };
expect((body.data ?? []).map((s) => s.state)).toEqual([
'inactive',
'firing',
'inactive',
]);
await expect(
page.getByTestId('timeline-graph').locator('canvas'),
).toBeVisible();
expect(watch.errors).toEqual([]);
expect(watch.failedResponses).toEqual([]);
});
test('AT-19 handles nodata state without console errors', async ({
authedPage: page,
noDataHistory,
}) => {
const watch = watchConsole(page);
const statusPromise = page.waitForResponse((res) =>
isHistoryRequest(res.request(), 'overall_status'),
);
await gotoAlertHistory(page, noDataHistory.ruleId);
const status = await statusPromise;
const body = (await status.json()) as { data: { state: string }[] | null };
const KNOWN_STATES = [
'firing',
'inactive',
'pending',
'nodata',
'recovering',
'disabled',
];
const states = (body.data ?? []).map((segment) => segment.state);
expect(states.length).toBeGreaterThan(0);
for (const state of states) {
expect(KNOWN_STATES).toContain(state);
}
await expect(
page.getByTestId('timeline-graph').locator('canvas'),
).toBeVisible();
expect(watch.errors).toEqual([]);
expect(watch.failedResponses).toEqual([]);
});
});

View File

@@ -1,170 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
encodeTimelineCursor,
expectFirstPage,
gotoAlertHistory,
isHistoryRequest,
sortTimelineDescending,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRowLabels,
timelineRows,
} from '../../../helpers/alerts';
import { requestUrl } from '../../../helpers/common';
test.describe('Alert history — timeline pagination', () => {
test('AT-06 next page sends cursor and shows different rows', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const firstPageLabels = await timelineRowLabels(page);
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByTestId('timeline-next-page').click();
const request = await requestPromise;
expect(requestUrl(request).searchParams.get('cursor')).toBe(
encodeTimelineCursor(2),
);
await expect(page).toHaveURL(/[?&]page=2/);
await expect(timelineRows(page)).toHaveCount(
alertHistory.total - TIMELINE_PAGE_SIZE,
);
await expect(timelineFooterRange(page)).toHaveText(
`${TIMELINE_PAGE_SIZE + 1}${alertHistory.total} of ${alertHistory.total}`,
);
await expect(page.getByTestId('timeline-prev-page')).toBeEnabled();
expect(await timelineRowLabels(page)).not.toEqual(firstPageLabels);
});
test('AT-07 prev page drops the cursor from request', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByTestId('timeline-prev-page').click();
const request = await requestPromise;
expect(requestUrl(request).searchParams.get('cursor')).toBeNull();
await expect(timelineFooterRange(page)).toHaveText(
`1 — ${TIMELINE_PAGE_SIZE} of ${alertHistory.total}`,
);
});
test('AT-08 pagination buttons disable at first and last page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(page.getByTestId('timeline-prev-page')).toBeDisabled();
await expect(page.getByTestId('timeline-next-page')).toBeEnabled();
await page.getByTestId('timeline-next-page').click();
await expect(page.getByTestId('timeline-next-page')).toBeDisabled();
await expect(page.getByTestId('timeline-prev-page')).toBeEnabled();
});
test('AT-09 browser back after paging returns to previous page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await page.getByTestId('timeline-next-page').click();
await expect(page).toHaveURL(/[?&]page=2/);
await page.goBack();
await expect(page).not.toHaveURL(/[?&]page=2/);
await expect(timelineFooterRange(page)).toHaveText(
`1 — ${TIMELINE_PAGE_SIZE} of ${alertHistory.total}`,
);
});
test('AT-10 deep-link ?page=2 loads second page directly', async ({
authedPage: page,
alertHistory,
}) => {
const requestPromise = page.waitForRequest(
(req) =>
isHistoryRequest(req, 'timeline') &&
requestUrl(req).searchParams.get('cursor') === encodeTimelineCursor(2),
);
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
await requestPromise;
await expect(timelineRows(page)).toHaveCount(
alertHistory.total - TIMELINE_PAGE_SIZE,
);
});
test('AT-11 default sort order is ascending', async ({
authedPage: page,
alertHistory,
}) => {
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await gotoAlertHistory(page, alertHistory.ruleId);
const request = await requestPromise;
expect(new URL(page.url()).searchParams.get('order')).toBeNull();
expect(requestUrl(request).searchParams.get('order')).toBe('asc');
});
test('AT-12 sorting toggles order and resets to first page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
await sortTimelineDescending(page);
await expect(page).toHaveURL(/[?&]order=desc/);
await expectFirstPage(page);
});
test('AT-13 single page disables both pagination buttons', async ({
authedPage: page,
metricsHistory,
}) => {
await gotoAlertHistory(page, metricsHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(metricsHistory.total);
await expect(page.getByTestId('timeline-prev-page')).toBeDisabled();
await expect(page.getByTestId('timeline-next-page')).toBeDisabled();
await expect(timelineFooterRange(page)).toHaveText(
`1 — ${metricsHistory.total} of ${metricsHistory.total}`,
);
});
test('AT-21 all pages together cover the complete row set', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const pageOne = await timelineRowLabels(page);
await page.getByTestId('timeline-next-page').click();
await expect(timelineRows(page)).toHaveCount(
alertHistory.total - TIMELINE_PAGE_SIZE,
);
await expect(timelineFooterRange(page)).toHaveText(
`${TIMELINE_PAGE_SIZE + 1}${alertHistory.total} of ${alertHistory.total}`,
);
const pageTwo = await timelineRowLabels(page);
const union = new Set([...pageOne, ...pageTwo]);
expect(pageOne.length + pageTwo.length).toBe(alertHistory.total);
expect(union.size).toBe(alertHistory.total);
});
});

View File

@@ -1,173 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
firstTimelineRowCreatedAt,
gotoAlertHistory,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRows,
} from '../../../helpers/alerts';
test.describe('Alert history — timeline table', () => {
test('AT-01 timeline section renders all chrome elements', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(page.locator('.timeline__title')).toHaveText('Timeline');
await expect(page.getByTestId('timeline-tab-overall-status')).toBeVisible();
await expect(page.getByTestId('timeline-filter-all')).toBeVisible();
await expect(page.getByTestId('timeline-filter-fired')).toBeVisible();
await expect(page.getByTestId('timeline-filter-resolved')).toBeVisible();
await expect(page.getByTestId('timeline-graph')).toBeVisible();
await expect(page.getByTestId('timeline-table')).toBeVisible();
});
test('AT-02 Top 5 Contributors tab is disabled with Coming Soon indicator', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const tab = page.getByTestId('timeline-tab-top-contributors');
await expect(tab).toBeDisabled();
await expect(tab).toContainText('Coming Soon');
});
test('AT-04 table rows display state, labels and formatted timestamp', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
const first = timelineRows(page).first();
await expect(first.getByTestId('timeline-row-state')).toHaveText('Firing');
await expect(first.getByTestId('timeline-row-labels')).toContainText(
'service.name',
);
await expect(first.getByTestId('timeline-row-created-at')).toHaveText(
/^[A-Z][a-z]{2} \d{1,2}, \d{4} ⎯ \d{2}:\d{2}:\d{2}$/,
);
});
test('AT-05 footer shows correct row range', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineFooterRange(page)).toHaveText(
`1 — ${TIMELINE_PAGE_SIZE} of ${alertHistory.total}`,
);
});
test('AT-14 row click does not navigate away', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const before = page.url();
await timelineRows(page).first().click();
expect(page.url()).toBe(before);
await expect(page.getByTestId('timeline-table')).toBeVisible();
});
test('AT-15 row actions link navigates to logs explorer', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await timelineRows(page).first().getByTestId('timeline-row-actions').click();
const viewLogs = page.getByTestId('alert-popover-view-logs');
await expect(viewLogs).toBeVisible();
await viewLogs.click();
await expect(page).toHaveURL(/\/logs\/logs-explorer/);
const search = new URL(page.url()).searchParams;
expect(search.get('startTime')).toBeTruthy();
expect(search.get('endTime')).toBeTruthy();
expect(page.url()).toContain('compositeQuery');
});
test('AT-16 metrics rule rows show disabled action (no related links)', async ({
authedPage: page,
metricsHistory,
}) => {
await gotoAlertHistory(page, metricsHistory.ruleId);
const action = timelineRows(page).first().getByTestId('timeline-row-actions');
await expect(action).toBeDisabled();
await expect(page.getByTestId('alert-popover-view-logs')).toHaveCount(0);
await expect(page.getByTestId('alert-popover-view-traces')).toHaveCount(0);
});
test('AT-17 CREATED AT column respects app timezone setting', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const utcCell = await firstTimelineRowCreatedAt(page);
await page.addInitScript(() =>
localStorage.setItem('PREFERRED_TIMEZONE', 'Asia/Kolkata'),
);
await gotoAlertHistory(page, alertHistory.ruleId);
expect(await firstTimelineRowCreatedAt(page)).not.toBe(utcCell);
});
test('AT-18 state cell renders Firing, Resolved, and No Data correctly', async ({
authedPage: page,
resolvedHistory,
noDataHistory,
}) => {
await gotoAlertHistory(page, resolvedHistory.ruleId);
const stateCells = timelineRows(page).getByTestId('timeline-row-state');
const labels = [...new Set(await stateCells.allInnerTexts())];
expect(labels).toContain('Firing');
expect(labels).toContain('Resolved');
await gotoAlertHistory(page, noDataHistory.ruleId);
await expect(
timelineRows(page).getByTestId('timeline-row-state').first(),
).toHaveText('No Data');
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('AT-18b pending/recovering states render blank (coverage gap)', async () => {
test.skip(
true,
'Needs SEED-D (coverage doc §3.5): `pending` and `recovering` are ' +
'transient states no cheap fixture produces. AlertState.tsx has no ' +
'`case` for either, so both hit `default` and the STATE cell renders ' +
'empty — write this as a bug-catch once the seeder endpoint exists.',
);
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('AT-18c disabled state renders as "Muted" (coverage gap)', async () => {
test.skip(
true,
'Needs SEED-D (coverage doc §3.5): a `disabled` history row is ' +
'policy-driven and the ruler never writes one for a rule we disable ' +
'(verified: disabling appends no row).',
);
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('AT-20 time-range boundaries inclusive/exclusive (coverage gap)', async () => {
test.skip(
true,
'Needs SEED-D (coverage doc §3.5): asserting a row exactly at `start` ' +
'and one at `start-1ms` requires controlling the row timestamps, and ' +
'evaluation times are whatever the ruler chose.',
);
});
});

View File

@@ -1,118 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import { gotoAlertHistory } from '../../../helpers/alerts';
test.describe('Alert history — top contributors', () => {
test('AS-05 card displays max 3 rows with count ratios', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const card = page.getByTestId('top-contributors-card');
await expect(card).toBeVisible();
await expect(card).toContainText('top contributors');
const rows = card.getByTestId('top-contributors-row');
await expect(rows).toHaveCount(3);
for (let i = 0; i < 3; i += 1) {
await expect(
rows.nth(i).getByTestId('top-contributors-row-count'),
).toHaveText(`1/${alertHistory.total}`);
await expect(rows.nth(i)).toContainText('service.name');
}
});
// AS-05 reads the `count/total` text, which a *different* column renders than
// the bar does. Dropping the `/ total * 100` scaling from the bar leaves that
// text untouched, so the width needs its own assertion. Radix surfaces the
// clamped percent as `aria-valuenow`.
test('AS-13 contributor bar width is the count as a percentage of the total', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const rows = page
.getByTestId('top-contributors-card')
.getByTestId('top-contributors-row');
await expect(rows).toHaveCount(3);
// Every SEED-A contributor fired exactly once out of `total`.
const percent = String((1 / alertHistory.total) * 100);
for (let i = 0; i < 3; i += 1) {
await expect(rows.nth(i).getByRole('progressbar')).toHaveAttribute(
'aria-valuenow',
percent,
);
}
});
test('AS-06 "View all" button only appears when more than 3 contributors', async ({
authedPage: page,
alertHistory,
metricsHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(page.getByTestId('top-contributors-view-all')).toBeVisible();
await gotoAlertHistory(page, metricsHistory.ruleId);
await expect(page.getByTestId('top-contributors-card')).toBeVisible();
await expect(page.getByTestId('top-contributors-view-all')).toHaveCount(0);
});
test('AS-07 View-all drawer shows paginated list of all contributors', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await page.getByTestId('top-contributors-view-all').click();
const drawer = page.getByTestId('top-contributors-drawer');
await expect(drawer).toBeVisible();
await expect(page.getByText('Viewing All Contributors')).toBeVisible();
await expect(drawer.getByTestId('top-contributors-row')).toHaveCount(10);
await expect(drawer.locator('.total')).toHaveText(
` of ${alertHistory.total}`,
);
});
test('AS-07b drawer opens from deep link with ?viewAllTopContributors=true', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
viewAllTopContributors: 'true',
});
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
});
test('AS-08 View-all click adds ?viewAllTopContributors=true to URL', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await page.getByTestId('top-contributors-view-all').click();
await expect(page).toHaveURL(/[?&]viewAllTopContributors=true/);
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
});
test('AS-10 contributor rows show related-logs link for logs-based rules', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await page
.getByTestId('top-contributors-card')
.getByTestId('top-contributors-row')
.first()
.getByTestId('top-contributors-row-count')
.click();
await expect(page.getByTestId('alert-popover-view-logs')).toBeVisible();
await expect(page.getByTestId('alert-popover-view-traces')).toHaveCount(0);
});
});

View File

@@ -1,74 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERT_LIST_PAGE_SIZE,
alertRuleRows,
gotoAlertList,
} from '../../../helpers/alerts';
test.describe('Alert rules list — columns', () => {
test('LR-01 renders all default columns (Status, Alert Name, Severity, Labels, Actions)', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
for (const header of ['Status', 'Alert Name', 'Severity', 'Labels']) {
await expect(page.getByRole('columnheader', { name: header })).toBeVisible();
}
await expect(
page.getByRole('columnheader', { name: 'Actions' }),
).toBeVisible();
for (const hidden of [
'Created At',
'Created By',
'Updated At',
'Updated By',
]) {
await expect(page.getByRole('columnheader', { name: hidden })).toHaveCount(
0,
);
}
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
await expect(page.getByTestId('alert-columns-button')).toBeVisible();
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('LR-02 shows empty state when no rules exist', async () => {
test.skip(
true,
'AlertsEmptyState needs a zero-rule workspace; unreachable without ' +
'tearing down SEED-B mid-suite. Covered by the component test.',
);
});
test('LR-10 column selector hides and shows a column', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await expect(
page.getByRole('columnheader', { name: 'Severity' }),
).toBeVisible();
await page.getByTestId('alert-columns-button').click();
await page.getByText('Toggle Columns').waitFor();
const severityToggle = page.locator('label', { hasText: 'Severity' });
await severityToggle.click();
await expect(
page.getByRole('columnheader', { name: 'Severity' }),
).toHaveCount(0);
await page.reload();
await expect(
page.getByRole('columnheader', { name: 'Severity' }),
).toHaveCount(0);
await page.getByTestId('alert-columns-button').click();
await page.locator('label', { hasText: 'Severity' }).click();
await expect(
page.getByRole('columnheader', { name: 'Severity' }),
).toBeVisible();
});
});

View File

@@ -1,88 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import { alertRuleRows, gotoAlertList } from '../../../helpers/alerts';
test.describe('Alert rules list — navigation', () => {
test('LR-11 row click opens the overview page', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await alertRuleRows(page).first().click();
await expect(page).toHaveURL(/\/alerts\/overview\?/);
await expect(page).toHaveURL(/[?&]ruleId=/);
await expect(page).toHaveURL(/[?&]compositeQuery=/);
await expect(page).toHaveURL(/[?&]panelTypes=/);
});
test('LR-12 ctrl/cmd-click opens the overview in a new tab', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
// Bounded on purpose. This scenario is intermittently red (~1 in 5 at
// --workers=1, worse under parallel load): the ctrl+click lands, one or
// more popups open, and the `page` event never arrives. Unbounded, the
// wait burns the whole test timeout — 120s in a mutation run — which made
// it the single largest item on the critical path. The flake is *not*
// fixed by this; it just costs seconds instead of minutes when it trips.
const [newPage] = await Promise.all([
page.context().waitForEvent('page', { timeout: 15_000 }),
alertRuleRows(page)
.first()
.click({ modifiers: ['ControlOrMeta'] }),
]);
await newPage.waitForLoadState();
expect(newPage.url()).toContain('/alerts/overview');
expect(newPage.url()).toContain('ruleId=');
await newPage.close();
});
test('LR-13 actions menu Edit and Edit in New Tab navigate correctly', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await alertRuleRows(page).first().getByTestId('alert-actions').click();
await page.getByRole('menuitem', { name: 'Edit in New Tab' }).waitFor();
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
page.getByRole('menuitem', { name: 'Edit in New Tab' }).click(),
]);
await newPage.waitForLoadState();
expect(newPage.url()).toContain('/alerts/overview');
await newPage.close();
await alertRuleRows(page).first().getByTestId('alert-actions').click();
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
await expect(page).toHaveURL(/\/alerts\/overview\?/);
await expect(page).toHaveURL(/[?&]ruleId=/);
});
test('LR-17 New Alert button navigates to alert creation', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await page.getByTestId('list-alerts-new-alert-button').click();
await expect(page).toHaveURL(/\/alerts\/new/);
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('LR-18 shows ErrorEmptyState when list fails to load', async () => {
test.skip(
true,
'Not covered: the suite never stubs network, and there is no server-side ' +
'way to make GET /api/v1/rules fail on demand. Left explicitly ' +
'untested rather than mocked.',
);
});
});

View File

@@ -1,58 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERT_LIST_PAGE_SIZE,
alertRuleRows,
gotoAlertList,
} from '../../../helpers/alerts';
test.describe('Alert rules list — pagination and sorting', () => {
test('LR-07 navigates between pages', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await expect(page.getByTestId('pagination-total-count')).toContainText(
`of ${alertList.count}`,
);
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
await page.getByLabel('Go to next page').click();
await expect(page).toHaveURL(/[?&]page=2/);
await expect(alertRuleRows(page)).toHaveCount(
alertList.count - ALERT_LIST_PAGE_SIZE,
);
await expect(page.getByTestId('pagination-total-count')).toContainText(
`${ALERT_LIST_PAGE_SIZE + 1} - ${alertList.count}`,
);
});
test('LR-08 changes page size', async ({ authedPage: page, alertList }) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
await page.getByTestId('pagination-page-size').click();
await page.getByRole('option', { name: '20', exact: true }).click();
await expect(page).toHaveURL(/[?&]limit=20/);
await expect(alertRuleRows(page)).toHaveCount(alertList.count);
});
test('LR-09 sorts by column header click', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
const firstCell = alertRuleRows(page).first();
const ascFirst = await firstCell.textContent();
await page.getByRole('button', { name: 'Alert Name' }).click();
await expect(page).toHaveURL(/[?&]orderBy=/);
await page.getByRole('button', { name: 'Alert Name' }).click();
await expect(page).toHaveURL(/[?&]orderBy=/);
await expect(alertRuleRows(page).first()).not.toHaveText(ascFirst ?? '');
});
});

View File

@@ -1,92 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import { alertRuleRows, gotoAlertList } from '../../../helpers/alerts';
test.describe('Alert rules list — row actions', () => {
test('LR-14 Disable then Enable toggles the rule state', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-alert-row-toggle-${Date.now()}`;
const ruleId = await ownedRules.threshold(name);
await gotoAlertList(page, { search: name });
const row = alertRuleRows(page).first();
await expect(row.getByTestId(`alert-row-${ruleId}-state`)).toHaveText('OK');
await row.getByTestId('alert-actions').click();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'PATCH',
),
page.getByRole('menuitem', { name: 'Disable' }).click(),
]);
await expect(row.getByTestId(`alert-row-${ruleId}-state`)).toHaveText(
'Disabled',
);
await row.getByTestId('alert-actions').click();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'PATCH',
),
page.getByRole('menuitem', { name: 'Enable' }).click(),
]);
await expect(row.getByTestId(`alert-row-${ruleId}-state`)).toHaveText('OK');
});
test('LR-15 Clone creates a copy and shows success toast', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-alert-row-clone-${Date.now()}`;
await ownedRules.threshold(name);
await gotoAlertList(page, { search: name });
await alertRuleRows(page).first().getByTestId('alert-actions').click();
const [createResponse] = await Promise.all([
page.waitForResponse(
(res) =>
/\/api\/v\d\/rules$/.test(new URL(res.url()).pathname) &&
res.request().method() === 'POST',
),
page.getByRole('menuitem', { name: 'Clone' }).click(),
]);
await ownedRules.register(createResponse);
await expect(page.getByText('Alert cloned successfully')).toBeVisible();
await gotoAlertList(page, { search: name });
await expect(page.getByText(`${name} - Copy`)).toBeVisible();
});
test('LR-16 Delete removes the rule and shows success toast', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-alert-row-delete-${Date.now()}`;
const ruleId = await ownedRules.threshold(name);
await gotoAlertList(page, { search: name });
await alertRuleRows(page).first().getByTestId('alert-actions').click();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'DELETE',
),
page.getByRole('menuitem', { name: 'Delete' }).click(),
]);
await expect(page.getByText('Alert deleted successfully')).toBeVisible();
await expect(page.getByText(name, { exact: true })).toHaveCount(0);
});
});

View File

@@ -1,113 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERT_LIST_PAGE_SIZE,
alertRuleRows,
expectFirstPage,
gotoAlertList,
SEED_B_SEVERITIES,
} from '../../../helpers/alerts';
test.describe('Alert rules list — search', () => {
test('LR-03 filters by name', async ({ authedPage: page, alertList }) => {
await gotoAlertList(page);
await page
.getByTestId('list-alerts-search-input')
.fill(`${alertList.namePrefix}-03`);
await expect(page).toHaveURL(new RegExp(`search=${alertList.namePrefix}-03`));
await expect(alertRuleRows(page)).toHaveCount(1);
await expect(page.getByText(`${alertList.namePrefix}-03`)).toBeVisible();
});
test('LR-04 filters by severity and by label', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page);
const search = page.getByTestId('list-alerts-search-input');
const perSeverity = Math.floor(alertList.count / SEED_B_SEVERITIES.length);
await search.fill('warning');
await expect(page).toHaveURL(/search=warning/);
await expect(alertRuleRows(page)).not.toHaveCount(0);
for (const row of await alertRuleRows(page).all()) {
await expect(row).toContainText('warning');
}
expect(await alertRuleRows(page).count()).toBeGreaterThanOrEqual(perSeverity);
await search.fill(alertList.paymentsLabel);
await expect(page).toHaveURL(new RegExp(`search=${alertList.paymentsLabel}`));
await expect(alertRuleRows(page)).toHaveCount(alertList.count / 2);
});
test('LR-05 shows no-results state with clear button', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await page
.getByTestId('list-alerts-search-input')
.fill('no-such-alert-anywhere');
await expect(page.getByText('No matching alert rules')).toBeVisible();
await page.getByRole('button', { name: 'Clear Search' }).click();
await expect(page).not.toHaveURL(/search=/);
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
});
test('LR-06 resets pagination when searching', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { page: '2' });
await expect(page).toHaveURL(/[?&]page=2/);
await page.getByTestId('list-alerts-search-input').fill(alertList.namePrefix);
await expectFirstPage(page);
});
test('LR-19 state and severity filters intersect, they do not union', async ({
authedPage: page,
alertList,
}) => {
const perSeverity = alertList.count / SEED_B_SEVERITIES.length;
const scoped = { search: alertList.namePrefix };
// One filter kind: a third of the batch.
await gotoAlertList(page, {
...scoped,
alertRulesFilters: JSON.stringify(['severity:warning']),
});
await expect(alertRuleRows(page)).toHaveCount(perSeverity);
for (const row of await alertRuleRows(page).all()) {
await expect(row).toContainText('warning');
}
// Both kinds, both satisfied — seeded rules never fire, so they sit in
// `inactive`. The intersection is unchanged.
await gotoAlertList(page, {
...scoped,
alertRulesFilters: JSON.stringify(['severity:warning', 'state:inactive']),
});
await expect(alertRuleRows(page)).toHaveCount(perSeverity);
// Both kinds, only one satisfied. Under AND this is empty; under OR the
// severity match alone would still surface all `perSeverity` rules.
await gotoAlertList(
page,
{
...scoped,
alertRulesFilters: JSON.stringify(['severity:warning', 'state:firing']),
},
{ expectRows: false },
);
await expect(alertRuleRows(page)).toHaveCount(0);
await expect(page.getByText('No matching alert rules')).toBeVisible();
});
});

View File

@@ -1,130 +0,0 @@
import { expect, test } from '../../fixtures/alert-rules';
import { ALERTS_LIST_PATH } from '../../helpers/alerts';
import { watchConsole } from '../../helpers/common';
// AL-* — the Alerts page shell: the four top-level tabs, how they map to
// `?tab=`, and a navigation smoke check per tab. Tab *internals* (channel CRUD,
// planned downtime, routing policies) are deliberately out of scope — each
// deserves its own spec file.
const TAB_NAMES = {
triggered: /triggered alerts/i,
rules: /alert rules/i,
channels: /notification channels/i,
configuration: /configuration/i,
};
test.describe('Alerts page shell', () => {
test('AL-01 all four top-level tabs render', async ({ authedPage: page }) => {
await page.goto(ALERTS_LIST_PATH);
for (const name of Object.values(TAB_NAMES)) {
await expect(page.getByRole('tab', { name })).toBeVisible();
}
});
test('AL-02 default tab is Alert Rules', async ({ authedPage: page }) => {
await page.goto(ALERTS_LIST_PATH);
// No `tab` param at all — `getActiveKey()` falls back to AlertRules.
await expect(page).toHaveURL(ALERTS_LIST_PATH);
await expect(
page.getByRole('tab', { name: TAB_NAMES.rules }),
).toHaveAttribute('aria-selected', 'true');
// This spec seeds no rules, so the pane may render either the table (with
// its search input) or `AlertsEmptyState` depending on what neighbouring
// specs have in flight. Assert the pane itself, not one of its variants —
// `ListAlertRules` hides the search input entirely in the empty state.
await expect(
page
.getByRole('heading', { name: 'Alert Rules' })
.or(page.getByTestId('list-alerts-search-input')),
).toBeVisible();
});
test('AL-03 tab switch writes ?tab= and clears subTab', async ({
authedPage: page,
}) => {
await page.goto(ALERTS_LIST_PATH);
await page.getByRole('tab', { name: TAB_NAMES.triggered }).click();
await expect(page).toHaveURL(/[?&]tab=TriggeredAlerts/);
await page.getByRole('tab', { name: TAB_NAMES.channels }).click();
await expect(page).toHaveURL(/[?&]tab=Channels/);
// Entering Configuration adds the default subTab...
await page.getByRole('tab', { name: TAB_NAMES.configuration }).click();
await expect(page).toHaveURL(/[?&]tab=Configuration/);
await expect(page).toHaveURL(/[?&]subTab=planned-downtime/);
// ...and leaving it drops subTab entirely (onChange rebuilds the search
// from scratch rather than mutating the existing params).
await page.getByRole('tab', { name: TAB_NAMES.rules }).click();
await expect(page).toHaveURL(/[?&]tab=AlertRules/);
await expect(page).not.toHaveURL(/subTab=/);
});
test('AL-04 Configuration deep-link', async ({ authedPage: page }) => {
// Deep-linking without subTab: the inner Tabs falls back to
// planned-downtime for its activeKey without writing it to the URL, so
// assert the rendered tab, not the param.
await page.goto(`${ALERTS_LIST_PATH}?tab=Configuration`);
await expect(
page.getByRole('tab', { name: /planned downtime/i }),
).toHaveAttribute('aria-selected', 'true');
await page.goto(
`${ALERTS_LIST_PATH}?tab=Configuration&subTab=routing-policies`,
);
await expect(
page.getByRole('tab', { name: /routing policies/i }),
).toHaveAttribute('aria-selected', 'true');
});
test('AL-05 Triggered Alerts tab smoke', async ({ authedPage: page }) => {
// Known application defect, out of scope here: an icon on this tab renders
// with a NaN dimension ("<svg> attribute viewBox: Expected number, \"0 0 32
// NaN\""). Ignore that one string so the rest of the console guard still
// has teeth.
const watch = watchConsole(page, {
ignore: ['attribute viewBox: Expected number'],
});
await page.goto(`${ALERTS_LIST_PATH}?tab=TriggeredAlerts`);
// A fresh stack has no firing instances, so either the table or the empty
// state is correct — what matters is that the tab mounts and its controls
// render.
await expect(page.getByTestId('triggered-alerts-search-input')).toBeVisible();
expect(watch.errors).toEqual([]);
expect(watch.failedResponses).toEqual([]);
});
test('AL-06 Notification Channels tab smoke', async ({
authedPage: page,
alertChannel,
}) => {
// The worker's channel gives this tab a row to render.
await page.goto(`${ALERTS_LIST_PATH}?tab=Channels`);
await expect(page.getByText(alertChannel.name)).toBeVisible();
await expect(
page.getByRole('button', { name: /new alert channel/i }),
).toBeVisible();
});
test('AL-07 tab state survives reload', async ({ authedPage: page }) => {
await page.goto(`${ALERTS_LIST_PATH}?tab=Channels`);
await expect(
page.getByRole('tab', { name: TAB_NAMES.channels }),
).toHaveAttribute('aria-selected', 'true');
await page.reload();
await expect(page).toHaveURL(/[?&]tab=Channels/);
await expect(
page.getByRole('tab', { name: TAB_NAMES.channels }),
).toHaveAttribute('aria-selected', 'true');
});
});

View File

@@ -0,0 +1,524 @@
import uuid
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
BASE_URL = "/api/v2/saved_views"
def _query(*, disabled: bool = False, legend: str = "") -> dict:
return {
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}],
"disabled": disabled,
"legend": legend,
},
}
def _body(
*,
name: str = "my-view",
source_page: str = "logs",
panel_type: str = "table",
max_lines: int = 0,
font_size: str = "",
fmt: str = "",
color: str = "",
selected_fields: list | None = None,
disabled: bool = False,
legend: str = "",
) -> dict:
return {
"name": name,
"sourcePage": source_page,
"schemaVersion": "v2",
"spec": {
"panelType": panel_type,
"queries": [_query(disabled=disabled, legend=legend)],
"selectedFields": [] if selected_fields is None else selected_fields,
"display": {"maxLines": max_lines, "fontSize": font_size, "format": fmt, "color": color},
},
}
# ─── failure cases (create no saved views) ───────────────────────────────────
def test_create_rejects_wrong_schema_version(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
body = _body()
body["schemaVersion"] = "v9"
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "saved_view_invalid_input"
assert response.json()["error"]["message"] == 'schemaVersion must be "v2", got "v9"'
def test_create_rejects_invalid_panel_type(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
body = _body()
body["spec"]["panelType"] = "bogus"
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "saved_view_invalid_input"
def test_create_rejects_empty_queries(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
body = _body()
body["spec"]["queries"] = []
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
# CompositeQuery.Validate() (querybuildertypesv5) raises this with the generic
# invalid_input code, not saved_view_invalid_input -- unlike schemaVersion/
# panelType/sourcePage, which are validated directly by savedviewtypes.
assert response.json()["error"]["code"] == "invalid_input"
assert "at least one query is required" in response.json()["error"]["message"]
def test_create_rejects_invalid_source_page(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
body = _body()
body["sourcePage"] = "bogus"
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "saved_view_invalid_input"
def test_create_rejects_unknown_field(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Unlike dashboard v2's PostableDashboardV2 (which has a custom UnmarshalJSON
# that rewraps this as its own dashboard_invalid_input code), PostableSavedView
# relies solely on binding.WithDisallowUnknownFields, so this surfaces as the
# generic invalid_input code rather than saved_view_invalid_input.
body = _body()
body["unknownfield"] = "boom"
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "invalid_input"
assert "unknown field" in response.json()["error"]["message"]
# ─── not-found cases ──────────────────────────────────────────────────────────
def test_get_rejects_malformed_id(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_get_missing_view_returns_not_found(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.json()["error"]["code"] == "saved_view_not_found"
def test_update_missing_view_returns_not_found(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
json=_body(),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.json()["error"]["code"] == "saved_view_not_found"
def test_delete_missing_view_returns_not_found(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.json()["error"]["code"] == "saved_view_not_found"
# ─── lifecycle ───────────────────────────────────────────────────────────────
def test_saved_view_lifecycle(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
# ── create ────────────────────────────────────────────────────────────────
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=_body(name="lc-logs-overview", source_page="logs"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
view_id = response.json()["data"]
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=_body(name="lc-traces-overview", source_page="traces"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
try:
# ── get echoes back the created shape ────────────────────────────────
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
got = response.json()["data"]
assert got["id"] == view_id
assert got["name"] == "lc-logs-overview"
assert got["sourcePage"] == "logs"
assert got["spec"]["panelType"] == "table"
# ── list filters by sourcePage and name ──────────────────────────────
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sourcePage": "logs"},
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert {v["name"] for v in response.json()["data"]} == {"lc-logs-overview"}
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sourcePage": "logs", "name": "overview"},
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert {v["name"] for v in response.json()["data"]} == {"lc-logs-overview"}
# ── update mutates name, sourcePage and spec ─────────────────────────
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json=_body(name="lc-logs-renamed", source_page="metrics", panel_type="graph"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
updated = response.json()["data"]
assert updated["name"] == "lc-logs-renamed"
assert updated["sourcePage"] == "metrics"
assert updated["spec"]["panelType"] == "graph"
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
# ── delete removes it from get and list ──────────────────────────────────
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND
# ─── round-trip serialization: zero/empty values must not get corrupted ──────
# A value that's genuinely zero (maxLines: 0), empty (""), or an explicit empty
# list must survive being written and read back exactly as sent — never dropped,
# defaulted, or turned into null. The riskier case is not create -> GET (a fresh
# row), it's UPDATE -> GET: overwriting a *previously non-zero* value down to its
# zero value must actually take effect on the persisted row, not silently retain
# the old value or lose the field. See test_dashboard_v2_roundtrip_preserves_zero_values
# for the analogous dashboard v2 case.
def test_create_roundtrip_preserves_zero_values(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=_body(
name="create-zero-values",
max_lines=0,
font_size="",
fmt="",
color="",
selected_fields=[],
disabled=False,
legend="",
),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
view_id = response.json()["data"]
try:
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["spec"]
query = spec["queries"][0]["spec"]
cases = [
("maxLines 0", spec["display"]["maxLines"], 0),
("fontSize empty", spec["display"]["fontSize"], ""),
("format empty", spec["display"]["format"], ""),
("color empty", spec["display"]["color"], ""),
("selectedFields explicit empty list", spec["selectedFields"], []),
("query disabled false", query["disabled"], False),
("query legend empty", query["legend"], ""),
]
for description, actual, expected in cases:
assert actual == expected, description
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
def test_selected_fields_omitted_on_create_reads_back_as_empty_list_not_null(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
body = _body(name="omitted-selected-fields")
del body["spec"]["selectedFields"]
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=body,
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
view_id = response.json()["data"]
try:
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["spec"]["selectedFields"] == []
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
def test_update_does_not_corrupt_zero_values(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""The failure mode this guards against: an update that writes maxLines=0 (or
any other zero/empty value) either silently keeps the previous non-zero value
(a partial-update bug) or drops/nulls the field on read-back (a serialization
bug). Both are round-trip corruption; only an exact zero on GET proves neither
happened."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
# ── create with deliberately non-zero values everywhere ──────────────────
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=_body(
name="update-zero-values",
max_lines=25,
font_size="large",
fmt="table",
color="blue",
selected_fields=[{"name": "service.name"}],
disabled=True,
legend="Custom Legend",
),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
view_id = response.json()["data"]
try:
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["spec"]
assert spec["display"]["maxLines"] == 25
# signal/fieldContext/fieldDataType always serialize on TelemetryFieldKey
# (no omitempty -- see pkg/types/telemetrytypes/field.go), so an entry sent
# with only "name" reads back with those three as explicit "".
assert spec["selectedFields"] == [{"name": "service.name", "signal": "", "fieldContext": "", "fieldDataType": ""}]
assert spec["queries"][0]["spec"]["disabled"] is True
# ── update overwrites every one of those fields down to its zero value ──
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json=_body(
name="update-zero-values",
max_lines=0,
font_size="",
fmt="",
color="",
selected_fields=[],
disabled=False,
legend="",
),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
# ── the zero values took effect -- not retained, not dropped, not null ──
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["spec"]
query = spec["queries"][0]["spec"]
cases = [
("maxLines reset to 0", spec["display"]["maxLines"], 0),
("fontSize reset to empty", spec["display"]["fontSize"], ""),
("format reset to empty", spec["display"]["format"], ""),
("color reset to empty", spec["display"]["color"], ""),
("selectedFields reset to empty list", spec["selectedFields"], []),
("query disabled reset to false", query["disabled"], False),
("query legend reset to empty", query["legend"], ""),
]
for description, actual, expected in cases:
assert actual == expected, description
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)