Compare commits

..

25 Commits

Author SHA1 Message Date
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
58 changed files with 2878 additions and 1687 deletions

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

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

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

@@ -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,16 +18,26 @@ 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">
@@ -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,126 +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> => ({
onClick: (): void => {
void logEvent('Alert history: Timeline table row: Clicked', {
logEvent('Alert history: Timeline table row: Clicked', {
ruleId: record.ruleID,
labels: record.labels,
});
@@ -129,106 +55,23 @@ function TimelineTableContent(): JSX.Element {
return (
<div className="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">
<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
/>
</div>
)}
<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">
<ErrorContent error={apiError} />
</div>
) : undefined,
}}
footer={(): JSX.Element => (
<div className="timeline-table__pagination">
<div className="timeline-table__pagination-info">
{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,
@@ -27,7 +95,9 @@ export const timelineTableColumns = ({
),
},
{
title: 'LABELS',
title: (
<LabelFilter setFilters={setFilters} filters={filters} labels={labels} />
),
dataIndex: 'labels',
render: (labels): JSX.Element => (
<div className="alert-rule-labels">
@@ -49,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>
<Ellipsis className="dropdown-icon" size="md" />
</Button>
</Tooltip>
);
}
return (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink ?? ''}
relatedLogsLink={record.relatedLogsLink ?? ''}
>
<Button type="text" ghost>
<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

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

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

@@ -1,97 +0,0 @@
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../variableFormModel';
import { dtoToFormModel, formModelToDto } from '../variableAdapters';
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), name: 'v', ...overrides };
}
/** The list spec a saved variable carries, whatever its plugin. */
function listSpec(m: VariableFormModel): {
allowMultiple?: boolean;
allowAllValue?: boolean;
} {
return formModelToDto(m).spec as {
allowMultiple?: boolean;
allowAllValue?: boolean;
};
}
describe('formModelToDto — the ALL flag needs multi-select', () => {
it('keeps ALL for a multi-select dynamic variable', () => {
expect(
listSpec(
model({
type: 'DYNAMIC',
dynamicAttribute: 'host.name',
multiSelect: true,
}),
),
).toMatchObject({ allowMultiple: true, allowAllValue: true });
});
it('drops ALL for a single-select dynamic variable', () => {
// The API rejects allowAllValue without allowMultiple, and this used to be forced
// true for every dynamic variable — which blocked saving the whole dashboard.
expect(
listSpec(
model({
type: 'DYNAMIC',
dynamicAttribute: 'host.name',
multiSelect: false,
}),
),
).toMatchObject({ allowMultiple: false, allowAllValue: false });
});
it('drops ALL for a single-select query variable that still carries the flag', () => {
expect(
listSpec(
model({
type: 'QUERY',
queryValue: 'SELECT 1',
multiSelect: false,
showAllOption: true,
}),
),
).toMatchObject({ allowMultiple: false, allowAllValue: false });
});
it('respects the toggle on a multi-select query variable', () => {
expect(
listSpec(
model({
type: 'QUERY',
queryValue: 'SELECT 1',
multiSelect: true,
showAllOption: false,
}),
),
).toMatchObject({ allowMultiple: true, allowAllValue: false });
});
it('round-trips a single-select dynamic variable unchanged', () => {
// What the dashboard in the report holds: saving it must not flip the flag.
const dto = {
kind: 'ListVariable',
spec: {
name: 'host.name',
display: { name: 'host.name', description: '' },
allowMultiple: false,
allowAllValue: false,
sort: 'alphabetical-asc',
plugin: {
kind: 'signoz/DynamicVariable',
spec: { name: 'host.name', signal: 'all' },
},
},
} as never;
expect(listSpec(dtoToFormModel(dto))).toMatchObject({
allowMultiple: false,
allowAllValue: false,
});
});
});

View File

@@ -133,14 +133,9 @@ export function formModelToDto(
name: model.name,
display,
allowMultiple: model.multiSelect,
// Dynamic variables always expose the aggregate "ALL" entry (matches V1, which
// forced showALLOption true on save); other types respect the toggle. Either
// way it needs multi-select — ALL is a set of values, and the API rejects the
// flag without it, which used to make a single-select dynamic variable
// unsaveable and blocked every other edit to the dashboard with it.
allowAllValue:
model.multiSelect &&
(model.type === 'DYNAMIC' ? true : model.showAllOption),
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
// which forced showALLOption true on save); other types respect the toggle.
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
sort: model.sort,
defaultValue: model.defaultValue,

View File

@@ -13,7 +13,6 @@ import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/Toolt
import HiddenVariablesTooltip from './components/HiddenVariablesTooltip/HiddenVariablesTooltip';
import { useVariableSelection } from './hooks/useVariableSelection';
import { resolveDefaultSelection } from './utils/resolveVariableSelection';
import VariableSelector from './components/VariableSelector/VariableSelector';
import styles from './VariablesBar.module.scss';
@@ -97,10 +96,12 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
variable={variable}
variables={variables}
selections={selection}
// Until the seed commits a selection, stand in the same default it will
// commit, through the one resolver — an empty stand-in reads as "nothing
// selected" to a control that snapshots it on mount.
selection={selection[variable.name] ?? resolveDefaultSelection(variable)}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
onChange={(next): void => setSelection(variable.name, next)}
onAutoSelect={(next): void => autoSelect(variable.name, next)}
/>

View File

@@ -1,92 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { VariableSelection } from '../selectionTypes';
import TextSelector from '../components/selectors/TextSelector';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
function renderSelector(
selection: VariableSelection,
onChange = jest.fn(),
): { rerender: (selection: VariableSelection) => void; onChange: jest.Mock } {
const { rerender } = render(
<TextSelector
selection={selection}
defaultValue="flower"
onChange={onChange}
testId="variable-input-service"
/>,
);
return {
onChange,
rerender: (next): void =>
rerender(
<TextSelector
selection={next}
defaultValue="flower"
onChange={onChange}
testId="variable-input-service"
/>,
),
};
}
function input(): HTMLInputElement {
return screen.getByTestId('variable-input-service') as HTMLInputElement;
}
describe('TextSelector', () => {
it('shows the value the seed commits after mount', () => {
// The bar mounts before the seed resolves the definition's default, so the first
// render sees nothing selected. The box must follow the value once it lands.
const { rerender } = renderSelector({ value: '', allSelected: false });
rerender({ value: 'flower', allSelected: false });
expect(input().value).toBe('flower');
});
it('follows a selection replaced from outside (share link, reset)', () => {
const { rerender } = renderSelector({ value: 'flower', allSelected: false });
rerender({ value: 'rose', allSelected: false });
expect(input().value).toBe('rose');
});
it('keeps what the user types until they commit it', async () => {
const user = userEvent.setup();
const { onChange } = renderSelector({ value: 'flower', allSelected: false });
await user.clear(input());
await user.type(input(), 'tulip');
// Still local — a keystroke must not cascade to dependent panels.
expect(input().value).toBe('tulip');
expect(onChange).not.toHaveBeenCalled();
await user.tab();
expect(onChange).toHaveBeenCalledWith({
value: 'tulip',
allSelected: false,
});
});
it('restores the default when emptied', async () => {
const user = userEvent.setup();
const { onChange } = renderSelector({ value: 'tulip', allSelected: false });
await user.clear(input());
await user.tab();
expect(onChange).toHaveBeenCalledWith({
value: 'flower',
allSelected: false,
});
expect(input().value).toBe('flower');
});
});

View File

@@ -87,99 +87,4 @@ describe('ValueSelector', () => {
expect(screen.getByText('ALL')).toBeInTheDocument();
});
describe('an ALL selection whose options are still loading', () => {
it('reads ALL, not the "Select value" placeholder', () => {
// Options arrive after the selection is known (first fetch, or a dynamic ALL,
// which carries no values at all) — the control must not read as unselected.
renderSelector({ value: null, allSelected: true }, [], true);
expect(
document.querySelector('.ant-select-selection-placeholder'),
).toHaveTextContent('ALL');
});
it('still shows concrete values while options load', () => {
renderSelector(
{ value: ['checkout-service-prod'], allSelected: false },
[],
true,
);
expect(
document.querySelector('.ant-select-selection-placeholder'),
).toBeNull();
});
});
describe('clearing', () => {
function clearIcon(): Element | null {
return document.querySelector('.ant-select-clear');
}
async function openDropdown(): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const control = screen.getByTestId('variable-select-env');
await user.click(control.querySelector('input') as HTMLInputElement);
}
it('offers no clear icon while the list is closed', () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
expect(clearIcon()).toBeNull();
});
it('offers it once the list is open', async () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
await openDropdown();
expect(clearIcon()).not.toBeNull();
});
it('offers no clear icon while every option is selected', async () => {
// ALL is every option, so there is nothing to clear — and the shared control
// refuses to empty an ALL selection, which would leave the icon inert.
renderSelector({ value: OPTIONS, allSelected: true }, OPTIONS);
await openDropdown();
expect(clearIcon()).toBeNull();
});
it('empties the list and commits nothing until it closes', async () => {
const onChange = jest.fn();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(
<TooltipProvider>
<ValueSelector
options={OPTIONS}
variableType="query"
multiSelect
showAllOption
selection={{ value: VALUES, allSelected: false }}
onChange={onChange}
emptyFallback={{ value: [OPTIONS[0]], allSelected: false }}
testId="variable-select-env"
/>
</TooltipProvider>,
);
await openDropdown();
await user.click(clearIcon() as Element);
expect(document.querySelectorAll('.ant-select-selection-item')).toHaveLength(
0,
);
expect(onChange).not.toHaveBeenCalled();
// Closing fills in whatever the variable should hold.
await user.keyboard('{Escape}');
expect(onChange).toHaveBeenCalledWith({
value: [OPTIONS[0]],
allSelected: false,
});
});
});
});

