Compare commits

..

29 Commits

Author SHA1 Message Date
Nikhil Soni
61a42c78cc refactor(savedview): extract Store interface, mock-backed module tests
Per review feedback on PR #12342 (module_test.go used a real sqlite DB
instead of a mock), savedview had no Store abstraction at all -- module.go
called sqlstore.SQLStore directly. Extract savedviewtypes.Store (matching
dashboardtypes.Store/ruletypes.RuleStore/alertmanagertypes.RouteStore), with
the real implementation in implsavedview/store.go (same package as
module.go, mirroring impldashboard's single-package shape) and module.go
reduced to claims lookup + thin delegation.

The mock lives in pkg/modules/savedview/implsavedviewtest, not under
pkg/types as literally suggested -- mirroring rulestore/rulestoretest and
nfroutingstore/nfroutingstoretest, both of which keep the concrete
implementation and its sqlmock-backed mock together in the module/store
hierarchy, specifically to avoid a types package depending on a modules
package. It wraps the real implsavedview store via sqlmock so tests assert
against genuinely-generated SQL.

Store.List also fixes a real bug found while writing this: source_page was
an unconditional exact-match clause, so an omitted/zero-value sourcePage
matched zero rows -- even though ListSavedViewsParams.Validate() already
treated a zero SourcePage as valid ("no filter"). The clause is now applied
only when sourcePage is non-zero, so List serves both GetViewsForFilters
and Collect (org-wide, no filters) with one method instead of two.

module_test.go is now an external test package (implsavedview_test) --
needed because implsavedviewtest imports implsavedview, so an internal test
file importing implsavedviewtest would be a cycle.
2026-07-31 19:18:03 +05:30
Nikhil Soni
ecd665056f refactor(savedview): reintroduce core SavedView type
pkg/types/savedviewtypes had only flavor types (Postable/Gettable) and no
canonical core type, per review feedback on PR #12342. Rename
StorableSavedView -> SavedView so it's the canonical type every Store/Module
signature is expressed against (matching docs/contributing/go/types.md).

GettableSavedView stays a distinct type rather than an alias: bun only
treats an embedded SavedViewData as a single opaque "data" column when it's
a named field, but the API response needs schemaVersion/spec flattened to
the top level, so the storage and response shapes genuinely diverge
(confirmed empirically -- NewCreateTable decomposes an anonymously-embedded
tagged field into per-field columns regardless of the bun tag). No wire
shape change; OpenAPI regen produced no diff.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -7430,8 +7430,6 @@ components:
- below
- equal
- not_equal
- above_or_equal
- below_or_equal
- outside_bounds
type: string
RuletypesCumulativeSchedule:
@@ -7747,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:
@@ -22579,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
@@ -24700,149 +25086,6 @@ paths:
summary: Replace variables
tags:
- querier
/prometheus/api/v1/query:
get:
deprecated: false
description: Evaluate a PromQL expression at a single instant. Request and
response follow the Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/);
the /prometheus prefix distinguishes these PromQL-only endpoints from the
SigNoz query APIs. Also accepts POST with form-encoded parameters.
operationId: PrometheusInstantQuery
parameters:
- description: PromQL expression to evaluate
in: query
name: query
required: true
schema:
type: string
- description: 'Evaluation timestamp: float unix seconds or RFC3339. Defaults
to the server''s current time.'
in: query
name: time
schema:
type: string
- description: 'Evaluation timeout: float seconds or a Prometheus duration
string (e.g. 30s).'
in: query
name: timeout
schema:
type: string
- description: Set to any value to include query statistics in the response.
in: query
name: stats
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
properties:
result: {}
resultType:
enum:
- matrix
- vector
- scalar
- string
type: string
stats: {}
type: object
status:
enum:
- success
type: string
type: object
description: Query evaluated successfully
"400":
description: Unparsable expression or parameters (errorType bad_data)
"422":
description: Expression failed to evaluate (errorType execution)
"503":
description: Query timed out or was canceled
summary: Prometheus instant query
tags:
- prometheus
/prometheus/api/v1/query_range:
get:
deprecated: false
description: Evaluate a PromQL expression over a range of time on a fixed
step grid. Request and response follow the Prometheus HTTP API
(https://prometheus.io/docs/prometheus/latest/querying/api/); the
/prometheus prefix distinguishes these PromQL-only endpoints from the
SigNoz query APIs. Grids are capped at 11,000 points per series. Also
accepts POST with form-encoded parameters.
operationId: PrometheusRangeQuery
parameters:
- description: PromQL expression to evaluate
in: query
name: query
required: true
schema:
type: string
- description: 'Start timestamp: float unix seconds or RFC3339.'
in: query
name: start
required: true
schema:
type: string
- description: 'End timestamp: float unix seconds or RFC3339.'
in: query
name: end
required: true
schema:
type: string
- description: 'Grid step: float seconds or a Prometheus duration string
(e.g. 30s). Must be positive.'
in: query
name: step
required: true
schema:
type: string
- description: 'Evaluation timeout: float seconds or a Prometheus duration
string (e.g. 30s).'
in: query
name: timeout
schema:
type: string
- description: Set to any value to include query statistics in the response.
in: query
name: stats
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
properties:
result: {}
resultType:
enum:
- matrix
type: string
stats: {}
type: object
status:
enum:
- success
type: string
type: object
description: Query evaluated successfully
"400":
description: Unparsable expression or parameters, or a grid past the 11,000-point
cap (errorType bad_data)
"422":
description: Expression failed to evaluate (errorType execution)
"503":
description: Query timed out or was canceled
summary: Prometheus range query
tags:
- prometheus
servers:
- description: The fully qualified URL to the SigNoz APIServer.
url: https://{host}:{port}{base_path}

View File

@@ -1,353 +0,0 @@
# PromQL Serving — clickhouseprometheusv2
This document is the subsystem context for `pkg/prometheus/clickhouseprometheusv2`,
the second-generation ClickHouse-backed Prometheus provider. It explains why the
package exists, the correctness constraints that shaped it, and how each
construct is proven not to change results. Any change to the provider must keep
these invariants; if a change would violate one, it must be flagged and
discussed.
---
## Why a second provider
The v1 provider (`pkg/prometheus/clickhouseprometheus`) serves the promql engine
through the remote-read protobuf adapter: every raw sample of a query's union
window is fetched, serialized, and handed to the engine. The cost is a function
of ingested data, not of the question asked — which is how a dashboard of PromQL
panels can take an instance down.
In v2, every query runs in one of two ways, decided per query:
- **Transpiled**: the query is evaluated entirely inside ClickHouse and only
final (or near-final) per-group grid arrays come back, built on the
`timeSeries*ToGrid` aggregate functions (the supported ClickHouse floor is
>= 25.6, so they are assumed available).
- **Engine**: the stock promql engine evaluates over this package's native
`storage.Querier`. This is the path for everything not transpilable.
**The core constraint: a PromQL result that differs from upstream Prometheus is
a lost user, so anything that cannot reproduce engine semantics exactly falls
back rather than approximate.** The conformance suite
(`tests/integration/tests/promqlconformance/`) replays Prometheus' own test
corpus against both providers and is the arbiter; the classification golden
(`testdata/classification_golden.json`) freezes which of the two ways each
corpus expression takes. The rest of this document is the PromQL -> SQL story,
because that mapping is where correctness is won or lost.
---
## The evaluation model the SQL must reproduce
A PromQL range query is an instant query evaluated at every grid point
t_i = start + i*step, i = 0..(end-start)/step. At each t_i:
- an instant selector resolves to the latest sample in the left-open
lookback window (t_i - lookback, t_i], and to nothing when that latest
sample is a stale marker — even if older real samples sit inside the
window;
- a range selector [r] collects every sample in (t_i - r, t_i], stale
markers excluded;
- offset d shifts both windows to (t_i - d - w, t_i - d].
The transpilation invariant follows from this: every transpiled construct
produces, per output series, one array with exactly one slot per grid
point — slot i holds the value at t_i, NULL means absent. This is what
makes composition correct, not just convenient: the engine evaluates
these operators independently per t_i, so any representation that gets
every slot right gets the whole query right, and spatial aggregation over
arrays is sound because it combines values that belong to the same t_i by
construction. Slot index i maps back to t_i = start + i*step at scan time
(toMatrix). Everything below is about filling those slots with exactly
the numbers the engine would compute — and each equivalence was validated
against the vendored engine on live data before its shape entered the
allowlist; anything unproven stays on the engine path.
## Classification: finding what a statement can answer
classify walks the parsed AST looking for "core units" — maximal subtrees
of the shape
[agg by/without (...)] [fn(] selector[range] [offset d] [)] [op scalar]...
classifyCore peels that chain from the outside in: an optional
sum/min/max/avg/count aggregation, then one of the allowlisted functions
or a bare instant selector, then the selector with its offset; on the way
out it accumulates number-literal arithmetic, comparisons (including
bool) and unary minus into a scalar-op pipeline. A node qualifies only if
its type, arguments and children are in the proven set — an allowlist, so
an overlooked construct becomes a fallback instead of a wrong number.
Three unit kinds come out of this, each with its own SQL form:
unitRange (rate, irate, increase, delta, idelta over a range selector),
unitInstant (instant vector selection, bare or comparison-filtered) and
unitOverTime (avg/min/max/sum/count/last _over_time).
If the entire tree is one unit, the plan is "full": the statement's rows
are the query result. Otherwise every maximal unit is cut out and replaced
in the expression with a synthetic selector __signoz_transpiled_N__, and
the rewritten expression runs in the engine over the units' materialized
results ("hybrid") — histogram_quantile, topk, or/and/unless and vector
matching keep exact engine semantics while their expensive inputs were
aggregated server-side.
Classification refuses when exact semantics cannot be guaranteed
server-side: the @ modifier anywhere and default-resolution subqueries
(their resolution is a server runtime setting the transpiler cannot see);
duration expressions (offset step(), [range()], ...) anywhere — they are
resolved into the selector's static fields only at evaluation time, so at
classification time those fields still hold their zero values and
transpiling would silently use the wrong offset or range;
steps or ranges that are not whole seconds (the grid functions take
whole-second parameters); grouping by or matching on __name__ in hybrid
plans (the synthetic name would leak into results); name-keeping units —
bare/comparison instant selectors and last_over_time keep their real
__name__ (keepsName), which substitution would replace, so they transpile
only as full plans; and every function outside the allowlist (changes,
resets, quantile_over_time, absent, native-histogram functions, ...).
Units inside a fixed-resolution subquery evaluate on the subquery's own
grid instead of the query grid: epoch-aligned multiples of the resolution
strictly after outerStart - offset - range, ending at outer end - offset —
the exact derivation the engine uses, because a grid shifted by one step
changes which samples every window sees.
## From one unit to one statement
buildUnitSQL renders each unit as a single statement. For
sum by (pod) (rate(m{job="api"}[5m])) the skeleton is:
SELECT gkey, sumForEach(grid) AS grid FROM (
SELECT series.gkey AS gkey,
timeSeriesRateToGrid(<start>, <end>, <step>, <range>)(fromUnixTimestamp64Milli(unix_milli), value) AS grid
FROM signoz_metrics.distributed_samples_v4 AS points
INNER JOIN (
SELECT fingerprint, <group key expr> AS gkey
FROM signoz_metrics.time_series_v4
WHERE <series predicates>
GROUP BY fingerprint, gkey
) AS series ON points.fingerprint = series.fingerprint
WHERE metric_name = ? AND temporality IN ['Cumulative', 'Unspecified']
AND points.fingerprint IN (<matched fingerprints>)
AND unix_milli > <start - range> AND unix_milli <= <end>
AND bitAnd(flags, 1) = 0
GROUP BY points.fingerprint, series.gkey
) GROUP BY gkey
SETTINGS allow_experimental_ts_to_grid_aggregate_function = 1
Reading it inside out:
The time window is the selector's semantics verbatim: strict > on the
lower bound and <= on the upper is the left-open (t - w, t] rule, with the
whole window shifted by the offset. bitAnd(flags, 1) = 0 drops stale
markers, which PromQL excludes from range vectors.
The inner GROUP BY computes one grid array per series.
timeSeriesRateToGrid(start, end, step, range) is a parametric aggregate:
fed (timestamp, value) pairs it produces Array(Nullable(Float64)) with one
slot per grid point. Correct because it implements the engine's
extrapolatedRate decision for decision — counter resets, the zero-point
clamp, the extrapolation thresholds, the >= 2 samples rule, the left-open
window — verified by feeding identical samples to both and comparing
slot for slot: the only difference ever observed is the last bit
(ClickHouse's C++ and Go round the same formula differently), which is
the floating-point floor, not a semantic gap. irate/delta/idelta map to
their own timeSeries*ToGrid functions with the same verification;
increase has no function of its own and is emitted as
arrayMap(x -> x * <range seconds>, <rate expr>), exact by definition —
extrapolatedRate computes the same extrapolated delta for both and
divides by the range only when isRate, so multiplying it back is the
identity, not an approximation. The grid parameters are rendered as
literals, not bound args — they are aggregate-function parameters — and
the experimental gate rides as a SETTINGS clause on the statement itself
so telemetrystore hooks cannot clobber it.
The join annotates each series with its group key, in one of two forms.
by (...) extracts each listed label as a plain column
(JSONExtractString(labels, 'pod') AS g0) and groups on the columns
directly: the projection is a known short list and the label names live
in Go, so building, sorting and stringifying every label pair per row
would be waste. Correct because column-tuple equality is label-set
equality on the projection, and an extracted '' is the label being
absent — Prometheus semantics for by() over missing labels, and empties
are skipped when the columns turn back into labels. without and
no-aggregation project a label SET that varies per series, so they get
the canonical key: toJSONString of the sorted [label, value] pairs the
unit projects (without excludes the listed labels plus __name__; no
aggregation keeps everything, the name coming off in Go per the engine's
name-dropping rules). There the sort is load-bearing — stored JSON key
order is not canonical across fingerprints, and two orderings of the same
labels must land in one group — empty values are filtered for the same
absent-label reason, and the same string parses back into the output
label set (labelsFromGroupKey).
The outer GROUP BY is the spatial aggregation: sum/min/max/avg/count
by/without become the -ForEach combinators. Element-wise aggregation over
grid arrays is the engine's per-t_i aggregation, because slot i of every
input array refers to the same t_i; the combinators skip NULLs, which is
the engine aggregating only the series present at t_i, and an index where
every series is absent stays NULL. Two edges need explicit handling:
countForEach wraps in a mapping of 0 back to NULL, because a count over
an all-absent index is an absent point, not 0; and a unit without
aggregation still passes through maxForEach — the identity for the common
one-fingerprint group, and a deterministic NULL-skipping merge when a
regex __name__ selector collapses distinct metrics onto one projected
label set. One caveat is inherent: summation order over series differs
from the engine's, so spatial aggregates can differ in the last ULP —
float addition is not associative; no ordering reproduces the engine's
bit-exactly from inside a GROUP BY.
## Instant selectors: staleness needs two aggregates
unitInstant uses window = lookback and must reproduce the shadowing rule:
the point is absent when the latest in-window sample is a stale marker.
timeSeriesLastToGrid alone cannot express that — skipping stale rows in
WHERE would resurrect the older real sample the marker was written to
bury. So stale rows stay in the scan for this kind only, and the grid
expression compares three aggregates per slot:
arrayMap((tall, tok, vok) -> if(tall IS NULL OR tok IS NULL OR tall != tok, NULL, vok),
timeSeriesLastToGrid(...)(ts, toFloat64(unix_milli)), -- last sample overall
timeSeriesLastToGridIf(...)(ts, toFloat64(unix_milli), bitAnd(flags, 1) = 0), -- last non-stale, its timestamp
timeSeriesLastToGridIf(...)(ts, value, bitAnd(flags, 1) = 0)) -- last non-stale, its value
Correct by cases on a slot's window. No samples at all: both timestamp
aggregates are NULL, the slot is NULL — absent, as the engine says. Latest
sample non-stale: it is the latest overall and the latest non-stale, the
timestamps agree, the slot takes its value — the engine's pick. Latest
sample stale: the last-overall timestamp is the marker's, the
last-non-stale timestamp is older (or NULL when only markers are in
window), they disagree, the slot is NULL — the marker shadows, exactly
the engine's rule. Timestamps are unique per series (ingest dedups), so
timestamp equality identifies "the same sample" without ambiguity. The
-If combinator's applicability to these experimental aggregates was
probed before being trusted, not assumed.
## Windowed *_over_time: whole buckets instead of a grid function
avg/min/max/sum/count _over_time aggregate every raw sample in the window,
and no timeSeries*ToGrid function computes them. (last_over_time is the
exception: the last sample of a range vector — stale markers excluded from
range vectors by PromQL, excluded here in WHERE — is exactly
timeSeriesLastToGrid.) These transpile only when the range is a whole
multiple of the step, and then the window needs no per-sample fan-out at
all: with W = range/step, the window (t_k - range, t_k] is exactly the
union of W step buckets — both are left-open on the same boundaries — so
bucket membership fully determines window membership. Each sample lands
in exactly one bucket by a plain GROUP BY:
intDiv(unix_milli - <start> + <range> - 1, <step>) AS jj
(ceil((ts - start)/step) shifted by W-1 so the earliest in-window sample
sits at 0; slot k's window is buckets jj in [k, k+W-1]). The alternative —
fanning each sample into all W windows that cover it — multiplies rows by
W, which for a long range over a short step is a row explosion measured
in billions; the bucketed form's row count is series x buckets, the size
of the output, regardless of W.
The shard level aggregates per (series, group key, bucket): a bucket
count plus the function's value aggregate (sum for sum/avg, min, max).
The assembly level places the partials into dense arrays
(groupArrayInsertAt — positions are unique, one row per bucket; counts
and sums default to 0, which contributes nothing) and slides: slot k
combines its at-most-W bucket partials by direct aggregation, so window
sums are added the way the engine adds them — no prefix-sum differencing,
whose large-minus-large cancellation would drift past the shadow
tolerance on counter-sized values. Correct per slot because the bucket
union is the exact window multiset and avg/min/max/sum/count are
order-insensitive on a multiset (sum/avg up to summation order, the float
caveat above). A slot with zero window count is absent; min/max filter
their slices on the bucket counts, so an empty bucket's default can never
be mistaken for a value — a real sample can legitimately be +Inf.
Ranges that don't divide the step, and windows wider than
maxWindowBuckets buckets (the slide costs W combines per slot), fall back
to the engine path, which is exact.
## Scalar ops, full plans, hybrid plans
The scalar-op pipeline applies in Go to the returned arrays
(applyScalarOps), slot by slot: arithmetic operators compute, comparisons
filter (the slot keeps the vector-side value or becomes NULL) or return
0/1 under bool. Correct trivially: it is the same float64 operation the
engine would apply to the same slot value, in the same operator order the
AST dictates — running it in Go instead of another SQL layer changes
where, not what.
A full plan's arrays map straight to the result matrix. A hybrid plan
materializes each unit's arrays as synthetic series under its
__signoz_transpiled_N__ name and evaluates the rewritten expression over
a storage that serves synthetic names from memory and everything else
live. Substitution is sound because a unit's output is a plain instant
vector to the engine — same values at same timestamps under a different
name, and the name cannot matter: plans that group by or match on
__name__ were refused at classification, and name-keeping units are never
substituted. One subtlety makes it exact: stale markers are written at
absent grid points, because the engine's lookback would otherwise
resurrect a point from up to lookback earlier — the marker encodes
"absent here" the way the engine itself encodes it. Units evaluate
concurrently; each is one series lookup plus one grid statement. A step
of 0 is an instant query: a single evaluation at end.
## Series lookup
Both paths resolve matchers the same way, once per selector
(selectSeries), against the series tables holding one row per
(fingerprint, bucket) at 1h/6h/1d/1w granularities; timeSeriesTableFor
picks the table whose bucket fits the window and rounds the window start
down to the bucket boundary. How matchers become SQL, and why regexes are
anchored, is documented at applySeriesConditions. Empty-valued labels come
off at this boundary: an empty value means "label absent" in Prometheus,
but stored attribute JSON can carry them.
## The engine path
Queries that do not transpile run in the stock engine over this package's
storage.Querier, which is still not the v1 path. Samples are fetched per
selector using the engine's per-selector hints, not the query-wide union
window, so foo / foo offset 1d reads two narrow windows instead of the
widest one twice. Instant selectors of subquery-free queries fetch only
the last sample per step bucket (lastSamplePerStep): buckets anchor at the
selector's first evaluation timestamp — recovered from the hints as
hints.Start + lookback - 1ms, the inverse of how the engine derives
hints.Start — so bucket boundaries coincide with evaluation timestamps and
a non-final sample of a bucket can never be the latest sample in
(t - lookback, t] for any grid t. Real timestamps are preserved, so the
engine's own lookback and staleness handling stay exact. Range selectors
always fetch raw — every sample feeds the range function — and the
subquery-free proof travels in the context as prometheus.QueryTraits,
because subquery selectors evaluate at the subquery's step while the
hints carry the top-level step. Row assembly maps stale flags to the
engine's StaleNaN and merges series with identical label sets
(sortAndMerge) — the engine assumes storages never emit duplicates.
## Sharding
samples_v4 and time_series_v4 (and all their rollups) shard on the same
key — cityHash64(env, temporality, metric_name, fingerprint) — so a
series' samples and catalog rows live on the same shard. The transpiled
statement above exploits that: the distributed samples table at the
top-level FROM makes ClickHouse rewrite the whole inner query per shard,
where the join against the shard-local series table and the per-series
grid aggregation run next to the data; the initiator only merges
aggregate states and applies the spatial -ForEach step. Same layout as
the telemetrymetrics statement builder. The group-key join alone
restricts the transpiled scan to the matched series; the engine path's
samples fetch restricts by the same predicates as a shard-local
semi-join, not a GLOBAL broadcast of the matched set. The temporality
filter on every
samples statement is a semantic no-op — the matched fingerprints already
come from those temporalities — that engages the leading samples
primary-key column. Delta-temporality series stay invisible to PromQL
here exactly as they are in v1: the rollout gate is parity with v1, and
making Delta visible is its own change with its own semantics to design —
a Delta stream fed to rate() as-if-cumulative would be wrong, not just
new.
## Observability
Every statement carries a log_comment with
code.namespace=clickhouse-prometheus-v2 and code.function.name naming the
call site (selectSeries, selectSamples, transpiledUnit, LabelValues,
LabelNames), so this provider's work is attributable in system.query_log
without guessing from query text.

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

@@ -8479,8 +8479,6 @@ export enum RuletypesCompareOperatorDTO {
below = 'below',
equal = 'equal',
not_equal = 'not_equal',
above_or_equal = 'above_or_equal',
below_or_equal = 'below_or_equal',
outside_bounds = 'outside_bounds',
}
export interface RuletypesBasicRuleThresholdDTO {
@@ -8844,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
@@ -12031,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

@@ -3,7 +3,6 @@ import { useQueryClient } from 'react-query';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
@@ -21,7 +20,6 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import './CreateServiceAccountModal.styles.scss';
import { Skeleton } from 'antd';
interface FormValues {
name: string;
@@ -97,39 +95,33 @@ function CreateServiceAccountModal(): JSX.Element {
testId="create-service-account-modal"
>
<div className="create-sa-modal__content">
<AuthZGuardContent
checks={[SACreatePermission]}
fallbackOnLoading={<Skeleton active paragraph={{ rows: 1 }} />}
<form
id="create-sa-form"
className="create-sa-form"
onSubmit={handleSubmit(handleCreate)}
>
<form
id="create-sa-form"
className="create-sa-form"
onSubmit={handleSubmit(handleCreate)}
>
<div className="create-sa-form__item">
<label htmlFor="sa-name">Name</label>
<Controller
name="name"
control={control}
rules={{ required: 'Name is required' }}
render={({ field }): JSX.Element => (
<Input
id="sa-name"
placeholder="Enter a name"
className="create-sa-form__input"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
data-testid="create-sa-name-input"
/>
)}
/>
{errors.name && (
<p className="create-sa-form__error">{errors.name.message}</p>
<div className="create-sa-form__item">
<label htmlFor="sa-name">Name</label>
<Controller
name="name"
control={control}
rules={{ required: 'Name is required' }}
render={({ field }): JSX.Element => (
<Input
id="sa-name"
placeholder="Enter a name"
className="create-sa-form__input"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
</div>
</form>
</AuthZGuardContent>
/>
{errors.name && (
<p className="create-sa-form__error">{errors.name.message}</p>
)}
</div>
</form>
</div>
<DialogFooter className="create-sa-modal__footer">
@@ -138,7 +130,6 @@ function CreateServiceAccountModal(): JSX.Element {
variant="solid"
color="secondary"
onClick={handleClose}
data-testid="create-sa-cancel-btn"
>
<X size={12} />
Cancel
@@ -146,14 +137,12 @@ function CreateServiceAccountModal(): JSX.Element {
<AuthZButton
checks={[SACreatePermission]}
withPortal={false}
type="submit"
form="create-sa-form"
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
data-testid="create-sa-submit-btn"
>
Create Service Account
</AuthZButton>

View File

@@ -1,14 +1,19 @@
import { toast } from '@signozhq/ui/sonner';
import {
setupAuthzAdmin,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CreateServiceAccountModal from '../CreateServiceAccountModal';
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,
}: {
children: React.ReactElement;
}): React.ReactElement => children,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
@@ -40,7 +45,6 @@ describe('CreateServiceAccountModal', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
setupAuthzAdmin(),
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: {} })),
),
@@ -51,41 +55,23 @@ describe('CreateServiceAccountModal', () => {
server.resetHandlers();
});
it('submit button is disabled while the form is empty', async () => {
it('submit button is disabled when form is empty', () => {
renderModal();
// The form only renders once the create check resolves, and the name field
// registers its `required` rule on mount, so the empty-form invalid state
// settles a tick later.
await screen.findByTestId('create-sa-name-input');
await waitFor(() =>
expect(screen.getByTestId('create-sa-submit-btn')).toBeDisabled(),
);
});
it('submit button becomes disabled after clearing the name field', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const nameInput = await screen.findByTestId('create-sa-name-input');
const submitBtn = await screen.findByTestId('create-sa-submit-btn');
await user.type(nameInput, 'test');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.clear(nameInput);
await waitFor(() => expect(submitBtn).toBeDisabled());
expect(
screen.getByRole('button', { name: /Create Service Account/i }),
).toBeDisabled();
});
it('successful submit shows toast.success and closes modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const nameInput = await screen.findByTestId('create-sa-name-input');
await user.type(nameInput, 'Deploy Bot');
await user.type(screen.getByPlaceholderText('Enter a name'), 'Deploy Bot');
const submitBtn = screen.getByTestId('create-sa-submit-btn');
const submitBtn = screen.getByRole('button', {
name: /Create Service Account/i,
});
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
@@ -116,10 +102,11 @@ describe('CreateServiceAccountModal', () => {
renderModal();
const nameInput = await screen.findByTestId('create-sa-name-input');
await user.type(nameInput, 'Dupe Bot');
await user.type(screen.getByPlaceholderText('Enter a name'), 'Dupe Bot');
const submitBtn = screen.getByTestId('create-sa-submit-btn');
const submitBtn = screen.getByRole('button', {
name: /Create Service Account/i,
});
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
@@ -144,45 +131,8 @@ describe('CreateServiceAccountModal', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const cancelBtn = await screen.findByTestId('create-sa-cancel-btn');
await user.click(cancelBtn);
await waitFor(() => {
expect(
screen.queryByTestId('create-service-account-modal'),
).not.toBeInTheDocument();
});
});
it('shows inline permission denial and hides the form when create permission is denied', async () => {
server.use(setupAuthzDenyAll());
renderModal();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('create-sa-name-input')).not.toBeInTheDocument();
expect(
screen.getByTestId('create-service-account-modal'),
).toBeInTheDocument();
});
it('keeps the footer usable when create permission is denied', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(setupAuthzDenyAll());
renderModal();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
// The footer lives outside the guard: submit is gated, Cancel still works.
expect(screen.getByTestId('create-sa-submit-btn')).toBeDisabled();
await user.click(screen.getByTestId('create-sa-cancel-btn'));
await screen.findByTestId('create-service-account-modal');
await user.click(screen.getByRole('button', { name: /Cancel/i }));
await waitFor(() => {
expect(
@@ -195,7 +145,7 @@ describe('CreateServiceAccountModal', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const nameInput = await screen.findByTestId('create-sa-name-input');
const nameInput = screen.getByPlaceholderText('Enter a name');
await user.type(nameInput, 'Bot');
await user.clear(nameInput);

View File

@@ -6,7 +6,6 @@ import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { VIEWS } from './constants';
import { MouseEventHandler } from 'react';
export type LogDetailProps = {
log: ILog | null;
@@ -17,8 +16,6 @@ export type LogDetailProps = {
logs?: ILog[];
onNavigateLog?: (log: ILog) => void;
onScrollToLog?: (logId: string) => void;
handleOpenInExplorer?: MouseEventHandler;
getContainer?: DrawerProps['getContainer'];
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
Pick<DrawerProps, 'onClose'>;

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useCopyToClipboard } from 'react-use';
import { useSelector } from 'react-redux'; // old code, TODO: fix this correctly
import { useCopyToClipboard, useLocation } from 'react-use';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui/button';
import { Drawer, Tooltip } from 'antd';
@@ -13,6 +14,9 @@ import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySear
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import Overview from 'container/LogDetailedView/Overview';
@@ -27,6 +31,8 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import { cloneDeep } from 'lodash-es';
import {
ArrowDown,
@@ -44,9 +50,12 @@ import {
} from '@signozhq/icons';
import { JsonView } from 'periscope/components/JsonView';
import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { isModifierKeyPressed } from 'utils/app';
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
@@ -66,8 +75,6 @@ function LogDetailInner({
logs,
onNavigateLog,
onScrollToLog,
handleOpenInExplorer,
getContainer,
}: LogDetailInnerProps): JSX.Element {
const initialContextQuery = useInitialQuery(log);
const [contextQuery, setContextQuery] = useState<Query | undefined>(
@@ -84,7 +91,7 @@ function LogDetailInner({
const [filters, setFilters] = useState<TagFilter | null>(null);
const [isEdit, setIsEdit] = useState<boolean>(false);
const { stagedQuery } = useQueryBuilder();
const { stagedQuery, updateAllQueriesOperators } = useQueryBuilder();
// Handle clicks outside to close drawer, except on explicitly ignored regions
useEffect(() => {
@@ -179,6 +186,11 @@ function LogDetailInner({
});
const isDarkMode = useIsDarkMode();
const location = useLocation();
const { safeNavigate } = useSafeNavigate();
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { notifications } = useNotifications();
@@ -234,6 +246,25 @@ function LogDetailInner({
});
};
// Go to logs explorer page with the log data
const handleOpenInExplorer = (e?: React.MouseEvent): void => {
const queryParams = {
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: minTime?.toString() || '',
[QueryParams.endTime]: maxTime?.toString() || '',
[QueryParams.compositeQuery]: JSON.stringify(
updateAllQueriesOperators(
initialQueriesMap[DataSource.LOGS],
PANEL_TYPES.LIST,
DataSource.LOGS,
),
),
};
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`, {
newTab: !!e && isModifierKeyPressed(e),
});
};
const handleQueryExpressionChange = useCallback(
(value: string, queryIndex: number) => {
// update the query at the given index
@@ -302,6 +333,13 @@ function LogDetailInner({
}
};
// Only show when opened from infra monitoring page
const showOpenInExplorerBtn = useMemo(
() => location.pathname?.includes('/infrastructure-monitoring'),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const logType = log?.attributes_string?.log_level || LogType.INFO;
const currentLogIndex = logs ? logs.findIndex((l) => l.id === log.id) : -1;
const isPrevDisabled =
@@ -336,7 +374,6 @@ function LogDetailInner({
width="60%"
mask={false}
maskClosable={false}
getContainer={getContainer}
title={
<div className="log-detail-drawer__title" data-log-detail-ignore="true">
<div className="log-detail-drawer__title-left">
@@ -374,7 +411,7 @@ function LogDetailInner({
/>
</Tooltip>
</div>
{handleOpenInExplorer && (
{showOpenInExplorerBtn && (
<div>
<Button
variant="outlined"

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

@@ -5,7 +5,6 @@ import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import {
IQuickFiltersConfig,
QuickFilterChangeEventData,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
@@ -29,12 +28,11 @@ interface ICheckboxProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
const { source, filter, onFilterChange, onQuickFilterChange } = props;
const { source, filter, onFilterChange } = props;
const [searchText, setSearchText] = useState<string>('');
const activeQueryIndex = useActiveQueryIndex(source);
@@ -63,7 +61,6 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
attributeValues,
activeQueryIndex,
onFilterChange,
onQuickFilterChange,
});
const setSearchTextDebounced = useDebouncedFn((...args) => {

View File

@@ -1,6 +1,5 @@
import {
IQuickFiltersConfig,
QuickFilterChangeEventData,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -20,7 +19,6 @@ interface UseCheckboxFilterActionsProps {
attributeValues: string[];
activeQueryIndex: number;
onFilterChange?: ((query: Query) => void) | null;
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
}
interface UseCheckboxFilterActionsReturn {
@@ -44,7 +42,6 @@ function useCheckboxFilterActions({
attributeValues,
activeQueryIndex,
onFilterChange,
onQuickFilterChange,
}: UseCheckboxFilterActionsProps): UseCheckboxFilterActionsReturn {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
@@ -63,34 +60,20 @@ function useCheckboxFilterActions({
previousState?: CheckedState,
sectionType?: SectionType,
): void => {
const updatedQuery = applyCheckboxToggle({
currentQuery,
activeQueryIndex,
filter,
source,
attributeValues,
value,
checked,
isOnlyOrAllClicked,
previousState,
sectionType,
});
dispatch(updatedQuery);
if (onQuickFilterChange) {
const queryData = updatedQuery.builder.queryData[activeQueryIndex];
const expression = queryData?.filter?.expression || '';
const filterItemKeys = (queryData?.filters?.items || [])
.map((item) => item.key?.key)
.filter((key): key is string => !!key);
onQuickFilterChange({
filterKey: filter.attributeKey.key,
expression,
filterItemKeys,
});
}
dispatch(
applyCheckboxToggle({
currentQuery,
activeQueryIndex,
filter,
source,
attributeValues,
value,
checked,
isOnlyOrAllClicked,
previousState,
sectionType,
}),
);
};
const onClear = (): void => {

View File

@@ -5,7 +5,6 @@ import { Typography } from '@signozhq/ui/typography';
import { LoaderCircle } from '@signozhq/icons';
import {
IQuickFiltersConfig,
QuickFilterChangeEventData,
QuickFilterCheckboxUseFieldApis,
QuickFiltersSource,
} from 'components/QuickFilters/types';
@@ -34,15 +33,13 @@ interface CheckboxFilterV2Props {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
useFieldApis: QuickFilterCheckboxUseFieldApis;
}
export default function CheckboxFilterV2(
props: CheckboxFilterV2Props,
): JSX.Element {
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
props;
const { source, filter, onFilterChange, useFieldApis } = props;
const [searchText, setSearchText] = useState<string>('');
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
@@ -106,7 +103,6 @@ export default function CheckboxFilterV2(
attributeValues,
activeQueryIndex,
onFilterChange,
onQuickFilterChange,
});
const setSearchTextDebounced = useDebouncedFn((...args) => {

View File

@@ -49,7 +49,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
handleFilterVisibilityChange,
source,
onFilterChange,
onQuickFilterChange,
signal,
showFilterCollapse = true,
showQueryName = true,
@@ -306,7 +305,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
source={source}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
useFieldApis={useFieldApis}
/>
) : (
@@ -315,7 +313,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
source={source}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
/>
);
case FiltersType.DURATION:
@@ -336,7 +333,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
source={source}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
useFieldApis={useFieldApis}
/>
) : (
@@ -345,7 +341,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
source={source}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
/>
);
}

View File

@@ -42,18 +42,11 @@ export interface IQuickFiltersConfig {
defaultOpen: boolean;
}
export interface QuickFilterChangeEventData {
filterKey: string;
expression: string;
filterItemKeys: string[];
}
export interface IQuickFiltersProps {
config: IQuickFiltersConfig[];
handleFilterVisibilityChange: () => void;
source: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
signal?: SignalType;
className?: string;
showFilterCollapse?: boolean;

View File

@@ -5,7 +5,6 @@ import { Input } from '@signozhq/ui/input';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import {
APIKeyCreatePermission,
buildSAAttachPermission,
@@ -37,104 +36,91 @@ function KeyFormPhase({
onClose,
accountId,
}: KeyFormPhaseProps): JSX.Element {
const checks = accountId
? [APIKeyCreatePermission, buildSAAttachPermission(accountId)]
: [];
return (
<>
<form id={FORM_ID} className="add-key-modal__form" onSubmit={onSubmit}>
<AuthZGuardContent checks={checks}>
<>
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="key-name">
Name <span style={{ color: 'var(--destructive)' }}>*</span>
</label>
<Input
id="key-name"
placeholder="Enter key name e.g.: Service Owner"
className="add-key-modal__input"
testId="add-key-name-input"
{...register('keyName', {
required: true,
validate: (v) => !!v.trim(),
})}
/>
</div>
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="key-name">
Name <span style={{ color: 'var(--destructive)' }}>*</span>
</label>
<Input
id="key-name"
placeholder="Enter key name e.g.: Service Owner"
className="add-key-modal__input"
{...register('keyName', {
required: true,
validate: (v) => !!v.trim(),
})}
/>
</div>
<div className="add-key-modal__field">
<span className="add-key-modal__label">Expiration</span>
<div className="add-key-modal__field">
<span className="add-key-modal__label">Expiration</span>
<Controller
name="expiryMode"
control={control}
render={({ field }): JSX.Element => (
<ToggleGroupSimple
type="single"
value={field.value}
onChange={(val: string): void => {
if (val) {
field.onChange(val);
}
}}
size="sm"
className="add-key-modal__expiry-toggle"
items={[
{ value: ExpiryMode.NONE, label: 'No Expiration' },
{ value: ExpiryMode.DATE, label: 'Set Expiration Date' },
]}
/>
)}
/>
</div>
{expiryMode === ExpiryMode.DATE && (
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="expiry-date">
Expiration Date
</label>
<div className="add-key-modal__datepicker">
<Controller
name="expiryMode"
name="expiryDate"
control={control}
render={({ field }): JSX.Element => (
<ToggleGroupSimple
type="single"
<DatePicker
id="expiry-date"
value={field.value}
onChange={(val: string): void => {
if (val) {
field.onChange(val);
}
}}
size="sm"
className="add-key-modal__expiry-toggle"
items={[
{ value: ExpiryMode.NONE, label: 'No Expiration' },
{ value: ExpiryMode.DATE, label: 'Set Expiration Date' },
]}
onChange={field.onChange}
popupClassName="add-key-modal-datepicker-popup"
getPopupContainer={popupContainer}
disabledDate={disabledDate}
/>
)}
/>
</div>
{expiryMode === ExpiryMode.DATE && (
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="expiry-date">
Expiration Date
</label>
<div className="add-key-modal__datepicker">
<Controller
name="expiryDate"
control={control}
render={({ field }): JSX.Element => (
<DatePicker
id="expiry-date"
value={field.value}
onChange={field.onChange}
popupClassName="add-key-modal-datepicker-popup"
getPopupContainer={popupContainer}
disabledDate={disabledDate}
/>
)}
/>
</div>
</div>
)}
</>
</AuthZGuardContent>
</div>
)}
</form>
<div className="add-key-modal__footer">
<div className="add-key-modal__footer-right">
<Button
variant="solid"
color="secondary"
onClick={onClose}
testId="add-key-cancel-btn"
>
<Button variant="solid" color="secondary" onClick={onClose}>
Cancel
</Button>
<AuthZButton
checks={checks}
checks={[
APIKeyCreatePermission,
buildSAAttachPermission(accountId ?? ''),
]}
authZEnabled={!!accountId}
withPortal={false}
type="submit"
form={FORM_ID}
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
testId="add-key-submit-btn"
>
Create Key
</AuthZButton>

View File

@@ -92,7 +92,6 @@ function DeleteAccountModal(): JSX.Element {
loading={isDeleting}
onClick={handleConfirm}
data-testid="confirm-delete-btn"
withPortal={false}
>
<Trash2 size={12} />
Delete

View File

@@ -60,7 +60,6 @@ function EditKeyForm({
<AuthZTooltip
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
enabled={!!keyItem?.id}
withPortal={false}
>
<div className="edit-key-modal__key-display">
<span className="edit-key-modal__id-text">{keyItem?.name || '—'}</span>
@@ -169,7 +168,6 @@ function EditKeyForm({
variant="link"
color="destructive"
onClick={onRevokeClick}
withPortal={false}
>
<Trash2 size={12} />
Revoke Key
@@ -188,7 +186,6 @@ function EditKeyForm({
color="primary"
loading={isSaving}
disabled={!isDirty}
withPortal={false}
>
Save Changes
</AuthZButton>

View File

@@ -114,14 +114,13 @@ function buildColumns({
render: (_, record): JSX.Element => {
const tooltipTitle = isDisabled ? 'Service account disabled' : 'Revoke Key';
return (
<Tooltip title={tooltipTitle} placement="bottom">
<Tooltip title={tooltipTitle}>
<AuthZButton
checks={[
buildAPIKeyDeletePermission(record.id),
buildSADetachPermission(accountId),
]}
authZEnabled={!isDisabled && !!accountId}
withPortal={false}
variant="ghost"
size="sm"
color="destructive"
@@ -215,7 +214,6 @@ function KeysTab({
<AuthZButton
checks={[APIKeyCreatePermission, buildSAAttachPermission(accountId)]}
authZEnabled={!isDisabled && !!accountId}
withPortal={false}
variant="link"
color="primary"
onClick={async (): Promise<void> => {

View File

@@ -90,20 +90,14 @@ function OverviewTab({
Name
</label>
{isDisabled ? (
<AuthZTooltip
checks={[buildSAUpdatePermission(account.id)]}
withPortal={false}
>
<AuthZTooltip checks={[buildSAUpdatePermission(account.id)]}>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{localName || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</AuthZTooltip>
) : (
<AuthZTooltip
checks={[buildSAUpdatePermission(account.id)]}
withPortal={false}
>
<AuthZTooltip checks={[buildSAUpdatePermission(account.id)]}>
<Input
id="sa-name"
value={localName}

View File

@@ -55,7 +55,6 @@ export function RevokeKeyFooter({
color="destructive"
loading={isRevoking}
onClick={onConfirm}
withPortal={false}
>
<Trash2 size={12} />
Revoke Key

View File

@@ -38,7 +38,6 @@ import {
APIKeyCreatePermission,
buildSAAttachPermission,
buildSADeletePermission,
buildSAReadPermission,
buildSAUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import {
@@ -377,7 +376,6 @@ function ServiceAccountDrawer({
<AuthZButton
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
authZEnabled={!!selectedAccountId}
withPortal={false}
variant="link"
color="destructive"
onClick={(): void => {
@@ -393,12 +391,8 @@ function ServiceAccountDrawer({
Cancel
</Button>
<AuthZButton
checks={[
buildSAReadPermission(selectedAccountId ?? ''),
buildSAUpdatePermission(selectedAccountId ?? ''),
]}
checks={[buildSAUpdatePermission(selectedAccountId ?? '')]}
authZEnabled={!!selectedAccountId}
withPortal={false}
variant="solid"
color="primary"
loading={isSaving}
@@ -471,7 +465,6 @@ function ServiceAccountDrawer({
buildSAAttachPermission(selectedAccountId ?? ''),
]}
authZEnabled={!isDeleted && !!selectedAccountId}
withPortal={false}
variant="outlined"
size="sm"
color="secondary"

View File

@@ -1,10 +1,5 @@
import { toast } from '@signozhq/ui/sonner';
import { buildSAAttachPermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import {
setupAuthzAdmin,
setupAuthzDeny,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
@@ -71,29 +66,28 @@ describe('AddKeyModal', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
// The form only renders once the checks resolve, so waiting for it also
// guarantees the button is no longer in its authz-loading state.
const nameInput = await screen.findByTestId('add-key-name-input');
const createBtn = await screen.findByTestId('add-key-submit-btn');
expect(screen.getByRole('button', { name: /Create Key/i })).toBeDisabled();
expect(createBtn).toBeDisabled();
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'My Key');
await user.type(nameInput, 'My Key');
await waitFor(() => expect(createBtn).not.toBeDisabled());
await user.clear(nameInput);
await waitFor(() => expect(createBtn).toBeDisabled());
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Key/i }),
).not.toBeDisabled(),
);
});
it('successful creation transitions to phase 2 with key displayed and security callout', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const nameInput = await screen.findByTestId('add-key-name-input');
const submitBtn = await screen.findByTestId('add-key-submit-btn');
await user.type(nameInput, 'Deploy Key');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Key/i }),
).not.toBeDisabled(),
);
await user.click(screen.getByRole('button', { name: /Create Key/i }));
await screen.findByText('snz_abc123xyz456secret');
expect(screen.getByText(/Store the key securely/i)).toBeInTheDocument();
@@ -105,11 +99,13 @@ describe('AddKeyModal', () => {
renderModal();
const nameInput = await screen.findByTestId('add-key-name-input');
const submitBtn = await screen.findByTestId('add-key-submit-btn');
await user.type(nameInput, 'Deploy Key');
await waitFor(() => expect(submitBtn).not.toBeDisabled());
await user.click(submitBtn);
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Create Key/i }),
).not.toBeDisabled(),
);
await user.click(screen.getByRole('button', { name: /Create Key/i }));
await screen.findByText('snz_abc123xyz456secret');
@@ -127,57 +123,12 @@ describe('AddKeyModal', () => {
});
});
it('shows inline permission denial and hides the form when key create is denied', async () => {
server.use(setupAuthzDenyAll());
renderModal();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('add-key-name-input')).not.toBeInTheDocument();
expect(screen.getByTestId('add-key-modal')).toBeInTheDocument();
});
it('keeps the footer usable when key create is denied', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(setupAuthzDenyAll());
renderModal();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
// The footer lives outside the guard: submit is gated, Cancel still works.
expect(screen.getByTestId('add-key-submit-btn')).toBeDisabled();
await user.click(screen.getByTestId('add-key-cancel-btn'));
await waitFor(() => {
expect(screen.queryByTestId('add-key-modal')).not.toBeInTheDocument();
});
});
it('shows inline permission denial when attach on the service account is denied', async () => {
server.use(setupAuthzDeny(buildSAAttachPermission('sa-1')));
renderModal();
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('add-key-name-input')).not.toBeInTheDocument();
});
it('Cancel button closes the modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const cancelBtn = await screen.findByTestId('add-key-cancel-btn');
await user.click(cancelBtn);
await screen.findByTestId('add-key-modal');
await user.click(screen.getByRole('button', { name: /Cancel/i }));
await waitFor(() => {
expect(screen.queryByTestId('add-key-modal')).not.toBeInTheDocument();

View File

@@ -1,12 +1,8 @@
import { type ReactNode, useLayoutEffect, useMemo } from 'react';
import { useLayoutEffect, type ReactNode } from 'react';
import { chromePerformanceTanstackTableEndHover } from './perfDevtools';
import { useIsRowHovered } from './TanStackTableStateContext';
import {
TooltipContentProps,
TooltipSimple,
TooltipSimpleProps,
} from '@signozhq/ui/tooltip';
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
export type HoverTooltipProps = Omit<TooltipSimpleProps, 'open'> & {
rowId: string;
@@ -26,28 +22,9 @@ export function TanStackHoverTooltip({
}
}, [isHovered, rowId]);
const tooltipContentProps = useMemo(
() =>
({
onPointerDownOutside: (e): void => {
e.preventDefault();
e.stopPropagation();
},
}) satisfies TooltipContentProps,
[],
);
if (!isHovered) {
return <>{children}</>;
}
return (
<TooltipSimple
delayDuration={700}
tooltipContentProps={tooltipContentProps}
{...tooltipProps}
>
{children}
</TooltipSimple>
);
return <TooltipSimple {...tooltipProps}>{children}</TooltipSimple>;
}

View File

@@ -51,7 +51,6 @@ import { useColumnHandlers } from './useColumnHandlers';
import { useColumnState } from './useColumnState';
import { useEffectiveData } from './useEffectiveData';
import { useFlatItems } from './useFlatItems';
import { useResetScroll } from './useResetScroll';
import { useRowKeyData } from './useRowKeyData';
import { useTableParams } from './useTableParams';
import { buildPageSizeItems, buildTanstackColumnDef } from './utils';
@@ -111,7 +110,6 @@ function TanStackTableInner<TData, TItemKey = string>(
suffixPaginationContent,
enableAlternatingRowColors,
disableVirtualScroll,
resetScrollKey,
}: TanStackTableProps<TData, TItemKey>,
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
): JSX.Element {
@@ -326,8 +324,6 @@ function TanStackTableInner<TData, TItemKey = string>(
});
}, [flatIndexForActiveRow]);
useResetScroll(virtuosoRef, resetScrollKey);
const { sensors, columnIds, handleDragEnd } = useColumnDnd({
columns: effectiveColumns,
onColumnOrderChange: handleColumnOrderChange,

View File

@@ -217,55 +217,6 @@ describe('useColumnState', () => {
});
});
describe('storageKey changes', () => {
it('does not auto-show hidden columns when switching between different storageKeys', () => {
// Regression test: when switching Pods→Nodes→Pods, hidden columns were
// incorrectly shown because prevColumnIdsRef wasn't reset on storageKey change.
// The autoShowNewlyAddedColumns effect would see pod columns as "new" (not in
// node columns) and unhide them.
const KEY_PODS = 'k8s-pods';
const KEY_NODES = 'k8s-nodes';
const podColumns = [
col('podName'),
col('podStatus'),
col('cpu_request', { defaultVisibility: false }),
col('memory_request', { defaultVisibility: false }),
];
const nodeColumns = [
col('nodeName'),
col('nodeStatus'),
col('cpu'),
col('memory'),
];
// Initialize both tables
act(() => {
useColumnStore.getState().initializeFromDefaults(KEY_PODS, podColumns);
useColumnStore.getState().initializeFromDefaults(KEY_NODES, nodeColumns);
});
// Start with pods - cpu_request and memory_request should be hidden
const { result, rerender } = renderHook(
({ storageKey, columns }) => useColumnState({ storageKey, columns }),
{ initialProps: { storageKey: KEY_PODS, columns: podColumns } },
);
expect(result.current.hiddenColumnIds).toContain('cpu_request');
expect(result.current.hiddenColumnIds).toContain('memory_request');
// Switch to nodes
rerender({ storageKey: KEY_NODES, columns: nodeColumns });
// Switch back to pods - hidden columns should STILL be hidden
rerender({ storageKey: KEY_PODS, columns: podColumns });
expect(result.current.hiddenColumnIds).toContain('cpu_request');
expect(result.current.hiddenColumnIds).toContain('memory_request');
});
});
describe('actions', () => {
it('hideColumn hides a column', () => {
const columns = [col('a'), col('b')];

View File

@@ -1,103 +0,0 @@
import { RefObject } from 'react';
import { renderHook } from '@testing-library/react';
import type { TableVirtuosoHandle } from 'react-virtuoso';
import { useResetScroll } from '../useResetScroll';
function createMockRef(scrollTo: jest.Mock): RefObject<TableVirtuosoHandle> {
return {
current: {
scrollToIndex: jest.fn(),
scrollIntoView: jest.fn(),
scrollTo,
scrollBy: jest.fn(),
},
};
}
describe('useResetScroll', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('does not call scrollTo on initial mount', () => {
const scrollTo = jest.fn();
const ref = createMockRef(scrollTo);
renderHook(() => useResetScroll(ref, 'initial-key'));
expect(scrollTo).not.toHaveBeenCalled();
});
it('calls scrollTo when key changes', () => {
const scrollTo = jest.fn();
const ref = createMockRef(scrollTo);
const { rerender } = renderHook(
({ resetKey }) => useResetScroll(ref, resetKey),
{ initialProps: { resetKey: 'key-1' } },
);
expect(scrollTo).not.toHaveBeenCalled();
rerender({ resetKey: 'key-2' });
expect(scrollTo).toHaveBeenCalledWith({ left: 0 });
});
it('calls scrollTo once per key change', () => {
const scrollTo = jest.fn();
const ref = createMockRef(scrollTo);
const { rerender } = renderHook(
({ resetKey }) => useResetScroll(ref, resetKey),
{ initialProps: { resetKey: 'key-1' } },
);
rerender({ resetKey: 'key-2' });
rerender({ resetKey: 'key-3' });
rerender({ resetKey: 'key-4' });
expect(scrollTo).toHaveBeenCalledTimes(3);
});
it('does not call scrollTo when key unchanged', () => {
const scrollTo = jest.fn();
const ref = createMockRef(scrollTo);
const { rerender } = renderHook(
({ resetKey }) => useResetScroll(ref, resetKey),
{ initialProps: { resetKey: 'same-key' } },
);
rerender({ resetKey: 'same-key' });
rerender({ resetKey: 'same-key' });
expect(scrollTo).not.toHaveBeenCalled();
});
it('handles null ref.current gracefully', () => {
const ref: RefObject<TableVirtuosoHandle | null> = { current: null };
const { rerender } = renderHook(
({ resetKey }) => useResetScroll(ref, resetKey),
{ initialProps: { resetKey: 'key-1' } },
);
expect(() => rerender({ resetKey: 'key-2' })).not.toThrow();
});
it('calls scrollTo when changing from undefined to defined', () => {
const scrollTo = jest.fn();
const ref = createMockRef(scrollTo);
const { rerender } = renderHook(
({ resetKey }) => useResetScroll(ref, resetKey),
{ initialProps: { resetKey: undefined as string | undefined } },
);
rerender({ resetKey: 'new-key' });
expect(scrollTo).toHaveBeenCalledWith({ left: 0 });
});
});

View File

@@ -215,16 +215,6 @@ export * from './useTableParams';
* />
* ```
*
* @example Reset scroll on context change — use `resetScrollKey` to scroll back to start
* when the data context changes (e.g., switching between categories or tabs).
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* resetScrollKey={selectedCategory}
* />
* ```
*
* @example useTableParams — manages pagination state with URL sync and persistence
*
* The `useTableParams` hook handles page, limit, orderBy, and expanded state. It can sync

View File

@@ -213,8 +213,6 @@ export type TanStackTableProps<TData, TItemKey = string> = {
enableAlternatingRowColors?: boolean;
/** Disable virtual scrolling and render all rows at once. Cannot be used with onEndReached. */
disableVirtualScroll?: boolean;
/** When this value changes, the table's scroll resets to the start. Useful for resetting scroll when the data context changes (e.g., switching categories). */
resetScrollKey?: string;
};
export type TanStackTableHandle = TableVirtuosoHandle & {

View File

@@ -67,13 +67,6 @@ export function useColumnState<TData>({
const columnSizing = useStoreSizing(storageKey ?? '');
const prevColumnIdsRef = useRef<Set<string> | null>(null);
const prevStorageKeyRef = useRef<string | undefined>(storageKey);
// Reset prevColumnIdsRef when storageKey changes to avoid cross-table interference
if (prevStorageKeyRef.current !== storageKey) {
prevColumnIdsRef.current = null;
prevStorageKeyRef.current = storageKey;
}
useEffect(
function autoShowNewlyAddedColumns(): void {

View File

@@ -1,18 +0,0 @@
import { RefObject, useEffect, useRef } from 'react';
import type { TableVirtuosoHandle } from 'react-virtuoso';
export function useResetScroll(
scrollRef: RefObject<TableVirtuosoHandle | null>,
resetKey: string | undefined,
): void {
const prevKeyRef = useRef(resetKey);
useEffect(() => {
if (prevKeyRef.current !== resetKey) {
prevKeyRef.current = resetKey;
scrollRef.current?.scrollTo({
left: 0,
});
}
}, [resetKey, scrollRef]);
}

View File

@@ -1,9 +1,3 @@
import logEvent from 'api/common/logEvent';
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { getNavigationReferrer } from 'lib/navigation';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isCustomTimeRange } from 'store/globalTime';
export enum Events {
UPDATE_GRAPH_VISIBILITY_STATE = 'UPDATE_GRAPH_VISIBILITY_STATE',
UPDATE_GRAPH_MANAGER_TABLE = 'UPDATE_GRAPH_MANAGER_TABLE',
@@ -45,155 +39,3 @@ export enum InfraMonitoringEvents {
StatefulSet = 'statefulSet',
Volumes = 'volumes',
}
export function logInfraFilterCustomizedEvent(
entityType: InfraMonitoringEntity,
source: 'quick_filter' | 'search' | 'host_status_toggle',
expression: string,
extraKeys?: string[],
): void {
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
(pair) => pair.key,
);
if (extraKeys) {
extraKeys.forEach((key) => expressionKeys.push(key));
}
if (expressionKeys.length === 0) {
return;
}
void logEvent('infra_filter_customized', {
entity_type: entityType,
source,
expression_keys: [...new Set(expressionKeys)],
});
}
export function logInfraMonitoringListViewedEvent(
entity: InfraMonitoringEntity,
): void {
const referrer = getNavigationReferrer();
void logEvent('infra_list_viewed', {
entity,
referrer,
});
}
export function logInfraTimeRangeCustomizedEvent(
entityType: InfraMonitoringEntity,
rangeLabel: string,
): void {
void logEvent('infra_time_range_customized', {
entity_type: entityType,
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
});
}
export function logInfraColumnCustomizedEvent(
entityType: InfraMonitoringEntity,
columnsList: string[],
fontSize: string,
maxLinesPerRow: number,
source: 'list' | 'expanded',
): void {
void logEvent('infra_column_customized', {
entity_type: entityType,
columns_list: columnsList,
font_size: fontSize,
max_lines_per_row: maxLinesPerRow,
source,
});
}
export function logInfraColumnSortedEvent(
entityType: InfraMonitoringEntity,
columnKey: string,
direction: 'asc' | 'desc',
source: 'list' | 'expanded',
): void {
void logEvent('infra_column_sorted', {
entity_type: entityType,
column_key: columnKey,
direction,
source,
});
}
export function logInfraDrawerTimeRangeCustomizedEvent(
entityType: InfraMonitoringEntity,
rangeLabel: string,
): void {
void logEvent('infra_drawer_time_range_customized', {
entity_type: entityType,
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
});
}
export function logInfraDrawerFilterCustomizedEvent(
entityType: InfraMonitoringEntity,
tab: 'metrics' | 'logs' | 'traces' | 'events' | 'pod_metrics',
expression: string,
filterSource: 'search' | 'logs',
): void {
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
(pair) => pair.key,
);
if (expressionKeys.length === 0) {
return;
}
void logEvent('infra_drawer_filter_customized', {
entity_type: entityType,
tab,
expression_keys: [...new Set(expressionKeys)],
filter_source: filterSource,
});
}
export function logInfraGroupByCustomizedEvent(
entityType: InfraMonitoringEntity,
groupByKeysList: string[],
): void {
void logEvent('infra_group_by_customized', {
entity_type: entityType,
group_by_keys_list: groupByKeysList,
});
}
export function logInfraDrawerTabViewedEvent(
entityType: InfraMonitoringEntity,
tab: string,
isDefaultTab: boolean,
): void {
void logEvent('infra_drawer_tab_viewed', {
entity_type: entityType,
tab,
is_default_tab: isDefaultTab,
});
}
export function logInfraExplorerNavigatedEvent(params: {
entityType: InfraMonitoringEntity;
destination:
| 'metrics_explorer'
| 'logs_explorer'
| 'traces_explorer'
| 'k8s_list';
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
tab: string;
sourceKey: string | null;
drawerDurationMsAtNavigation: number | null;
}): void {
void logEvent('infra_explorer_navigated', {
entity_type: params.entityType,
destination: params.destination,
source: params.source,
tab: params.tab,
source_key: params.sourceKey,
drawer_duration_ms_at_navigation: params.drawerDurationMsAtNavigation,
});
}

View File

@@ -3,7 +3,3 @@ export const DASHBOARD_CACHE_TIME = 30_000;
export const DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED = 0;
export const FIELD_API_CACHE_TIME = 60_000;
// intentionally lower since I only want to prevent refresh in background
// after click in the row
export const INFRA_MONITORING_DETAILS_CACHE_TIME = 1_000;

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

@@ -66,10 +66,6 @@ function ThresholdItem({
return '=';
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return '!=';
case AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO:
return '>=';
case AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO:
return '<=';
default:
return '';
}

View File

@@ -83,10 +83,6 @@ const getOperatorWord = (op: AlertThresholdOperator): string => {
return 'equal';
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return 'not equal';
case AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO:
return 'equal or exceed';
case AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO:
return 'equal or fall below';
default:
return 'exceed';
}
@@ -102,10 +98,6 @@ const getThresholdValue = (op: AlertThresholdOperator): number => {
return 100;
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return 0;
case AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO:
return 80;
case AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO:
return 50;
default:
return 80;
}
@@ -124,8 +116,6 @@ const getDataPoints = (
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 100, 105, 90, 100],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 0, 10, 15, 0],
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: [75, 80, 90, 78, 95],
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: [60, 50, 40, 55, 35],
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
},
[AlertThresholdMatchType.ALL_THE_TIME]: {
@@ -133,8 +123,6 @@ const getDataPoints = (
[AlertThresholdOperator.IS_EQUAL_TO]: [100, 100, 100, 100, 100],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
[AlertThresholdOperator.IS_ABOVE]: [85, 87, 90, 88, 95],
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: [80, 87, 90, 88, 95],
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: [50, 40, 35, 42, 38],
[AlertThresholdOperator.ABOVE_BELOW]: [85, 87, 90, 88, 95],
},
[AlertThresholdMatchType.ON_AVERAGE]: {
@@ -142,8 +130,6 @@ const getDataPoints = (
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 105, 100, 95, 105],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: [70, 85, 90, 75, 80],
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: [60, 40, 55, 45, 50],
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
},
[AlertThresholdMatchType.IN_TOTAL]: {
@@ -151,8 +137,6 @@ const getDataPoints = (
[AlertThresholdOperator.IS_EQUAL_TO]: [20, 20, 20, 20, 20],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [10, 15, 25, 5, 30],
[AlertThresholdOperator.IS_ABOVE]: [10, 15, 25, 5, 30],
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: [10, 15, 25, 5, 25],
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: [8, 5, 10, 12, 15],
[AlertThresholdOperator.ABOVE_BELOW]: [10, 15, 25, 5, 30],
},
[AlertThresholdMatchType.LAST]: {
@@ -160,8 +144,6 @@ const getDataPoints = (
[AlertThresholdOperator.IS_EQUAL_TO]: [75, 85, 90, 78, 100],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [75, 85, 90, 78, 25],
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: [75, 85, 90, 78, 80],
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: [75, 85, 90, 78, 50],
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
},
};
@@ -175,8 +157,6 @@ const getTooltipOperatorSymbol = (op: AlertThresholdOperator): string => {
[AlertThresholdOperator.IS_BELOW]: '<',
[AlertThresholdOperator.IS_EQUAL_TO]: '=',
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: '!=',
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: '>=',
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: '<=',
[AlertThresholdOperator.ABOVE_BELOW]: '>',
};
return symbolMap[op] || '>';
@@ -272,10 +252,6 @@ export const getMatchTypeTooltip = (
return p === thresholdValue;
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return p !== thresholdValue;
case AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO:
return p >= thresholdValue;
case AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO:
return p <= thresholdValue;
default:
return p > thresholdValue;
}
@@ -318,8 +294,7 @@ export const getMatchTypeTooltip = (
matchType={matchType}
>
Alert triggers (all points {operatorWord} {thresholdValue})<br />
If any point didn&apos;t {operatorWord} {thresholdValue}, no alert would
fire
If any point was {thresholdValue}, no alert would fire
</TooltipExample>
<TooltipLink />
</TooltipContent>

View File

@@ -532,7 +532,7 @@ describe('Footer utils', () => {
['symbol', '>', 'at_least_once'],
['literal', 'above', 'at_least_once'],
['short', 'eq', 'avg'],
['inclusive', 'above_or_equal', 'at_least_once'],
['UI-unexposed', 'above_or_equal', 'at_least_once'],
])(
'round-trips %s op/matchType unchanged through the submit payload (%s / %s)',
(_desc, op, matchType) => {

View File

@@ -332,20 +332,25 @@ describe('CreateAlertV2 utils', () => {
['not_equal', AlertThresholdOperator.IS_NOT_EQUAL_TO],
['not_eq', AlertThresholdOperator.IS_NOT_EQUAL_TO],
['!=', AlertThresholdOperator.IS_NOT_EQUAL_TO],
['5', AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO],
['above_or_equal', AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO],
['above_or_eq', AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO],
['>=', AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO],
['6', AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO],
['below_or_equal', AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO],
['below_or_eq', AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO],
['<=', AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO],
['7', AlertThresholdOperator.ABOVE_BELOW],
['outside_bounds', AlertThresholdOperator.ABOVE_BELOW],
])('maps backend alias %s to canonical enum', (alias, expected) => {
expect(normalizeOperator(alias)).toBe(expected);
});
it.each([
['5', 'above_or_equal'],
['above_or_equal', 'above_or_equal'],
['above_or_eq', 'above_or_equal'],
['>=', 'above_or_equal'],
['6', 'below_or_equal'],
['below_or_equal', 'below_or_equal'],
['below_or_eq', 'below_or_equal'],
['<=', 'below_or_equal'],
])('returns undefined for UI-unexposed alias %s (%s family)', (alias) => {
expect(normalizeOperator(alias)).toBeUndefined();
});
it('returns undefined for unknown values', () => {
expect(normalizeOperator('gibberish')).toBeUndefined();
expect(normalizeOperator(undefined)).toBeUndefined();
@@ -408,8 +413,8 @@ describe('CreateAlertV2 utils', () => {
['symbol', '>', 'at_least_once'],
['short form', 'eq', 'avg'],
['mixed numeric and literal', '7', 'last'],
['inclusive literal operator', 'above_or_equal', 'at_least_once'],
['inclusive numeric operator', '5', 'at_least_once'],
['UI-unexposed operator', 'above_or_equal', 'at_least_once'],
['UI-unexposed numeric operator', '5', 'at_least_once'],
])('preserves %s op/matchType verbatim (%s / %s)', (_desc, op, matchType) => {
const state = getThresholdStateFromAlertDef(buildDef(op, matchType));
expect(state.operator).toBe(op);

View File

@@ -2,8 +2,9 @@ import { AlertThresholdMatchType, AlertThresholdOperator } from './types';
// Mirrors the backend's CompareOperator.Normalize() in
// pkg/types/ruletypes/compare.go. Maps any accepted alias to the enum value
// the dropdown understands. Returns undefined for unknown values so callers
// can keep the raw value on screen instead of silently rewriting it.
// the dropdown understands. Returns undefined for aliases the UI does not
// expose (e.g. above_or_equal, below_or_equal) so callers can keep the raw
// value on screen instead of silently rewriting it.
export function normalizeOperator(
raw: string | undefined,
): AlertThresholdOperator | undefined {
@@ -26,16 +27,6 @@ export function normalizeOperator(
case 'not_eq':
case '!=':
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
case '5':
case 'above_or_equal':
case 'above_or_eq':
case '>=':
return AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO;
case '6':
case 'below_or_equal':
case 'below_or_eq':
case '<=':
return AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO;
case '7':
case 'outside_bounds':
return AlertThresholdOperator.ABOVE_BELOW;

View File

@@ -125,14 +125,6 @@ export const THRESHOLD_OPERATOR_OPTIONS = [
{ value: AlertThresholdOperator.IS_BELOW, label: 'BELOW' },
{ value: AlertThresholdOperator.IS_EQUAL_TO, label: 'EQUAL TO' },
{ value: AlertThresholdOperator.IS_NOT_EQUAL_TO, label: 'NOT EQUAL TO' },
{
value: AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO,
label: 'ABOVE OR EQUAL TO',
},
{
value: AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO,
label: 'BELOW OR EQUAL TO',
},
];
export const ANOMALY_THRESHOLD_OPERATOR_OPTIONS = [

View File

@@ -99,8 +99,6 @@ export enum AlertThresholdOperator {
IS_BELOW = 'below',
IS_EQUAL_TO = 'equal',
IS_NOT_EQUAL_TO = 'not_equal',
IS_ABOVE_OR_EQUAL_TO = 'above_or_equal',
IS_BELOW_OR_EQUAL_TO = 'below_or_equal',
ABOVE_BELOW = 'outside_bounds',
}

View File

@@ -1,66 +0,0 @@
import { sortByMeanDesc } from '../sortByMeanDesc';
interface Item {
name: string;
values: (number | null)[];
}
const item = (name: string, values: (number | null)[]): Item => ({
name,
values,
});
const sorted = (items: Item[]): string[] =>
sortByMeanDesc(items, {
getValues: (entry) => entry.values,
getKey: (entry) => entry.name,
}).map((entry) => entry.name);
describe('sortByMeanDesc', () => {
it('orders items by descending mean', () => {
expect(
sorted([item('a', [1, 1]), item('b', [10, 20]), item('c', [5, 5])]),
).toStrictEqual(['b', 'c', 'a']);
});
it('sinks items with no finite values to the bottom', () => {
expect(
sorted([item('a', []), item('b', [NaN, Infinity]), item('c', [1])]),
).toStrictEqual(['c', 'a', 'b']);
});
it('ignores non-finite and null values when averaging', () => {
// Mean over the finite values only is 10, so NaN must not poison it to last place.
expect(
sorted([item('a', [2, 2]), item('b', [10, NaN]), item('c', [4, null])]),
).toStrictEqual(['b', 'c', 'a']);
});
it('produces the same order whichever order the items arrive in', () => {
const a = item('a', [5]);
const b = item('b', [5]);
const c = item('c', [9]);
expect(sorted([a, b, c])).toStrictEqual(sorted([c, b, a]));
expect(sorted([b, a, c])).toStrictEqual(['c', 'a', 'b']);
});
it('tiebreaks equal means on the key', () => {
expect(sorted([item('b', [1]), item('a', [1])])).toStrictEqual(['a', 'b']);
});
it('tiebreaks on the key for items that have no finite values either', () => {
expect(sorted([item('b', []), item('a', [NaN])])).toStrictEqual(['a', 'b']);
});
it('does not mutate the input array', () => {
const items = [item('a', [1]), item('b', [9])];
expect(sorted(items)).toStrictEqual(['b', 'a']);
expect(items.map((entry) => entry.name)).toStrictEqual(['a', 'b']);
});
it('returns an empty array for no items', () => {
expect(sorted([])).toStrictEqual([]);
});
});

View File

@@ -1,45 +0,0 @@
type MeanInput = number | null | undefined;
function finiteMean(values: Iterable<MeanInput>): number | null {
let sum = 0;
let count = 0;
for (const value of values) {
if (typeof value === 'number' && Number.isFinite(value)) {
sum += value;
count += 1;
}
}
return count > 0 ? sum / count : null;
}
export interface SortByMeanDescOptions<T> {
/** Numeric values of an item; non-finite entries are ignored. */
getValues: (item: T) => Iterable<MeanInput>;
/** Stable identity of an item, used to break ties on equal means. */
getKey: (item: T) => string;
}
/**
* Orders series by descending mean, on a copy (V1 parity: `utils/getSortedSeriesData`). The wire
* order can't stand in for it: the backend returns series in Go map-iteration order.
*
* Call this before building the uPlot config and aligned data — those two and click attribution
* all index the list positionally, and uPlot draws a stacked bar's segments in series-index order.
*/
export function sortByMeanDesc<T>(
items: T[],
{ getValues, getKey }: SortByMeanDescOptions<T>,
): T[] {
return items
.map((item) => ({ item, mean: finiteMean(getValues(item)) }))
.sort((a, b) => {
if (a.mean !== null && b.mean !== null && a.mean !== b.mean) {
return b.mean - a.mean;
}
if ((a.mean === null) !== (b.mean === null)) {
return a.mean === null ? 1 : -1;
}
return getKey(a.item).localeCompare(getKey(b.item));
})
.map(({ item }) => item);
}

View File

@@ -13,15 +13,8 @@ import {
import { AxiosError } from 'axios';
import APIError from 'types/api/error';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import {
QuickFilterChangeEventData,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import {
InfraMonitoringEvents,
logInfraFilterCustomizedEvent,
logInfraMonitoringListViewedEvent,
} from 'constants/events';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { initialQueriesMap } from 'constants/queryBuilder';
import K8sBaseDetails, {
K8sDetailsFilters,
@@ -193,19 +186,6 @@ function Hosts(): JSX.Element {
hostInitialLogTracesExpression(host),
[],
);
const handleQuickFilterChange = useCallback(
(data: QuickFilterChangeEventData): void => {
logInfraFilterCustomizedEvent(
InfraMonitoringEntity.HOSTS,
'quick_filter',
data.expression,
data.filterItemKeys,
);
},
[],
);
const controlListPrefix = !showFilters ? (
<div className={styles.quickFiltersToggleContainer}>
<Button
@@ -219,10 +199,6 @@ function Hosts(): JSX.Element {
</div>
) : undefined;
useEffect(() => {
logInfraMonitoringListViewedEvent(InfraMonitoringEntity.HOSTS);
}, []);
return (
<>
<div className={styles.infraMonitoringContainer}>
@@ -251,7 +227,6 @@ function Hosts(): JSX.Element {
startUnixMilli,
endUnixMilli,
}}
onQuickFilterChange={handleQuickFilterChange}
/>
</>
</OverlayScrollbar>

View File

@@ -78,7 +78,7 @@
display: flex;
flex-direction: column;
align-items: stretch;
min-width: 800px;
min-width: 595px;
> * {
min-width: 0;

View File

@@ -1,12 +1,9 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { logInfraFilterCustomizedEvent } from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
StatusFilterValue,
useInfraMonitoringPageListing,
useInfraMonitoringStatusFilter,
} from 'container/InfraMonitoringK8sV2/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import styles from './StatusFilter.module.scss';
@@ -22,21 +19,11 @@ const statusOptions: Array<{
function StatusFilter(): JSX.Element {
const [statusFilter, setStatusFilter] = useInfraMonitoringStatusFilter();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const { currentQuery } = useQueryBuilder();
const handleChange = (value: string): void => {
if (value !== undefined) {
void setStatusFilter(value === 'all' ? '' : (value as StatusFilterValue));
void setCurrentPage(1);
const expression =
currentQuery.builder.queryData[0]?.filter?.expression || '';
logInfraFilterCustomizedEvent(
InfraMonitoringEntity.HOSTS,
'host_status_toggle',
expression,
value === 'all' ? [] : ['host_status'],
);
}
};

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Color } from '@signozhq/design-tokens';
import { Badge } from '@signozhq/ui/badge';
import { Progress } from '@signozhq/ui/progress';
import { Typography } from '@signozhq/ui/typography';
import {
InframonitoringtypesHostRecordDTO,
InframonitoringtypesHostStatusDTO,
@@ -9,7 +10,6 @@ import {
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { TextNoData } from 'container/InfraMonitoringK8sV2/components';
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
import {
getHostQueryPayload,
@@ -70,7 +70,7 @@ export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
{value}
</Badge>
) : (
<TextNoData type="typography" />
<Typography.Text>-</Typography.Text>
),
},
{

View File

@@ -175,7 +175,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
accessorFn: (row): number => row.cpu,
width: { min: 220 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<div className={styles.progressContainer}>
@@ -183,7 +183,6 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
value={cpu}
entity={InfraMonitoringEntity.HOSTS}
attribute="CPU metric"
rowId={rowId}
>
<EntityProgressBar value={cpu} type="cpu" />
</ValidateColumnValueWrapper>
@@ -198,13 +197,14 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
tooltip="Excluding cache memory."
docPath="/infrastructure-monitoring/host-monitoring#memory-usage"
>
Memory Usage (WSS)
Memory Usage
<br /> (WSS)
</ColumnHeader>
),
accessorFn: (row): number => row.memory,
width: { min: 200 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<div className={styles.progressContainer}>
@@ -212,7 +212,6 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
value={memory}
entity={InfraMonitoringEntity.HOSTS}
attribute="memory metric"
rowId={rowId}
>
<EntityProgressBar value={memory} type="memory" />
</ValidateColumnValueWrapper>
@@ -220,33 +219,6 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
);
},
},
{
id: 'diskUsage',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#disk-usage">
Disk Usage
</ColumnHeader>
),
accessorFn: (row): number => row.diskUsage,
width: { min: 200 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => {
const diskUsage = value as number;
return (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={diskUsage}
entity={InfraMonitoringEntity.HOSTS}
attribute="disk usage metric"
rowId={rowId}
>
<EntityProgressBar value={diskUsage} type="disk" />
</ValidateColumnValueWrapper>
</div>
);
},
},
{
id: 'wait',
header: (): React.ReactNode => (
@@ -257,7 +229,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
accessorFn: (row): number => row.wait,
width: { min: 120 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const wait = value as number;
return (
@@ -265,7 +237,6 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
value={wait}
entity={InfraMonitoringEntity.HOSTS}
attribute="IOWait metric"
rowId={rowId}
>
<TanStackTable.Text>
{`${Number((wait * 100).toFixed(1))}%`}
@@ -278,13 +249,14 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
id: 'load15',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#load-avg">
Load Avg (15min)
Load Avg
<br /> (15min)
</ColumnHeader>
),
accessorFn: (row): number => row.load15,
width: { min: 160 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const load15 = Number(value);
return (
@@ -292,11 +264,36 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
value={load15}
entity={InfraMonitoringEntity.HOSTS}
attribute="load average metric"
rowId={rowId}
>
<TanStackTable.Text>{load15.toFixed(2)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'diskUsage',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#disk-usage">
Disk Usage
</ColumnHeader>
),
accessorFn: (row): number => row.diskUsage,
width: { min: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const diskUsage = value as number;
return (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={diskUsage}
entity={InfraMonitoringEntity.HOSTS}
attribute="disk usage metric"
>
<EntityProgressBar value={diskUsage} type="disk" />
</ValidateColumnValueWrapper>
</div>
);
},
},
];

View File

@@ -7,7 +7,7 @@ import {
IQuickFiltersConfig,
} from 'components/QuickFilters/types';
import { TriangleAlert } from '@signozhq/icons';
import TanStackTable from 'components/TanStackTableView';
import { CellValueTooltip } from 'container/InfraMonitoringK8sV2/components';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
@@ -21,7 +21,7 @@ export function HostnameCell({
}): React.ReactElement {
const isEmpty = !hostName || !hostName.trim();
if (!isEmpty) {
return <TanStackTable.Text>{hostName}</TanStackTable.Text>;
return <CellValueTooltip value={hostName} />;
}
return (
<>

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
@@ -21,9 +21,6 @@ import {
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
@@ -36,14 +33,9 @@ import {
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
import { ILog } from 'types/api/logs/log';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { isModifierKeyPressed } from 'utils/app';
import { validateQuery } from 'utils/queryValidationUtils';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
@@ -142,7 +134,7 @@ function EntityLogsContent({
if (validation.isValid) {
querySearchOnRun(newUserExpression);
void logEvent(InfraMonitoringEvents.FilterApplied, {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
@@ -170,45 +162,6 @@ function EntityLogsContent({
virtuosoRef,
});
const { updateAllQueriesOperators } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const handleOpenInExplorer = useCallback(
(e: React.MouseEvent<Element, MouseEvent>, log: ILog) => {
const baseQuery = updateAllQueriesOperators(
initialQueriesMap[DataSource.LOGS],
PANEL_TYPES.LIST,
DataSource.LOGS,
);
const queryParams = {
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
[QueryParams.compositeQuery]: JSON.stringify({
...baseQuery,
builder: {
...baseQuery.builder,
queryData: baseQuery.builder.queryData.map((item) => ({
...item,
filter: { expression },
})),
},
} satisfies Query),
};
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`, {
newTab: !!e && isModifierKeyPressed(e),
});
},
[
timeRange.startTime,
timeRange.endTime,
expression,
safeNavigate,
updateAllQueriesOperators,
],
);
const getItemContent = useCallback(
(_: number, logToRender: ILog): JSX.Element => {
return (
@@ -334,7 +287,6 @@ function EntityLogsContent({
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
handleOpenInExplorer={(e): void => handleOpenInExplorer(e, activeLog)}
/>
)}
</div>

View File

@@ -2,7 +2,6 @@
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
text-wrap: auto;
}
.infoIcon {

View File

@@ -8,6 +8,7 @@ const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
interface ColumnHeaderProps {
children?: React.ReactNode;
title?: string;
docPath?: string;
tooltip?: string;
className?: string;
@@ -15,6 +16,7 @@ interface ColumnHeaderProps {
function ColumnHeader({
children,
title,
docPath,
tooltip,
className,
@@ -24,6 +26,16 @@ function ColumnHeader({
return children;
}
if (title) {
const parts = title.split('\n');
return parts.map((part, index) => (
<div key={`${part}-${index}`}>
{part}
{index < parts.length - 1 && <br />}
</div>
));
}
return null;
};

View File

@@ -1,17 +1,15 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { useCopyToClipboard } from 'react-use';
import { Copy, X } from '@signozhq/icons';
import { Color, Spacing } from '@signozhq/design-tokens';
import { X } from '@signozhq/icons';
import { Divider } from '@signozhq/ui/divider';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { Drawer } from 'antd';
import logEvent from 'api/common/logEvent';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import APIError from 'types/api/error';
import { InfraMonitoringEvents } from 'constants/events';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
GlobalTimeProvider,
NANO_SECOND_MULTIPLIER,
@@ -24,10 +22,8 @@ import LoadingContainer from '../LoadingContainer';
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
import { K8sBaseDetailsProps } from './types';
import { useDrawerLifecycleStore } from './useDrawerLifecycleStore';
import styles from '../EntityDetailsUtils/entityDetails.module.scss';
import { INFRA_MONITORING_DETAILS_CACHE_TIME } from 'constants/queryCacheTime';
import '../EntityDetailsUtils/entityDetails.styles.scss';
export type {
CustomTab,
@@ -38,23 +34,6 @@ export type {
K8sDetailsMetadataConfig,
} from './types';
// TODO(H4ad): Improve this on component level
const DRAWER_TRANSITION = { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] };
const DRAWER_MOTION_PROPS = {
onOpenAutoFocus: (e: Event): void => e.preventDefault(),
initial: { opacity: 0, transform: 'translateX(100%)' },
animate: {
opacity: 1,
transform: 'translateX(0%)',
transition: DRAWER_TRANSITION,
},
exit: {
opacity: 0,
transform: 'translateX(100%)',
transition: DRAWER_TRANSITION,
},
} as unknown as DrawerWrapperProps['drawerContentProps'];
export default function K8sBaseDetails<T>({
category,
eventCategory,
@@ -79,6 +58,8 @@ export default function K8sBaseDetails<T>({
(s) => s.getAutoRefreshQueryKey,
);
const isDarkMode = useIsDarkMode();
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
const selectedItem = selectedItemParams.selectedItem;
@@ -120,8 +101,6 @@ export default function K8sBaseDetails<T>({
return fetchEntityData({ filter: { expression }, start, end }, signal);
},
cacheTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
staleTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
enabled: !!selectedItem,
});
@@ -142,39 +121,10 @@ export default function K8sBaseDetails<T>({
return getInitialEventsExpression(entity);
}, [entity, getInitialEventsExpression]);
const markDrawerOpened = useDrawerLifecycleStore((s) => s.markOpened);
const markDrawerClosed = useDrawerLifecycleStore((s) => s.markClosed);
useEffect(() => {
if (selectedItem) {
markDrawerOpened();
} else {
markDrawerClosed();
}
}, [selectedItem, markDrawerOpened, markDrawerClosed]);
const handleClose = useCallback((): void => {
setSelectedItemParams(null);
}, [setSelectedItemParams]);
const handleOpenChange = useCallback(
(open: boolean): void => {
if (!open) {
handleClose();
}
},
[handleClose],
);
const [, copyToClipboard] = useCopyToClipboard();
const handleCopyId = useCallback((): void => {
if (selectedItem) {
copyToClipboard(selectedItem);
toast.success('ID copied to clipboard', { position: 'bottom-left' });
}
}, [copyToClipboard, selectedItem]);
const entityName = entity ? getEntityName(entity) : '';
useEffect(() => {
@@ -187,49 +137,31 @@ export default function K8sBaseDetails<T>({
}
}, [entity, eventCategory]);
// TODO(H4ad): Improve this on component level
// DrawerWrapper types `title` as string but renders any ReactNode.
const drawerTitle = (
<>
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleClose}
data-testid="close-drawer-button"
className={styles.closeButton}
prefix={<X />}
/>
<Divider type="vertical" />
<Typography.Text className={styles.title}>
{entityName ||
((isEntityError || hasResponseError) && 'Failed to load entity details') ||
(isEntityLoading && 'Loading...') ||
'-'}
</Typography.Text>
<TooltipSimple title="Copy ID">
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleCopyId}
data-testid="copy-id-button"
>
<Copy size={14} />
</Button>
</TooltipSimple>
</>
) as unknown as string;
return (
<DrawerWrapper
<Drawer
width="70%"
title={
<>
<Divider type="vertical" />
<Typography.Text className="title">
{entityName ||
((isEntityError || hasResponseError) &&
'Failed to load entity details') ||
(isEntityLoading && 'Loading...') ||
'-'}
</Typography.Text>
</>
}
placement="right"
onClose={handleClose}
open={!!selectedItem}
onOpenChange={handleOpenChange}
direction="right"
title={drawerTitle}
showCloseButton={false}
drawerContentProps={DRAWER_MOTION_PROPS}
className={styles.entityDetailDrawer}
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
}}
className="entity-detail-drawer"
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
{(isEntityLoading || !selectedItem) && <LoadingContainer />}
@@ -278,6 +210,6 @@ export default function K8sBaseDetails<T>({
/>
</GlobalTimeProvider>
)}
</DrawerWrapper>
</Drawer>
);
}

View File

@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useRef } from 'react';
import { Fragment, useEffect, useMemo } from 'react';
import {
BarChart,
ChevronsLeftRight,
@@ -6,17 +6,12 @@ import {
DraftingCompass,
ScrollText,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { Button, Tooltip } from 'antd';
import logEvent from 'api/common/logEvent';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import {
InfraMonitoringEvents,
logInfraDrawerTabViewedEvent,
logInfraExplorerNavigatedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
@@ -47,9 +42,6 @@ import {
import { EntityCountsSection } from './components/EntityCountsSection/EntityCountsSection';
import { K8sBaseDetailsContentProps } from './types';
import { getDrawerDurationMs } from './useDrawerLifecycleStore';
import styles from '../EntityDetailsUtils/entityDetails.module.scss';
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function K8sBaseDetailsContent<T>({
@@ -115,17 +107,6 @@ export default function K8sBaseDetailsContent<T>({
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
// Wait until the view is a valid tab so we don't log a pending value before
// the validation effect above corrects an out-of-scope query param
const hasLoggedDefaultTab = useRef(false);
useEffect(() => {
if (hasLoggedDefaultTab.current || !validTabs.includes(effectiveView)) {
return;
}
hasLoggedDefaultTab.current = true;
logInfraDrawerTabViewedEvent(category, effectiveView, true);
}, [validTabs, effectiveView, category]);
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
const [, setEventsFiltersParam] = useInfraMonitoringEventsFilters();
@@ -152,7 +133,6 @@ export default function K8sBaseDetailsContent<T>({
category: eventCategory,
view: value,
});
logInfraDrawerTabViewedEvent(category, value, false);
};
const handleExplorePagesRedirect = (): void => {
@@ -174,16 +154,6 @@ export default function K8sBaseDetailsContent<T>({
view: selectedView,
});
logInfraExplorerNavigatedEvent({
entityType: category,
destination:
selectedView === VIEW_TYPES.LOGS ? 'logs_explorer' : 'traces_explorer',
source: 'tab_cta_button',
tab: selectedView,
sourceKey: null,
drawerDurationMsAtNavigation: getDrawerDurationMs(),
});
if (selectedView === VIEW_TYPES.LOGS) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
@@ -239,39 +209,34 @@ export default function K8sBaseDetailsContent<T>({
return (
<>
<div className={styles.entityDetailsEntity}>
<div className={styles.entityDetailsGrid}>
<div className={styles.labelsRow}>
<div className="entity-detail-drawer__entity">
<div className="entity-details-grid">
<div className="labels-row">
{metadataConfig.map((config) => (
<Typography.Text
key={config.label}
color="muted"
size="small"
weight="medium"
className={styles.entityDetailsMetadataLabel}
className="entity-details-metadata-label"
>
{config.label}
</Typography.Text>
))}
</div>
<div className={styles.valuesRow}>
<div className="values-row">
{metadataConfig.map((config) => {
const value = config.getValue(entity);
if (config.render) {
return config.render(value, entity);
}
const displayValue = String(value);
return (
<Typography.Text
key={config.label}
size="small"
weight="medium"
className={styles.entityDetailsMetadataValue}
className="entity-details-metadata-value"
>
{displayValue}
{config.render ? (
config.render(value, entity)
) : (
<Tooltip title={displayValue}>{displayValue}</Tooltip>
)}
</Typography.Text>
);
})}
@@ -288,17 +253,15 @@ export default function K8sBaseDetailsContent<T>({
selectedItem={selectedItem}
filterExpression={getCountsFilterExpression(entity)}
closeDrawer={handleClose}
entityType={category}
activeTab={selectedView}
/>
)}
</div>
{!hideDetailViewTabs && (
<div className={styles.viewsTabsContainer}>
<div className="views-tabs-container">
<ToggleGroupSimple
type="single"
className={styles.viewsTabs}
className="views-tabs"
onChange={handleTabChange}
value={selectedView}
items={[
@@ -307,7 +270,7 @@ export default function K8sBaseDetailsContent<T>({
{
value: VIEW_TYPES.METRICS,
label: (
<div className={styles.viewTitle}>
<div className="view-title">
<BarChart size={14} />
Metrics
</div>
@@ -320,7 +283,7 @@ export default function K8sBaseDetailsContent<T>({
{
value: VIEW_TYPES.LOGS,
label: (
<div className={styles.viewTitle}>
<div className="view-title">
<ScrollText size={14} />
Logs
</div>
@@ -333,7 +296,7 @@ export default function K8sBaseDetailsContent<T>({
{
value: VIEW_TYPES.TRACES,
label: (
<div className={styles.viewTitle}>
<div className="view-title">
<DraftingCompass size={14} />
Traces
</div>
@@ -346,7 +309,7 @@ export default function K8sBaseDetailsContent<T>({
{
value: VIEW_TYPES.EVENTS,
label: (
<div className={styles.viewTitle}>
<div className="view-title">
<ChevronsLeftRight size={14} />
Events
</div>
@@ -357,7 +320,7 @@ export default function K8sBaseDetailsContent<T>({
...(customTabs?.map((tab) => ({
value: tab.key,
label: (
<div className={styles.viewTitle}>
<div className="view-title">
{tab.icon}
{tab.label}
</div>
@@ -367,30 +330,22 @@ export default function K8sBaseDetailsContent<T>({
/>
{selectedView === VIEW_TYPES.LOGS && (
<TooltipSimple title="Go to Logs Explorer" side="left" arrow>
<Tooltip title="Go to Logs Explorer" placement="left">
<Button
variant="ghost"
size="icon"
color="secondary"
className={styles.compassButton}
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
>
<Compass size={18} />
</Button>
</TooltipSimple>
/>
</Tooltip>
)}
{selectedView === VIEW_TYPES.TRACES && (
<TooltipSimple title="Go to Traces Explorer" side="left" arrow>
<Tooltip title="Go to Traces Explorer" placement="left">
<Button
variant="ghost"
size="icon"
color="secondary"
className={styles.compassButton}
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
>
<Compass size={18} />
</Button>
</TooltipSimple>
/>
</Tooltip>
)}
</div>
)}
@@ -403,7 +358,6 @@ export default function K8sBaseDetailsContent<T>({
getEntityQueryPayload={getEntityQueryPayload}
category={category}
queryKey={`${queryKeyPrefix}Metrics`}
view={VIEW_TYPES.METRICS}
/>
)}
{effectiveView === VIEW_TYPES.LOGS && (

View File

@@ -1,19 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import TanStackTable, {
SortState,
TableColumnDef,
useCalculatedPageSize,
useHiddenColumnIds,
useTableParams,
} from 'components/TanStackTableView';
import {
InfraMonitoringEvents,
logInfraColumnSortedEvent,
logInfraTimeRangeCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
@@ -32,16 +27,11 @@ import {
useInfraMonitoringSelectedItemParams,
useInfraMonitoringStatusFilter,
} from '../hooks';
import {
useInfraMonitoringFontSize,
useInfraMonitoringLineClamp,
} from './useInfraMonitoringTablePreferencesStore';
import { useInfraMonitoringLineClamp } from '../components';
import { K8sEmptyState } from './K8sEmptyState';
import { K8sExpandedRow } from './K8sExpandedRow';
import K8sOptionsSidePanel from './K8sOptionsSidePanel';
import K8sHeader from './K8sHeader';
import { K8sPaginationWarning } from './K8sPaginationWarning';
import K8sTableToolbar from './K8sTableToolbar';
import { K8sBaseFilters } from './types';
import { getGroupedByMeta } from './utils';
import { K8sInstrumentationChecksCallout } from './components/K8sInstrumentationChecksCallout/K8sInstrumentationChecksCallout';
@@ -114,7 +104,6 @@ export function K8sBaseList<
const { currentQuery } = useQueryBuilder();
const expression = currentQuery.builder.queryData[0]?.filter?.expression || '';
const lineClamp = useInfraMonitoringLineClamp();
const fontSize = useInfraMonitoringFontSize();
const [groupBy] = useInfraMonitoringGroupBy();
const [orderBy] = useInfraMonitoringOrderBy();
const [statusFilter] = useInfraMonitoringStatusFilter();
@@ -124,7 +113,6 @@ export function K8sBaseList<
const columnStorageKey = `k8s-${entity}-columns`;
const hiddenColumnIds = useHiddenColumnIds(columnStorageKey);
const [isOptionsDrawerOpen, setIsOptionsDrawerOpen] = useState(false);
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
rowHeight: 42,
@@ -229,14 +217,6 @@ export function K8sBaseList<
void queryClient.cancelQueries({ queryKey });
}, [queryClient, queryKey]);
const handleOpenOptionsDrawer = useCallback((): void => {
setIsOptionsDrawerOpen(true);
}, []);
const handleCloseOptionsDrawer = useCallback((): void => {
setIsOptionsDrawerOpen(false);
}, []);
const pageData = data?.data ?? [];
const totalCount = data?.total || 0;
const hasFilters = !!expression?.trim();
@@ -255,14 +235,6 @@ export function K8sBaseList<
});
}, [eventCategory, totalCount]);
const prevSelectedTimeRef = useRef(selectedTime);
useEffect(() => {
if (prevSelectedTimeRef.current !== selectedTime) {
logInfraTimeRangeCustomizedEvent(entity, selectedTime);
prevSelectedTimeRef.current = selectedTime;
}
}, [selectedTime, entity]);
const handleRowClick = useCallback(
(record: T, itemKey: TItemKey): void => {
if (groupBy.length === 0) {
@@ -374,7 +346,6 @@ export function K8sBaseList<
extraQueryKeyParts={extraQueryKeyParts}
getRowKey={getRowKey}
getItemKey={getItemKey}
detailsQueryKeyPrefix={detailsQueryKeyPrefix}
/>
),
[
@@ -384,7 +355,6 @@ export function K8sBaseList<
getItemKey,
expandedRowColumns,
extraQueryKeyParts,
detailsQueryKeyPrefix,
],
);
@@ -393,15 +363,6 @@ export function K8sBaseList<
[isGroupedByAttribute],
);
const handleSort = useCallback(
(sort: SortState | null): void => {
if (sort) {
logInfraColumnSortedEvent(entity, sort.columnName, sort.order, 'list');
}
},
[entity],
);
const showTableLoadingState = isLoading;
const emptyTableMessage: React.ReactNode = renderEmptyState?.({
@@ -431,21 +392,17 @@ export function K8sBaseList<
<>
<K8sHeader
controlListPrefix={controlListPrefix}
leftFilters={leftFilters}
entity={entity}
showAutoRefresh={!selectedItem}
columns={tableColumns}
columnStorageKey={columnStorageKey}
isFetching={isFetching}
cancelQuery={cancelQuery}
/>
<div ref={containerRef} className={styles.tableContainer}>
<K8sInstrumentationChecksCallout entity={entity} />
<K8sTableToolbar
entity={entity}
eventCategory={eventCategory}
leftFilters={leftFilters}
onOpenOptionsDrawer={handleOpenOptionsDrawer}
/>
{isError && (
<Typography>
{data?.error?.toString() || 'Something went wrong'}
@@ -466,7 +423,6 @@ export function K8sBaseList<
getGroupKey={getGroupKeyFn}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
onSort={handleSort}
renderExpandedRow={isGroupedByAttribute ? renderExpandedRow : undefined}
getRowCanExpand={isGroupedByAttribute ? getRowCanExpand : undefined}
className={cx(styles.k8SListTable)}
@@ -484,21 +440,11 @@ export function K8sBaseList<
onLimitChange: setLimit,
}}
plainTextCellLineClamp={lineClamp}
cellTypographySize={fontSize}
prefixPaginationContent={paginationWarningContent}
paginationClassname={styles.paginationContainer}
resetScrollKey={entity}
/>
)}
</div>
<K8sOptionsSidePanel
open={isOptionsDrawerOpen}
columns={tableColumns}
storageKey={columnStorageKey}
entity={entity}
onClose={handleCloseOptionsDrawer}
/>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { useQuery } from 'react-query';
import { useLocation } from 'react-router-dom';
import { Querybuildertypesv5QueryWarnDataDTO } from 'api/generated/services/sigNoz.schemas';
import { Button } from '@signozhq/ui/button';
@@ -21,7 +21,6 @@ import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { logInfraColumnSortedEvent } from 'constants/events';
import { InfraMonitoringEntity } from '../constants';
import {
SelectedItemParams,
@@ -31,8 +30,6 @@ import {
useInfraMonitoringSelectedItemParams,
} from '../hooks';
import { K8sBaseFilters } from './types';
import { useLogEventForColumnCustomized } from './useLogEventForColumnCustomized';
import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferencesStore';
import styles from './K8sExpandedRow.module.scss';
import { buildExpressionFromGroupMeta } from './utils';
@@ -65,14 +62,9 @@ export type K8sExpandedRowProps<T, TItemKey = string> = {
getRowKey?: (record: T) => string;
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
getItemKey?: (record: T) => TItemKey;
/** Query key prefix for pre-caching detail data on row click */
detailsQueryKeyPrefix?: string;
};
export function K8sExpandedRow<
T,
TItemKey extends string | SelectedItemParams = string,
>({
export function K8sExpandedRow<T, TItemKey = string>({
rowKey,
groupMeta,
entity,
@@ -81,9 +73,7 @@ export function K8sExpandedRow<
extraQueryKeyParts = [],
getRowKey,
getItemKey,
detailsQueryKeyPrefix,
}: K8sExpandedRowProps<T, TItemKey>): JSX.Element {
const fontSize = useInfraMonitoringFontSize();
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const { currentQuery } = useQueryBuilder();
@@ -94,7 +84,6 @@ export function K8sExpandedRow<
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const location = useLocation();
const queryClient = useQueryClient();
const orderByParamKey = useMemo(
() => `orderBy_${rowKey.replace(/[^a-zA-Z0-9]/g, '_')}`,
@@ -116,13 +105,6 @@ export function K8sExpandedRow<
const storageKey = `k8s-${entity}-columns-expanded`;
useLogEventForColumnCustomized({
entity,
source: 'expanded',
storageKey,
columns: tableColumns,
});
const expressionForRecord = useMemo(
() => buildExpressionFromGroupMeta(parentExpression || '', groupMeta),
[parentExpression, groupMeta],
@@ -195,45 +177,18 @@ export function K8sExpandedRow<
const expandedData = data?.data ?? [];
const handleRowClick = useCallback(
(row: T, itemKey: TItemKey): void => {
const params: SelectedItemParams =
typeof itemKey === 'object' && itemKey !== null
? itemKey
: {
selectedItem: itemKey,
clusterName: null,
namespaceName: null,
};
if (detailsQueryKeyPrefix) {
const detailQueryKey = getAutoRefreshQueryKey(
selectedTime,
`${detailsQueryKeyPrefix}EntityDetails`,
params.selectedItem,
params.clusterName,
params.namespaceName,
);
queryClient.setQueryData(detailQueryKey, { data: row });
}
setSelectedItemParams(params);
},
[
setSelectedItemParams,
detailsQueryKeyPrefix,
getAutoRefreshQueryKey,
selectedTime,
queryClient,
],
);
const handleSort = useCallback(
(sort: SortState | null): void => {
if (sort) {
logInfraColumnSortedEvent(entity, sort.columnName, sort.order, 'expanded');
(_row: T, itemKey: TItemKey): void => {
if (typeof itemKey === 'object' && itemKey !== null) {
setSelectedItemParams(itemKey as unknown as SelectedItemParams);
} else {
setSelectedItemParams({
selectedItem: itemKey as string,
clusterName: null,
namespaceName: null,
});
}
},
[entity],
[setSelectedItemParams],
);
const handleViewAllClick = (): void => {
@@ -302,7 +257,6 @@ export function K8sExpandedRow<
getRowKey={getRowKey}
getItemKey={getItemKey}
onRowClick={handleRowClick}
onSort={handleSort}
enableQueryParams={{
orderBy: orderByParamKey,
}}
@@ -310,7 +264,7 @@ export function K8sExpandedRow<
className: styles.expandedTable,
}}
disableVirtualScroll
cellTypographySize={fontSize}
cellTypographySize="medium"
/>
</TanStackTableStateProvider>
{!isLoading && expandedData.length > 0 && (

View File

@@ -0,0 +1,62 @@
.drawer {
--dialog-description-padding: 0px 0px var(--spacing-8) 0px;
}
.columnItem {
display: flex;
align-items: center;
}
.columnsTitle {
color: var(--l2-foreground);
font-size: var(--periscope-font-size-small, 11px);
font-weight: var(--periscope-font-weight-medium, 500);
text-transform: uppercase;
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--l2-border);
&:not(:first-of-type) {
border-top: 1px solid var(--l2-border);
}
}
.columnsList {
display: flex;
flex-direction: column;
[data-slot='icon'] {
display: none;
}
[data-slot='entity-group-header'] {
padding-left: 0;
}
[data-slot='column-header'] {
display: flex;
width: auto;
span {
padding: 0;
text-align: left;
}
br {
display: none;
}
}
}
.columnItem {
justify-content: flex-start !important;
width: 100%;
> span {
width: auto !important;
}
}
.horizontalDivider {
border-top: 1px solid var(--l1-border);
}

View File

@@ -0,0 +1,143 @@
import { ReactNode, useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import {
hideColumn,
showColumn,
TableColumnDef,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import styles from './K8sFiltersSidePanel.module.scss';
type ColumnPickerItem = {
id: string;
label: ReactNode;
canBeHidden: boolean;
visibilityBehavior:
| 'hidden-on-expand'
| 'hidden-on-collapse'
| 'always-visible';
};
function renderHeader(header: string | (() => ReactNode)): ReactNode {
return typeof header === 'function' ? header() : header;
}
function toColumnPickerItems<T>(
columns: TableColumnDef<T>[],
): ColumnPickerItem[] {
return columns.map((col) => ({
id: col.id,
label: renderHeader(col.header),
canBeHidden: col.canBeHidden !== false && col.enableRemove !== false,
visibilityBehavior: col.visibilityBehavior ?? 'always-visible',
}));
}
function K8sFiltersSidePanel<TData>({
open,
onClose,
columns,
storageKey,
}: {
open: boolean;
onClose: () => void;
columns: TableColumnDef<TData>[];
storageKey: string;
}): JSX.Element {
const columnPickerItems = useMemo(
() => toColumnPickerItems(columns),
[columns],
);
const hiddenColumnIds = useHiddenColumnIds(storageKey);
const addedColumns = useMemo(
() =>
columnPickerItems.filter(
(column) =>
!hiddenColumnIds.includes(column.id) &&
column.visibilityBehavior !== 'hidden-on-collapse',
),
[columnPickerItems, hiddenColumnIds],
);
const hiddenColumns = useMemo(
() =>
columnPickerItems.filter((column) => hiddenColumnIds.includes(column.id)),
[columnPickerItems, hiddenColumnIds],
);
const handleRemoveColumn = (columnId: string): void => {
hideColumn(storageKey, columnId);
};
const handleAddColumn = (columnId: string): void => {
showColumn(storageKey, columnId);
};
const drawerContent = (
<>
<div className={styles.columnsTitle}>Added Columns (Click to remove)</div>
<div className={styles.columnsList}>
{addedColumns.map((column) => (
<div className={styles.columnItem} key={column.id}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
disabled={!column.canBeHidden}
data-testid={`remove-column-${column.id}`}
onClick={(): void => handleRemoveColumn(column.id)}
>
{column.label}
</Button>
</div>
))}
</div>
<div className={styles.horizontalDivider} />
<div className={styles.columnsTitle}>Other Columns (Click to add)</div>
<div className={styles.columnsList}>
{hiddenColumns.map((column) => (
<div className={styles.columnItem} key={column.id}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
data-can-be-added="true"
data-testid={`add-column-${column.id}`}
onClick={(): void => handleAddColumn(column.id)}
tabIndex={0}
>
{column.label}
</Button>
</div>
))}
</div>
</>
);
return (
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="Columns"
direction="right"
showCloseButton
showOverlay={false}
className={styles.drawer}
>
{drawerContent}
</DrawerWrapper>
);
}
export default K8sFiltersSidePanel;

View File

@@ -5,10 +5,25 @@
align-items: stretch;
gap: var(--spacing-4);
width: 100%;
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--l2-border) !important;
background-color: var(--l2-background) !important;
input {
font-size: 12px;
}
:global([data-slot='badge'] .ant-typography) {
font-size: 12px;
}
}
}
.k8SListControlsRow {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
width: 100%;
align-items: center;
@@ -19,6 +34,27 @@
width: 100%;
}
.k8SFiltersGroupByRow {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-4);
min-width: 0;
@media (max-width: 1600px) {
flex: 1 1 100%;
order: 9;
}
}
.k8SAttributeSearchContainer {
flex: 1 1 240px;
max-width: 400px;
display: flex;
align-items: center;
}
.k8SRunButton {
flex: 0 0 auto;
}
@@ -28,12 +64,17 @@
}
.k8SDateTimeSelection {
margin-left: auto;
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
--date-time-selector-border-color: var(--l2-border);
@media (max-width: 1200px) {
flex: 1 1 100%;
order: 10;
}
:global(.timeSelection-input) {
height: 32px;
}
@@ -54,3 +95,42 @@
}
}
}
.groupByLabel {
min-width: max-content;
font-size: var(--periscope-font-size-base, 13px);
font-weight: var(--periscope-font-weight-regular, 400);
line-height: 18px;
letter-spacing: -0.07px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l2-border);
border-right: none;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
display: flex;
height: 32px;
padding: var(--spacing-3) var(--spacing-3) var(--spacing-3) var(--spacing-4);
justify-content: center;
align-items: center;
gap: var(--spacing-2);
}
.groupBySelect {
width: 100%;
:global(.ant-select-selector) {
border-left: none;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
}
.k8SListControlsRight {
min-width: 240px;
display: flex;
align-items: center;
gap: var(--spacing-2);
}

View File

@@ -1,19 +1,29 @@
import React, { useCallback, useMemo, useRef } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Select } from 'antd';
import logEvent from 'api/common/logEvent';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import {
InfraMonitoringEvents,
logInfraFilterCustomizedEvent,
} from 'constants/events';
TelemetrytypesFieldContextDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TableColumnDef } from 'components/TanStackTableView';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { SlidersHorizontal } from '@signozhq/icons';
import { v4 as uuid } from 'uuid';
import { useGlobalTimeQueryInvalidate } from 'store/globalTime';
import {
useGlobalTimeQueryInvalidate,
useGlobalTimeStore,
} from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { DataSource } from 'types/common/queryBuilder';
import {
@@ -21,25 +31,37 @@ import {
InfraMonitoringEntity,
METRIC_NAMESPACE_BY_ENTITY,
} from '../constants';
import { useInfraMonitoringPageListing } from '../hooks';
import {
useInfraMonitoringGroupBy,
useInfraMonitoringPageListing,
} from '../hooks';
import K8sFiltersSidePanel from './K8sFiltersSidePanel';
import styles from './K8sHeader.module.scss';
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface K8sHeaderProps {
interface K8sHeaderProps<TData> {
controlListPrefix?: React.ReactNode;
leftFilters?: React.ReactNode;
entity: InfraMonitoringEntity;
showAutoRefresh: boolean;
columns: TableColumnDef<TData>[];
columnStorageKey: string;
isFetching?: boolean;
cancelQuery: () => void;
}
function K8sHeader({
function K8sHeader<TData>({
controlListPrefix,
leftFilters,
entity,
showAutoRefresh,
columns,
columnStorageKey,
isFetching = false,
cancelQuery,
}: K8sHeaderProps): JSX.Element {
}: K8sHeaderProps<TData>): JSX.Element {
const [isFiltersSidePanelOpen, setIsFiltersSidePanelOpen] = useState(false);
// null = user never touched the search box; fall back to the current
// query expression on Run so an untouched search doesn't wipe filters
const stagedExpressionRef = useRef<string | null>(null);
@@ -99,8 +121,6 @@ function K8sHeader({
page: InfraMonitoringEvents.ListPage,
category: entity,
});
logInfraFilterCustomizedEvent(entity, 'search', finalExpression);
}
},
[
@@ -125,11 +145,101 @@ function K8sHeader({
cancelQuery();
}, [cancelQuery]);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const { minTime, maxTime } = getMinMaxTime();
const startUnixMilli = Math.floor(minTime / NANO_SECOND_MULTIPLIER);
const endUnixMilli = Math.floor(maxTime / NANO_SECOND_MULTIPLIER);
const { data: groupByFiltersData, isLoading: isLoadingGroupByFilters } =
useGetFieldsKeys(
{
signal: TelemetrytypesSignalDTO.metrics,
metricNamespace: METRIC_NAMESPACE_BY_ENTITY[entity],
limit: 100,
startUnixMilli,
endUnixMilli,
fieldContext: TelemetrytypesFieldContextDTO.resource,
// the search text is intentionally not included
// searchText: expression,
},
{
query: {
queryKey: ['getFieldsKeys', entity, startUnixMilli, endUnixMilli],
},
},
);
const flatFieldKeys = useMemo(() => {
const keys = groupByFiltersData?.data?.keys;
if (!keys) {
return [];
}
const allKeys = Object.values(keys).flat();
const seen = new Set<string>();
return allKeys.filter((field) => {
if (seen.has(field.name)) {
return false;
}
seen.add(field.name);
return true;
});
}, [groupByFiltersData]);
const groupByOptions = useMemo(
() =>
flatFieldKeys.map((field) => ({
value: field.name,
label: field.name,
})),
[flatFieldKeys],
);
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const handleGroupByChange = useCallback(
(value: string[]) => {
void setCurrentPage(1);
void setGroupBy(value);
void logEvent(InfraMonitoringEvents.GroupByChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
},
[setCurrentPage, setGroupBy],
);
const onClickOutside = useCallback(() => {
setIsFiltersSidePanelOpen(false);
}, []);
return (
<div className={styles.k8SListControls}>
<div className={styles.k8SListControlsRow}>
{controlListPrefix}
<div className={styles.k8SFiltersGroupByRow}>
{leftFilters}
<div className={styles.k8SAttributeSearchContainer}>
<div className={styles.groupByLabel}>Group by</div>
<Select
className={styles.groupBySelect}
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}
allowClear
maxTagCount="responsive"
placeholder="Search for attribute"
options={groupByOptions}
onChange={handleGroupByChange}
/>
</div>
</div>
<div className={styles.k8SDateTimeSelection}>
<DateTimeSelectionV2
showAutoRefresh={showAutoRefresh}
@@ -145,6 +255,20 @@ function K8sHeader({
handleCancelQuery={handleCancelQuery}
className={styles.k8SRunButton}
/>
<TooltipSimple title="Click to add more columns to this table">
<Button
type="button"
variant="outlined"
size="icon"
color="secondary"
data-testid="k8s-list-filters-button"
onClick={(): void => setIsFiltersSidePanelOpen(true)}
className={styles.k8SFiltersButton}
>
<SlidersHorizontal size={14} />
</Button>
</TooltipSimple>
</div>
<div className={styles.k8SQbSearchContainer}>
@@ -159,6 +283,13 @@ function K8sHeader({
metricNamespace={METRIC_NAMESPACE_BY_ENTITY[entity]}
/>
</div>
<K8sFiltersSidePanel
open={isFiltersSidePanelOpen}
columns={columns}
storageKey={columnStorageKey}
onClose={onClickOutside}
/>
</div>
);
}

View File

@@ -1,105 +0,0 @@
.drawer {
// for some reason, the scrollbar is overriding this
// for now, I will keep forcing but needs to investigate a global level
// why all drawers are being override with l1-background
background-color: var(--l2-background) !important;
border-color: var(--l2-border) !important;
--dialog-description-padding: 0px 0px var(--spacing-8) 0px;
[data-slot='drawer-description'] {
overflow-y: auto;
}
}
.sectionTitle {
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--l2-border);
&:not(:first-of-type) {
border-top: 1px solid var(--l2-border);
}
}
.sectionTitleText {
text-transform: uppercase;
}
.fontSizeContainer {
display: flex;
flex-direction: column;
padding: var(--spacing-4) 0;
}
.fontSizeOption {
width: 100%;
--button-display: flex;
--button-justify-content: space-between;
--button-align-items: center;
--button-padding: var(--spacing-4) var(--spacing-8);
}
.fontSizeLabel {
text-transform: capitalize;
}
.checkIcon {
color: var(--accent-foreground);
}
.lineClampContainer {
padding: 12px;
--input-prefix-padding: var(--spacing-3);
--input-suffix-padding: var(--spacing-3);
}
.columnsList {
display: flex;
flex-direction: column;
[data-slot='icon'] {
display: none;
}
[data-slot='entity-group-header'] {
padding-left: 0;
}
[data-slot='column-header'] {
display: flex;
width: auto;
span {
padding: 0;
text-align: left;
}
br {
display: none;
}
}
}
.columnItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2) var(--spacing-8);
> span {
width: auto !important;
}
&:first-child {
padding-top: var(--spacing-8);
}
}
.columnLabel {
flex: 1;
text-align: left;
}
.horizontalDivider {
border-top: 1px solid var(--l1-border);
}

View File

@@ -1,256 +0,0 @@
import { ChangeEvent, ReactNode, useCallback, useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Check, Minus, Plus } from '@signozhq/icons';
import {
hideColumn,
showColumn,
TableColumnDef,
useColumnOrder,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import { InfraMonitoringEntity } from '../constants';
import {
FontSize,
useInfraMonitoringTablePreferencesStore,
} from './useInfraMonitoringTablePreferencesStore';
import { useLogEventForColumnCustomized } from './useLogEventForColumnCustomized';
import { sortByColumnOrder } from './utils';
import styles from './K8sOptionsSidePanel.module.scss';
import { Typography } from '@signozhq/ui/typography';
type ColumnPickerItem = {
id: string;
label: ReactNode;
canBeHidden: boolean;
visibilityBehavior:
| 'hidden-on-expand'
| 'hidden-on-collapse'
| 'always-visible';
};
function renderHeader(header: string | (() => ReactNode)): ReactNode {
return typeof header === 'function' ? header() : header;
}
function toColumnPickerItems<T>(
columns: TableColumnDef<T>[],
): ColumnPickerItem[] {
return columns.map((col) => ({
id: col.id,
label: renderHeader(col.header),
canBeHidden: col.canBeHidden !== false && col.enableRemove !== false,
visibilityBehavior: col.visibilityBehavior ?? 'always-visible',
}));
}
const FONT_SIZE_OPTIONS: { value: FontSize; label: string }[] = [
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
];
function K8sOptionsSidePanel<TData>({
open,
onClose,
columns,
storageKey,
entity,
}: {
open: boolean;
onClose: () => void;
columns: TableColumnDef<TData>[];
storageKey: string;
entity: InfraMonitoringEntity;
}): JSX.Element {
const columnPickerItems = useMemo(
() => toColumnPickerItems(columns),
[columns],
);
const hiddenColumnIds = useHiddenColumnIds(storageKey);
const columnOrder = useColumnOrder(storageKey);
const lineClamp = useInfraMonitoringTablePreferencesStore((s) => s.lineClamp);
const fontSize = useInfraMonitoringTablePreferencesStore((s) => s.fontSize);
const increaseLineClamp = useInfraMonitoringTablePreferencesStore(
(s) => s.increaseLineClamp,
);
const decreaseLineClamp = useInfraMonitoringTablePreferencesStore(
(s) => s.decreaseLineClamp,
);
const setLineClamp = useInfraMonitoringTablePreferencesStore(
(s) => s.setLineClamp,
);
const setFontSize = useInfraMonitoringTablePreferencesStore(
(s) => s.setFontSize,
);
const visibleColumnItems = useMemo(
() =>
columnPickerItems.filter(
(column) => column.visibilityBehavior !== 'hidden-on-collapse',
),
[columnPickerItems],
);
const orderedVisibleColumnItems = useMemo(
() => sortByColumnOrder(visibleColumnItems, (col) => col.id, columnOrder),
[visibleColumnItems, columnOrder],
);
useLogEventForColumnCustomized({
entity,
source: 'list',
storageKey,
columns,
});
const handleToggleColumn = (columnId: string, checked: boolean): void => {
if (checked) {
showColumn(storageKey, columnId);
} else {
hideColumn(storageKey, columnId);
}
};
const handleLineClampChange = useCallback(
(value: ChangeEvent<HTMLInputElement>): void => {
const valueAsNumber = value.target.valueAsNumber;
if (value && Number.isInteger(valueAsNumber)) {
setLineClamp(valueAsNumber);
}
},
[setLineClamp],
);
const drawerContent = (
<>
<div className={styles.sectionTitle}>
<Typography.Text size="sm" className={styles.sectionTitleText}>
Font Size
</Typography.Text>
</div>
<div className={styles.fontSizeContainer}>
{FONT_SIZE_OPTIONS.map((option) => (
<Button
key={option.value}
variant="ghost"
color="none"
className={styles.fontSizeOption}
data-testid={`font-size-${option.value}`}
onClick={(): void => setFontSize(option.value)}
>
{option.label}
{fontSize === option.value && (
<Check size={14} className={styles.checkIcon} />
)}
</Button>
))}
</div>
<div className={styles.horizontalDivider} />
<div className={styles.sectionTitle}>
<Typography.Text size="sm" className={styles.sectionTitleText}>
Max lines per row
</Typography.Text>
</div>
<div className={styles.lineClampContainer}>
<Input
min={1}
max={10}
value={lineClamp}
onChange={handleLineClampChange}
data-testid="line-clamp-input"
type="number"
prefix={
<Button
variant="solid"
color="primary"
size="sm"
className={styles.lineClampButton}
data-testid="line-clamp-decrease"
onClick={decreaseLineClamp}
prefix={<Minus />}
disabled={lineClamp <= 1}
/>
}
suffix={
<Button
variant="solid"
color="primary"
size="sm"
className={styles.lineClampButton}
data-testid="line-clamp-increase"
onClick={increaseLineClamp}
prefix={<Plus />}
disabled={lineClamp >= 10}
/>
}
/>
</div>
<div className={styles.horizontalDivider} />
<div className={styles.sectionTitle}>
<Typography.Text size="sm" className={styles.sectionTitleText}>
Columns
</Typography.Text>
</div>
<div className={styles.columnsList}>
{orderedVisibleColumnItems.map((column) => {
const isVisible = !hiddenColumnIds.includes(column.id);
const switchElement = (
<Switch
value={isVisible}
disabled={!column.canBeHidden}
data-testid={`toggle-column-${column.id}`}
onChange={(checked): void => handleToggleColumn(column.id, checked)}
/>
);
return (
<div className={styles.columnItem} key={column.id}>
<Typography.Text size="sm" className={styles.columnLabel}>
{column.label}
</Typography.Text>
{column.canBeHidden ? (
switchElement
) : (
<TooltipSimple title="Required column cannot be hidden" arrow>
{switchElement}
</TooltipSimple>
)}
</div>
);
})}
</div>
</>
);
return (
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="Options"
direction="right"
width="narrow"
showCloseButton
showOverlay={false}
className={styles.drawer}
>
{drawerContent}
</DrawerWrapper>
);
}
export default K8sOptionsSidePanel;

View File

@@ -1,65 +0,0 @@
.toolbar {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: 0px var(--spacing-4) var(--spacing-4) var(--spacing-4);
background: var(--l1-background);
border-bottom: 1px solid var(--l1-border);
}
.groupByContainer {
display: flex;
align-items: center;
min-width: 300px;
max-width: 400px;
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--l2-border) !important;
background-color: var(--l2-background) !important;
input {
font-size: 12px;
}
:global([data-slot='badge'] .ant-typography) {
font-size: 12px;
}
}
}
.groupByLabel {
min-width: max-content;
border-top-left-radius: 2px;
border-bottom-left-radius: 2px;
border: 1px solid var(--l2-border);
border-right: none;
display: flex;
height: 32px;
padding: var(--spacing-3) var(--spacing-3) var(--spacing-3) var(--spacing-4);
justify-content: center;
align-items: center;
gap: var(--spacing-2);
}
.groupBySelect {
width: 100%;
:global(.ant-select-selector) {
border-left: none;
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;
}
}
.spacer {
flex: 1;
}
.toolbarButton {
flex-shrink: 0;
--button-padding: 0px;
}

View File

@@ -1,112 +0,0 @@
import { useCallback } from 'react';
import { Button } from '@signozhq/ui/button';
import { Select } from 'antd';
import { Download, SlidersVertical } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import {
InfraMonitoringEvents,
logInfraGroupByCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEntity } from '../constants';
import {
useInfraMonitoringGroupBy,
useInfraMonitoringPageListing,
} from '../hooks';
import { useInfraMonitoringGroupByData } from './useInfraMonitoringGroupByData';
import styles from './K8sTableToolbar.module.scss';
interface K8sTableToolbarProps {
entity: InfraMonitoringEntity;
eventCategory: InfraMonitoringEvents;
leftFilters?: React.ReactNode;
onOpenOptionsDrawer: () => void;
onDownload?: () => void;
}
function K8sTableToolbar({
entity,
eventCategory,
leftFilters,
onOpenOptionsDrawer,
onDownload,
}: K8sTableToolbarProps): JSX.Element {
const { groupByOptions, isLoading: isLoadingGroupByFilters } =
useInfraMonitoringGroupByData(entity);
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const handleGroupByChange = useCallback(
(value: string[]) => {
void setCurrentPage(1);
void setGroupBy(value);
void logEvent(InfraMonitoringEvents.GroupByChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
logInfraGroupByCustomizedEvent(entity, value);
},
[entity, eventCategory, setCurrentPage, setGroupBy],
);
return (
<div className={styles.toolbar}>
<div className={styles.groupByContainer}>
<div className={styles.groupByLabel}>Group by</div>
<Select
className={styles.groupBySelect}
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}
allowClear
maxTagCount="responsive"
placeholder="Search for attribute"
options={groupByOptions}
onChange={handleGroupByChange}
/>
</div>
<div className={styles.spacer} />
{leftFilters}
{onDownload && (
<TooltipSimple title="Download">
<Button
type="button"
variant="ghost"
size="icon"
color="secondary"
data-testid="k8s-table-download-button"
onClick={onDownload}
className={styles.toolbarButton}
>
<Download size={14} />
</Button>
</TooltipSimple>
)}
<TooltipSimple title="Options">
<Button
type="button"
variant="ghost"
size="icon"
color="secondary"
data-testid="k8s-table-options-button"
onClick={onOpenOptionsDrawer}
className={styles.toolbarButton}
>
<SlidersVertical size={14} />
</Button>
</TooltipSimple>
</div>
);
}
export default K8sTableToolbar;

View File

@@ -1,187 +0,0 @@
/* eslint-disable no-restricted-syntax */
import { act, renderHook } from '@testing-library/react';
import { TableColumnDef, useColumnStore } from 'components/TanStackTableView';
import { logInfraColumnCustomizedEvent } from 'constants/events';
import { InfraMonitoringEntity } from '../../constants';
import { useInfraMonitoringTablePreferencesStore } from '../useInfraMonitoringTablePreferencesStore';
import { useLogEventForColumnCustomized } from '../useLogEventForColumnCustomized';
jest.mock('constants/events', () => ({
logInfraColumnCustomizedEvent: jest.fn(),
}));
const mockLogEvent = logInfraColumnCustomizedEvent as jest.Mock;
type TestRow = { id: string; name: string };
const col = (id: string): TableColumnDef<TestRow> => ({
id,
header: id,
cell: (): null => null,
});
const columns = [col('a'), col('b'), col('c')];
describe('useLogEventForColumnCustomized', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
useInfraMonitoringTablePreferencesStore.setState({
lineClamp: 1,
fontSize: 'medium',
});
localStorage.clear();
mockLogEvent.mockClear();
});
it('does not emit on mount', () => {
const storageKey = 'test-no-emit-on-mount';
act(() => {
useColumnStore.getState().initializeFromDefaults(storageKey, columns);
});
renderHook(() =>
useLogEventForColumnCustomized({
entity: InfraMonitoringEntity.PODS,
source: 'list',
storageKey,
columns,
}),
);
expect(mockLogEvent).not.toHaveBeenCalled();
});
it('emits when a column is hidden after mount', () => {
const storageKey = 'test-emit-on-hide';
act(() => {
useColumnStore.getState().initializeFromDefaults(storageKey, columns);
});
renderHook(() =>
useLogEventForColumnCustomized({
entity: InfraMonitoringEntity.PODS,
source: 'list',
storageKey,
columns,
}),
);
act(() => {
useColumnStore.getState().hideColumn(storageKey, 'b');
});
expect(mockLogEvent).toHaveBeenCalledTimes(1);
expect(mockLogEvent).toHaveBeenCalledWith(
InfraMonitoringEntity.PODS,
['a', 'c'],
'medium',
1,
'list',
);
});
it('emits when font size changes after mount', () => {
const storageKey = 'test-emit-on-font-size';
act(() => {
useColumnStore.getState().initializeFromDefaults(storageKey, columns);
});
renderHook(() =>
useLogEventForColumnCustomized({
entity: InfraMonitoringEntity.PODS,
source: 'list',
storageKey,
columns,
}),
);
act(() => {
useInfraMonitoringTablePreferencesStore.getState().setFontSize('small');
});
expect(mockLogEvent).toHaveBeenCalledTimes(1);
});
// Regression: K8sDynamicList keeps K8sBaseList (and the options side panel)
// mounted across category switches, so this hook survives a Pods -> Nodes
// switch with every input changed. The switch itself is not a customization
// and must not emit.
it('does not emit when the storage key changes (category switch)', () => {
const podsKey = 'test-switch-pods';
const nodesKey = 'test-switch-nodes';
const nodeColumns = [col('x'), col('y')];
act(() => {
useColumnStore.getState().initializeFromDefaults(podsKey, columns);
useColumnStore.getState().initializeFromDefaults(nodesKey, nodeColumns);
});
const { rerender } = renderHook(
(props) => useLogEventForColumnCustomized(props),
{
initialProps: {
entity: InfraMonitoringEntity.PODS,
source: 'list' as const,
storageKey: podsKey,
columns,
},
},
);
rerender({
entity: InfraMonitoringEntity.NODES,
source: 'list' as const,
storageKey: nodesKey,
columns: nodeColumns,
});
expect(mockLogEvent).not.toHaveBeenCalled();
// A real customization after the switch still emits, for the new entity
act(() => {
useColumnStore.getState().hideColumn(nodesKey, 'y');
});
expect(mockLogEvent).toHaveBeenCalledTimes(1);
expect(mockLogEvent).toHaveBeenCalledWith(
InfraMonitoringEntity.NODES,
['x'],
'medium',
1,
'list',
);
});
// Regression: every expanded group row mounts its own instance sharing the
// `k8s-<entity>-columns-expanded` storage key; one customization must not
// emit once per row.
it('emits once when multiple instances share a storage key', () => {
const storageKey = 'test-shared-key';
act(() => {
useColumnStore.getState().initializeFromDefaults(storageKey, columns);
});
const params = {
entity: InfraMonitoringEntity.PODS,
source: 'expanded' as const,
storageKey,
columns,
};
renderHook(() => useLogEventForColumnCustomized(params));
renderHook(() => useLogEventForColumnCustomized(params));
renderHook(() => useLogEventForColumnCustomized(params));
act(() => {
useColumnStore.getState().hideColumn(storageKey, 'a');
});
expect(mockLogEvent).toHaveBeenCalledTimes(1);
// A subsequent distinct customization emits again, exactly once
act(() => {
useColumnStore.getState().hideColumn(storageKey, 'b');
});
expect(mockLogEvent).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,9 +1,7 @@
import { Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { Compass } from '@signozhq/icons';
import { TextNoData } from '../../../components/TextNoData';
import { logInfraExplorerNavigatedEvent } from 'constants/events';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
@@ -15,7 +13,6 @@ import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../../../constants';
import { getDrawerDurationMs } from '../../useDrawerLifecycleStore';
import styles from './EntityCountsSection.module.scss';
export interface EntityCountConfig<T> {
@@ -30,8 +27,6 @@ interface EntityCountsSectionProps<T> {
selectedItem: string;
filterExpression: string;
closeDrawer: () => void;
entityType: InfraMonitoringEntity;
activeTab: string;
}
export function EntityCountsSection<T>({
@@ -40,21 +35,7 @@ export function EntityCountsSection<T>({
selectedItem,
filterExpression,
closeDrawer,
entityType,
activeTab,
}: EntityCountsSectionProps<T>): JSX.Element {
const handleCardNavigate = (cardLabel: string): void => {
logInfraExplorerNavigatedEvent({
entityType,
destination: 'k8s_list',
source: 'stats_card',
tab: activeTab,
sourceKey: cardLabel,
drawerDurationMsAtNavigation: getDrawerDurationMs(),
});
closeDrawer();
};
const buildNavigationUrl = (targetCategory: InfraMonitoringEntity): string => {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
@@ -135,26 +116,17 @@ export function EntityCountsSection<T>({
>
{config.label}
</Typography.Text>
{config.getValue(entity) ? (
<Typography.Text
className={styles.countValue}
size="xl"
weight="semibold"
>
{config.getValue(entity)}
</Typography.Text>
) : (
<TextNoData type="typography" className={styles.countValue} />
)}
<Typography.Text className={styles.countValue} size="xl" weight="semibold">
{config.getValue(entity) || '-'}
</Typography.Text>
<Link
to={buildNavigationUrl(config.targetCategory)}
onClick={(): void => handleCardNavigate(config.label)}
onClick={closeDrawer}
data-testid={`navigate-${config.label.toLowerCase().replace(/\s+/g, '-')}`}
>
<TooltipSimple
<Tooltip
title={`View ${config.label.toLowerCase()} of '${selectedItem}'`}
side="top"
arrow
placement="top"
>
<Button
size="icon"
@@ -163,7 +135,7 @@ export function EntityCountsSection<T>({
className={styles.navigateButton}
prefix={<Compass size={14} />}
/>
</TooltipSimple>
</Tooltip>
</Link>
</div>
))}

View File

@@ -1,23 +0,0 @@
import { create } from 'zustand';
export interface IDrawerLifecycleStore {
openedAt: number | null;
markOpened: () => void;
markClosed: () => void;
getDrawerDurationMs: () => number | null;
}
export const useDrawerLifecycleStore = create<IDrawerLifecycleStore>()(
(set, get) => ({
openedAt: null,
markOpened: (): void => set({ openedAt: Date.now() }),
markClosed: (): void => set({ openedAt: null }),
getDrawerDurationMs: (): number | null => {
const { openedAt } = get();
return openedAt == null ? null : Date.now() - openedAt;
},
}),
);
export const getDrawerDurationMs = (): number | null =>
useDrawerLifecycleStore.getState().getDrawerDurationMs();

View File

@@ -1,73 +0,0 @@
import { useMemo } from 'react';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import {
InfraMonitoringEntity,
METRIC_NAMESPACE_BY_ENTITY,
} from '../constants';
export interface GroupByOption {
value: string;
label: string;
}
export interface UseInfraMonitoringGroupByDataReturn {
groupByOptions: GroupByOption[];
isLoading: boolean;
}
export function useInfraMonitoringGroupByData(
entity: InfraMonitoringEntity,
): UseInfraMonitoringGroupByDataReturn {
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const { minTime, maxTime } = getMinMaxTime();
const startUnixMilli = Math.floor(minTime / NANO_SECOND_MULTIPLIER);
const endUnixMilli = Math.floor(maxTime / NANO_SECOND_MULTIPLIER);
const { data: groupByFiltersData, isLoading } = useGetFieldsKeys(
{
signal: TelemetrytypesSignalDTO.metrics,
metricNamespace: METRIC_NAMESPACE_BY_ENTITY[entity],
limit: 100,
startUnixMilli,
endUnixMilli,
fieldContext: TelemetrytypesFieldContextDTO.resource,
},
{
query: {
queryKey: ['getFieldsKeys', entity, startUnixMilli, endUnixMilli],
},
},
);
const groupByOptions = useMemo(() => {
const keys = groupByFiltersData?.data?.keys;
if (!keys) {
return [];
}
const allKeys = Object.values(keys).flat();
const seen = new Set<string>();
return allKeys
.filter((field) => {
if (seen.has(field.name)) {
return false;
}
seen.add(field.name);
return true;
})
.map((field) => ({
value: field.name,
label: field.name,
}));
}, [groupByFiltersData]);
return { groupByOptions, isLoading };
}

View File

@@ -1,58 +0,0 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { CellTypographySize } from 'components/TanStackTableView';
export type FontSize = CellTypographySize;
export interface IInfraMonitoringTablePreferencesStore {
lineClamp: number;
fontSize: FontSize;
increaseLineClamp: () => void;
decreaseLineClamp: () => void;
setLineClamp: (value: number) => void;
setFontSize: (value: FontSize) => void;
}
const MIN_LINE_CLAMP = 1;
const MAX_LINE_CLAMP = 10;
const DEFAULT_LINE_CLAMP = 1;
const DEFAULT_FONT_SIZE: FontSize = 'medium';
export const useInfraMonitoringTablePreferencesStore =
create<IInfraMonitoringTablePreferencesStore>()(
persist(
(set, get) => ({
lineClamp: DEFAULT_LINE_CLAMP,
fontSize: DEFAULT_FONT_SIZE,
increaseLineClamp: (): void => {
const current = get().lineClamp;
if (current < MAX_LINE_CLAMP) {
set({ lineClamp: current + 1 });
}
},
decreaseLineClamp: (): void => {
const current = get().lineClamp;
if (current > MIN_LINE_CLAMP) {
set({ lineClamp: current - 1 });
}
},
setLineClamp: (value: number): void => {
if (value >= MIN_LINE_CLAMP && value <= MAX_LINE_CLAMP) {
set({ lineClamp: value });
}
},
setFontSize: (value: FontSize): void => {
set({ fontSize: value });
},
}),
{
name: '@signoz/infra-monitoring-table-preferences',
},
),
);
export const useInfraMonitoringLineClamp = (): number =>
useInfraMonitoringTablePreferencesStore((s) => s.lineClamp);
export const useInfraMonitoringFontSize = (): FontSize =>
useInfraMonitoringTablePreferencesStore((s) => s.fontSize);

View File

@@ -1,89 +0,0 @@
import { useEffect, useMemo, useRef } from 'react';
import {
TableColumnDef,
useColumnOrder,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import { logInfraColumnCustomizedEvent } from 'constants/events';
import { InfraMonitoringEntity } from '../constants';
import {
useInfraMonitoringFontSize,
useInfraMonitoringLineClamp,
} from './useInfraMonitoringTablePreferencesStore';
import { sortByColumnOrder } from './utils';
interface UseEmitColumnCustomizedParams<TData> {
entity: InfraMonitoringEntity;
source: 'list' | 'expanded';
storageKey: string;
columns: TableColumnDef<TData>[];
}
// Multiple instances of this hook can share a storageKey (every expanded group
// row mounts one), so the last emitted payload is tracked per key to collapse
// their simultaneous effect runs into a single event.
const lastEmittedPayloadByKey = new Map<string, string>();
export function useLogEventForColumnCustomized<TData>({
entity,
source,
storageKey,
columns,
}: UseEmitColumnCustomizedParams<TData>): void {
const hiddenColumnIds = useHiddenColumnIds(storageKey);
const columnOrder = useColumnOrder(storageKey);
const fontSize = useInfraMonitoringFontSize();
const lineClamp = useInfraMonitoringLineClamp();
const hiddenBehavior =
source === 'expanded' ? 'hidden-on-expand' : 'hidden-on-collapse';
const orderedVisibleColumnIds = useMemo(() => {
const visibleColumns = columns.filter(
(col) =>
(col.visibilityBehavior ?? 'always-visible') !== hiddenBehavior &&
!hiddenColumnIds.includes(col.id),
);
return sortByColumnOrder(visibleColumns, (col) => col.id, columnOrder).map(
(col) => col.id,
);
}, [columns, hiddenColumnIds, columnOrder, hiddenBehavior]);
const isFirstRender = useRef(true);
const prevStorageKeyRef = useRef(storageKey);
// Re-arm the first-render skip when the table identity changes (the hook
// survives category switches because K8sDynamicList keeps K8sBaseList
// mounted), so the switch itself is not reported as a customization
if (prevStorageKeyRef.current !== storageKey) {
prevStorageKeyRef.current = storageKey;
isFirstRender.current = true;
}
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
const dedupeKey = `${storageKey}:${source}`;
const payload = JSON.stringify([
entity,
orderedVisibleColumnIds,
fontSize,
lineClamp,
]);
if (lastEmittedPayloadByKey.get(dedupeKey) === payload) {
return;
}
lastEmittedPayloadByKey.set(dedupeKey, payload);
logInfraColumnCustomizedEvent(
entity,
orderedVisibleColumnIds,
fontSize,
lineClamp,
source,
);
}, [entity, source, storageKey, orderedVisibleColumnIds, fontSize, lineClamp]);
}

View File

@@ -9,22 +9,6 @@ import {
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
export function sortByColumnOrder<T>(
items: T[],
getId: (item: T) => string,
columnOrder: string[],
): T[] {
if (columnOrder.length === 0) {
return items;
}
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
return [...items].sort(
(a, b) =>
(orderIndex.get(getId(a)) ?? Number.MAX_SAFE_INTEGER) -
(orderIndex.get(getId(b)) ?? Number.MAX_SAFE_INTEGER),
);
}
export function getGroupedByMeta<
T extends { meta?: Record<string, string> | null },
>(itemData: T, groupBy: string[]): Record<string, string> {

View File

@@ -10,8 +10,8 @@ import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
CellValueTooltip,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
@@ -75,9 +75,10 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
cell: ({ value }): React.ReactNode => {
const clusterName = value as string;
return <CellValueTooltip value={clusterName} />;
},
},
{
id: 'nodeCountsByReadiness',
@@ -94,7 +95,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
enableSort: false,
cell: ({ row, rowId }): React.ReactNode => {
if (!row.nodeCountsByReadiness) {
return <TextNoData type="tanstack" />;
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
@@ -132,7 +133,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TextNoData type="tanstack" />;
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts
@@ -146,17 +147,17 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
id: 'cpu',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/clusters#cpu-usage-cores">
CPU Usage (cores)
CPU Usage
<br /> (cores)
</ColumnHeader>
),
accessorFn: (row): number => row.clusterCPU,
width: { min: 160 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const cpu = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpu}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="CPU metric"
@@ -170,17 +171,17 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
id: 'cpu_allocatable',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/clusters#cpu-alloc-cores">
CPU Allocatable (cores)
CPU Allocatable
<br /> (cores)
</ColumnHeader>
),
accessorFn: (row): number => row.clusterCPUAllocatable,
width: { min: 200 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const cpuAllocatable = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpuAllocatable}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="CPU allocatable metric"
@@ -194,17 +195,17 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
id: 'memory',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/clusters#memory-usage-wss">
Memory Usage (WSS)
Memory Usage
<br /> (WSS)
</ColumnHeader>
),
accessorFn: (row): number => row.clusterMemory,
width: { min: 180 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memory}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="memory metric"
@@ -218,17 +219,17 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
id: 'memory_allocatable',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/clusters#memory-allocatable">
Memory Allocatable
Memory
<br /> Allocatable
</ColumnHeader>
),
accessorFn: (row): number => row.clusterMemoryAllocatable,
width: { min: 180 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const memoryAllocatable = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memoryAllocatable}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="memory allocatable metric"

View File

@@ -9,9 +9,9 @@ import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
CellValueTooltip,
EntityProgressBar,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
@@ -85,9 +85,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
cell: ({ value }): React.ReactNode => {
const daemonsetName = value as string;
return <CellValueTooltip value={daemonsetName} />;
},
},
{
id: 'namespaceName',
@@ -101,9 +102,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
width: { min: 160 },
enableSort: false,
enableResize: true,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return <CellValueTooltip value={namespaceName} />;
},
},
{
id: 'pod_counts_by_status',
@@ -122,7 +124,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TextNoData type="tanstack" />;
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts
@@ -175,7 +177,8 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-req-usage-">
CPU Request Usage (%)
CPU Request
<br /> Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.daemonSetCPURequest,
@@ -183,11 +186,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpuRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Request"
@@ -201,18 +203,18 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-limit-usage-">
CPU Limit Usage (%)
CPU Limit
<br /> Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.daemonSetCPULimit,
width: { min: 160 },
enableSort: true,
enableResize: true,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpuLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Limit"
@@ -226,7 +228,8 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
id: 'cpu',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-usage-cores">
CPU Usage (cores)
CPU Usage
<br /> (cores)
</ColumnHeader>
),
accessorFn: (row): number => row.daemonSetCPU,
@@ -234,11 +237,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const cpu = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpu}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU metric"
@@ -252,7 +254,8 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-req-usage-">
Memory Request Usage (%)
Memory Request
<br /> Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.daemonSetMemoryRequest,
@@ -260,11 +263,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memoryRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Request"
@@ -278,18 +280,18 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-limit-usage-">
Memory Limit Usage (%)
Memory Limit
<br /> Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.daemonSetMemoryLimit,
width: { min: 190 },
enableSort: true,
enableResize: true,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memoryLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Limit"
@@ -303,18 +305,18 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
id: 'memory',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-usage-wss">
Memory Usage (WSS)
Memory Usage
<br /> (WSS)
</ColumnHeader>
),
accessorFn: (row): number => row.daemonSetMemory,
width: { min: 180 },
enableSort: true,
enableResize: true,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memory}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="memory metric"
@@ -335,11 +337,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const readyNodes = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={readyNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="ready node"
@@ -360,11 +361,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const currentNodes = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={currentNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="current node"
@@ -385,11 +385,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const desiredNodes = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={desiredNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="desired node"
@@ -410,11 +409,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const misscheduledNodes = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={misscheduledNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="misscheduled node"

View File

@@ -9,9 +9,9 @@ import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
CellValueTooltip,
EntityProgressBar,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
@@ -86,9 +86,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value}</TanStackTable.Text>
),
cell: ({ value }): React.ReactNode => {
const deploymentName = value as string;
return <CellValueTooltip value={deploymentName} />;
},
},
{
id: 'namespaceName',
@@ -120,7 +121,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
cell: ({ row, rowId }): React.ReactNode => {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TextNoData type="tanstack" />;
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts
@@ -163,7 +164,8 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-req-usage-">
CPU Request Usage (%)
CPU Request
<br /> Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.deploymentCPURequest,
@@ -171,11 +173,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpuRequest}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Request"
@@ -189,18 +190,18 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-limit-usage-">
CPU Limit Usage (%)
CPU Limit
<br /> Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.deploymentCPULimit,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const cpuLimit = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpuLimit}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Limit"
@@ -214,18 +215,18 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
id: 'cpu',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-usage-cores">
CPU Usage (cores)
CPU Usage
<br /> (cores)
</ColumnHeader>
),
accessorFn: (row): number => row.deploymentCPU,
width: { min: 160 },
enableSort: true,
enableResize: true,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const cpu = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={cpu}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU metric"
@@ -239,7 +240,8 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-req-usage-">
Memory Request Usage (%)
Memory Request
<br /> Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.deploymentMemoryRequest,
@@ -247,11 +249,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const memoryRequest = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memoryRequest}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Request"
@@ -265,18 +266,18 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-limit-usage-">
Memory Limit Usage (%)
Memory Limit
<br /> Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.deploymentMemoryLimit,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const memoryLimit = Number(value);
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memoryLimit}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Limit"
@@ -290,18 +291,18 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
id: 'memory',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-usage-wss">
Memory Usage (WSS)
Memory Usage
<br /> (WSS)
</ColumnHeader>
),
accessorFn: (row): number => row.deploymentMemory,
width: { min: 180 },
enableSort: true,
enableResize: true,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={memory}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="memory metric"
@@ -322,11 +323,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
width: { min: 120 },
enableSort: true,
defaultVisibility: false,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const availablePods = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={availablePods}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="available pod"
@@ -347,11 +347,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
width: { min: 120 },
enableSort: true,
defaultVisibility: false,
cell: ({ value, rowId }): React.ReactNode => {
cell: ({ value }): React.ReactNode => {
const desiredPods = value as number;
return (
<ValidateColumnValueWrapper
rowId={rowId}
value={desiredPods}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="desired pod"

View File

@@ -1,5 +0,0 @@
.container {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -3,10 +3,7 @@ import { Undo } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import {
InfraMonitoringEvents,
logInfraDrawerTimeRangeCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
@@ -16,8 +13,6 @@ import {
import { useEntityDetailsTime } from './useEntityDetailsTime';
import styles from './EntityDateTimeSelector.module.scss';
interface EntityDateTimeSelectorProps {
eventEntity: string;
category: InfraMonitoringEntity;
@@ -46,14 +41,13 @@ function EntityDateTimeSelector({
view,
interval,
});
logInfraDrawerTimeRangeCustomizedEvent(category, interval);
handleTimeChange(interval, dateTimeRange);
},
[category, view, eventEntity, handleTimeChange],
);
return (
<div className={styles.container}>
<div className="entity-date-time-selector">
{hasTimeChanged && (
<TooltipSimple title="Reset to list time" side="bottom">
<Button

View File

@@ -16,10 +16,7 @@ import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import {
InfraMonitoringEvents,
logInfraDrawerFilterCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
@@ -127,13 +124,6 @@ function EntityEventsContent({
view: InfraMonitoringEvents.EventsView,
});
logInfraDrawerFilterCustomizedEvent(
category,
'events',
newUserExpression || '',
'search',
);
refetch();
}
},

View File

@@ -73,7 +73,7 @@
.listContainer {
flex: 1;
height: calc(100vh - 330px) !important;
height: calc(100vh - 312px) !important;
display: flex;
height: 100%;

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
@@ -20,10 +20,7 @@ import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import {
InfraMonitoringEvents,
logInfraDrawerFilterCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
@@ -45,14 +42,6 @@ import { K8S_ENTITY_LOGS_EXPRESSION_KEY, useInfiniteEntityLogs } from './hooks';
import { getEntityLogsQueryPayload } from './utils';
import styles from './EntityLogs.module.scss';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import createQueryParams from 'lib/createQueryParams';
import { isModifierKeyPressed } from 'utils/app';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
interface Props {
eventEntity: string;
@@ -68,9 +57,6 @@ function EntityLogsContent({
}: Omit<Props, 'initialExpression'>): JSX.Element {
const { timeRange } = useEntityDetailsTime();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const logDetailContainerRef = useRef<HTMLDivElement>(null);
const { safeNavigate } = useSafeNavigate();
const expression = useExpression();
const inputExpression = useInputExpression();
@@ -99,10 +85,8 @@ function EntityLogsContent({
: partExpression;
querySearchOnRun(newUser);
logInfraDrawerFilterCustomizedEvent(category, 'logs', newUser, 'logs');
},
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
[userExpression, querySearchOnRun, handleCloseLogDetail],
);
const {
@@ -143,13 +127,6 @@ function EntityLogsContent({
view: InfraMonitoringEvents.LogsView,
});
logInfraDrawerFilterCustomizedEvent(
category,
'logs',
newUserExpression || '',
'search',
);
refetch();
}
},
@@ -171,44 +148,6 @@ function EntityLogsContent({
virtuosoRef,
});
const { updateAllQueriesOperators } = useQueryBuilder();
const handleOpenInExplorer = useCallback(
(e: React.MouseEvent<Element, MouseEvent>, log: ILog) => {
const baseQuery = updateAllQueriesOperators(
initialQueriesMap[DataSource.LOGS],
PANEL_TYPES.LIST,
DataSource.LOGS,
);
const queryParams = {
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
[QueryParams.compositeQuery]: JSON.stringify({
...baseQuery,
builder: {
...baseQuery.builder,
queryData: baseQuery.builder.queryData.map((item) => ({
...item,
filter: { expression },
})),
},
} satisfies Query),
};
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`, {
newTab: !!e && isModifierKeyPressed(e),
});
},
[
timeRange.startTime,
timeRange.endTime,
expression,
safeNavigate,
updateAllQueriesOperators,
],
);
const getItemContent = useCallback(
(_: number, logToRender: ILog): JSX.Element => {
return (
@@ -318,7 +257,6 @@ function EntityLogsContent({
{renderContent}
</div>
)}
<div ref={logDetailContainerRef} data-log-detail-ignore="true" />
{selectedTab && activeLog && (
<LogDetail
log={activeLog}
@@ -329,10 +267,6 @@ function EntityLogsContent({
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
handleOpenInExplorer={(e) => handleOpenInExplorer(e, activeLog)}
getContainer={(): HTMLElement =>
logDetailContainerRef.current || document.body
}
/>
)}
</div>

View File

@@ -2,7 +2,6 @@
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
width: 100%;
}
.infoIcon {
@@ -21,7 +20,6 @@
align-items: center;
color: var(--l2-foreground);
transition: opacity 0.2s;
margin-left: auto;
&:hover {
color: var(--l3-foreground);

View File

@@ -1,6 +1,6 @@
import { Link } from 'react-router-dom';
import { Compass, Info } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from 'antd';
import styles from './ChartHeader.module.scss';
@@ -12,7 +12,6 @@ interface ChartHeaderProps {
tooltip?: string;
metricsExplorerUrl?: string;
metricsExplorerTestId?: string;
onExploreClick?: () => void;
}
function ChartHeader({
@@ -21,13 +20,12 @@ function ChartHeader({
tooltip,
metricsExplorerUrl,
metricsExplorerTestId = 'open-metrics-explorer',
onExploreClick,
}: ChartHeaderProps): JSX.Element {
const renderInfoIcon = (): React.ReactNode => {
if (docPath) {
const tooltipTitle = tooltip || 'Not sure what this represents?';
return (
<TooltipSimple
<Tooltip
arrow
title={
<>
@@ -46,17 +44,17 @@ function ChartHeader({
<span className={styles.infoIcon} data-testid="chart-header-info-icon">
<Info size="md" />
</span>
</TooltipSimple>
</Tooltip>
);
}
if (tooltip) {
return (
<TooltipSimple title={tooltip} arrow>
<Tooltip title={tooltip}>
<span className={styles.infoIcon} data-testid="chart-header-info-icon">
<Info size="md" />
</span>
</TooltipSimple>
</Tooltip>
);
}
@@ -68,16 +66,15 @@ function ChartHeader({
<span className={styles.chartHeaderLabel}>{title}</span>
{renderInfoIcon()}
{metricsExplorerUrl && (
<TooltipSimple title="Go to Metrics Explorer" arrow>
<Tooltip title="Open in Metrics Explorer">
<Link
to={metricsExplorerUrl}
className={styles.metricsExplorerLink}
data-testid={metricsExplorerTestId}
onClick={onExploreClick}
>
<Compass size={14} />
</Link>
</TooltipSimple>
</Tooltip>
)}
</div>
);

View File

@@ -2,17 +2,11 @@ import { useCallback, useMemo, useRef } from 'react';
import { UseQueryResult } from 'react-query';
import { Skeleton } from 'antd';
import cx from 'classnames';
import {
InfraMonitoringEvents,
logInfraExplorerNavigatedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import { PANEL_TYPES } from 'constants/queryBuilder';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import {
InfraMonitoringEntity,
VIEW_TYPES,
} from 'container/InfraMonitoringK8sV2/constants';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -23,8 +17,6 @@ import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { getMetricsExplorerUrl } from 'utils/explorerUtils';
import { getDrawerDurationMs } from 'container/InfraMonitoringK8sV2/Base/useDrawerLifecycleStore';
import { buildEntityMetricsChartConfig } from './configBuilder';
import ChartHeader from './ChartHeader';
@@ -51,7 +43,6 @@ interface EntityMetricsProps<T> {
) => GetQueryResultsProps[];
queryKey: string;
category: InfraMonitoringEntity;
view?: string;
}
function EntityMetrics<T>({
@@ -61,7 +52,6 @@ function EntityMetrics<T>({
getEntityQueryPayload,
queryKey,
category,
view = VIEW_TYPES.METRICS,
}: EntityMetricsProps<T>): JSX.Element {
const { timeRange, selectedInterval, handleTimeChange } =
useEntityDetailsTime();
@@ -213,16 +203,6 @@ function EntityMetrics<T>({
: undefined
}
metricsExplorerTestId={`open-metrics-explorer-${idx}`}
onExploreClick={(): void =>
logInfraExplorerNavigatedEvent({
entityType: category,
destination: 'metrics_explorer',
source: 'chart_compass_icon',
tab: view,
sourceKey: entityWidgetInfo[idx].title,
drawerDurationMsAtNavigation: getDrawerDurationMs(),
})
}
/>
<div className={styles.entityMetricsCard} ref={graphRef}>
{renderCardContent(query, idx)}

View File

@@ -1,6 +1,5 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import * as appContextHooks from 'providers/App/App';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
@@ -301,16 +300,14 @@ const renderEntityMetrics = (overrides = {}): any => {
return render(
<MemoryRouter>
<TooltipProvider>
<EntityMetrics
entity={defaultProps.entity}
eventEntity="test"
entityWidgetInfo={defaultProps.entityWidgetInfo}
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
queryKey={defaultProps.queryKey}
category={defaultProps.category}
/>
</TooltipProvider>
<EntityMetrics
entity={defaultProps.entity}
eventEntity="test"
entityWidgetInfo={defaultProps.entityWidgetInfo}
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
queryKey={defaultProps.queryKey}
category={defaultProps.category}
/>
</MemoryRouter>,
);
};

View File

@@ -16,10 +16,7 @@ import {
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { ResizeTable } from 'components/ResizeTable';
import {
InfraMonitoringEvents,
logInfraDrawerFilterCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
@@ -109,13 +106,6 @@ function EntityTracesContent({
view: InfraMonitoringEvents.TracesView,
});
logInfraDrawerFilterCustomizedEvent(
category,
'traces',
newUserExpression || '',
'search',
);
refetch();
}
},

View File

@@ -2,7 +2,6 @@ import { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { TextNoData } from '../../components/TextNoData';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import {
BlockLink,
@@ -136,11 +135,12 @@ export const getTraceListColumns = (
if (!isValidCode) {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
{numericCode === 0 || !statusCode ? (
<TextNoData type="typography" className={styles.cellText} />
) : (
<Typography className={styles.cellText}>{statusCode}</Typography>
)}
<Typography
className={styles.cellText}
data-novalue={numericCode === 0 || !statusCode}
>
{numericCode === 0 || !statusCode ? '-' : statusCode}
</Typography>
</BlockLink>
);
}

View File

@@ -63,7 +63,6 @@ export function createPodMetricsTab<T>({
getEntityQueryPayload={getQueryPayload}
queryKey={queryKey}
category={category}
view={VIEW_TYPES.POD_METRICS}
/>
),
};

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