View File

@@ -61,47 +61,6 @@ describe('useAutoSelect', () => {
expect(next).toBeUndefined();
});
it('selects ALL for an ALL-enabled multi-select with nothing selected', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: true });
});
it('falls back to ALL, not the first option, when every selected value is gone', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['x', 'y'],
{ value: ['a', 'b'], allSelected: false },
);
expect(next).toStrictEqual({ value: ['x', 'y'], allSelected: true });
});
it('selects the ALL sentinel for an empty ALL-enabled dynamic multi-select', () => {
const next = run(
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: null, allSelected: true });
});
it('still honours a configured default over ALL', () => {
const next = run(
model({
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'b',
}),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: ['b'], allSelected: false });
});
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true }),

View File

@@ -97,125 +97,6 @@ describe('useSeedVariableSelection', () => {
});
});
it('prefers the configured default over a stored empty text value', () => {
// Persisted from a session where the box was never filled — the default the
// definition carries must win, or the variable reads as unset forever.
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: '', allSelected: false } });
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT', textValue: 'prod' }),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: 'prod',
allSelected: false,
});
});
it('defaults an ALL-enabled multi-select to ALL over a stored empty array', () => {
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: [], allSelected: false } });
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b',
multiSelect: true,
showAllOption: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
// Custom options need no request, so ALL is materialized here rather than left as
// a flag for the post-fetch reconcile to expand.
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a', 'b'],
allSelected: true,
});
});
it('materializes ALL for a custom variable so nothing has to reconcile it later', () => {
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b,c',
multiSelect: true,
showAllOption: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a', 'b', 'c'],
allSelected: true,
});
});
it('drops values a custom variable no longer offers, at seed time', () => {
useDashboardStore.getState().setVariableValues('d1', {
env: { value: ['a', 'gone'], allSelected: false },
});
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b',
multiSelect: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a'],
allSelected: false,
});
});
it('keeps a stored ALL selection that has not materialized yet', () => {
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: null, allSelected: true } });
const dash = dashboard('d1', [
model({
name: 'env',
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'b',
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: null,
allSelected: true,
});
});
it('does not rewrite the store when the effect re-runs with the same values', () => {
// A dashboard refetch or any spec edit hands back an equal-but-new variables array,
// re-running the seed. Writing an identical map would re-render every subscriber.
const variable = model({ name: 'env', type: 'TEXT', textValue: 'prod' });
const { rerender } = renderHook(
({ dash }) => useSeedVariableSelection(dash),
{ initialProps: { dash: dashboard('d1', [variable]) } },
);
const afterSeed = useDashboardStore.getState().variableValues;
rerender({ dash: dashboard('d1', [{ ...variable }]) });
expect(useDashboardStore.getState().variableValues).toBe(afterSeed);
});
it('initializes the fetch context with idle states for every variable', () => {
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT' }),

View File

@@ -1,152 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useVariableSelection } from '../hooks/useVariableSelection';
jest.mock('nuqs', () => ({
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
useQueryState: (): unknown => [null, jest.fn()],
}));
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({
globalTime: { minTime: 1, maxTime: 2, selectedTime: '5m' },
}),
}));
jest.mock('../../DashboardSettings/Variables/variableAdapters', () => ({
dtoToFormModel: (dto: unknown): unknown => dto,
}));
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
// env → svc, so a cascade off `env` is observable as a cycle bump on `svc`.
const env = model({
name: 'env',
type: 'QUERY',
multiSelect: true,
queryValue: 'SELECT env',
});
const svc = model({
name: 'svc',
type: 'QUERY',
queryValue: 'SELECT svc WHERE env = $env',
});
const dashboard = {
id: 'd1',
spec: { variables: [env, svc] },
} as unknown as DashboardtypesGettableDashboardV2DTO;
function svcCycleId(): number {
return useDashboardStore.getState().variableCycleIds.svc ?? 0;
}
describe('useVariableSelection — setSelection', () => {
beforeEach(() => {
useDashboardStore.setState({
variableValues: {},
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
});
});
it('does not re-cascade when the picked value is the one already held', () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', {
value: ['a', 'b'],
allSelected: false,
});
});
const afterFirstPick = svcCycleId();
// Same values, then the same set in a different order: neither is a change, so
// the dependent must not be re-fetched.
act(() => {
result.current.setSelection('env', {
value: ['a', 'b'],
allSelected: false,
});
});
act(() => {
result.current.setSelection('env', {
value: ['b', 'a'],
allSelected: false,
});
});
expect(svcCycleId()).toBe(afterFirstPick);
});
it('ignores an auto-fill that the store already satisfies', async () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
// What a selector's first-render reconcile produces before the seed commits: a
// value the store then resolves to on its own.
await act(async () => {
result.current.autoSelect('env', { value: ['a'], allSelected: false });
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
expect(svcCycleId()).toBe(before);
});
it('still applies an auto-fill that changes the value', async () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
await act(async () => {
result.current.autoSelect('env', { value: ['a', 'b'], allSelected: false });
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
value: ['a', 'b'],
allSelected: false,
});
expect(svcCycleId()).toBe(before + 1);
});
it('still cascades a genuine change', () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
act(() => {
result.current.setSelection('env', {
value: ['a', 'c'],
allSelected: false,
});
});
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
value: ['a', 'c'],
allSelected: false,
});
expect(svcCycleId()).toBe(before + 1);
});
});

View File

@@ -9,7 +9,6 @@ import type {
VariableSelection,
VariableSelectionMap,
} from '../../selectionTypes';
import { textDefault } from '../../utils/resolveVariableSelection';
import { computeVariableDependencies } from '../../utils/variableDependencies';
import TextSelector from '../selectors/TextSelector';
import VariableValueControl from '../selectors/VariableValueControl';
@@ -66,7 +65,7 @@ function VariableSelector({
variable.type === 'TEXT' ? (
<TextSelector
selection={selection}
defaultValue={textDefault(variable)}
defaultValue={variable.textValue}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import type { InputRef } from 'antd';
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
import { Input } from 'antd';
@@ -31,17 +31,6 @@ function TextSelector({
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
// The committed value can land after this input mounts — the seed resolves the
// definition's default one tick later, and a share link or a reset can replace it
// at any point. Without following it the box would keep showing its first render
// (empty, since nothing is seeded yet) until the user focused and blurred it.
// Typing never touches the selection, so an edit in progress cannot be interrupted.
useEffect(() => {
setValue(
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
}, [selection.value, defaultValue]);
const commit = useCallback(
(next: string): void => {
void logEvent(

View File

@@ -54,26 +54,11 @@ function ValueSelector({
[selection, options],
);
// That "all" path needs the options, so an ALL selection whose options have not
// arrived yet has nothing to render and the control would read "Select value"
// while it spins — as if nothing were selected. Say ALL in that slot instead: the
// selection is known, only its options are pending. Display only, so it can never
// be committed as a value.
const isAllPendingOptions = selection.allSelected && options.length === 0;
// Buffer edits while the dropdown is open; the committed selection is shown
// when closed. This defers the dependent cascade to a single commit-on-close.
const [isOpen, setIsOpen] = useState(false);
const [draft, setDraft] = useState<string[]>(committedValues);
// ALL is every option, so there is nothing to clear — and the shared control refuses
// to empty an ALL selection anyway, which would leave the icon inert. Unchecking ALL
// in the list is the way out of it.
const draftIsAll =
showAllOption &&
options.length > 0 &&
options.every((option) => draft.includes(option));
const commit = (values: string[]): void => {
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
@@ -109,11 +94,8 @@ function ValueSelector({
errorMessage={errorMessage}
onRetry={onRetry}
showSearch
// Clearing belongs to the open list: on the closed control the icon would
// appear on hover, in a row of variable pills, for an action whose result is
// not visible.
allowClear={isOpen && !draftIsAll}
placeholder={isAllPendingOptions ? 'ALL' : 'Select value'}
allowClear
placeholder="Select value"
maxTagCount={1}
maxTagTextLength={10}
maxTagPlaceholder={(omitted): JSX.Element => (
@@ -147,10 +129,12 @@ function ValueSelector({
void logEvent(DashboardDetailEvents.VariableMultiSelectCleared, {
variableType,
});
// Empties the list, committing nothing. Closing resolves an empty draft
// to whatever the variable should hold — its configured default, else ALL
// where it offers one, else the first option.
setDraft([]);
// A clear on the closed control falls back to the default immediately;
// while open it just empties the draft (committed on close).
if (!isOpen) {
onChange(emptyFallback);
}
}}
/>
);

View File

@@ -6,13 +6,7 @@ import { dtoToFormModel } from '../../DashboardSettings/Variables/variableAdapte
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { selectVariableValues } from '../../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
import { knownVariableOptions } from '../utils/knownVariableOptions';
import {
areSelectionsEqual,
reconcileWithOptions,
resolveDefaultSelection,
} from '../utils/resolveVariableSelection';
import { hasUsableValue } from '../utils/selectionUtils';
import { resolveDefaultSelection } from '../utils/resolveVariableSelection';
import type {
SelectedVariableValue,
VariableSelection,
@@ -24,44 +18,6 @@ import {
} from '../utils/variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from '../utils/variablesUrlState';
function isStoredSelectionSet(
stored: VariableSelection,
model: VariableFormModel,
): boolean {
return !!stored.allSelected || hasUsableValue(stored, model.type);
}
/** Whether the seeded map matches, entry for entry, what the store already holds. */
function isSameSelection(
seeded: VariableSelectionMap,
current: VariableSelectionMap,
): boolean {
const names = Object.keys(seeded);
return (
names.length === Object.keys(current).length &&
names.every(
(name) => !!current[name] && areSelectionsEqual(seeded[name], current[name]),
)
);
}
/**
* A seeded value taken as far as it can go: for a variable whose options need no
* request, resolve against them now — ALL becomes the concrete array, values the list
* no longer offers are dropped. Left alone otherwise; the post-fetch reconcile does it
* once the options arrive.
*/
function resolveAgainstKnownOptions(
value: VariableSelection,
model: VariableFormModel,
): VariableSelection {
const options = knownVariableOptions(model);
if (options.length === 0) {
return value;
}
return reconcileWithOptions(model, value, options) ?? value;
}
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
function fromUrlValue(
@@ -114,31 +70,22 @@ export function useSeedVariableSelection(
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
const stored = selection[variable.name];
const seed = (value: VariableSelection): void => {
seeded[variable.name] = resolveAgainstKnownOptions(value, variable);
};
if (urlValue !== undefined) {
const fromUrl = fromUrlValue(urlValue, variable);
// When the URL carries only the ALL sentinel but the store already holds
// the materialized full-option array, reuse it — avoids the re-fetch +
// re-materialize round-trip (and its dependent-refetch cascade) on load.
seed(
seeded[variable.name] =
fromUrl.allSelected && stored?.allSelected && Array.isArray(stored.value)
? stored
: fromUrl,
);
} else if (stored && isStoredSelectionSet(stored, variable)) {
seed(stored);
: fromUrl;
} else if (stored) {
seeded[variable.name] = stored;
} else {
seed(resolveDefaultSelection(variable));
seeded[variable.name] = resolveDefaultSelection(variable);
}
});
// This runs again whenever the spec's variable array changes identity — a refetch
// or any spec edit — and writing an identical map would re-render every selection
// subscriber for nothing.
if (!isSameSelection(seeded, selection)) {
setVariableValues(dashboardId, seeded);
}
setVariableValues(dashboardId, seeded);
// Read-once: a share link's `?variables=` seeds the store, then the param is
// dropped so the store is the sole source of truth. Selection changes never

View File

@@ -1,11 +1,14 @@
import { useEffect, useMemo } from 'react';
import logEvent from 'api/common/logEvent';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import { VARIABLE_TYPE_EVENT_LABEL } from '../../DashboardSettings/Variables/variableFormModel';
import {
sortValuesByOrder,
VARIABLE_TYPE_EVENT_LABEL,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from '../selectionTypes';
import { knownVariableOptions } from '../utils/knownVariableOptions';
import {
useFetchedVariableOptions,
type VariableOptions,
@@ -27,12 +30,14 @@ export function useVariableOptions(
): VariableOptions {
const fetched = useFetchedVariableOptions(variable, variables, selections);
// Keyed on the fields the parse actually reads, not the model identity: a dashboard
// refetch hands back an equal-but-new model, and a new options array would re-fire
// the post-fetch reconcile for nothing.
const customOptions = useMemo(
() => knownVariableOptions(variable),
// eslint-disable-next-line react-hooks/exhaustive-deps
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: ([] as string[]),
[variable.type, variable.customValue, variable.sort],
);

View File

@@ -12,7 +12,6 @@ import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { areSelectionsEqual } from '../utils/resolveVariableSelection';
import { useSeedVariableSelection } from './useSeedVariableSelection';
/**
@@ -104,10 +103,6 @@ export function useVariableSelection(
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
const current = selectionRef.current[name];
if (current && areSelectionsEqual(next, current)) {
return;
}
setVariableValue(dashboardId, name, next);
enqueueDescendants(name);
},
@@ -129,19 +124,8 @@ export function useVariableSelection(
if (names.length === 0 || !dashboardId) {
return;
}
// A fill can arrive already satisfied: a selector reconciles against the options
// on its first render, before the seed has committed, and the seed then resolves
// to the same value. Refresh only what actually moved, so a settled load ends
// without a write or a dependent refetch.
const current = selectionRef.current;
const changed = names.filter(
(name) => !current[name] || !areSelectionsEqual(fills[name], current[name]),
);
if (changed.length === 0) {
return;
}
setVariableValues(dashboardId, { ...current, ...fills });
enqueueDescendantsBatch(changed);
setVariableValues(dashboardId, { ...selectionRef.current, ...fills });
enqueueDescendantsBatch(names);
}, [dashboardId, setVariableValues, enqueueDescendantsBatch]);
const autoSelect = useCallback(

View File

@@ -1,23 +0,0 @@
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
/**
* The options of a variable whose list needs no request — a CUSTOM variable's comma
* list. Empty for QUERY and DYNAMIC, whose options only exist once fetched, and for
* TEXT, which has none.
*
* Knowing them up front lets the seed resolve such a variable's value completely
* (materializing ALL, dropping values the list no longer offers) instead of leaving
* that to the post-fetch reconcile, which would cost a second store write and a
* refetch of everything downstream.
*/
export function knownVariableOptions(model: VariableFormModel): string[] {
if (model.type !== 'CUSTOM') {
return [];
}
return sortValuesByOrder(commaValuesParser(model.customValue), model.sort).map(
String,
);
}

View File

@@ -37,7 +37,7 @@ function firstConfiguredDefault(model: VariableFormModel): string | undefined {
}
/** A TEXT variable's default: its configured default, else its textValue (always a string). */
export function textDefault(model: VariableFormModel): string {
function textDefault(model: VariableFormModel): string {
return firstConfiguredDefault(model) ?? model.textValue;
}
@@ -53,31 +53,15 @@ function isAllDefault(
);
}
/**
* The default selection for a variable with nothing usable selected: its configured
* default, else ALL for an ALL-enabled multi-select, else the first option.
*/
/** The configured default (or first option) as a fresh selection. */
function fillDefault(
model: VariableFormModel,
options: string[],
): VariableSelection {
const fallback = firstConfiguredDefault(model);
if (fallback && options.includes(fallback)) {
return {
value: model.multiSelect ? [fallback] : fallback,
allSelected: false,
};
}
// No usable configured default: an ALL-enabled multi-select defaults to ALL, not
// to an arbitrary first option — the same default the seed applies (keep in sync
// with resolveDefaultSelection).
if (model.multiSelect && model.showAllOption) {
return materializeAll(model, options, null) ?? ALL_SELECTION;
}
const initial = fallback && options.includes(fallback) ? fallback : options[0];
return {
value: model.multiSelect ? [options[0]] : options[0],
value: model.multiSelect ? [initial] : initial,
allSelected: false,
};
}

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,144 +19,76 @@ 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 updating 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{}).
Model(&savedviewtypes.StorableSavedView{}).
Where("id = ?", uuid.StringValue()).
Where("org_id = ?", orgID).
Exec(ctx)
@@ -172,7 +99,7 @@ func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.
}
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,243 @@
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_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)
}
})
}
}