Compare commits

...

26 Commits

Author SHA1 Message Date
Ashwin Bhatkal
1ed852da93 Merge branch 'feat/dashboard-v2-variables' into feat/dashboard-v2-variable-selection 2026-06-11 21:06:54 +05:30
Ashwin Bhatkal
23931f25b6 Merge branch 'main' into feat/dashboard-v2-variables 2026-06-11 21:06:44 +05:30
Ashwin Bhatkal
e1ad451003 chore(dashboard-v2): apply formatter to variable selection slice 2026-06-11 19:07:46 +05:30
Ashwin Bhatkal
9155c635f8 refactor(dashboard-v2): use NewSelect for variable value pickers
Swap the value picker from @signozhq SelectSimple to the shared NewSelect
CustomSelect/CustomMultiSelect, which provide search, the ALL option and
apply-on-close batching (multi-select edits no longer cascade per toggle).
Deliberate exception to the @signozhq-first preference — reuses the existing,
richer variable-selection control.
2026-06-11 18:57:54 +05:30
Ashwin Bhatkal
870a4b3dc3 feat(dashboard-v2): scope dynamic variable options by sibling selections
Dynamic variables now pass an existingQuery built from the other dynamic
variables' current selections (e.g. `namespace IN ['prod']`) to the field-values
API, so related dynamic variables cascade-filter each other. Ported from the V1
runtime.
2026-06-11 18:57:54 +05:30
Naman Verma
7eb0095133 fix: proper definition of user dashboard preferences (#11643)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* fix: proper definition of user dashboard preferences

* fix: use org id in deletion methods of pref table

* fix: make migration name fit regex

* fix: make compile return empty sql instead of nil

* fix: remove dashboard dependency from user module

* test: remove cleanup fixture from integration test
2026-06-11 12:50:32 +00:00
Naman Verma
df26eb1c1d chore: make some fields required in perses replicated spec (#11612)
* chore: make some fields required in perses replicated spec

* chore: build frondend spec

* revert: revert accidental change

* fix: make duration optional

* chore: add todo for duration and refresh interval
2026-06-11 12:17:25 +00:00
Ashwin Bhatkal
e9e7646403 Merge branch 'main' into feat/dashboard-v2-variables 2026-06-11 16:30:10 +05:30
Vikrant Gupta
36334309bb feat(resource): add resource middleware (#11607)
* feat(resource): initial commit

* feat(resource): add related resources

* feat(resource): audit cleanup

* refactor(resource): set audit category on resource defs; drop MustNew/validate

- Set Category (access_control) on every service account and role ResourceDef
  so audit events carry signoz.audit.action_category
- Remove MustNewResourceDef/MustNewResourcesDef and validate(); registration
  via plain ResourceDef literals again. Validation to be revisited separately

* feat(audit): emit audit events only for mutating verbs

- Add coretypes.Verb.IsMutation() (create/update/delete/attach/detach)
- Audit skips read/list defs (they remain for authz); failed and denied
  mutations still emit with Outcome=failure

* feat(audit): mirror attach/detach audit on both ends for role↔service account

Role def on SetRole/DeleteRole now carries Related=ServiceAccount so each
permission-checked end emits its own event (serviceaccount.attached to role
and role.attached to serviceaccount), matching the both-ends authz model.

* refactor(resource): co-locate resolved context in handler; slice-of-pointers accessor

Move the resolved-resource context plumbing (resolvedKey, accessors) out of
the resource middleware and into pkg/http/handler next to ResolvedResource, so
type and accessor live in one package (matching the authtypes/ctxtypes
convention) and consumers import a single package.

- Store []*ResolvedResource instead of *[]ResolvedResource; in-place response-id
  finalization still works via the element pointers.
- ResolvedResourcesFromContext returns an error (errCodeResolvedResourcesNotFound)
  instead of a bool; authz surfaces it, audit treats absence as a no-op.
- Drop the now-dead authz Check/CheckAll/AuthZCheckGroup helpers superseded by
  CheckResources.

* refactor(resource): unify id resolution into a single phase-driven mechanism

Replace the two-shaped id mechanism (a resolved string plus a stashed
responseID extractor, decided by resolveID's magic tuple and a zero-value
sentinel) with one retained extractor whose phase decides when it runs.

- ResolvedResource/ResolvedRelated keep idExtractor (renamed from responseID);
  it is run in its declared phase, never re-run.
- ResourceIDExtractor gains isPhase + runFor; ResolvedResource gains resolve,
  called once per phase (request by the resource middleware, response by audit).
- resolveID and resolveRelated(ec) are gone; FinalizeResponseIDs collapses to a
  single resolve(phaseResponse) call. Request and response resolution are now
  symmetric.

* refactor(resource): split resourcedef.go along its logical seams

Break the ~320-line resourcedef.go into cohesive files within the handler
package (pure relocation, no behavior or API change):

- extractor.go        — extraction: ExtractorContext, phases, extractors + constructors
- resourcedef.go      — declaration: ResourceDef/ResourcesDef/RelatedResource/
                         ResourceSpec + their functions (resolveRequest, ResolveRequest)
                         and the selectors
- resolved_resource.go — resolved types + their functions (resolve,
                         newResolvedRelated, FinalizeResponseIDs, HasResponseIDs)
- resolved_context.go — context plumbing (resolvedKey + accessors)

Each file's imports narrow to its concern; mux/gjson are now confined to
extractor.go.

* refactor(resource): extract selectors into selector.go

Move SelectorFunc + WildcardSelector/IDSelector (and the errCode they use)
out of resourcedef.go into selector.go. Pure relocation, no behavior change:
resourcedef.go now holds only the route-author declaration types and narrows
its imports to audittypes + coretypes.

* refactor(resource): extract ResourceSpec into resource_spec.go

Move the sealed ResourceSpec interface out of resourcedef.go into its own
file. Pure relocation, no behavior change.

* refactor(resource): split ResourcesDef into resourcesdef.go

Move the fan-out ResourcesDef (struct + sealResourceSpec/resolveRequest) out
of resourcedef.go into its own file. resourcedef.go keeps ResourceDef, the
shared RelatedResource, and the ResolveRequest orchestrator. Pure relocation,
no behavior change.

* refactor(resource): move RelatedResource and ResolveRequest into resource_spec.go

Cluster the spec contract together: the shared RelatedResource type and the
ResolveRequest orchestrator (over []ResourceSpec) join the ResourceSpec
interface. resourcedef.go now holds only ResourceDef. Pure relocation, no
behavior change.

* refactor(resource): seal ResourceSpec via resolveRequest alone

Drop the redundant sealResourceSpec() marker method; the unexported
resolveRequest already prevents implementations outside the package.

* feat(resource): scaffold coretypes-based resolved model

Introduce the referenceable, coretypes-resident resource model (additive;
the existing ResourceDef path is untouched and the build stays green):

- coretypes: ExtractorContext + ExtractPhase + ResourceIDExtractor/
  ResourceIDsExtractor (extractor machinery moved out of handler; handler keeps
  only the mux/gjson constructors).
- coretypes: SelectorFunc (now (ctx, resource, id, orgID) to stay cycle-free) +
  WildcardSelector/IDSelector.
- coretypes: ResolvedResource + ResolvedResourceWithTargetResource interfaces,
  their concrete types with two-phase fill (request ids at construction,
  response ids via ResolveResponse), and the resolved-context accessors.
- handler: the three explicit declaration types — BasicResourceDef,
  AttachDetachSiblingResourceDef, AttachDetachParentChildResourceDef.

Wiring (defs -> ResolveRequest, middleware, route migration) follows next.

* refactor(resource): wire the coretypes resolved model end-to-end

Cut the resource middleware over to the coretypes-resident resolved model and
the explicit declaration types, replacing the generic ResourceDef/ResourcesDef.

- handler: ResourceDef is now a sealed interface (unexported resolveRequest)
  implemented by BasicResourceDef / AttachDetachSiblingResourceDef /
  AttachDetachParentChildResourceDef, all consolidated into resourcedef.go.
  Removed the old generic defs, the handler-side resolved/selector/context
  (moved to coretypes), and the dead AuditDef.
- coretypes: ActionCategory moved here; Category() exposed on the resolved
  interface (declared on the def, read by audit; no kind-based derivation).
- middleware: authz does M+N absolute checks (source always, sibling target
  too, parent-child child never) via the resolved selectors; audit type-switches
  on the resolved interface to emit per resource / per relationship.
- authz forbidden message is now AWS-style: principal is not authorized to
  perform <kind>:<verb> on resource "<id>".
- routes: service account + role routes migrated to the explicit defs;
  roleSelector takes orgID.

Note: resourcedef_test.go (old API) removed; new tests to follow.

* feat(resource): instrument query-range with telemetry resource authz

Authorize /api/v5/query_range at the telemetry-resource level, derived from the
request body rather than a path/body id:

- coretypes: ResourceExtractor now yields []ResourceWithID (resource + id), and
  TelemetrySignalSource maps each query's spec.signal+spec.source to a telemetry
  resource (via TelemetryResourceForSignalSource) and reads a per-query id — one
  entry per query, no de-duplication, so repeated signals each get their own
  resource + id.
- handler: TelemetryResourceDef fans out one resolved resource per query through
  NewResolvedResourceWithID; resolveRequest returns a slice to allow fan-out.
- The extractor model (types + constructors + ResourceExtractor) now lives wholly
  in coretypes (handler/extractor.go removed); coretypes gains mux/gjson.
- querier route: ViewAccess -> CheckResources + the telemetry def (spec.name is a
  placeholder id; the owner picks the real field).

Carries the in-progress removal of Verb.IsMutation and its audit mutation-gate,
so audit currently emits per resolved resource regardless of verb (to revisit).

* feat(resource): instrument planned-maintenance routes + tidy resolved id handling

- ruler.go: downtime_schedules routes move from ViewAccess/EditAccess to
  CheckResources with resource defs — Basic for list/read/create/update/delete on
  PlannedMaintenance, plus a sibling Attach (schedule <-> the rules in alertIds)
  on create/update so both the schedule and each rule are authz-checked.
- coretypes: SourceIDs/TargetIDs return a single empty id when there are none, so
  collection-level access lives in the resolved value; authz.checkResource drops
  its empty-id shim and just iterates.
- readability: expand crammed multi-arg signatures and calls (checkResource,
  NewResolvedResource/WithID, forbidden errors.Newf, telemetry mapping) to one
  argument per line.

* refactor(resource): drop query-range/planned-maintenance instrumentation; mirror sibling audit

- Revert /api/v5/query_range and downtime_schedules routes to ViewAccess/EditAccess
  and remove the telemetry-resource scaffolding that only query-range consumed
  (TelemetryResourceDef, TelemetrySignalSource, TelemetryResourceForSignalSource,
  ResourceExtractor/ResourceWithID, NewResolvedResourceWithID).
- audit: a sibling attach/detach now emits the event from both ends, matching the
  both-ends authz model (parent-child stays one-directional).
- Strip non-essential doc/inline comments across the resource middleware files.

* refactor(coretypes): fold extractor/selector _func files into their concept files

- Merge extractor_func.go + extractor_context.go into extractor.go, and
  selector_func.go into selector.go, matching the type.go/object.go/verb.go
  convention of keeping a type with its constructors and helpers.
- Order each file const/var -> type -> func (also reorders action_category.go).

* feat(resource): capture response body only when an id is resolved from it

- Restore the capture gate lost in the coretypes move: ResolvedResource gains an
  unexported hasResponsePhase(), and ShouldCaptureResponseBody(ctx) drives the
  audit middleware so the body is buffered only when some resolved resource reads
  an id out of it (e.g. a create), not for every resource-declared route.
- Add ResourceIDsExtractor.IsPhase (mirroring ResourceIDExtractor) and reuse it.
- Fold resolved_context.go into resolved.go.

---------

Co-authored-by: grandwizard28 <vibhupandey28@gmail.com>
2026-06-11 10:57:56 +00:00
Ashwin Bhatkal
cfcd58b341 feat(dashboards): serve V2 dashboard pages behind use_dashboard_v2 flag (#11642)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(dashboard-v2): align list & patch code with the generated client

Match the now-upstream V2 client: PatchOpDTO (was JSONPatchOperationDTOOp),
ListedDashboardV2DTO as the list item, and ListSortDTO/ListOrderDTO casts on the
list params. No behaviour change — these files are still parked here.

* feat(dashboard-v2): serve V2 dashboard pages behind use_dashboard_v2 flag

Gate the dashboards list & detail entry points on useIsDashboardV2() — V1 falls
through when the flag is off (V1 detail logic moved into DashboardPage.tsx). Un-park
the V2 page directories in tsconfig so they typecheck and ship.

Also convert the // header comments in states.module.scss to /* */ — once the V2
list is wired into the import graph, vite compiles that stylesheet and the
backtick in the // comment crashed the CSS-modules parser ('Unclosed string').

* refactor(dashboard-v2): type list sort/order with the generated enums

Drop the local string-literal SortColumn/SortOrder unions and the `as` casts:
the nuqs query-state hooks now return DashboardtypesListSortDTO / ListOrderDTO
directly, and ListHeader/DashboardsList use the enum members.
2026-06-11 07:39:56 +00:00
Ashwin Bhatkal
736a2a751e feat(dashboard-v2): query & dynamic selectors with dependency orchestration
Query variables fetch options via /variables/query (passing the other variables'
values); Dynamic variables fetch live telemetry field values. Dependencies are
orchestrated declaratively: a Query selector is enabled only once its parents
resolve and its query key carries the parent values, so it refetches when a
parent changes and a cyclic dependency is simply never enabled. Options
auto-select the default/first value so dependent children always have input.
2026-06-11 11:52:00 +05:30
Ashwin Bhatkal
d01e8aff22 feat(dashboard-v2): variable selector bar with static selectors
Render a runtime variable bar above the panels (one control per spec variable),
seeding each value from URL -> localStorage(store) -> default and persisting
changes to the store + URL (?variables=). Custom (static options) and Text
selectors are wired; Query/Dynamic render the picker shell (option-fetching and
dependency orchestration follow).
2026-06-11 10:54:27 +05:30
Ashwin Bhatkal
6ae1033752 feat(dashboard-v2): variable-selection foundation — dependency graph & store
Pure runtime-selection groundwork (no UI yet): inter-variable dependency graph
(detect query references, topo order, cycle detection, transitive descendants)
ported to the V2 model; selection value types; and a persisted zustand
variableValues slice (frontend-only — selecting a value never patches the spec).
2026-06-11 10:31:31 +05:30
Ashwin Bhatkal
0587fe41e4 feat(dashboard-v2): variables settings tab — list, CRUD & persistence
Wire the Variables tab into the settings drawer (replacing the placeholder):
list variables, add/edit in place, delete via inline confirm, reorder, each
persisted with a single /spec/variables patch -> toast -> refetch.
2026-06-11 09:54:25 +05:30
Ashwin Bhatkal
75f5a8b82c feat(dashboard-v2): variable editor form for all variable types
In-drawer master-detail editor reproducing the V1 VariableItem layout with
@signozhq components: segmented type selector, per-type bodies (Custom comma
values, Text default + constant, Query editor + test-run preview, Dynamic
signal + field autocomplete) and the shared preview / sort / multi-select /
ALL / default-value rows.
2026-06-11 09:54:25 +05:30
Ashwin Bhatkal
2f541df60e feat(dashboard-v2): variable model, adapters & patch builder
Flat VariableFormModel + adapters between the nested envelope/plugin DTO union
(ListVariable{Query,Custom,Dynamic} / TextVariable) and the model, plus a
JSON-patch builder that replaces /spec/variables atomically. Pure, no UI.
2026-06-11 09:54:25 +05:30
pandareen
45fedefbab fix(frontend): always show SigNoz version in sidebar header (#11596)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(ui): missing version next to sidebar logo

* fix(ui): missing version next to sidebar logo
2026-06-10 20:34:54 +00:00
Jay Dorsey
01ae688b58 fix(frontend): don't crash app when Web Speech API access throws (#11618)
Fix issue with Web Speech API.
2026-06-10 20:33:16 +00:00
SagarRajput-7
f4e1465c13 feat(auth): add back to login CTA on reset password token error screen (#11634) 2026-06-10 18:11:41 +00:00
Naman Verma
b22eef6a65 feat: v2 list, delete, pin, unpin dashboards api (#11219)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: compile error fix

* fix: remove soft delete references

* fix: use new pattern of checking for admin permission

* fix: remove soft delete reference

* test: key value tags in test

* fix: build error in patch module method

* feat: method to build postable tags from tags

* fix: build error in Apply method

* chore: remove newline

* fix: remove soft delete references

* fix: build error fix

* chore: embed StorableDashboard in listedRow

* fix: visitor should follow new tag struct

* fix: diff error codes for invalid keys and values

* fix: correct pk in bun model for tag relations

* fix: created and updated by schema

* fix: use coretypes.Kind instead of defining entity type

* fix: singular table name

* chore: remove org ID from tag relation

* feat: foreign key on tag id

* feat: add SyncTags method that covers creation and linking

* fix: remove entity type definition

* fix: fix build errors in dashboard module

* chore: bump migration number

* chore: change entity id to resource id

* fix: add org id filter in all list and delete queries

* fix: remove user auditable

* fix: add ID in tag relation

* fix: fix build error

* fix: fix build error

* chore: bump migration number

* fix: add len check on tags keys and values

* fix: add regex for tags

* chore: remove methods that shouldn't be exposed

* fix: use sync tags in create api

* feat: functional unique index in sql schema

* fix: only ascii in regex

* fix: use sync tags method in update

* fix: use sync tags method in update

* fix: correct the method name being called

* chore: rename create method to createOrGet

* chore: use tagtypestest package for mock store

* chore: combine functional unique index with unique index

* chore: move tag resolution to module

* test: add unit tests for new idx type

* chore: comment out tags unique index for now

* chore: add a todo comment

* chore: comment out unique index test

* feat: add created at to tag relations

* chore: comment out unique index test

* chore: bump migration number

* chore: remove uploaded grafana flag from metadata

* Merge branch 'main' into nv/v2-dashboard-create

* chore: revert idx generation to resolve conflicts

* fix: use store.RunInTx instead of taking in sqlstore

* fix: use binding package to get request

* chore: move NewDashboardV2 to NewDashboardV2WithoutTags

* chore: rename module to m

* fix: add ctx needed in sqlstore

* fix: remove sqlstore passage in ee pkg

* chore: change dashboardData to dashboardSpec

* feat: follow the metadata+spec key structure

* feat: follow the metadata+spec key structure in open api spec

* feat: v2 dashboard GET API (#11136)

* feat: v2 dashboard GET API

* Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get

* chore: update api specs

* fix: remove soft delete references

* chore: embed StorableDashboard into joinedRow in store method

* fix: fix build error

* chore: revert all frontend changes

* fix: remove public dashboard from get v2 call

* chore: revert all frontend changes

* fix: fix build errors post merge conflict resolution

* feat: lock, unlock, create public, update public v2 dashboard APIs (#11167)

* feat: lock, unlock, create public, update public v2 dashboard APIs

* chore: update api specs

* fix: use new pattern of checking for admin permission

* fix: remove soft delete reference

* chore: revert all frontend changes

* fix: fix build errors and remove v2 create/update public apis

* chore: use v1 methods wherever possible

* fix: use update v2 store method

* chore: update frontend schema

* chore: update frontend schema

* chore: generate api specs

* chore: generate api specs

* feat: patch dashboard api (#11182)

* feat: lock, unlock, create public, update public v2 dashboard APIs

* feat: delete dashboard v2 API and hard delete cron job

* feat: patch dashboard api

* chore: update api specs

* chore: update api specs

* chore: update api specs

* chore: remove delete related work

* fix: add examples of structs for value param in param description

* test: unit test fixes

* fix: use new pattern of checking for admin permission

* fix: remove soft delete reference

* test: key value tags in test

* fix: build error in patch module method

* fix: build error in Apply method

* fix: use sync tags method in update

* fix: fix build errors

* fix: fix all patch application tests

* chore: add more mapper methods

* fix: fix build errors

* chore: generate api specs

* fix: update migration numbering

* fix: add missing request struct in list api

* fix: remove hasMore from list response

* chore: bump migration number

* fix: send total count in response + bug fixes

* fix: add source for v2 dashboards

* chore: incorporate source

* chore: incorporate source in api spec

* chore: incorporate source

* fix: remove system dashboards from list v2 response

* fix: add some required fields

* feat: add immutable name in dashboard v2

* feat: add immutable name in dashboard v2

* feat: add immutable name in dashboard v2 api specs

* fix: remove unused param in constructor

* fix: improve api descriptions

* fix: remove unneeded comment

* chore: increase MaxTagsPerDashboard to 10

* fix: set display name in unmarshal json

* chore: remove integration test for now (will add along with list api)

* feat: add validation on dashboard name

* test: fix build errors and tests based on name related changes

* chore: bump migration number

* chore: generate api specs

* fix: fix tests based on name related changes

* fix: dont include full data in list response

* fix: add quotes around tag relation kind

* chore: bump migration number

* fix: correct convertor method name

* test: add unit tests for type conversions

* chore: remove enum def of threshold comparison operator

* feat: add flag to generate unique name in backend

* chore: generate api specs

* chore: make tags required in postable

* fix: build error fix

* chore: bump migration number

* fix: fix build error in test after merge conflict

* fix: remove unused store method

* fix: remove unused module methods

* fix: use v1 store update method

* fix: change data to spec in api param description

* chore: add back accidentally removed tests

* chore: update api spec

* chore: bump migration number

* feat: delete dashboard v2 API (#11299)

* feat: delete dashboard v2 API

* fix: fix post merge build and spec errors

* fix: address review comments

* chore: generate frontend api spec

* fix: add missing name fetch in listv2 store method

* fix: change title to name in api description

* fix: add all error codes for new apis

* test: change data to spec in unit tests

* fix: remove join to public dashboard table in list call

* fix: use valuer string for list order and sort

* test: integration test and fixes found through it

* chore: use same jsonpatch package as done in zeus

* chore: remove JSONPatchDocument and use patchable everywhere

* fix: make remove idempotent in patch

* chore: separate file for patch types

* chore: better error passage

* fix: remove extra decodePatch calls

* fix: use must new org id

* fix: proper error passage

* chore: rename updateable to updatable

* fix: use must new org id

* feat: include list of all dashboard tags in list api response

* fix: remove wrong api description msg

* fix: use must method for user id as well

* chore: add nolint comment

* fix: add missing image field in list response

* chore: regenerate api specs

* fix: make GettableTag a defined type instead of an alias

* fix: dont allow system dashboards to be deleted

* fix: remove public filter from visitor

* chore: use go sqlbuilder

* fix: use ESCAPE literal in contains and like operators

* fix: use correct perses package in list v2 file

* feat: change pinned dashboard table to user dashboard preference table

* fix: delete preferences on dashboard delete

* test: add integration test for pinning

* fix: wrap naked errors

* fix: integration dashboards should not be deletable either

* fix: remove org column in preferences and add foreign key to users table

* chore: add fk from prefs to dashbaord table

* chore: remove outer parenthesis removal function

* test: add unit test to ensure that all reserved keys have handlers

* fix: proper url for pin apis

* fix: delete preferences on user deletion

* test: address integration test comments

* test: change limit

* fix: revert the check in can delete

* fix: remove unit test from ee package

* fix: move list filter to impl to avoid db impl logic in types

* chore: code movement

* feat: add a pin free list dashboards api

* fix: update api specs

* fix: use request query in api defs for list apis

* chore: explicitly mark request as nil in list apis

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-10 17:34:46 +00:00
Naman Verma
4d3d1ef423 fix: add check for percentile aggregation for non-histogram metrics (#11387)
* fix: add check for percentile aggregation for non-histogram metrics

* test: correct errors pkg in test file

* fix: catch type related errors in querier

* fix: remove comparison related tests

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-10 17:17:26 +00:00
Naman Verma
c775d7e398 chore: add discriminator on kind in perses spec (#11635)
* chore: add discriminator on kind in perses spec

* chore: add discriminator to builder query spec

* chore: update in query builder directly

* docs: add info about discriminator in handler.md

* fix: move back to restrictKindToOneValue

* fix: move back to restrictKindToOneValue
2026-06-10 16:02:44 +00:00
Nikhil Mantri
27603e09d0 feat(infra-monitoring): v2 statefulsets integration tests (#11440)
* chore: updated logic and use centralized function in the module

* chore: filter metric groups

* chore: filter metric groups

* chore: formula correction

* chore: added step flooring note

* chore: comment correction

* chore: comment correction

* chore: removed function

* chore: renamed variables

* chore: added happy test

* chore: added test 2 for accuracy and test 3 for missing metrics check

* chore: added filter test 4

* chore: added 5th test for filterByStatus

* chore: added group by tests

* chore: pagination test added

* chore: added validation tests

* chore: added auth test

* chore: added all tests

* chore: fix for surfacing meta for pods custom group by

* chore: added nodes integration test suite

* chore: namespaces integration tests

* ci: register inframonitoring suite + ruff format 01_hosts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: added integration tests for clusters

* chore: formatting changed

* chore: formatting changed

* chore: formatting changed

* chore: added volumes integration tests

* chore: added deployments

* chore: added tests

* chore: added order by host.name test

* refactor(infra-monitoring): address review comments on hosts integration tests

- inline _post helper at call sites
- combine filter operator tests into parametrized test_hosts_filter
- combine bad attr/grammar tests into parametrized test_hosts_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_hosts_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align pods integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_pods_filter
- combine bad attr/grammar tests into parametrized test_pods_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_pods_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align nodes integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_nodes_filter
- combine bad attr/grammar tests into parametrized test_nodes_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_nodes_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align namespaces integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_namespaces_filter
- combine bad attr/grammar tests into parametrized test_namespaces_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_namespaces_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align clusters integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_clusters_filter
- combine bad attr/grammar tests into parametrized test_clusters_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_clusters_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align volumes integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_volumes_filter
- combine bad attr/grammar tests into parametrized test_volumes_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_volumes_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align deployments integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_deployments_filter
- combine bad attr/grammar tests into parametrized test_deployments_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_deployments_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align statefulsets integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_statefulsets_filter
- combine bad attr/grammar tests into parametrized test_statefulsets_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_statefulsets_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts groupby tests into parametrized test

- merge test_hosts_groupby_hostname + test_hosts_groupby_os_type into
  test_hosts_groupby parametrized on (dataset, group key, expected counts/values)
- preserves all assertions incl hostName populated-vs-empty branch coverage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts orderby tests into parametrized test

- merge total_invariant_across_orderby + orderby_correctness + orderby_by_host_name
  into test_hosts_orderby parametrized on (column, record_field) x direction
- each case asserts both the total/len invariant and sortedness; sortedness now
  covered for all metric columns, not just cpu
- single dataset (hosts_orderby.jsonl) + CONTAINS 'order-' guard on all cases

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts pagination tests

- fold offset-beyond-total case into the test_hosts_pagination page walk
  (offset K+5 expects 0 records via max(0, min(limit, K-offset)); total
  invariant covers the beyond-total page's total == K)
- single seed instead of two

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): merge hosts happy_path into test_hosts_accuracy

- happy_path and value_accuracy datasets were structurally identical
  (same 4 metrics, same sample counts, 2 hosts); one test now asserts
  shape/contract + exact metric values in a single seed/request
- drop unused hosts_happy_path.jsonl

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine pods integration tests logically

- merge happy_path into test_pods_accuracy (datasets structurally identical);
  drop unused pods_happy_path.jsonl
- merge groupby namespace/deployment into parametrized test_pods_groupby
- merge orderby invariant + correctness + by-pod-name into test_pods_orderby
  ((column, record_field) x direction; sortedness now covered for all columns)
- fold offset-beyond-total into test_pods_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine nodes integration tests logically

- merge happy_path into test_nodes_accuracy; drop unused nodes_happy_path.jsonl
- merge orderby invariant + correctness + by-node-name into test_nodes_orderby
  ((column, record_field) x direction; sortedness now covered for all columns)
- fold offset-beyond-total into test_nodes_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine namespaces integration tests logically

- merge happy_path into test_namespaces_accuracy; drop unused namespaces_happy_path.jsonl
- merge orderby invariant + correctness + by-namespace-name into test_namespaces_orderby
- fold offset-beyond-total into test_namespaces_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine clusters integration tests logically

- merge happy_path into test_clusters_accuracy; drop unused clusters_happy_path.jsonl
- merge orderby invariant + correctness + by-cluster-name into test_clusters_orderby
- fold offset-beyond-total into test_clusters_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine volumes integration tests logically

- merge happy_path into test_volumes_accuracy; drop unused volumes_happy_path.jsonl
- merge orderby invariant + correctness + by-pvc-name into test_volumes_orderby
- fold offset-beyond-total into test_volumes_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine deployments integration tests logically

- merge happy_path into test_deployments_accuracy; drop unused deployments_happy_path.jsonl
- merge orderby invariant + correctness + by-deployment-name into test_deployments_orderby
- fold offset-beyond-total into test_deployments_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine statefulsets integration tests logically

- merge happy_path into test_statefulsets_accuracy; drop unused statefulsets_happy_path.jsonl
- merge orderby invariant + correctness + by-statefulset-name into test_statefulsets_orderby
- fold offset-beyond-total into test_statefulsets_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in hosts filter tests

- regenerate hosts_filter_dataset.jsonl so every host mirrors the acc-h1
  sample pattern from hosts_value_accuracy.jsonl (CI-proven expected values)
- test_hosts_filter now asserts cpu/memory/wait/load15/diskUsage per filtered
  record against FILTER_DATASET_EXPECTED (1e-9), proving filters don't
  distort aggregation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): scope filter expected values inside test_hosts_filter

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in pods filter tests

- regenerate pods_filter_dataset.jsonl so every pod mirrors the acc-p1
  sample pattern from pods_value_accuracy.jsonl (CI-proven expected values)
- test_pods_filter now asserts the 6 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in nodes filter tests

- regenerate nodes_filter_dataset.jsonl so every node mirrors the acc-n1
  sample pattern from nodes_value_accuracy.jsonl (CI-proven expected values)
- test_nodes_filter now asserts the 4 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in namespaces filter tests

- regenerate namespaces_filter_dataset.jsonl so every namespace mirrors the
  acc-ns-1 sample pattern (2 pods) from namespaces_value_accuracy.jsonl
- test_namespaces_filter now asserts namespaceCPU/namespaceMemory per record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in clusters filter tests

- regenerate clusters_filter_dataset.jsonl so every cluster mirrors the
  acc-cluster-1 sample pattern (2 nodes) from clusters_value_accuracy.jsonl
- test_clusters_filter now asserts the 4 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in volumes filter tests

- regenerate volumes_filter_dataset.jsonl so every PVC mirrors the acc-pvc-1
  sample pattern from volumes_value_accuracy.jsonl (CI-proven expected values)
- test_volumes_filter now asserts all 6 volume fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in deployments filter tests

- regenerate deployments_filter_dataset.jsonl so every deployment mirrors the
  acc-dep-1 sample pattern (2 pods) from deployments_value_accuracy.jsonl
- test_deployments_filter now asserts the 6 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in statefulsets filter tests

- regenerate statefulsets_filter_dataset.jsonl so every statefulset mirrors
  the acc-ss-1 sample pattern (2 pods) from statefulsets_value_accuracy.jsonl
- test_statefulsets_filter now asserts the 6 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align nodes groupby + pod-phase tests with structure

- merge pod_phase_counts_list_mode + _no_pods_on_node into parametrized
  test_nodes_pod_phase_counts ((dataset, node, filter, expected) cases)
- rename groupby_cluster -> test_nodes_groupby, parametrize over
  k8s.node.name + k8s.cluster.name; adds the node-name-in-groupBy branch
  (nodeName populated, condition derived) that hosts/pods both cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align namespaces groupby with structure

- rename groupby_cluster -> test_namespaces_groupby, parametrize over
  k8s.namespace.name + k8s.cluster.name; adds the namespace-name-in-groupBy
  branch (namespaceName populated) that hosts/pods/nodes all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align clusters groupby with structure

- rename groupby_cloud_provider -> test_clusters_groupby, parametrize over
  k8s.cluster.name + cloud.provider; adds the cluster-name-in-groupBy branch
  (clusterName populated) that hosts/pods/nodes/namespaces all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align volumes groupby with structure

- rename groupby_namespace -> test_volumes_groupby, parametrize over
  k8s.persistentvolumeclaim.name + k8s.namespace.name; adds the
  pvc-name-in-groupBy branch (persistentVolumeClaimName populated) that the
  other endpoints all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align deployments groupby with structure

- rename groupby_namespace -> test_deployments_groupby, parametrize over
  k8s.deployment.name + k8s.namespace.name; adds the deployment-name-in-groupBy
  branch (deploymentName populated) that the other endpoints all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align statefulsets groupby with structure

- rename groupby_namespace -> test_statefulsets_groupby, parametrize over
  k8s.statefulset.name + k8s.namespace.name; adds the statefulset-name-in-groupBy
  branch (statefulSetName populated) that the other endpoints all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 10:58:22 +00:00
Gaurav Tewari
7b2882abde feat: show recent queries to user (#11523)
* feat: initial commit

* refactor: recent query

* refactor: move all components into one

* refactor: queries

* refactor: self review comments

* refactor: css

* refactor: comments

* refactor: more comments

* feat: add time feature

* refactor : more changes

* chore: remove comments

* refactor: more code

* chore: remove extra commentes

* refactor: store in local storage

* fix: update types

* refactor: more changes

* refactor: import issue

* fix: lint

* chore: update styling

* fix: update query

* feat: use source as well in query key

* refactor: review changes

* refactor: self review changes

* refactor: more changes

* fix: more refactor

* refactor: make code much better

* fix: minor refactor

* refactor: review changes

* refactor: getRecentHook and make logic simple

* chore: drop useSaveRecentQuery from gridcard

* refactor: run save logic on handleRunQuery

* chore: remove space

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-10 10:58:19 +00:00
Nikhil Mantri
30f1c2d92d feat(infra-monitoring): v2 deployments integration tests (#11437)
* chore: updated logic and use centralized function in the module

* chore: filter metric groups

* chore: filter metric groups

* chore: formula correction

* chore: added step flooring note

* chore: comment correction

* chore: comment correction

* chore: removed function

* chore: renamed variables

* chore: added happy test

* chore: added test 2 for accuracy and test 3 for missing metrics check

* chore: added filter test 4

* chore: added 5th test for filterByStatus

* chore: added group by tests

* chore: pagination test added

* chore: added validation tests

* chore: added auth test

* chore: added all tests

* chore: fix for surfacing meta for pods custom group by

* chore: added nodes integration test suite

* chore: namespaces integration tests

* ci: register inframonitoring suite + ruff format 01_hosts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: added integration tests for clusters

* chore: formatting changed

* chore: formatting changed

* chore: formatting changed

* chore: added volumes integration tests

* chore: added deployments

* chore: added order by host.name test

* refactor(infra-monitoring): address review comments on hosts integration tests

- inline _post helper at call sites
- combine filter operator tests into parametrized test_hosts_filter
- combine bad attr/grammar tests into parametrized test_hosts_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_hosts_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align pods integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_pods_filter
- combine bad attr/grammar tests into parametrized test_pods_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_pods_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align nodes integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_nodes_filter
- combine bad attr/grammar tests into parametrized test_nodes_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_nodes_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align namespaces integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_namespaces_filter
- combine bad attr/grammar tests into parametrized test_namespaces_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_namespaces_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align clusters integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_clusters_filter
- combine bad attr/grammar tests into parametrized test_clusters_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_clusters_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align volumes integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_volumes_filter
- combine bad attr/grammar tests into parametrized test_volumes_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_volumes_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align deployments integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_deployments_filter
- combine bad attr/grammar tests into parametrized test_deployments_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_deployments_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts groupby tests into parametrized test

- merge test_hosts_groupby_hostname + test_hosts_groupby_os_type into
  test_hosts_groupby parametrized on (dataset, group key, expected counts/values)
- preserves all assertions incl hostName populated-vs-empty branch coverage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts orderby tests into parametrized test

- merge total_invariant_across_orderby + orderby_correctness + orderby_by_host_name
  into test_hosts_orderby parametrized on (column, record_field) x direction
- each case asserts both the total/len invariant and sortedness; sortedness now
  covered for all metric columns, not just cpu
- single dataset (hosts_orderby.jsonl) + CONTAINS 'order-' guard on all cases

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts pagination tests

- fold offset-beyond-total case into the test_hosts_pagination page walk
  (offset K+5 expects 0 records via max(0, min(limit, K-offset)); total
  invariant covers the beyond-total page's total == K)
- single seed instead of two

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): merge hosts happy_path into test_hosts_accuracy

- happy_path and value_accuracy datasets were structurally identical
  (same 4 metrics, same sample counts, 2 hosts); one test now asserts
  shape/contract + exact metric values in a single seed/request
- drop unused hosts_happy_path.jsonl

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine pods integration tests logically

- merge happy_path into test_pods_accuracy (datasets structurally identical);
  drop unused pods_happy_path.jsonl
- merge groupby namespace/deployment into parametrized test_pods_groupby
- merge orderby invariant + correctness + by-pod-name into test_pods_orderby
  ((column, record_field) x direction; sortedness now covered for all columns)
- fold offset-beyond-total into test_pods_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine nodes integration tests logically

- merge happy_path into test_nodes_accuracy; drop unused nodes_happy_path.jsonl
- merge orderby invariant + correctness + by-node-name into test_nodes_orderby
  ((column, record_field) x direction; sortedness now covered for all columns)
- fold offset-beyond-total into test_nodes_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine namespaces integration tests logically

- merge happy_path into test_namespaces_accuracy; drop unused namespaces_happy_path.jsonl
- merge orderby invariant + correctness + by-namespace-name into test_namespaces_orderby
- fold offset-beyond-total into test_namespaces_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine clusters integration tests logically

- merge happy_path into test_clusters_accuracy; drop unused clusters_happy_path.jsonl
- merge orderby invariant + correctness + by-cluster-name into test_clusters_orderby
- fold offset-beyond-total into test_clusters_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine volumes integration tests logically

- merge happy_path into test_volumes_accuracy; drop unused volumes_happy_path.jsonl
- merge orderby invariant + correctness + by-pvc-name into test_volumes_orderby
- fold offset-beyond-total into test_volumes_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine deployments integration tests logically

- merge happy_path into test_deployments_accuracy; drop unused deployments_happy_path.jsonl
- merge orderby invariant + correctness + by-deployment-name into test_deployments_orderby
- fold offset-beyond-total into test_deployments_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in hosts filter tests

- regenerate hosts_filter_dataset.jsonl so every host mirrors the acc-h1
  sample pattern from hosts_value_accuracy.jsonl (CI-proven expected values)
- test_hosts_filter now asserts cpu/memory/wait/load15/diskUsage per filtered
  record against FILTER_DATASET_EXPECTED (1e-9), proving filters don't
  distort aggregation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): scope filter expected values inside test_hosts_filter

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in pods filter tests

- regenerate pods_filter_dataset.jsonl so every pod mirrors the acc-p1
  sample pattern from pods_value_accuracy.jsonl (CI-proven expected values)
- test_pods_filter now asserts the 6 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in nodes filter tests

- regenerate nodes_filter_dataset.jsonl so every node mirrors the acc-n1
  sample pattern from nodes_value_accuracy.jsonl (CI-proven expected values)
- test_nodes_filter now asserts the 4 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in namespaces filter tests

- regenerate namespaces_filter_dataset.jsonl so every namespace mirrors the
  acc-ns-1 sample pattern (2 pods) from namespaces_value_accuracy.jsonl
- test_namespaces_filter now asserts namespaceCPU/namespaceMemory per record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in clusters filter tests

- regenerate clusters_filter_dataset.jsonl so every cluster mirrors the
  acc-cluster-1 sample pattern (2 nodes) from clusters_value_accuracy.jsonl
- test_clusters_filter now asserts the 4 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in volumes filter tests

- regenerate volumes_filter_dataset.jsonl so every PVC mirrors the acc-pvc-1
  sample pattern from volumes_value_accuracy.jsonl (CI-proven expected values)
- test_volumes_filter now asserts all 6 volume fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in deployments filter tests

- regenerate deployments_filter_dataset.jsonl so every deployment mirrors the
  acc-dep-1 sample pattern (2 pods) from deployments_value_accuracy.jsonl
- test_deployments_filter now asserts the 6 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align nodes groupby + pod-phase tests with structure

- merge pod_phase_counts_list_mode + _no_pods_on_node into parametrized
  test_nodes_pod_phase_counts ((dataset, node, filter, expected) cases)
- rename groupby_cluster -> test_nodes_groupby, parametrize over
  k8s.node.name + k8s.cluster.name; adds the node-name-in-groupBy branch
  (nodeName populated, condition derived) that hosts/pods both cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align namespaces groupby with structure

- rename groupby_cluster -> test_namespaces_groupby, parametrize over
  k8s.namespace.name + k8s.cluster.name; adds the namespace-name-in-groupBy
  branch (namespaceName populated) that hosts/pods/nodes all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align clusters groupby with structure

- rename groupby_cloud_provider -> test_clusters_groupby, parametrize over
  k8s.cluster.name + cloud.provider; adds the cluster-name-in-groupBy branch
  (clusterName populated) that hosts/pods/nodes/namespaces all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align volumes groupby with structure

- rename groupby_namespace -> test_volumes_groupby, parametrize over
  k8s.persistentvolumeclaim.name + k8s.namespace.name; adds the
  pvc-name-in-groupBy branch (persistentVolumeClaimName populated) that the
  other endpoints all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align deployments groupby with structure

- rename groupby_namespace -> test_deployments_groupby, parametrize over
  k8s.deployment.name + k8s.namespace.name; adds the deployment-name-in-groupBy
  branch (deploymentName populated) that the other endpoints all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 10:35:23 +00:00
Nikhil Mantri
446dd4589f feat(infra-monitoring): v2 volumes integration tests (#11431)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: updated logic and use centralized function in the module

* chore: filter metric groups

* chore: filter metric groups

* chore: formula correction

* chore: added step flooring note

* chore: comment correction

* chore: comment correction

* chore: removed function

* chore: renamed variables

* chore: added happy test

* chore: added test 2 for accuracy and test 3 for missing metrics check

* chore: added filter test 4

* chore: added 5th test for filterByStatus

* chore: added group by tests

* chore: pagination test added

* chore: added validation tests

* chore: added auth test

* chore: added all tests

* chore: fix for surfacing meta for pods custom group by

* chore: added nodes integration test suite

* chore: namespaces integration tests

* ci: register inframonitoring suite + ruff format 01_hosts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: added integration tests for clusters

* chore: formatting changed

* chore: formatting changed

* chore: formatting changed

* chore: added volumes integration tests

* chore: added order by host.name test

* refactor(infra-monitoring): address review comments on hosts integration tests

- inline _post helper at call sites
- combine filter operator tests into parametrized test_hosts_filter
- combine bad attr/grammar tests into parametrized test_hosts_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_hosts_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align pods integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_pods_filter
- combine bad attr/grammar tests into parametrized test_pods_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_pods_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align nodes integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_nodes_filter
- combine bad attr/grammar tests into parametrized test_nodes_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_nodes_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align namespaces integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_namespaces_filter
- combine bad attr/grammar tests into parametrized test_namespaces_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_namespaces_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align clusters integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_clusters_filter
- combine bad attr/grammar tests into parametrized test_clusters_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_clusters_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align volumes integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_volumes_filter
- combine bad attr/grammar tests into parametrized test_volumes_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_volumes_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts groupby tests into parametrized test

- merge test_hosts_groupby_hostname + test_hosts_groupby_os_type into
  test_hosts_groupby parametrized on (dataset, group key, expected counts/values)
- preserves all assertions incl hostName populated-vs-empty branch coverage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts orderby tests into parametrized test

- merge total_invariant_across_orderby + orderby_correctness + orderby_by_host_name
  into test_hosts_orderby parametrized on (column, record_field) x direction
- each case asserts both the total/len invariant and sortedness; sortedness now
  covered for all metric columns, not just cpu
- single dataset (hosts_orderby.jsonl) + CONTAINS 'order-' guard on all cases

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts pagination tests

- fold offset-beyond-total case into the test_hosts_pagination page walk
  (offset K+5 expects 0 records via max(0, min(limit, K-offset)); total
  invariant covers the beyond-total page's total == K)
- single seed instead of two

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): merge hosts happy_path into test_hosts_accuracy

- happy_path and value_accuracy datasets were structurally identical
  (same 4 metrics, same sample counts, 2 hosts); one test now asserts
  shape/contract + exact metric values in a single seed/request
- drop unused hosts_happy_path.jsonl

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine pods integration tests logically

- merge happy_path into test_pods_accuracy (datasets structurally identical);
  drop unused pods_happy_path.jsonl
- merge groupby namespace/deployment into parametrized test_pods_groupby
- merge orderby invariant + correctness + by-pod-name into test_pods_orderby
  ((column, record_field) x direction; sortedness now covered for all columns)
- fold offset-beyond-total into test_pods_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine nodes integration tests logically

- merge happy_path into test_nodes_accuracy; drop unused nodes_happy_path.jsonl
- merge orderby invariant + correctness + by-node-name into test_nodes_orderby
  ((column, record_field) x direction; sortedness now covered for all columns)
- fold offset-beyond-total into test_nodes_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine namespaces integration tests logically

- merge happy_path into test_namespaces_accuracy; drop unused namespaces_happy_path.jsonl
- merge orderby invariant + correctness + by-namespace-name into test_namespaces_orderby
- fold offset-beyond-total into test_namespaces_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine clusters integration tests logically

- merge happy_path into test_clusters_accuracy; drop unused clusters_happy_path.jsonl
- merge orderby invariant + correctness + by-cluster-name into test_clusters_orderby
- fold offset-beyond-total into test_clusters_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine volumes integration tests logically

- merge happy_path into test_volumes_accuracy; drop unused volumes_happy_path.jsonl
- merge orderby invariant + correctness + by-pvc-name into test_volumes_orderby
- fold offset-beyond-total into test_volumes_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in hosts filter tests

- regenerate hosts_filter_dataset.jsonl so every host mirrors the acc-h1
  sample pattern from hosts_value_accuracy.jsonl (CI-proven expected values)
- test_hosts_filter now asserts cpu/memory/wait/load15/diskUsage per filtered
  record against FILTER_DATASET_EXPECTED (1e-9), proving filters don't
  distort aggregation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): scope filter expected values inside test_hosts_filter

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in pods filter tests

- regenerate pods_filter_dataset.jsonl so every pod mirrors the acc-p1
  sample pattern from pods_value_accuracy.jsonl (CI-proven expected values)
- test_pods_filter now asserts the 6 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in nodes filter tests

- regenerate nodes_filter_dataset.jsonl so every node mirrors the acc-n1
  sample pattern from nodes_value_accuracy.jsonl (CI-proven expected values)
- test_nodes_filter now asserts the 4 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in namespaces filter tests

- regenerate namespaces_filter_dataset.jsonl so every namespace mirrors the
  acc-ns-1 sample pattern (2 pods) from namespaces_value_accuracy.jsonl
- test_namespaces_filter now asserts namespaceCPU/namespaceMemory per record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in clusters filter tests

- regenerate clusters_filter_dataset.jsonl so every cluster mirrors the
  acc-cluster-1 sample pattern (2 nodes) from clusters_value_accuracy.jsonl
- test_clusters_filter now asserts the 4 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in volumes filter tests

- regenerate volumes_filter_dataset.jsonl so every PVC mirrors the acc-pvc-1
  sample pattern from volumes_value_accuracy.jsonl (CI-proven expected values)
- test_volumes_filter now asserts all 6 volume fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align nodes groupby + pod-phase tests with structure

- merge pod_phase_counts_list_mode + _no_pods_on_node into parametrized
  test_nodes_pod_phase_counts ((dataset, node, filter, expected) cases)
- rename groupby_cluster -> test_nodes_groupby, parametrize over
  k8s.node.name + k8s.cluster.name; adds the node-name-in-groupBy branch
  (nodeName populated, condition derived) that hosts/pods both cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align namespaces groupby with structure

- rename groupby_cluster -> test_namespaces_groupby, parametrize over
  k8s.namespace.name + k8s.cluster.name; adds the namespace-name-in-groupBy
  branch (namespaceName populated) that hosts/pods/nodes all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align clusters groupby with structure

- rename groupby_cloud_provider -> test_clusters_groupby, parametrize over
  k8s.cluster.name + cloud.provider; adds the cluster-name-in-groupBy branch
  (clusterName populated) that hosts/pods/nodes/namespaces all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align volumes groupby with structure

- rename groupby_namespace -> test_volumes_groupby, parametrize over
  k8s.persistentvolumeclaim.name + k8s.namespace.name; adds the
  pvc-name-in-groupBy branch (persistentVolumeClaimName populated) that the
  other endpoints all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 09:30:31 +00:00
158 changed files with 14969 additions and 933 deletions

View File

@@ -2436,13 +2436,6 @@ components:
url:
type: string
type: object
DashboardPanelDisplay:
properties:
description:
type: string
name:
type: string
type: object
DashboardTextVariableSpec:
properties:
constant:
@@ -2496,10 +2489,17 @@ components:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesBuilderQuerySpec:
discriminator:
mapping:
logs: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregation'
metrics: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregation'
traces: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
propertyName: signal
oneOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregation'
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregation'
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
type: object
DashboardtypesComparisonOperator:
enum:
- above
@@ -2563,13 +2563,12 @@ components:
$ref: '#/components/schemas/DashboardtypesDatasourceSpec'
type: object
display:
$ref: '#/components/schemas/CommonDisplay'
$ref: '#/components/schemas/DashboardtypesDisplay'
duration:
type: string
layouts:
items:
$ref: '#/components/schemas/DashboardtypesLayout'
nullable: true
type: array
links:
items:
@@ -2578,7 +2577,6 @@ components:
panels:
additionalProperties:
$ref: '#/components/schemas/DashboardtypesPanel'
nullable: true
type: object
refreshInterval:
type: string
@@ -2586,10 +2584,20 @@ components:
items:
$ref: '#/components/schemas/DashboardtypesVariable'
type: array
required:
- display
- variables
- panels
- layouts
type: object
DashboardtypesDatasourcePlugin:
discriminator:
mapping:
signoz/Datasource: '#/components/schemas/DashboardtypesDatasourcePluginVariantStruct'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesDatasourcePluginVariantStruct'
type: object
DashboardtypesDatasourcePluginKind:
enum:
- signoz/Datasource
@@ -2616,6 +2624,15 @@ components:
plugin:
$ref: '#/components/schemas/DashboardtypesDatasourcePlugin'
type: object
DashboardtypesDisplay:
properties:
description:
type: string
name:
type: string
required:
- name
type: object
DashboardtypesDynamicVariableSpec:
properties:
name:
@@ -2656,7 +2673,7 @@ components:
$ref: '#/components/schemas/DashboardtypesDashboardSpec'
tags:
items:
$ref: '#/components/schemas/TagtypesPostableTag'
$ref: '#/components/schemas/TagtypesGettableTag'
nullable: true
type: array
updatedAt:
@@ -2733,8 +2750,13 @@ components:
- path
type: object
DashboardtypesLayout:
discriminator:
mapping:
Grid: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpec'
type: object
DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpec:
properties:
kind:
@@ -2774,6 +2796,11 @@ components:
- solid
- dashed
type: string
DashboardtypesListOrder:
enum:
- asc
- desc
type: string
DashboardtypesListPanelSpec:
properties:
selectFields:
@@ -2781,6 +2808,12 @@ components:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
type: object
DashboardtypesListSort:
enum:
- updated_at
- created_at
- name
type: string
DashboardtypesListVariableSpec:
properties:
allowAllValue:
@@ -2794,7 +2827,7 @@ components:
defaultValue:
$ref: '#/components/schemas/VariableDefaultValue'
display:
$ref: '#/components/schemas/VariableDisplay'
$ref: '#/components/schemas/DashboardtypesDisplay'
name:
type: string
plugin:
@@ -2802,6 +2835,136 @@ components:
sort:
nullable: true
type: string
required:
- display
type: object
DashboardtypesListableDashboardForUserV2:
properties:
dashboards:
items:
$ref: '#/components/schemas/DashboardtypesListedDashboardForUserV2'
type: array
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
type: array
total:
format: int64
type: integer
required:
- dashboards
- total
- tags
type: object
DashboardtypesListableDashboardV2:
properties:
dashboards:
items:
$ref: '#/components/schemas/DashboardtypesListedDashboardV2'
type: array
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
type: array
total:
format: int64
type: integer
required:
- dashboards
- total
- tags
type: object
DashboardtypesListedDashboardForUserV2:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
id:
type: string
image:
type: string
locked:
type: boolean
name:
type: string
orgId:
type: string
pinned:
type: boolean
schemaVersion:
type: string
source:
$ref: '#/components/schemas/DashboardtypesSource'
spec:
$ref: '#/components/schemas/DashboardtypesListedDashboardV2Spec'
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
type: array
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
- orgId
- locked
- source
- schemaVersion
- name
- tags
- spec
- pinned
type: object
DashboardtypesListedDashboardV2:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
id:
type: string
image:
type: string
locked:
type: boolean
name:
type: string
orgId:
type: string
schemaVersion:
type: string
source:
$ref: '#/components/schemas/DashboardtypesSource'
spec:
$ref: '#/components/schemas/DashboardtypesListedDashboardV2Spec'
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
type: array
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
- orgId
- locked
- source
- schemaVersion
- name
- tags
- spec
type: object
DashboardtypesListedDashboardV2Spec:
properties:
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
type: object
DashboardtypesNumberPanelSpec:
properties:
@@ -2821,6 +2984,9 @@ components:
$ref: '#/components/schemas/DashboardtypesPanelKind'
spec:
$ref: '#/components/schemas/DashboardtypesPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelFormatting:
properties:
@@ -2834,6 +3000,16 @@ components:
- Panel
type: string
DashboardtypesPanelPlugin:
discriminator:
mapping:
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
@@ -2842,6 +3018,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
- signoz/TimeSeriesPanel
@@ -2939,7 +3116,7 @@ components:
DashboardtypesPanelSpec:
properties:
display:
$ref: '#/components/schemas/DashboardPanelDisplay'
$ref: '#/components/schemas/DashboardtypesDisplay'
links:
items:
$ref: '#/components/schemas/DashboardLink'
@@ -2949,7 +3126,12 @@ components:
queries:
items:
$ref: '#/components/schemas/DashboardtypesQuery'
nullable: true
type: array
required:
- display
- plugin
- queries
type: object
DashboardtypesPatchOp:
enum:
@@ -3018,8 +3200,20 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
spec:
$ref: '#/components/schemas/DashboardtypesQuerySpec'
required:
- kind
- spec
type: object
DashboardtypesQueryPlugin:
discriminator:
mapping:
signoz/BuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
signoz/ClickHouseSQL: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
signoz/CompositeQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
signoz/Formula: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
signoz/PromQLQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
signoz/TraceOperator: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderTraceOperator'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
@@ -3027,6 +3221,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderTraceOperator'
type: object
DashboardtypesQueryPluginKind:
enum:
- signoz/BuilderQuery
@@ -3114,6 +3309,8 @@ components:
type: string
plugin:
$ref: '#/components/schemas/DashboardtypesQueryPlugin'
required:
- plugin
type: object
DashboardtypesQueryVariableSpec:
properties:
@@ -3281,9 +3478,15 @@ components:
type: boolean
type: object
DashboardtypesVariable:
discriminator:
mapping:
ListVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
type: object
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec:
properties:
kind:
@@ -3309,10 +3512,17 @@ components:
- spec
type: object
DashboardtypesVariablePlugin:
discriminator:
mapping:
signoz/CustomVariable: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpec'
signoz/DynamicVariable: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpec'
signoz/QueryVariable: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpec'
type: object
DashboardtypesVariablePluginKind:
enum:
- signoz/DynamicVariable
@@ -5515,11 +5725,15 @@ components:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
signal:
$ref: '#/components/schemas/TelemetrytypesSignal'
enum:
- logs
type: string
source:
$ref: '#/components/schemas/TelemetrytypesSource'
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
required:
- signal
type: object
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregation:
properties:
@@ -5566,11 +5780,15 @@ components:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
signal:
$ref: '#/components/schemas/TelemetrytypesSignal'
enum:
- metrics
type: string
source:
$ref: '#/components/schemas/TelemetrytypesSource'
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
required:
- signal
type: object
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation:
properties:
@@ -5617,11 +5835,15 @@ components:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
signal:
$ref: '#/components/schemas/TelemetrytypesSignal'
enum:
- traces
type: string
source:
$ref: '#/components/schemas/TelemetrytypesSource'
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
required:
- signal
type: object
Querybuildertypesv5QueryBuilderTraceOperator:
properties:
@@ -7062,6 +7284,16 @@ components:
required:
- references
type: object
TagtypesGettableTag:
properties:
key:
type: string
value:
type: string
required:
- key
- value
type: object
TagtypesPostableTag:
properties:
key:
@@ -13097,6 +13329,82 @@ paths:
tags:
- preferences
/api/v2/dashboards:
get:
deprecated: false
description: Returns a page of v2-shape dashboards for the org. This is the
pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2
for the personalized, pin-aware list. Supports a filter DSL (`query`), sort
(`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based
pagination (`limit`/`offset`).
operationId: ListDashboardsV2
parameters:
- in: query
name: query
schema:
type: string
- in: query
name: sort
schema:
$ref: '#/components/schemas/DashboardtypesListSort'
- in: query
name: order
schema:
$ref: '#/components/schemas/DashboardtypesListOrder'
- in: query
name: limit
schema:
type: integer
- in: query
name: offset
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesListableDashboardV2'
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 dashboards (v2)
tags:
- dashboard
post:
deprecated: false
description: This endpoint creates a dashboard in the v2 format that follows
@@ -13155,6 +13463,62 @@ paths:
tags:
- dashboard
/api/v2/dashboards/{id}:
delete:
deprecated: false
description: This endpoint deletes a v2-shape dashboard along with its tag relations.
Locked dashboards are rejected.
operationId: DeleteDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Delete dashboard (v2)
tags:
- dashboard
get:
deprecated: false
description: This endpoint returns a v2-shape dashboard.
@@ -20377,6 +20741,196 @@ paths:
summary: Update my user v2
tags:
- users
/api/v2/users/me/dashboards:
get:
deprecated: false
description: 'Same as ListDashboardsV2 but personalized for the calling user:
each dashboard carries the caller''s `pinned` state, and pinned dashboards
float to the top of the requested ordering. Supports the same filter DSL,
sort, order, and pagination.'
operationId: ListDashboardsForUserV2
parameters:
- in: query
name: query
schema:
type: string
- in: query
name: sort
schema:
$ref: '#/components/schemas/DashboardtypesListSort'
- in: query
name: order
schema:
$ref: '#/components/schemas/DashboardtypesListOrder'
- in: query
name: limit
schema:
type: integer
- in: query
name: offset
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesListableDashboardForUserV2'
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 dashboards for the current user (v2)
tags:
- dashboard
/api/v2/users/me/dashboards/{id}/pins:
delete:
deprecated: false
description: Removes the pin for the calling user. Idempotent — unpinning a
dashboard that wasn't pinned still returns 204.
operationId: UnpinDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Unpin a dashboard for the current user (v2)
tags:
- dashboard
put:
deprecated: false
description: Pins the dashboard for the calling user. A user can pin at most
10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned
dashboard is a no-op success.
operationId: PinDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Pin a dashboard for the current user (v2)
tags:
- dashboard
/api/v2/users/me/factor_password:
put:
deprecated: false

View File

@@ -333,6 +333,50 @@ func (Step) JSONSchema() (jsonschema.Schema, error) {
}
```
### `oneOf` with a discriminator
For a sum type whose variants are keyed by a property (e.g. `kind`), expose the variants via `JSONSchemaOneOf()` and add a discriminator. Without it, code generators intersect the variants (`A & B & C`) instead of producing a clean discriminated union (`A | B | C`).
The parent keeps its `JSONSchemaOneOf()` (the `oneOf` itself) and *additionally* tags it via `PrepareJSONSchema` with the `x-signoz-discriminator` extension; `signoz.attachDiscriminators` then promotes that marker to a real OpenAPI 3 `discriminator` (and strips the duplicate parent properties) after reflection.
```go
// On the parent: expose the oneOf variants...
func (Plugin) JSONSchemaOneOf() []any {
return []any{FooVariant{}}
}
// ...and tag that same oneOf with the discriminator marker.
func (Plugin) PrepareJSONSchema(s *jsonschema.Schema) error {
if s.ExtraProperties == nil {
s.ExtraProperties = map[string]any{}
}
s.ExtraProperties["x-signoz-discriminator"] = map[string]any{
"propertyName": "kind",
"mapping": map[string]string{
"signoz/Foo": "#/components/schemas/FooVariant",
},
}
return nil
}
```
Each variant must declare the discriminator property (`kind`) and mark it `required`.
This produces the following in the generated OpenAPI spec:
```yaml
Plugin:
discriminator:
propertyName: kind
mapping:
signoz/Foo: '#/components/schemas/FooVariant'
oneOf:
- $ref: '#/components/schemas/FooVariant'
type: object
```
Note the discriminator property lives in the variants, not on the parent — the parent is only the union.
## What should I remember?

View File

@@ -229,10 +229,39 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
}
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
return module.pkgDashboardModule.DeleteV2(ctx, orgID, id)
})
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error) {
return module.pkgDashboardModule.ListV2(ctx, orgID, params)
}
func (module *module) ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error) {
return module.pkgDashboardModule.ListForUserV2(ctx, orgID, userID, params)
}
func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.PinV2(ctx, orgID, userID, id)
}
func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.UnpinV2(ctx, orgID, userID, id)
}
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, orgID, userID)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

@@ -185,6 +185,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)

View File

@@ -16,10 +16,11 @@ func newFormatter(dialect schema.Dialect) sqlstore.SQLFormatter {
}
func (f *formatter) JSONExtractString(column, path string) []byte {
var sql []byte
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, f.convertJSONPathToPostgres(path)...)
return sql
ops := f.convertJSONPathToPostgres(path)
if len(ops) == 0 {
return f.bunf.AppendIdent(nil, column)
}
return append(f.TextToJsonColumn(column), ops...)
}
func (f *formatter) JSONType(column, path string) []byte {

View File

@@ -18,19 +18,19 @@ func TestJSONExtractString(t *testing.T) {
name: "simple path",
column: "data",
path: "$.field",
expected: `"data"->>'field'`,
expected: `"data"::jsonb->>'field'`,
},
{
name: "nested path",
column: "metadata",
path: "$.user.name",
expected: `"metadata"->'user'->>'name'`,
expected: `"metadata"::jsonb->'user'->>'name'`,
},
{
name: "deeply nested path",
column: "json_col",
path: "$.level1.level2.level3",
expected: `"json_col"->'level1'->'level2'->>'level3'`,
expected: `"json_col"::jsonb->'level1'->'level2'->>'level3'`,
},
{
name: "root path",

View File

@@ -26,6 +26,7 @@ import type {
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatableDashboardV2DTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeleteDashboardV2PathParameters,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
GetDashboardV2PathParameters,
@@ -35,11 +36,17 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
ListDashboardsV2200,
ListDashboardsV2Params,
LockDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
PinDashboardV2PathParameters,
RenderErrorResponseDTO,
UnlockDashboardV2PathParameters,
UnpinDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdatePublicDashboardPathParameters,
@@ -641,6 +648,103 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
* @summary List dashboards (v2)
*/
export const listDashboardsV2 = (
params?: ListDashboardsV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDashboardsV2200>({
url: `/api/v2/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getListDashboardsV2QueryKey = (
params?: ListDashboardsV2Params,
) => {
return [`/api/v2/dashboards`, ...(params ? [params] : [])] as const;
};
export const getListDashboardsV2QueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListDashboardsV2QueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listDashboardsV2>>> = ({
signal,
}) => listDashboardsV2(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardsV2QueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardsV2>>
>;
export type ListDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboards (v2)
*/
export function useListDashboardsV2<
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardsV2QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboards (v2)
*/
export const invalidateListDashboardsV2 = async (
queryClient: QueryClient,
params?: ListDashboardsV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardsV2QueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint creates a dashboard in the v2 format that follows Perses spec.
* @summary Create dashboard (v2)
@@ -724,6 +828,85 @@ export const useCreateDashboardV2 = <
> => {
return useMutation(getCreateDashboardV2MutationOptions(options));
};
/**
* This endpoint deletes a v2-shape dashboard along with its tag relations. Locked dashboards are rejected.
* @summary Delete dashboard (v2)
*/
export const deleteDashboardV2 = (
{ id }: DeleteDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['deleteDashboardV2'];
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 deleteDashboardV2>>,
{ pathParams: DeleteDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof deleteDashboardV2>>
>;
export type DeleteDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete dashboard (v2)
*/
export const useDeleteDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
> => {
return useMutation(getDeleteDashboardV2MutationOptions(options));
};
/**
* This endpoint returns a v2-shape dashboard.
* @summary Get dashboard (v2)
@@ -1181,3 +1364,260 @@ export const useLockDashboardV2 = <
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};
/**
* Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.
* @summary List dashboards for the current user (v2)
*/
export const listDashboardsForUserV2 = (
params?: ListDashboardsForUserV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDashboardsForUserV2200>({
url: `/api/v2/users/me/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getListDashboardsForUserV2QueryKey = (
params?: ListDashboardsForUserV2Params,
) => {
return [`/api/v2/users/me/dashboards`, ...(params ? [params] : [])] as const;
};
export const getListDashboardsForUserV2QueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsForUserV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListDashboardsForUserV2QueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listDashboardsForUserV2>>
> = ({ signal }) => listDashboardsForUserV2(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardsForUserV2QueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardsForUserV2>>
>;
export type ListDashboardsForUserV2QueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboards for the current user (v2)
*/
export function useListDashboardsForUserV2<
TData = Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsForUserV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardsForUserV2QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboards for the current user (v2)
*/
export const invalidateListDashboardsForUserV2 = async (
queryClient: QueryClient,
params?: ListDashboardsForUserV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardsForUserV2QueryKey(params) },
options,
);
return queryClient;
};
/**
* Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.
* @summary Unpin a dashboard for the current user (v2)
*/
export const unpinDashboardV2 = (
{ id }: UnpinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/users/me/dashboards/${id}/pins`,
method: 'DELETE',
signal,
});
};
export const getUnpinDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['unpinDashboardV2'];
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 unpinDashboardV2>>,
{ pathParams: UnpinDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return unpinDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type UnpinDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof unpinDashboardV2>>
>;
export type UnpinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Unpin a dashboard for the current user (v2)
*/
export const useUnpinDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
> => {
return useMutation(getUnpinDashboardV2MutationOptions(options));
};
/**
* Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.
* @summary Pin a dashboard for the current user (v2)
*/
export const pinDashboardV2 = (
{ id }: PinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/users/me/dashboards/${id}/pins`,
method: 'PUT',
signal,
});
};
export const getPinDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['pinDashboardV2'];
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 pinDashboardV2>>,
{ pathParams: PinDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return pinDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type PinDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof pinDashboardV2>>
>;
export type PinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Pin a dashboard for the current user (v2)
*/
export const usePinDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
> => {
return useMutation(getPinDashboardV2MutationOptions(options));
};

View File

@@ -3156,17 +3156,6 @@ export interface DashboardLinkDTO {
url?: string;
}
export interface DashboardPanelDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name?: string;
}
export interface VariableDisplayDTO {
/**
* @type string
@@ -3495,6 +3484,9 @@ export interface TelemetrytypesTelemetryFieldKeyDTO {
unit?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal {
logs = 'logs',
}
export enum TelemetrytypesSourceDTO {
meter = 'meter',
}
@@ -3550,7 +3542,11 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
signal?: TelemetrytypesSignalDTO;
/**
* @enum logs
* @type string
*/
signal: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
@@ -3616,6 +3612,9 @@ export interface Querybuildertypesv5MetricAggregationDTO {
timeAggregation?: MetrictypesTimeAggregationDTO;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal {
metrics = 'metrics',
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTO {
/**
* @type array
@@ -3668,7 +3667,11 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
signal?: TelemetrytypesSignalDTO;
/**
* @enum metrics
* @type string
*/
signal: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
@@ -3684,6 +3687,9 @@ export interface Querybuildertypesv5TraceAggregationDTO {
expression?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal {
traces = 'traces',
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO {
/**
* @type array
@@ -3736,7 +3742,11 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
signal?: TelemetrytypesSignalDTO;
/**
* @enum traces
* @type string
*/
signal: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
@@ -3871,6 +3881,17 @@ export type DashboardtypesDashboardSpecDTODatasources = {
export enum DashboardtypesPanelKindDTO {
Panel = 'Panel',
}
export interface DashboardtypesDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name: string;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTOKind {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
}
@@ -4419,42 +4440,36 @@ export interface DashboardtypesQuerySpecDTO {
* @type string
*/
name?: string;
plugin?: DashboardtypesQueryPluginDTO;
plugin: DashboardtypesQueryPluginDTO;
}
export interface DashboardtypesQueryDTO {
kind?: Querybuildertypesv5RequestTypeDTO;
spec?: DashboardtypesQuerySpecDTO;
kind: Querybuildertypesv5RequestTypeDTO;
spec: DashboardtypesQuerySpecDTO;
}
export interface DashboardtypesPanelSpecDTO {
display?: DashboardPanelDisplayDTO;
display: DashboardtypesDisplayDTO;
/**
* @type array
*/
links?: DashboardLinkDTO[];
plugin?: DashboardtypesPanelPluginDTO;
plugin: DashboardtypesPanelPluginDTO;
/**
* @type array
* @type array,null
*/
queries?: DashboardtypesQueryDTO[];
queries: DashboardtypesQueryDTO[] | null;
}
export interface DashboardtypesPanelDTO {
kind?: DashboardtypesPanelKindDTO;
spec?: DashboardtypesPanelSpecDTO;
kind: DashboardtypesPanelKindDTO;
spec: DashboardtypesPanelSpecDTO;
}
export type DashboardtypesDashboardSpecDTOPanelsAnyOf = {
export type DashboardtypesDashboardSpecDTOPanels = {
[key: string]: DashboardtypesPanelDTO;
};
/**
* @nullable
*/
export type DashboardtypesDashboardSpecDTOPanels =
DashboardtypesDashboardSpecDTOPanelsAnyOf | null;
export enum DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTOKind {
Grid = 'Grid',
}
@@ -4551,7 +4566,7 @@ export interface DashboardtypesListVariableSpecDTO {
*/
customAllValue?: string;
defaultValue?: VariableDefaultValueDTO;
display?: VariableDisplayDTO;
display: DashboardtypesDisplayDTO;
/**
* @type string
*/
@@ -4593,23 +4608,23 @@ export interface DashboardtypesDashboardSpecDTO {
* @type object
*/
datasources?: DashboardtypesDashboardSpecDTODatasources;
display?: CommonDisplayDTO;
display: DashboardtypesDisplayDTO;
/**
* @type string
*/
duration?: string;
/**
* @type array,null
* @type array
*/
layouts?: DashboardtypesLayoutDTO[] | null;
layouts: DashboardtypesLayoutDTO[];
/**
* @type array
*/
links?: DashboardLinkDTO[];
/**
* @type object,null
* @type object
*/
panels?: DashboardtypesDashboardSpecDTOPanels;
panels: DashboardtypesDashboardSpecDTOPanels;
/**
* @type string
*/
@@ -4617,13 +4632,13 @@ export interface DashboardtypesDashboardSpecDTO {
/**
* @type array
*/
variables?: DashboardtypesVariableDTO[];
variables: DashboardtypesVariableDTO[];
}
export enum DashboardtypesDatasourcePluginKindDTO {
'signoz/Datasource' = 'signoz/Datasource',
}
export interface TagtypesPostableTagDTO {
export interface TagtypesGettableTagDTO {
/**
* @type string
*/
@@ -4673,7 +4688,7 @@ export interface DashboardtypesGettableDashboardV2DTO {
/**
* @type array,null
*/
tags: TagtypesPostableTagDTO[] | null;
tags: TagtypesGettableTagDTO[] | null;
/**
* @type string
* @format date-time
@@ -4731,6 +4746,157 @@ export interface DashboardtypesJSONPatchOperationDTO {
value?: unknown;
}
export enum DashboardtypesListOrderDTO {
asc = 'asc',
desc = 'desc',
}
export enum DashboardtypesListSortDTO {
updated_at = 'updated_at',
created_at = 'created_at',
name = 'name',
}
export interface DashboardtypesListedDashboardV2SpecDTO {
display?: DashboardtypesDisplayDTO;
}
export interface DashboardtypesListedDashboardForUserV2DTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
image?: string;
/**
* @type boolean
*/
locked: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type boolean
*/
pinned: boolean;
/**
* @type string
*/
schemaVersion: string;
source: DashboardtypesSourceDTO;
spec: DashboardtypesListedDashboardV2SpecDTO;
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface DashboardtypesListableDashboardForUserV2DTO {
/**
* @type array
*/
dashboards: DashboardtypesListedDashboardForUserV2DTO[];
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type integer
* @format int64
*/
total: number;
}
export interface DashboardtypesListedDashboardV2DTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
image?: string;
/**
* @type boolean
*/
locked: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
*/
schemaVersion: string;
source: DashboardtypesSourceDTO;
spec: DashboardtypesListedDashboardV2SpecDTO;
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface DashboardtypesListableDashboardV2DTO {
/**
* @type array
*/
dashboards: DashboardtypesListedDashboardV2DTO[];
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type integer
* @format int64
*/
total: number;
}
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
@@ -4747,6 +4913,17 @@ export type DashboardtypesPatchableDashboardV2DTO =
| DashboardtypesJSONPatchOperationDTO[]
| null;
export interface TagtypesPostableTagDTO {
/**
* @type string
*/
key: string;
/**
* @type string
*/
value: string;
}
export interface DashboardtypesPostableDashboardV2DTO {
/**
* @type boolean
@@ -9649,6 +9826,40 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListDashboardsV2Params = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @description undefined
*/
sort?: DashboardtypesListSortDTO;
/**
* @description undefined
*/
order?: DashboardtypesListOrderDTO;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListDashboardsV2200 = {
data: DashboardtypesListableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type CreateDashboardV2201 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
@@ -9657,6 +9868,9 @@ export type CreateDashboardV2201 = {
status: string;
};
export type DeleteDashboardV2PathParameters = {
id: string;
};
export type GetDashboardV2PathParameters = {
id: string;
};
@@ -10489,6 +10703,46 @@ export type GetMyUser200 = {
status: string;
};
export type ListDashboardsForUserV2Params = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @description undefined
*/
sort?: DashboardtypesListSortDTO;
/**
* @description undefined
*/
order?: DashboardtypesListOrderDTO;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListDashboardsForUserV2200 = {
data: DashboardtypesListableDashboardForUserV2DTO;
/**
* @type string
*/
status: string;
};
export type UnpinDashboardV2PathParameters = {
id: string;
};
export type PinDashboardV2PathParameters = {
id: string;
};
export type GetHosts200 = {
data: ZeustypesGettableHostDTO;
/**

View File

@@ -1,5 +1,11 @@
// TODO: Improve the styling of the query aggregation container and its components. - @YounixM , @H4ad
$dropdown-base-height: 250px;
$recents-header-height: 30px;
$recent-row-height: 36px;
// how many recents are rendered, this caps how tall the dropdown can grow to fit them.
$max-recents-shown: 5;
.code-mirror-where-clause {
width: 100%;
display: flex;
@@ -117,7 +123,23 @@
width: 100% !important;
max-width: 100% !important;
font-family: 'Space Mono', monospace !important;
min-height: 200px !important;
max-height: $dropdown-base-height !important;
overflow-y: auto !important;
// Recents render at the top of the dropdown ahead of regular suggestions.
// At the base max-height, having recents in view would crowd out the
// suggestion list below. This loop grows the dropdown by one row's worth
// of height for every recent present (up to $max-recents-shown), plus the
// section header. `:has(> li:nth-of-type(N) .cm-completionIcon-recent)`
// matches when the Nth child of <ul> is a recent — i.e. there are at
// least N recents visible.
@for $i from 1 through $max-recents-shown {
&:has(> li:nth-of-type(#{$i}) .cm-completionIcon-recent) {
max-height: $dropdown-base-height +
$recents-header-height +
($i * $recent-row-height) !important;
}
}
&::-webkit-scrollbar {
width: 0.3rem;
@@ -133,6 +155,19 @@
background: transparent;
}
completion-section {
display: block;
padding: 10px 12px 6px;
font-size: 10px !important;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--l3-foreground, var(--l2-foreground));
opacity: 0.7;
border-bottom: 0;
background-color: transparent;
}
li {
width: 100% !important;
max-width: 100% !important;
@@ -159,11 +194,78 @@
display: none !important;
}
.cm-completionDetail {
margin-left: auto;
font-style: normal;
font-size: var(--periscope-font-size-small, 11px);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.55;
}
&[aria-selected='true'] {
background: var(--l3-background) !important;
font-weight: 600 !important;
}
}
li:has(.cm-completionIcon-recent) {
&:hover .cm-recent-delete,
&[aria-selected='true'] .cm-recent-delete {
opacity: 1;
}
}
li .cm-completionLabel {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cm-recent-delete {
margin-left: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--l2-foreground);
font-size: 18px;
line-height: 1;
cursor: pointer;
opacity: 0.5;
transition:
opacity 0.12s ease,
background-color 0.12s ease;
&:hover {
opacity: 1;
background: color-mix(in srgb, var(--l2-foreground) 18%, transparent);
}
}
}
&::after {
content: '↓↑ to navigate · ↵ to apply · esc to dismiss';
display: block;
padding: 8px 12px;
border-top: 1px solid var(--l1-border);
font-family: 'Space Mono', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--l2-foreground);
opacity: 0.6;
background: color-mix(in srgb, var(--l1-background) 50%, transparent);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
}

View File

@@ -46,8 +46,15 @@ import {
import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils';
import { queryExamples } from './constants';
import { combineInitialAndUserExpression } from './utils';
import { getRecentQueries } from 'lib/recentQueries/getRecentQueries';
import type { SignalType } from 'types/api/v5/queryRange';
import { queryExamples, SUGGESTIONS_SECTION } from './constants';
import {
combineInitialAndUserExpression,
getRecentOptions,
renderRecentDeleteButton,
} from './utils';
import './QuerySearch.styles.scss';
@@ -1250,6 +1257,41 @@ function QuerySearch({
};
}
const signal = dataSource as SignalType;
function combinedSuggestions(
context: CompletionContext,
): CompletionResult | null {
const fullDoc = context.state.doc.toString();
const recentOptions = getRecentOptions(
getRecentQueries(signal, signalSource ?? ''),
fullDoc,
);
const result = autoSuggestions(context);
const suggestionOptions = (result?.options || []).map((opt) => ({
...opt,
section: SUGGESTIONS_SECTION,
}));
if (recentOptions.length === 0 && suggestionOptions.length === 0) {
return result;
}
if (!result) {
return {
from: 0,
to: fullDoc.length,
options: recentOptions,
};
}
return {
...result,
options: [...recentOptions, ...suggestionOptions],
};
}
// Effect to handle focus state and trigger suggestions
useEffect(() => {
const clearTimeout = toggleSuggestions(10);
@@ -1398,11 +1440,12 @@ function QuerySearch({
})}
extensions={[
autocompletion({
override: [autoSuggestions],
override: [combinedSuggestions],
defaultKeymap: true,
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 50,
addToOptions: [{ render: renderRecentDeleteButton, position: 100 }],
}),
javascript({ jsx: false, typescript: false }),
EditorView.lineWrapping,

View File

@@ -1,3 +1,14 @@
export const RECENTS_SECTION = { name: 'Recent searches', rank: 1 } as const;
export const SUGGESTIONS_SECTION = { name: 'Suggestions', rank: 2 } as const;
// Custom CodeMirror Completion.type for recent-query entries. Used to discriminate
// recents from regular autocomplete completions in renderers and event handlers.
export const RECENT_COMPLETION_TYPE = 'recent';
// Max number of recents rendered in the autocomplete dropdown.
// Do change $max-recents-shown: in QuerySearch.styles.scss if you change this.
export const RECENTS_DISPLAY_CAP = 5;
export const queryExamples = [
{
label: 'Basic Query',

View File

@@ -1,3 +1,19 @@
import { closeCompletion, startCompletion } from '@codemirror/autocomplete';
import type { Completion } from '@codemirror/autocomplete';
import type { EditorView } from '@uiw/react-codemirror';
import dayjs from 'dayjs';
import { normalizeFilterExpression } from 'lib/recentQueries/normalize';
import * as recentQueriesStore from 'lib/recentQueries/recentQueriesStore';
import type { RecentQueryEntry } from 'lib/recentQueries/types';
import type { SignalType } from 'types/api/v5/queryRange';
import 'utils/timeUtils';
import {
RECENT_COMPLETION_TYPE,
RECENTS_DISPLAY_CAP,
RECENTS_SECTION,
} from './constants';
export function combineInitialAndUserExpression(
initial: string,
user: string,
@@ -38,3 +54,106 @@ export function getUserExpressionFromCombined(
}
return c;
}
// Filters and projects a list of recent-query entries into CodeMirror completions.
// Entries are supplied by the caller (typically via the useRecents hook) so this
// function stays pure and React doesn't have to re-subscribe inside CodeMirror's
// autocomplete callback.
export function getRecentOptions(
entries: RecentQueryEntry[],
fullDoc: string,
): Completion[] {
const normalizedDoc = normalizeFilterExpression(fullDoc);
const matches = entries
.filter((e) => {
const normalizedRecent = normalizeFilterExpression(e.filter.expression);
if (normalizedRecent === normalizedDoc) {
return false;
}
if (normalizedDoc === '') {
return true;
}
return normalizedRecent.includes(normalizedDoc);
})
.slice(0, RECENTS_DISPLAY_CAP);
return matches.map((entry, index) => ({
label: entry.filter.expression,
type: RECENT_COMPLETION_TYPE,
// CodeMirror sorts within a section by boost desc, then label asc. The store
// returns entries newest-first, so we mirror that by giving the newest entry
// the highest boost — otherwise CM falls back to alphabetical order and the
// "most recently used" expectation breaks. Stays within the recents section
// because section.rank keeps recents above suggestions regardless of boost.
boost: matches.length - index,
section: RECENTS_SECTION,
detail: dayjs(entry.lastUsedAt).fromNow(),
recentId: entry.id,
recentSignal: entry.signal,
recentSource: entry.source,
apply: (view: EditorView): void => {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: entry.filter.expression,
},
selection: { anchor: entry.filter.expression.length },
});
closeCompletion(view);
},
}));
}
export function renderRecentDeleteButton(
completion: Completion,
_state: unknown,
view: EditorView | null,
): Node | null {
if (completion.type !== RECENT_COMPLETION_TYPE) {
return null;
}
const c = completion as Completion & {
recentId?: string;
recentSignal?: SignalType;
recentSource?: string;
};
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'cm-recent-delete';
btn.setAttribute('aria-label', 'Remove from recent searches');
btn.title = 'Remove from recent searches';
btn.textContent = '×';
queueMicrotask(() => {
if (btn.parentElement) {
btn.parentElement.title = completion.label;
}
});
const stop = (e: Event): void => {
e.preventDefault();
e.stopPropagation();
};
// CodeMirror's autocomplete closes the popup on pointerdown / mousedown outside
// the editor. The delete button lives inside the popup, so we must stop those
// events early — otherwise clicking × would dismiss the dropdown before the
// click handler fires and the entry wouldn't actually get removed.
btn.addEventListener('pointerdown', stop);
btn.addEventListener('mousedown', stop);
btn.addEventListener('click', (e) => {
stop(e);
if (!c.recentId || !c.recentSignal) {
return;
}
recentQueriesStore.remove(c.recentId, c.recentSignal, c.recentSource ?? '');
if (view) {
view.focus();
startCompletion(view);
}
});
return btn;
}

View File

@@ -40,13 +40,31 @@ type SpeechRecognitionConstructor = new () => ISpeechRecognition;
// ── Vendor-prefix shim for Safari / older browsers ────────────────────────────
const SpeechRecognitionAPI: SpeechRecognitionConstructor | null =
typeof window !== 'undefined'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
((window as any).SpeechRecognition ??
// Some hardened/enterprise browsers install a getter
// on window.SpeechRecognition that THROWS on access ("Web Speech API is disabled
// due to your security policy") instead of leaving the property undefined.
// Because this resolves at module-evaluation time, an uncaught throw here aborts
// the entire bundle and the app renders a blank page. Read defensively so a
// throwing getter degrades to "unsupported" rather than crashing the app.
function resolveSpeechRecognitionAPI(): SpeechRecognitionConstructor | null {
if (typeof window === 'undefined') {
return null;
}
try {
return (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).SpeechRecognition ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).webkitSpeechRecognition ??
null)
: null;
null
);
} catch {
return null;
}
}
const SpeechRecognitionAPI: SpeechRecognitionConstructor | null =
resolveSpeechRecognitionAPI();
export type SpeechRecognitionError =
| 'not-supported'

View File

@@ -142,6 +142,15 @@
}
}
.reset-password-back-action {
margin-top: var(--spacing-12);
width: 100%;
button {
width: 100%;
}
}
@media (max-width: 768px) {
width: 100%;
padding: 0 16px;

View File

@@ -1,7 +1,10 @@
import { CircleAlert } from '@signozhq/icons';
import { ArrowLeft, CircleAlert } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import APIError from 'types/api/error';
import './ResetPassword.styles.scss';
@@ -59,6 +62,16 @@ function TokenError({ error }: TokenErrorProps): JSX.Element {
</Typography.Text>
</div>
{error && <AuthError error={error} />}
<div className="reset-password-back-action">
<Button
variant="solid"
data-testid="back-to-login"
prefix={<ArrowLeft size={12} />}
onClick={(): void => history.push(ROUTES.LOGIN)}
>
Back to login
</Button>
</div>
</div>
</AuthPageContainer>
);

View File

@@ -119,6 +119,10 @@
border-radius: 0px 4px 4px 0px;
background: var(--l3-background);
&.version-container-standalone {
border-radius: 4px;
}
}
.version {

View File

@@ -1010,7 +1010,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
<img src={signozBrandLogoUrl} alt="SigNoz" />
</div>
{licenseTag && (
{(licenseTag || currentVersion) && (
<div
className={cx(
'brand-title-section',
@@ -1021,7 +1021,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
'version-update-notification',
)}
>
<span className="license-type"> {licenseTag} </span>
{licenseTag && <span className="license-type"> {licenseTag} </span>}
{currentVersion && (
<Tooltip
@@ -1043,7 +1043,12 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
)
}
>
<div className="version-container">
<div
className={cx(
'version-container',
!licenseTag && 'version-container-standalone',
)}
>
<span
className={cx('version', changelog && 'version-clickable')}
onClick={onClickVersionHandler}

View File

@@ -0,0 +1,5 @@
export const STORAGE_KEY_PREFIX = 'qb_recent_v1';
export const STORAGE_VERSION = 1;
// Maximum entries kept per (signal, source) bucket. Larger than the UI's
// RECENTS_DISPLAY_CAP so deleting a visible entry surfaces an older one.
export const MAX_ENTRIES = 10;

View File

@@ -0,0 +1,15 @@
import type { SignalType } from 'types/api/v5/queryRange';
import * as store from './recentQueriesStore';
import type { RecentQueryEntry } from './types';
// Synchronous, non-subscribing read of the recent-queries bucket for a given
// (signal, source). Read-on-demand by design — subscribing here would
// reconfigure CodeMirror on every store change and close any open completion
// popup. Pair with saveQuery() for the write path.
export function getRecentQueries(
signal: SignalType,
source = '',
): RecentQueryEntry[] {
return store.list(signal, source);
}

View File

@@ -0,0 +1,100 @@
import { normalizeFilterExpression } from './normalize';
describe('normalizeFilterExpression', () => {
it('returns empty string for empty input', () => {
expect(normalizeFilterExpression('')).toBe('');
});
it('returns empty string for whitespace-only input', () => {
expect(normalizeFilterExpression(' \t ')).toBe('');
});
it('strips whitespace around operators', () => {
expect(normalizeFilterExpression(' a = 1 ')).toBe('a=1');
expect(normalizeFilterExpression('a = 1')).toBe('a=1');
expect(normalizeFilterExpression('a=1')).toBe('a=1');
});
it('lowercases AND / OR / NOT outside quotes', () => {
expect(normalizeFilterExpression('A AND B OR NOT C')).toBe('AandBornotC');
});
it('lowercases IN / LIKE / ILIKE / CONTAINS', () => {
expect(normalizeFilterExpression('host IN [1, 2] AND name LIKE "foo"')).toBe(
'hostin[1,2]andnamelike"foo"',
);
});
it('lowercases REGEXP', () => {
expect(normalizeFilterExpression('path REGEXP "foo"')).toBe(
'pathregexp"foo"',
);
expect(normalizeFilterExpression('path REGEXP "foo"')).toBe(
normalizeFilterExpression('path regexp "foo"'),
);
});
it('lowercases HAS / HASANY / HASALL / HASTOKEN function names', () => {
expect(normalizeFilterExpression('HAS(tags, "x")')).toBe(
normalizeFilterExpression('has(tags, "x")'),
);
expect(normalizeFilterExpression('HASANY(tags, ["a","b"])')).toBe(
normalizeFilterExpression('hasAny(tags, ["a","b"])'),
);
expect(normalizeFilterExpression('HASALL(tags, ["a","b"])')).toBe(
normalizeFilterExpression('hasAll(tags, ["a","b"])'),
);
expect(normalizeFilterExpression('HASTOKEN(msg, "err")')).toBe(
normalizeFilterExpression('hasToken(msg, "err")'),
);
});
it('lowercases TRUE / FALSE boolean literals', () => {
expect(normalizeFilterExpression('active = TRUE')).toBe(
normalizeFilterExpression('active = true'),
);
expect(normalizeFilterExpression('active = FALSE')).toBe(
normalizeFilterExpression('active = false'),
);
});
it('preserves whitespace and casing inside single-quoted strings', () => {
expect(normalizeFilterExpression("a = 'X Y'")).toBe("a='X Y'");
});
it('preserves whitespace and casing inside double-quoted strings', () => {
expect(normalizeFilterExpression('a = "X Y"')).toBe('a="X Y"');
});
it('does not lowercase keyword-looking substrings inside quotes', () => {
expect(normalizeFilterExpression("msg = 'AND ERROR'")).toBe(
"msg='AND ERROR'",
);
});
it('handles escaped quotes inside strings', () => {
expect(normalizeFilterExpression("msg = 'a\\'b' AND x = 1")).toBe(
"msg='a\\'b'andx=1",
);
});
it('treats two formattings of the same expression as identical', () => {
const a = normalizeFilterExpression(
'service.name = "frontend" AND severity = error',
);
const b = normalizeFilterExpression(
'service.name="frontend" and severity=error',
);
expect(a).toBe(b);
});
it('preserves unquoted value casing (treats them as identifiers)', () => {
expect(normalizeFilterExpression('status = OK')).toBe('status=OK');
});
it('handles mixed quotes in one expression', () => {
expect(normalizeFilterExpression(`a = 'X' AND b = "Y"`)).toBe(
`a='X'andb="Y"`,
);
});
});

View File

@@ -0,0 +1,56 @@
import {
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
TRACE_OPERATOR_OPERATORS,
} from 'constants/antlrQueryConstants';
// Reserved keywords sourced from the ANTLR grammar constants so this list stays
// in sync with the parser. `\b` prevents partial matches inside identifiers
// (e.g. `OR` inside `originator`). `TRUE`/`FALSE` are BOOL literals, included
// so case variants of boolean values also dedup.
const WORD_KEYWORDS = [
...Object.keys(OPERATORS).filter((k) => /^[A-Z]+$/.test(k)),
...Object.keys(TRACE_OPERATOR_OPERATORS).filter((k) => /^[A-Z]+$/.test(k)),
...Object.values(QUERY_BUILDER_FUNCTIONS),
'TRUE',
'FALSE',
];
const KEYWORDS_RE = new RegExp(`\\b(${WORD_KEYWORDS.join('|')})\\b`, 'gi');
// Matches single- or double-quoted string literals, supporting escaped quotes
// (e.g. `'it\'s'` or `"a \" b"`). We preserve quoted spans verbatim during
// normalisation so user-meaningful whitespace and casing inside string values
// stays intact: `name = "Foo Bar"` must not collapse to `name="foobar"`.
const QUOTED_RE = /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g;
// Lowercases reserved keywords and strips ALL whitespace from the unquoted regions
// of the input. Keywords are normalised so casing variants dedup; whitespace is
// dropped so formatting variants (`a=1` vs `a = 1`) dedup too.
function processOutsideQuotes(s: string): string {
return s.replace(KEYWORDS_RE, (m) => m.toLowerCase()).replace(/\s+/g, '');
}
// Produces a canonical form of a filter expression suitable for dedup-key derivation.
// Walks the input alternating between unquoted regions (where we normalise keywords
// and whitespace) and quoted regions (which we copy verbatim).
export function normalizeFilterExpression(input: string): string {
if (!input) {
return '';
}
let result = '';
let lastIndex = 0;
QUOTED_RE.lastIndex = 0;
let match = QUOTED_RE.exec(input);
while (match !== null) {
result += processOutsideQuotes(input.slice(lastIndex, match.index));
result += match[0];
lastIndex = QUOTED_RE.lastIndex;
match = QUOTED_RE.exec(input);
}
result += processOutsideQuotes(input.slice(lastIndex));
return result.trim();
}

View File

@@ -0,0 +1,247 @@
import { MAX_ENTRIES } from './constants';
import * as store from './recentQueriesStore';
import type { RecentQueryInput } from './recentQueriesStore';
import type { RecentQueryEntry } from './types';
const baseInput = (
overrides: Partial<RecentQueryInput> = {},
): RecentQueryInput => ({
signal: 'logs',
filter: { expression: "service.name = 'frontend'" },
...overrides,
});
function saveOrThrow(input: RecentQueryInput): RecentQueryEntry {
const saved = store.save(input);
if (!saved) {
throw new Error('expected save to return an entry');
}
return saved;
}
describe('recentQueries store', () => {
beforeEach(() => {
store.useRecentQueriesStore.setState({ buckets: {} });
localStorage.clear();
});
describe('save + list', () => {
it('saves an entry and lists it', () => {
store.save(baseInput());
const entries = store.list('logs');
expect(entries).toHaveLength(1);
expect(entries[0].filter.expression).toBe("service.name = 'frontend'");
expect(entries[0].id).toBeTruthy();
expect(entries[0].lastUsedAt).toBeGreaterThan(0);
});
it('does not save when the filter expression is empty', () => {
const result = store.save(baseInput({ filter: { expression: '' } }));
expect(result).toBeNull();
expect(store.list('logs')).toHaveLength(0);
});
it('does not save when the filter expression is whitespace only', () => {
const result = store.save(baseInput({ filter: { expression: ' ' } }));
expect(result).toBeNull();
expect(store.list('logs')).toHaveLength(0);
});
});
describe('LRU ordering', () => {
it('places the most recently saved entry at the front', () => {
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
store.save(baseInput({ filter: { expression: 'http.status_code >= 500' } }));
store.save(baseInput({ filter: { expression: 'attempt = 1' } }));
const entries = store.list('logs');
expect(entries.map((e) => e.filter.expression)).toStrictEqual([
'attempt = 1',
'http.status_code >= 500',
"severity_text = 'ERROR'",
]);
});
it('re-saving an existing filter bumps it to the front', () => {
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
store.save(baseInput({ filter: { expression: 'http.status_code >= 500' } }));
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
const entries = store.list('logs');
expect(entries).toHaveLength(2);
expect(entries.map((e) => e.filter.expression)).toStrictEqual([
"severity_text = 'ERROR'",
'http.status_code >= 500',
]);
});
});
describe('dedup', () => {
it('treats formatting variations of the same filter as one entry', () => {
store.save(
baseInput({
filter: { expression: "severity_text = 'ERROR' AND attempt = 1" },
}),
);
store.save(
baseInput({
filter: { expression: "severity_text='ERROR' and attempt=1" },
}),
);
expect(store.list('logs')).toHaveLength(1);
});
});
describe('signal partitioning', () => {
it('saves to the right bucket per signal', () => {
store.save(
baseInput({
signal: 'logs',
filter: { expression: "severity_text = 'ERROR'" },
}),
);
store.save(
baseInput({
signal: 'traces',
filter: { expression: "service.name = 'orders-api'" },
}),
);
store.save(
baseInput({
signal: 'metrics',
filter: { expression: 'cpu_usage > 80' },
}),
);
expect(store.list('logs')).toHaveLength(1);
expect(store.list('traces')).toHaveLength(1);
expect(store.list('metrics')).toHaveLength(1);
expect(store.list('logs')[0].filter.expression).toBe(
"severity_text = 'ERROR'",
);
expect(store.list('traces')[0].filter.expression).toBe(
"service.name = 'orders-api'",
);
expect(store.list('metrics')[0].filter.expression).toBe('cpu_usage > 80');
});
it('does not leak between signals on dedup', () => {
store.save(
baseInput({
signal: 'logs',
filter: { expression: "service.name = 'frontend'" },
}),
);
store.save(
baseInput({
signal: 'traces',
filter: { expression: "service.name = 'frontend'" },
}),
);
expect(store.list('logs')).toHaveLength(1);
expect(store.list('traces')).toHaveLength(1);
});
});
describe('LRU cap', () => {
it('caps the bucket at MAX_ENTRIES and evicts the oldest', () => {
const total = MAX_ENTRIES + 1;
for (let i = 0; i < total; i += 1) {
store.save(baseInput({ filter: { expression: `attempt = ${i}` } }));
}
const entries = store.list('logs');
expect(entries).toHaveLength(MAX_ENTRIES);
expect(entries[0].filter.expression).toBe(`attempt = ${total - 1}`);
expect(entries.some((e) => e.filter.expression === 'attempt = 0')).toBe(
false,
);
});
});
describe('remove', () => {
it('removes an entry by id', () => {
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
const saved = saveOrThrow(
baseInput({ filter: { expression: 'http.status_code >= 500' } }),
);
store.remove(saved.id, 'logs');
const entries = store.list('logs');
expect(entries).toHaveLength(1);
expect(entries[0].filter.expression).toBe("severity_text = 'ERROR'");
});
it('is a no-op when the id does not exist', () => {
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
store.remove('does-not-exist', 'logs');
expect(store.list('logs')).toHaveLength(1);
});
it('does not touch other signals', () => {
const logsEntry = saveOrThrow(
baseInput({
signal: 'logs',
filter: { expression: "service.name = 'frontend'" },
}),
);
store.save(
baseInput({
signal: 'traces',
filter: { expression: "service.name = 'frontend'" },
}),
);
store.remove(logsEntry.id, 'logs');
expect(store.list('logs')).toHaveLength(0);
expect(store.list('traces')).toHaveLength(1);
});
});
describe('persistence', () => {
it('reads back the same entries after the in-memory state is reset', () => {
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
store.save(baseInput({ filter: { expression: 'http.status_code >= 500' } }));
store.useRecentQueriesStore.setState({ buckets: {} });
const entries = store.list('logs');
expect(entries.map((e) => e.filter.expression)).toStrictEqual([
'http.status_code >= 500',
"severity_text = 'ERROR'",
]);
});
});
describe('reactive subscription via zustand', () => {
it('notifies zustand subscribers on save', () => {
const cb = jest.fn();
const unsubscribe = store.useRecentQueriesStore.subscribe(cb);
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
expect(cb).toHaveBeenCalledTimes(1);
unsubscribe();
});
it('notifies zustand subscribers on remove', () => {
const saved = saveOrThrow(
baseInput({ filter: { expression: "severity_text = 'ERROR'" } }),
);
const cb = jest.fn();
const unsubscribe = store.useRecentQueriesStore.subscribe(cb);
store.remove(saved.id, 'logs');
expect(cb).toHaveBeenCalledTimes(1);
unsubscribe();
});
it('stops notifying after unsubscribe', () => {
const cb = jest.fn();
const unsubscribe = store.useRecentQueriesStore.subscribe(cb);
unsubscribe();
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
expect(cb).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,144 @@
import get from 'api/browser/localstorage/get';
import set from 'api/browser/localstorage/set';
import type { SignalType } from 'types/api/v5/queryRange';
import { create } from 'zustand';
import { MAX_ENTRIES, STORAGE_VERSION } from './constants';
import { normalizeFilterExpression } from './normalize';
import type { RecentQueriesStoreShape, RecentQueryEntry } from './types';
import { bucketKey, makeId, normalizeSource, storageKeyFor } from './utils';
// Mirrors parsed localStorage so equal raw strings return the same array ref —
// preserves Object.is for zustand selector bail-out.
const persistedBucketCache = new Map<
string,
{ raw: string; parsed: RecentQueryEntry[] }
>();
function loadBucketFromStorage(
signal: SignalType,
source: string,
): RecentQueryEntry[] | null {
const key = bucketKey(signal, source);
try {
const raw = get(storageKeyFor(signal, source));
if (!raw) {
persistedBucketCache.delete(key);
return null;
}
const cached = persistedBucketCache.get(key);
if (cached && cached.raw === raw) {
return cached.parsed;
}
const parsedShape = JSON.parse(raw) as RecentQueriesStoreShape;
if (
parsedShape?.version !== STORAGE_VERSION ||
!Array.isArray(parsedShape.entries)
) {
persistedBucketCache.delete(key);
return null;
}
persistedBucketCache.set(key, { raw, parsed: parsedShape.entries });
return parsedShape.entries;
} catch {
persistedBucketCache.delete(key);
return null;
}
}
function saveBucketToStorage(
signal: SignalType,
source: string,
entries: RecentQueryEntry[],
): void {
try {
const raw = JSON.stringify({ version: STORAGE_VERSION, entries });
if (set(storageKeyFor(signal, source), raw)) {
persistedBucketCache.set(bucketKey(signal, source), {
raw,
parsed: entries,
});
}
} catch {
// Ignore storage errors (e.g. quota exceeded, JSON.stringify failure).
}
}
export type RecentQueryInput = Omit<
RecentQueryEntry,
'id' | 'lastUsedAt' | 'source'
> & {
source?: string;
};
type RecentQueriesState = {
buckets: Record<string, RecentQueryEntry[]>;
save: (entry: RecentQueryInput) => RecentQueryEntry | null;
remove: (id: string, signal: SignalType, source?: string) => void;
};
export const useRecentQueriesStore = create<RecentQueriesState>()(
(set, get) => ({
buckets: {},
save: (entry): RecentQueryEntry | null => {
const normalized = normalizeFilterExpression(entry.filter.expression);
if (!normalized) {
return null;
}
const source = normalizeSource(entry.source);
const key = bucketKey(entry.signal, source);
const current =
get().buckets[key] ?? loadBucketFromStorage(entry.signal, source) ?? [];
const filtered = current.filter(
(e) => normalizeFilterExpression(e.filter.expression) !== normalized,
);
const newEntry: RecentQueryEntry = {
...entry,
source,
id: makeId(entry.signal, source, normalized),
lastUsedAt: Date.now(),
};
const next = [newEntry, ...filtered].slice(0, MAX_ENTRIES);
set({ buckets: { ...get().buckets, [key]: next } });
saveBucketToStorage(entry.signal, source, next);
return newEntry;
},
remove: (id, signal, source = ''): void => {
const key = bucketKey(signal, source);
const current = get().buckets[key] ?? loadBucketFromStorage(signal, source);
if (!current) {
return;
}
const next = current.filter((e) => e.id !== id);
if (next.length === current.length) {
return;
}
set({ buckets: { ...get().buckets, [key]: next } });
saveBucketToStorage(signal, source, next);
},
}),
);
// Plain-function wrappers for non-React callers — same pattern as useColumnStore.ts.
export function save(entry: RecentQueryInput): RecentQueryEntry | null {
return useRecentQueriesStore.getState().save(entry);
}
export function remove(id: string, signal: SignalType, source = ''): void {
useRecentQueriesStore.getState().remove(id, signal, source);
}
// Synchronous bucket read with localStorage fallback for non-React callers.
export function list(signal: SignalType, source = ''): RecentQueryEntry[] {
const key = bucketKey(signal, source);
const state = useRecentQueriesStore.getState();
if (state.buckets[key]) {
return state.buckets[key];
}
return loadBucketFromStorage(signal, source) ?? [];
}

View File

@@ -0,0 +1,113 @@
import { DataSource } from 'types/common/queryBuilder';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { validateQuery } from 'utils/queryValidationUtils';
import * as store from './recentQueriesStore';
import { saveRecentQuery } from './saveRecentQuery';
jest.mock('utils/queryValidationUtils', () => ({
validateQuery: jest.fn(),
}));
const mockedValidateQuery = validateQuery as jest.MockedFunction<
typeof validateQuery
>;
const buildComposite = (
overrides: Partial<IBuilderQuery>[] = [{}],
): { builder: { queryData: IBuilderQuery[] } } => ({
builder: {
queryData: overrides.map((o, i) => ({
queryName: `Q${i}`,
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
aggregateAttribute: undefined as never,
functions: [],
filter: { expression: 'service.name = "frontend"' },
groupBy: [],
expression: `Q${i}`,
disabled: false,
having: [],
limit: null,
stepInterval: null,
orderBy: [],
legend: '',
...o,
})) as IBuilderQuery[],
},
});
describe('saveRecentQuery', () => {
beforeEach(() => {
store.useRecentQueriesStore.setState({ buckets: {} });
localStorage.clear();
mockedValidateQuery.mockReturnValue({
isValid: true,
message: '',
errors: [],
});
});
it('saves the composite query when validation passes', () => {
saveRecentQuery(buildComposite());
const entries = store.list('logs');
expect(entries).toHaveLength(1);
expect(entries[0].filter.expression).toBe('service.name = "frontend"');
});
it('does not save when validateQuery rejects the expression', () => {
mockedValidateQuery.mockReturnValue({
isValid: false,
message: 'bad',
errors: [],
});
saveRecentQuery(buildComposite());
expect(store.list('logs')).toHaveLength(0);
});
it('does not save a builder query with an empty filter expression', () => {
saveRecentQuery(buildComposite([{ filter: { expression: '' } }]));
expect(store.list('logs')).toHaveLength(0);
});
it('saves each builder query in the composite separately', () => {
saveRecentQuery(
buildComposite([
{
dataSource: DataSource.LOGS,
filter: { expression: "service.name = 'frontend'" },
},
{
dataSource: DataSource.TRACES,
filter: { expression: "service.name = 'orders-api'" },
},
]),
);
expect(store.list('logs')).toHaveLength(1);
expect(store.list('traces')).toHaveLength(1);
});
it('skips builder queries whose dataSource is not a supported signal', () => {
saveRecentQuery(
buildComposite([{ dataSource: 'unknown' as IBuilderQuery['dataSource'] }]),
);
expect(store.list('logs')).toHaveLength(0);
expect(store.list('traces')).toHaveLength(0);
expect(store.list('metrics')).toHaveLength(0);
});
it('is a no-op when the composite is null, undefined, or empty', () => {
saveRecentQuery(null);
saveRecentQuery(undefined);
saveRecentQuery({ builder: { queryData: [] } });
saveRecentQuery({});
expect(store.list('logs')).toHaveLength(0);
});
});

View File

@@ -0,0 +1,52 @@
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import type { SignalType } from 'types/api/v5/queryRange';
import { validateQuery } from 'utils/queryValidationUtils';
import * as store from './recentQueriesStore';
function toSignal(dataSource: IBuilderQuery['dataSource']): SignalType | null {
if (
dataSource === 'logs' ||
dataSource === 'traces' ||
dataSource === 'metrics'
) {
return dataSource;
}
return null;
}
type CompositeWithBuilder = {
builder?: { queryData?: IBuilderQuery[] };
};
// Persists each builder query in the composite as a recent entry. Call this
// only from explicit user-driven Run triggers — reacting to stagedQuery or any
// other derived state pollutes recents with navigation/refresh/go-to traffic.
export function saveRecentQuery(
query: CompositeWithBuilder | null | undefined,
): void {
const queryData = query?.builder?.queryData;
if (!Array.isArray(queryData) || queryData.length === 0) {
return;
}
queryData.forEach((q) => {
const expression = q.filter?.expression?.trim();
if (!expression) {
return;
}
const validation = validateQuery(expression);
if (!validation.isValid) {
return;
}
const signal = toSignal(q.dataSource);
if (!signal) {
return;
}
store.save({
signal,
source: q.source ?? '',
filter: q.filter ?? { expression: '' },
});
});
}

View File

@@ -0,0 +1,14 @@
import type { Filter, SignalType } from 'types/api/v5/queryRange';
export interface RecentQueryEntry {
id: string;
signal: SignalType;
source: string;
filter: Filter;
lastUsedAt: number;
}
export interface RecentQueriesStoreShape {
version: 1;
entries: RecentQueryEntry[];
}

View File

@@ -0,0 +1,24 @@
import type { SignalType } from 'types/api/v5/queryRange';
import { STORAGE_KEY_PREFIX } from './constants';
export function normalizeSource(source: string | undefined): string {
return source ?? '';
}
export function bucketKey(signal: SignalType, source: string): string {
return `${signal}:${source}`;
}
export function storageKeyFor(signal: SignalType, source: string): string {
return `${STORAGE_KEY_PREFIX}:${bucketKey(signal, source)}`;
}
// Same (signal, source, normalized filter) ⇒ same id ⇒ upsert.
export function makeId(
signal: SignalType,
source: string,
normalizedFilter: string,
): string {
return `${signal}|${source}|${normalizedFilter}`;
}

View File

@@ -0,0 +1,53 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import DashboardContainer from 'container/DashboardContainer';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ErrorType } from 'types/common';
function DashboardPage(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
const [onModal, Content] = Modal.useModal();
const { isLoading, isError, isFetching, error } = useDashboardBootstrap(
dashboardId,
{ confirm: onModal.confirm },
);
const dashboardTitle = useDashboardStore((s) => s.dashboardData?.data.title);
useEffect(() => {
document.title = dashboardTitle || document.title;
}, [dashboardTitle]);
const errorMessage = isError
? (error as AxiosError<{ errorType: string }>)?.response?.data?.errorType
: 'Something went wrong';
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
return <NotFound />;
}
if (isError && errorMessage) {
return <Typography>{errorMessage}</Typography>;
}
if (isLoading) {
return <Spinner tip="Loading.." />;
}
return (
<>
{Content}
<DashboardContainer />
</>
);
}
export default DashboardPage;

View File

@@ -1,53 +1,15 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import DashboardContainer from 'container/DashboardContainer';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ErrorType } from 'types/common';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardPageV2 from 'pages/DashboardPageV2';
function DashboardPage(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
import DashboardPage from './DashboardPage';
const [onModal, Content] = Modal.useModal();
// Serves the V2 dashboard detail page when the `use_dashboard_v2` flag is active;
// otherwise the existing V1 page. Lets V2 dark-ship behind the flag without
// changing route definitions.
function DashboardPageEntry(): JSX.Element {
const isDashboardV2 = useIsDashboardV2();
const { isLoading, isError, isFetching, error } = useDashboardBootstrap(
dashboardId,
{ confirm: onModal.confirm },
);
const dashboardTitle = useDashboardStore((s) => s.dashboardData?.data.title);
useEffect(() => {
document.title = dashboardTitle || document.title;
}, [dashboardTitle]);
const errorMessage = isError
? (error as AxiosError<{ errorType: string }>)?.response?.data?.errorType
: 'Something went wrong';
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
return <NotFound />;
}
if (isError && errorMessage) {
return <Typography>{errorMessage}</Typography>;
}
if (isLoading) {
return <Spinner tip="Loading.." />;
}
return (
<>
{Content}
<DashboardContainer />
</>
);
return isDashboardV2 ? <DashboardPageV2 /> : <DashboardPage />;
}
export default DashboardPage;
export default DashboardPageEntry;

View File

@@ -0,0 +1,103 @@
import { useEffect, useMemo, useState } from 'react';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
import { Select } from 'antd';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import useDebounce from 'hooks/useDebounce';
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
import styles from './VariableForm.module.scss';
interface DynamicVariableFieldsProps {
attribute: string;
signal: TelemetrySignal;
onChange: (patch: {
dynamicAttribute?: string;
dynamicSignal?: TelemetrySignal;
}) => void;
onPreview: (values: (string | number)[]) => void;
}
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
function DynamicVariableFields({
attribute,
signal,
onChange,
onPreview,
}: DynamicVariableFieldsProps): JSX.Element {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const { data: keyData, isLoading } = useGetFieldKeys({
signal,
name: debouncedSearch || undefined,
});
// `keys` is a Record keyed BY field name; the field names are the map keys.
// When the API reports the list is `complete`, search filters locally.
const isComplete = keyData?.data?.complete === true;
const options = useMemo(
() =>
Object.keys(keyData?.data?.keys ?? {}).map((name) => ({
label: name,
value: name,
})),
[keyData],
);
const { data: valueData } = useGetFieldValues({
signal,
name: attribute,
enabled: !!attribute,
});
useEffect(() => {
const payload = valueData?.data;
const values =
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
onPreview(values);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [valueData]);
return (
<>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Source</Typography.Text>
</div>
<SelectSimple
className={styles.sortSelect}
value={signal}
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
onChange={(value): void =>
onChange({ dynamicSignal: value as TelemetrySignal })
}
testId="variable-signal-select"
/>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Attribute</Typography.Text>
</div>
<Select
className={styles.searchSelect}
showSearch
value={attribute || undefined}
placeholder="Select a telemetry field"
loading={isLoading}
filterOption={isComplete}
onSearch={setSearch}
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
options={options}
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
data-testid="variable-field-select"
/>
</div>
</>
);
}
export default DynamicVariableFields;

View File

@@ -0,0 +1,93 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import Editor from 'components/Editor';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { VariableSort } from '../variableModel';
import styles from './VariableForm.module.scss';
interface QueryVariableFieldsProps {
queryValue: string;
sort: VariableSort;
onChange: (queryValue: string) => void;
onPreview: (values: (string | number)[]) => void;
onError: (message: string | null) => void;
}
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
function QueryVariableFields({
queryValue,
sort,
onChange,
onPreview,
onError,
}: QueryVariableFieldsProps): JSX.Element {
const [isRunning, setIsRunning] = useState(false);
const runTest = async (): Promise<void> => {
setIsRunning(true);
onError(null);
try {
const res = await dashboardVariablesQuery({
query: queryValue,
variables: {},
});
if (res.statusCode === 200 && res.payload) {
onPreview(
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
);
} else {
onError(res.error || 'Failed to run query');
onPreview([]);
}
} catch (err) {
onError((err as Error).message || 'Failed to run query');
onPreview([]);
} finally {
setIsRunning(false);
}
};
return (
<div className={styles.queryContainer}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Query</Typography.Text>
</div>
<div className={styles.editorWrap}>
<Editor
language="sql"
value={queryValue}
onChange={(value): void => onChange(value)}
height="240px"
options={{
fontSize: 13,
wordWrap: 'on',
lineNumbers: 'off',
glyphMargin: false,
folding: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 0,
minimap: { enabled: false },
}}
/>
</div>
<div className={styles.testRow}>
<Button
variant="solid"
color="primary"
size="sm"
loading={isRunning}
disabled={!queryValue}
onClick={runTest}
testId="variable-test-run"
>
Test Run Query
</Button>
</div>
</div>
);
}
export default QueryVariableFields;

View File

@@ -0,0 +1,310 @@
/* Faithful reproduction of the V1 VariableItem layout, scoped as a module and
built on @signozhq components where possible. antd is retained only for the
monaco Editor, multiline TextArea, Collapse, and searchable Selects. */
.container {
display: flex;
flex-direction: column;
border: 1px solid var(--l1-border);
border-radius: 3px;
}
.allVariables {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--l1-border);
}
.allVariablesBtn {
--button-height: 24px;
--button-padding: 0;
color: var(--muted-foreground);
}
.content {
display: flex;
flex-direction: column;
gap: 20px;
padding: 12px 16px 20px;
}
/* VariableItemRow */
.row {
display: flex;
gap: 1rem;
margin-bottom: 0;
}
/* LabelContainer */
.labelContainer {
width: 200px;
}
.label {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.column {
flex-direction: column;
gap: 8px;
}
.input,
.textarea,
.defaultInput {
padding: 6px 6px 6px 8px;
border: 1px solid var(--l1-border);
border-radius: 2px;
background: var(--l3-background);
}
.input,
.textarea {
width: 100%;
}
.defaultInput {
width: 342px;
}
.errorText {
font-size: 12px;
color: var(--bg-amber-500);
}
/* Variable type segmented group */
.typeSection {
align-items: center;
justify-content: space-between;
}
.typeLabelContainer {
display: flex;
align-items: center;
gap: 8px;
width: auto;
}
.typeBtnGroup {
display: grid;
grid-template-columns: repeat(4, max-content);
height: 32px;
flex-shrink: 0;
border: 1px solid var(--l1-border);
border-radius: 2px;
background: var(--l2-background);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
}
.typeBtn {
--button-height: 32px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 114px;
border-radius: 0;
color: var(--l2-foreground);
& + & {
border-left: 1px solid var(--l1-border);
}
}
.typeBtnSelected {
background: var(--l1-border);
color: var(--l1-foreground);
}
.betaTag {
margin-left: 4px;
}
/* Query */
.queryContainer {
display: flex;
flex-flow: column wrap;
gap: 1rem;
min-width: 0;
margin-bottom: 0;
}
.editorWrap {
height: 240px;
overflow: hidden;
border: 1px solid var(--l1-border);
border-radius: 2px;
}
.testRow {
display: flex;
margin-top: 8px;
}
/* Custom — antd Collapse */
.customSection {
margin-bottom: 0;
}
.customSection :global(.custom-collapse) {
width: 100%;
border: 1px solid var(--l1-border);
border-radius: 3px 3px 0 0;
:global(.ant-collapse-item) {
border-bottom: none;
}
:global(.ant-collapse-header) {
align-items: center;
gap: 8px;
height: 38px;
padding: 12px;
background: var(--l3-background);
border-radius: 3px 3px 0 0;
}
:global(.ant-collapse-header-text) {
display: flex;
align-items: center;
gap: 10px;
padding: 1px 2px;
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
line-height: 18px;
border-radius: 2px;
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
}
:global(.ant-collapse-content-box) {
padding: 0;
}
:global(.comma-input) {
height: 109px;
border: none;
}
}
/* Textbox */
.textboxSection {
align-items: center;
justify-content: space-between;
margin-bottom: 0;
}
/* Preview strip */
.previewSection {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 88px;
margin-bottom: 0;
padding-bottom: 8px;
border: 1px solid var(--l1-border);
border-radius: 3px;
}
.previewLabel {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 4px 8px;
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
line-height: 18px;
border-radius: 3px 0 2px;
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
}
.previewValues {
display: flex;
flex-flow: wrap;
gap: 8px;
padding: 4.5px 11px;
overflow-y: auto;
}
.previewValues [data-slot='badge'] {
height: 30px;
align-items: center;
color: var(--l1-foreground);
font-family: 'Space Mono';
font-size: 14px;
border: 1px solid var(--l1-border);
border-radius: 2px;
}
.previewError {
color: var(--bg-amber-500);
}
/* Sort / multi / all / default rows */
.sortSection,
.multiSection,
.allOptionSection,
.dynamicSection {
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0;
}
.sortSection {
align-items: center;
}
.rowLabel {
width: 339px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
.sortSelect {
width: 192px;
}
.defaultValueSection {
display: grid;
grid-template-columns: max-content 1fr;
gap: 1rem;
align-items: center;
margin-bottom: 0;
}
.defaultValueSection .label {
display: block;
margin-bottom: 2px;
}
.defaultValueDesc {
display: block;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
line-height: 18px;
letter-spacing: -0.06px;
}
.searchSelect {
width: 100%;
}
/* Footer */
.footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 12px;
}

View File

@@ -0,0 +1,351 @@
import { useEffect, useState } from 'react';
import { ArrowLeft, Check, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse/searchable Select: no @signozhq/ui equivalent
import { Collapse, Input as AntdInput, Select } from 'antd';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import {
VARIABLE_SORTS,
type VariableFormModel,
type VariableSort,
type VariableType,
} from '../variableModel';
import DynamicVariableFields from './DynamicVariableFields';
import QueryVariableFields from './QueryVariableFields';
import VariableTypeSelector from './VariableTypeSelector';
import styles from './VariableForm.module.scss';
const SORT_LABEL: Record<VariableSort, string> = {
DISABLED: 'Disabled',
ASC: 'Ascending',
DESC: 'Descending',
};
function getNameError(name: string, existingNames: string[]): string | null {
if (name === '') {
return 'Variable name is required';
}
if (/\s/.test(name)) {
return 'Variable name cannot contain whitespaces';
}
if (existingNames.includes(name)) {
return 'Variable name already exists';
}
return null;
}
interface VariableFormProps {
initial: VariableFormModel;
/** Names of the other variables, for uniqueness validation. */
existingNames: string[];
isSaving: boolean;
onClose: () => void;
onSave: (model: VariableFormModel) => void;
}
/**
* In-drawer variable editor reproducing the V1 VariableItem layout, built on
* @signozhq components (antd kept only for the monaco editor, TextArea, Collapse
* and searchable selects). Master→detail: renders in place of the list.
*/
function VariableForm({
initial,
existingNames,
isSaving,
onClose,
onSave,
}: VariableFormProps): JSX.Element {
const [model, setModel] = useState<VariableFormModel>(initial);
const [previewValues, setPreviewValues] = useState<(string | number)[]>([]);
const [previewError, setPreviewError] = useState<string | null>(null);
const [defaultValue, setDefaultValue] = useState<string>(
((initial.defaultValue as { value?: string })?.value ?? '') as string,
);
useEffect(() => {
setModel(initial);
setPreviewValues([]);
setPreviewError(null);
setDefaultValue(
((initial.defaultValue as { value?: string })?.value ?? '') as string,
);
}, [initial]);
const set = (patch: Partial<VariableFormModel>): void =>
setModel((prev) => ({ ...prev, ...patch }));
const selectType = (type: VariableType): void => {
set({ type });
setPreviewValues([]);
setPreviewError(null);
};
const onCustomChange = (value: string): void => {
set({ customValue: value });
setPreviewValues(
sortValues(commaValuesParser(value), model.sort) as (string | number)[],
);
};
const trimmedName = model.name.trim();
const nameError = getNameError(trimmedName, existingNames);
const isListType =
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
const handleSave = (): void => {
onSave({
...model,
name: trimmedName,
defaultValue: defaultValue ? { value: defaultValue } : undefined,
});
};
return (
<>
<div className={styles.container}>
<div className={styles.allVariables}>
<Button
variant="ghost"
color="secondary"
className={styles.allVariablesBtn}
prefix={<ArrowLeft size={14} />}
onClick={onClose}
testId="variable-form-back"
>
All variables
</Button>
</div>
<div className={styles.content}>
{/* Name */}
<div className={cx(styles.row, styles.column)}>
<Typography.Text className={styles.label}>Name</Typography.Text>
<Input
className={styles.input}
value={model.name}
placeholder="Unique name of the variable"
onChange={(e): void => set({ name: e.target.value })}
testId="variable-name-input"
/>
{nameError ? (
<Typography.Text className={styles.errorText}>
{nameError}
</Typography.Text>
) : null}
</div>
{/* Description */}
<div className={cx(styles.row, styles.column)}>
<Typography.Text className={styles.label}>Description</Typography.Text>
<AntdInput.TextArea
className={styles.textarea}
value={model.description}
placeholder="Enter a description for the variable"
rows={3}
onChange={(e): void => set({ description: e.target.value })}
data-testid="variable-description-input"
/>
</div>
{/* Variable Type */}
<VariableTypeSelector value={model.type} onChange={selectType} />
{/* Type-specific body */}
{model.type === 'DYNAMIC' ? (
<DynamicVariableFields
attribute={model.dynamicAttribute}
signal={model.dynamicSignal}
onChange={(patch): void => set(patch)}
onPreview={setPreviewValues}
/>
) : null}
{model.type === 'QUERY' ? (
<QueryVariableFields
queryValue={model.queryValue}
sort={model.sort}
onChange={(queryValue): void => set({ queryValue })}
onPreview={setPreviewValues}
onError={setPreviewError}
/>
) : null}
{model.type === 'CUSTOM' ? (
<div className={cx(styles.row, styles.customSection)}>
<Collapse
collapsible="header"
rootClassName="custom-collapse"
defaultActiveKey={['1']}
items={[
{
key: '1',
label: 'Options',
children: (
<AntdInput.TextArea
value={model.customValue}
placeholder="Enter options separated by commas."
rootClassName="comma-input"
onChange={(e): void => onCustomChange(e.target.value)}
data-testid="variable-custom-input"
/>
),
},
]}
/>
</div>
) : null}
{model.type === 'TEXT' ? (
<div className={cx(styles.row, styles.textboxSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Default Value
</Typography.Text>
</div>
<Input
className={styles.defaultInput}
value={model.textValue}
placeholder="Enter a default value (if any)..."
onChange={(e): void => set({ textValue: e.target.value })}
testId="variable-text-input"
/>
</div>
) : null}
{/* Shared rows for list-type variables */}
{isListType ? (
<>
<div className={cx(styles.row, styles.previewSection)}>
<Typography.Text className={styles.previewLabel}>
Preview of Values
</Typography.Text>
<div className={styles.previewValues}>
{previewError ? (
<Typography.Text className={styles.previewError}>
{previewError}
</Typography.Text>
) : (
previewValues.map((value, idx) => (
<Badge
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
key={`${value}-${idx}`}
color="vanilla"
>
{value.toString()}
</Badge>
))
)}
</div>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
</div>
<SelectSimple
className={styles.sortSelect}
value={model.sort}
items={VARIABLE_SORTS.map((sort) => ({
label: SORT_LABEL[sort],
value: sort,
}))}
onChange={(value): void => set({ sort: value as VariableSort })}
testId="variable-sort-select"
/>
</div>
<div className={cx(styles.row, styles.multiSection)}>
<Typography.Text className={styles.rowLabel}>
Enable multiple values to be checked
</Typography.Text>
<Switch
value={model.multiSelect}
onChange={(checked): void => {
set({
multiSelect: checked,
showAllOption: checked ? model.showAllOption : false,
});
}}
testId="variable-multi-switch"
/>
</div>
{model.multiSelect && showAllOptionField ? (
<div className={cx(styles.row, styles.allOptionSection)}>
<Typography.Text className={styles.rowLabel}>
Include an option for ALL values
</Typography.Text>
<Switch
value={model.showAllOption}
onChange={(checked): void => set({ showAllOption: checked })}
testId="variable-all-switch"
/>
</div>
) : null}
<div className={cx(styles.row, styles.defaultValueSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Default Value
</Typography.Text>
<Typography.Text className={styles.defaultValueDesc}>
{model.type === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value'}
</Typography.Text>
</div>
<Select
className={styles.searchSelect}
showSearch
allowClear
placeholder="Select a default value"
value={defaultValue || undefined}
onChange={(value): void => setDefaultValue(value ?? '')}
options={previewValues.map((value) => ({
label: value.toString(),
value: value.toString(),
}))}
data-testid="variable-default-select"
/>
</div>
</>
) : null}
</div>
</div>
<div className={styles.footer}>
<Button
variant="solid"
color="secondary"
prefix={<X size={14} />}
onClick={onClose}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
prefix={<Check size={14} />}
disabled={!!nameError}
loading={isSaving}
onClick={handleSave}
testId="variable-save"
>
Save Variable
</Button>
</div>
</>
);
}
export default VariableForm;

View File

@@ -0,0 +1,99 @@
import {
ClipboardType,
DatabaseZap,
Info,
LayoutList,
Pyramid,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
import type { VariableType } from '../variableModel';
import styles from './VariableForm.module.scss';
interface VariableTypeSelectorProps {
value: VariableType;
onChange: (type: VariableType) => void;
}
/** The segmented Dynamic / Textbox / Custom / Query type picker. */
function VariableTypeSelector({
value,
onChange,
}: VariableTypeSelectorProps): JSX.Element {
return (
<div className={cx(styles.row, styles.typeSection)}>
<div className={styles.typeLabelContainer}>
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
<TextToolTip
text="Learn more about supported variable types"
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
urlText="here"
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<div className={styles.typeBtnGroup}>
<Button
variant="ghost"
color="secondary"
prefix={<Pyramid size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'DYNAMIC',
})}
onClick={(): void => onChange('DYNAMIC')}
testId="variable-type-dynamic"
>
Dynamic
<Badge color="robin" className={styles.betaTag}>
Beta
</Badge>
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<ClipboardType size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'TEXT',
})}
onClick={(): void => onChange('TEXT')}
testId="variable-type-textbox"
>
Textbox
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<LayoutList size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'CUSTOM',
})}
onClick={(): void => onChange('CUSTOM')}
testId="variable-type-custom"
>
Custom
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<DatabaseZap size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'QUERY',
})}
onClick={(): void => onChange('QUERY')}
testId="variable-type-query"
>
Query
<Badge color="amber" className={styles.betaTag}>
Not Recommended
</Badge>
</Button>
</div>
</div>
);
}
export default VariableTypeSelector;

View File

@@ -0,0 +1,101 @@
.container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 16px;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.titleRow {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
}
.title {
font-size: 14px;
font-weight: 500;
color: var(--l1-foreground);
}
.subtitle {
font-size: 12px;
color: var(--l2-foreground);
}
.empty {
padding: 32px;
text-align: center;
border: 1px dashed var(--l1-border);
border-radius: 4px;
color: var(--l2-foreground);
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l1-background);
}
.rowMain {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.varName {
font-weight: 500;
color: var(--l1-foreground);
}
.varDesc {
min-width: 0;
overflow: hidden;
font-size: 12px;
color: var(--l2-foreground);
text-overflow: ellipsis;
white-space: nowrap;
}
.typeTag {
flex-shrink: 0;
padding: 1px 8px;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--l2-foreground);
text-transform: uppercase;
background: var(--l2-background);
border-radius: 10px;
}
.rowActions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 2px;
}
.confirmText {
margin-right: 4px;
font-size: 12px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,139 @@
import {
Check,
ChevronDown,
ChevronUp,
PenLine,
Trash2,
X,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { VariableFormModel } from './variableModel';
import styles from './Variables.module.scss';
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
TEXT: 'Text',
DYNAMIC: 'Dynamic',
};
interface VariablesListProps {
variables: VariableFormModel[];
canEdit: boolean;
/** Index whose delete is awaiting inline confirmation, if any. */
confirmingIndex: number | null;
onEdit: (index: number) => void;
onRequestDelete: (index: number) => void;
onConfirmDelete: (index: number) => void;
onCancelDelete: () => void;
onMove: (from: number, to: number) => void;
}
function VariablesList({
variables,
canEdit,
confirmingIndex,
onEdit,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onMove,
}: VariablesListProps): JSX.Element {
return (
<div className={styles.list} data-testid="variables-list">
{variables.map((variable, index) => (
<div
className={styles.row}
key={variable.name || `variable-${index}`}
data-testid={`variable-row-${variable.name}`}
>
<div className={styles.rowMain}>
<Typography.Text className={styles.varName}>
${variable.name}
</Typography.Text>
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
{variable.description ? (
<Typography.Text className={styles.varDesc}>
{variable.description}
</Typography.Text>
) : null}
</div>
{canEdit && confirmingIndex === index ? (
<div className={styles.rowActions}>
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</div>
) : null}
{canEdit && confirmingIndex !== index ? (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === 0}
onClick={(): void => onMove(index, index - 1)}
aria-label="Move up"
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === variables.length - 1}
onClick={(): void => onMove(index, index + 1)}
aria-label="Move down"
>
<ChevronDown size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</div>
) : null}
</div>
))}
</div>
);
}
export default VariablesList;

View File

@@ -0,0 +1,147 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useSaveVariables } from './useSaveVariables';
import { dtoToFormModel } from './variableAdapters';
import {
emptyVariableFormModel,
type VariableFormModel,
} from './variableModel';
import VariableForm from './VariableForm/VariableForm';
import VariablesList from './VariablesList';
import styles from './Variables.module.scss';
interface VariablesSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
/** `null` index = adding a new variable; a number = editing that row. */
type EditingState = { index: number | null } | null;
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const { save, isSaving } = useSaveVariables();
const initialModels = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const [variables, setVariables] = useState<VariableFormModel[]>(initialModels);
// Resync from the dashboard after a save round-trips (refetch bumps updatedAt).
useEffect(() => {
setVariables(initialModels);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
const [editing, setEditing] = useState<EditingState>(null);
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);
const editingModel: VariableFormModel | null = useMemo(() => {
if (!editing) {
return null;
}
return editing.index === null
? emptyVariableFormModel()
: variables[editing.index];
}, [editing, variables]);
const existingNames = useMemo(() => {
const self = editing?.index ?? null;
return variables.filter((_, i) => i !== self).map((v) => v.name);
}, [variables, editing]);
const persist = (next: VariableFormModel[]): void => {
setVariables(next);
void save(next);
};
const handleFormSave = (model: VariableFormModel): void => {
const next = [...variables];
if (editing?.index == null) {
next.push(model);
} else {
next[editing.index] = model;
}
setEditing(null);
persist(next);
};
const handleMove = (from: number, to: number): void => {
if (to < 0 || to >= variables.length) {
return;
}
const next = [...variables];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
persist(next);
};
const handleConfirmDelete = (index: number): void => {
persist(variables.filter((_, i) => i !== index));
setConfirmDeleteIndex(null);
};
// Detail view — edit/new form replaces the list in place (no modal).
if (editingModel) {
return (
<VariableForm
initial={editingModel}
existingNames={existingNames}
isSaving={isSaving}
onClose={(): void => setEditing(null)}
onSave={handleFormSave}
/>
);
}
// Master view — the variables list.
return (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.titleRow}>
<Typography.Text className={styles.title}>Variables</Typography.Text>
<Typography.Text className={styles.subtitle}>
Define variables to parameterize panel queries.
</Typography.Text>
</div>
{isEditable ? (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={(): void => setEditing({ index: null })}
testId="add-variable"
>
New variable
</Button>
) : null}
</div>
{variables.length === 0 ? (
<div className={styles.empty}>
<Typography.Text>No variables defined yet.</Typography.Text>
</div>
) : (
<VariablesList
variables={variables}
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setEditing({ index })}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
/>
)}
</div>
);
}
export default VariablesSettings;

View File

@@ -0,0 +1,51 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { toast } from '@signozhq/ui/sonner';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useDashboardStore } from '../../store/useDashboardStore';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableModel';
import { buildVariablesPatch } from './variablePatchOps';
interface UseSaveVariables {
save: (variables: VariableFormModel[]) => Promise<boolean>;
isSaving: boolean;
}
/**
* Persists the dashboard's variable list via a single `/spec/variables` patch,
* then refetches. Mirrors the General-settings save flow (patch → toast →
* refetch → surface errors).
*/
export function useSaveVariables(): UseSaveVariables {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
const [isSaving, setIsSaving] = useState(false);
const save = useCallback(
async (variables: VariableFormModel[]): Promise<boolean> => {
if (!dashboardId) {
return false;
}
const dtos = variables.map(formModelToDto);
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, buildVariablesPatch(dtos));
toast.success('Variables updated');
refetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
return false;
} finally {
setIsSaving(false);
}
},
[dashboardId, refetch, showErrorModal],
);
return { save, isSaving };
}

View File

@@ -0,0 +1,154 @@
import {
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesListVariableSpecDTO,
DashboardtypesVariableDTO,
DashboardtypesVariablePluginDTO,
DashboardTextVariableSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
PLUGIN_KIND,
type TelemetrySignal,
type VariableFormModel,
type VariableSort,
} from './variableModel';
/** DTO envelope → flat form model (for display / editing). */
export function dtoToFormModel(
dto: DashboardtypesVariableDTO,
): VariableFormModel {
const base = emptyVariableFormModel();
const display = dto.spec?.display;
const common: VariableFormModel = {
...base,
name: dto.spec?.name ?? display?.name ?? '',
description: display?.description ?? '',
hidden: display?.hidden ?? false,
};
// Text variable — a distinct envelope (no list plugin).
if (dto.kind === TextEnvelopeKind.TextVariable) {
const spec = dto.spec as DashboardTextVariableSpecDTO;
return {
...common,
type: 'TEXT',
textValue: spec.value ?? '',
textConstant: spec.constant ?? false,
};
}
// List variable — Query / Custom / Dynamic, distinguished by plugin.kind.
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
const listCommon: VariableFormModel = {
...common,
multiSelect: spec.allowMultiple ?? false,
showAllOption: spec.allowAllValue ?? false,
sort: (spec.sort as VariableSort) ?? 'DISABLED',
defaultValue: spec.defaultValue,
};
const plugin = spec.plugin;
if (plugin?.kind === CustomPluginKind['signoz/CustomVariable']) {
return {
...listCommon,
type: 'CUSTOM',
customValue: plugin.spec.customValue ?? '',
};
}
if (plugin?.kind === DynamicPluginKind['signoz/DynamicVariable']) {
return {
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
dynamicSignal: (plugin.spec.signal as TelemetrySignal) ?? 'traces',
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).
return {
...listCommon,
type: 'QUERY',
queryValue:
plugin?.kind === QueryPluginKind['signoz/QueryVariable']
? (plugin.spec.queryValue ?? '')
: '',
};
}
function buildPlugin(
model: VariableFormModel,
): DashboardtypesVariablePluginDTO {
switch (model.type) {
case 'CUSTOM':
return {
kind: CustomPluginKind['signoz/CustomVariable'],
spec: { customValue: model.customValue },
};
case 'DYNAMIC':
return {
kind: DynamicPluginKind['signoz/DynamicVariable'],
spec: {
name: model.dynamicAttribute,
signal: model.dynamicSignal as TelemetrytypesSignalDTO,
},
};
case 'QUERY':
default:
return {
kind: QueryPluginKind['signoz/QueryVariable'],
spec: { queryValue: model.queryValue },
};
}
}
/** Flat form model → DTO envelope (for persistence). */
export function formModelToDto(
model: VariableFormModel,
): DashboardtypesVariableDTO {
const display = {
name: model.name,
description: model.description,
hidden: model.hidden,
};
if (model.type === 'TEXT') {
return {
kind: TextEnvelopeKind.TextVariable,
spec: {
name: model.name,
display,
value: model.textValue,
constant: model.textConstant,
},
};
}
return {
kind: ListEnvelopeKind.ListVariable,
spec: {
name: model.name,
display,
allowMultiple: model.multiSelect,
allowAllValue: model.showAllOption,
sort: model.sort,
defaultValue: model.defaultValue,
plugin: buildPlugin(model),
},
};
}
/** Maps the V2 plugin/envelope to the four UI-facing variable types. */
export function variableTypeOf(
dto: DashboardtypesVariableDTO,
): VariableFormModel['type'] {
return dtoToFormModel(dto).type;
}
export { PLUGIN_KIND };

View File

@@ -0,0 +1,78 @@
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
/**
* Flat, UI-friendly representation of a V2 dashboard variable. The wire format
* (`DashboardtypesVariableDTO`) is a nested envelope/plugin union that is awkward
* to bind a form to; `variableAdapters` converts between this model and the DTO.
*/
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
export type VariableSort = 'DISABLED' | 'ASC' | 'DESC';
export type TelemetrySignal = 'traces' | 'logs' | 'metrics';
/** Wire `kind` discriminators (string values of the generated enums). */
export const ENVELOPE_KIND = {
LIST: 'ListVariable',
TEXT: 'TextVariable',
} as const;
export const PLUGIN_KIND = {
QUERY: 'signoz/QueryVariable',
CUSTOM: 'signoz/CustomVariable',
DYNAMIC: 'signoz/DynamicVariable',
} as const;
export const VARIABLE_SORTS: VariableSort[] = ['DISABLED', 'ASC', 'DESC'];
export const TELEMETRY_SIGNALS: TelemetrySignal[] = [
'traces',
'logs',
'metrics',
];
export interface VariableFormModel {
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
name: string;
description: string;
hidden: boolean;
type: VariableType;
// List-variable common fields (Query / Custom / Dynamic).
multiSelect: boolean;
showAllOption: boolean;
sort: VariableSort;
// Type-specific.
queryValue: string; // QUERY
customValue: string; // CUSTOM
textValue: string; // TEXT
textConstant: boolean; // TEXT
dynamicAttribute: string; // DYNAMIC — the telemetry field name
dynamicSignal: TelemetrySignal; // DYNAMIC — the telemetry signal
/**
* Runtime-selected default, not editable in the management tab yet; carried
* through edits so saving a definition doesn't clobber it.
*/
defaultValue?: VariableDefaultValueDTO;
}
export function emptyVariableFormModel(): VariableFormModel {
return {
name: '',
description: '',
hidden: false,
type: 'QUERY',
multiSelect: false,
showAllOption: false,
sort: 'DISABLED',
queryValue: '',
customValue: '',
textValue: '',
textConstant: false,
dynamicAttribute: '',
dynamicSignal: 'traces',
};
}

View File

@@ -0,0 +1,22 @@
import type {
DashboardtypesJSONPatchOperationDTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Builds the JSON-Patch to persist the dashboard's variable list. Add/edit/
* delete/reorder all replace the whole `/spec/variables` array in one atomic op
* — simpler and race-free vs per-index patches. RFC-6902 `add` on an object
* member sets-or-replaces, so it works whether or not `variables` already exists.
*/
export function buildVariablesPatch(
variables: DashboardtypesVariableDTO[],
): DashboardtypesJSONPatchOperationDTO[] {
return [
{
op: 'add' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/variables',
value: variables,
},
];
}

View File

@@ -1,48 +1,39 @@
import { useMemo } from 'react';
import { Braces, Globe, Table } from '@signozhq/icons';
import { Tabs } from '@signozhq/ui/tabs';
import { TabItemProps, Tabs } from '@signozhq/ui/tabs';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import GeneralSettings from './General';
import { SettingsTabPlaceholder } from './utils';
import styles from './DashboardSettings.module.scss';
import VariablesSettings from './Variables';
interface DashboardSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
function tabLabel(icon: JSX.Element, text: string): JSX.Element {
return (
<span className={styles.tabLabel}>
{icon}
{text}
</span>
);
}
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
const items = useMemo(
const items: TabItemProps[] = useMemo(
() => [
{
key: 'general',
label: tabLabel(<Table size={14} />, 'General'),
label: 'General',
children: <GeneralSettings dashboard={dashboard} />,
prefixIcon: <Table size={14} />,
},
{
key: 'variables',
label: tabLabel(<Braces size={14} />, 'Variables'),
children: (
<SettingsTabPlaceholder message="V2 dashboard variables coming next." />
),
label: 'Variables',
children: <VariablesSettings dashboard={dashboard} />,
prefixIcon: <Braces size={14} />,
},
{
key: 'public-dashboard',
label: tabLabel(<Globe size={14} />, 'Publish'),
label: 'Publish',
children: (
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
),
prefixIcon: <Globe size={14} />,
},
],
[dashboard],

View File

@@ -0,0 +1,98 @@
import { useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import DynamicSelector from './selectors/DynamicSelector';
import QuerySelector from './selectors/QuerySelector';
import TextSelector from './selectors/TextSelector';
import ValueSelector from './selectors/ValueSelector';
import styles from './VariablesBar.module.scss';
interface VariableSelectorProps {
variable: VariableFormModel;
/** All variables (Dynamic uses them to scope options by sibling selections). */
variables: VariableFormModel[];
/** Names this variable depends on (for Query gating). */
parents: string[];
/** All current selections (Query passes them as the request payload). */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/** One labelled variable control; dispatches on the variable type. */
function VariableSelector({
variable,
variables,
parents,
selections,
selection,
onChange,
}: VariableSelectorProps): JSX.Element {
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValues(commaValuesParser(variable.customValue), variable.sort).map(
String,
)
: [],
[variable],
);
const renderControl = (): JSX.Element => {
switch (variable.type) {
case 'TEXT':
return (
<TextSelector
selection={selection}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>
);
case 'QUERY':
return (
<QuerySelector
variable={variable}
parents={parents}
selections={selections}
selection={selection}
onChange={onChange}
/>
);
case 'DYNAMIC':
return (
<DynamicSelector
variable={variable}
variables={variables}
selections={selections}
selection={selection}
onChange={onChange}
/>
);
case 'CUSTOM':
default:
return (
<ValueSelector
options={customOptions}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
};
return (
<div className={styles.variable} data-testid={`variable-${variable.name}`}>
<Typography.Text className={styles.label}>${variable.name}</Typography.Text>
{renderControl()}
</div>
);
}
export default VariableSelector;

View File

@@ -0,0 +1,29 @@
.bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--l1-border);
}
.variable {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.label {
font-size: 12px;
font-weight: 500;
color: var(--l2-foreground);
}
.select {
min-width: 160px;
}
.input {
min-width: 160px;
}

View File

@@ -0,0 +1,45 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useVariableSelection } from './useVariableSelection';
import VariableSelector from './VariableSelector';
import styles from './VariablesBar.module.scss';
interface VariablesBarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
/**
* Runtime variable selector bar shown above the panels. Renders one control per
* dashboard variable; selections live in the store + URL (never the spec).
*/
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const { variables, dependencyData, selection, setSelection } =
useVariableSelection(dashboard);
if (variables.length === 0) {
return null;
}
return (
<div className={styles.bar} data-testid="dashboard-variables-bar">
{variables.map((variable) => (
<VariableSelector
key={variable.name}
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
onChange={(next): void => setSelection(variable.name, next)}
/>
))}
</div>
);
}
export default VariablesBar;

View File

@@ -0,0 +1,56 @@
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
import type { VariableSelectionMap } from './selectionTypes';
function formatQueryValue(val: string): string {
const num = Number(val);
if (!Number.isNaN(num) && Number.isFinite(num)) {
return val;
}
return `'${val.replace(/'/g, "\\'")}'`;
}
function buildQueryPart(attribute: string, values: string[]): string {
const formatted = values.map(formatQueryValue);
if (formatted.length === 1) {
return `${attribute} = ${formatted[0]}`;
}
return `${attribute} IN [${formatted.join(', ')}]`;
}
/**
* Builds a filter expression from the OTHER dynamic variables' current
* selections (e.g. `k8s.namespace.name IN ['prod'] AND service = 'api'`), so a
* dynamic variable's option list is scoped by its sibling selections. Variables
* in the ALL state, with no selection, or non-dynamic are skipped. Ported from
* the V1 dynamic-variable runtime.
*/
export function buildExistingDynamicVariableQuery(
variables: VariableFormModel[],
selections: VariableSelectionMap,
currentName: string,
): string {
const parts: string[] = [];
variables.forEach((variable) => {
if (
variable.name === currentName ||
variable.type !== 'DYNAMIC' ||
!variable.dynamicAttribute
) {
return;
}
const selection = selections[variable.name];
if (!selection || selection.allSelected) {
return;
}
const raw = Array.isArray(selection.value)
? selection.value
: [selection.value];
const valid = raw
.filter((v) => v !== null && v !== undefined && v !== '')
.map((v) => String(v));
if (valid.length > 0) {
parts.push(buildQueryPart(variable.dynamicAttribute, valid));
}
});
return parts.join(' AND ');
}

View File

@@ -0,0 +1,16 @@
/** A user-selected variable value at runtime (not persisted to the spec). */
export type SelectedVariableValue =
| string
| number
| boolean
| (string | number | boolean)[]
| null;
export interface VariableSelection {
value: SelectedVariableValue;
/** True when every option is selected ("ALL"); for dynamic vars value may be null. */
allSelected: boolean;
}
/** Selected values for a dashboard's variables, keyed by variable name. */
export type VariableSelectionMap = Record<string, VariableSelection>;

View File

@@ -0,0 +1,31 @@
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from './selectionTypes';
/** A selection counts as resolved (usable as a parent value) when it's non-empty. */
export function isResolved(selection?: VariableSelection): boolean {
if (!selection) {
return false;
}
if (selection.allSelected) {
return true;
}
const { value } = selection;
if (Array.isArray(value)) {
return value.length > 0;
}
return value !== '' && value !== null && value !== undefined;
}
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
export function selectionToPayload(
selection: VariableSelectionMap,
): Record<string, SelectedVariableValue> {
const payload: Record<string, SelectedVariableValue> = {};
Object.entries(selection).forEach(([name, sel]) => {
payload[name] = sel.value;
});
return payload;
}

View File

@@ -0,0 +1,79 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface DynamicSelectorProps {
variable: VariableFormModel;
/** All variables + current selections, to scope options by sibling dynamics. */
variables: VariableFormModel[];
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/**
* Dynamic-variable options sourced from live telemetry field values for the
* chosen signal + attribute, scoped by the other dynamic variables' selections
* (so e.g. `pod` narrows to the chosen `namespace`).
*/
function DynamicSelector({
variable,
variables,
selections,
selection,
onChange,
}: DynamicSelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
[variables, selections, variable.name],
);
const { data, isFetching } = useGetFieldValues({
signal: variable.dynamicSignal,
name: variable.dynamicAttribute,
startUnixMilli: minTime,
endUnixMilli: maxTime,
existingQuery: existingQuery || undefined,
enabled: !!variable.dynamicAttribute,
});
const options = useMemo(() => {
const payload = data?.data;
const values =
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
return sortValues(values, variable.sort).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onChange);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default DynamicSelector;

View File

@@ -0,0 +1,89 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { isResolved, selectionToPayload } from '../selectionUtils';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface QuerySelectorProps {
variable: VariableFormModel;
/** Names this variable's query references; it waits until they're resolved. */
parents: string[];
/** All current selections, fed to the query as `{ name: value }`. */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/**
* Query-driven options. Dependency orchestration is declarative: the query is
* `enabled` only once every parent is resolved, and the parent values are in the
* query key — so it refetches automatically when a parent changes (and a cyclic
* dependency is simply never enabled).
*/
function QuerySelector({
variable,
parents,
selections,
selection,
onChange,
}: QuerySelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const enabled = parents.every((parent) => isResolved(selections[parent]));
const { data, isFetching } = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
payload,
minTime,
maxTime,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{ enabled, refetchOnWindowFocus: false },
);
const options = useMemo(() => {
if (!data || data.statusCode !== 200 || !data.payload) {
return [] as string[];
}
return sortValues(data.payload.variableValues ?? [], variable.sort).map(
String,
);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onChange);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default QuerySelector;

View File

@@ -0,0 +1,31 @@
import { Input } from '@signozhq/ui/input';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
interface TextSelectorProps {
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
testId?: string;
}
/** Free-text variable input. */
function TextSelector({
selection,
onChange,
testId,
}: TextSelectorProps): JSX.Element {
return (
<Input
className={styles.input}
value={typeof selection.value === 'string' ? selection.value : ''}
placeholder="Enter a value"
onChange={(e): void =>
onChange({ value: e.target.value, allSelected: false })
}
testId={testId}
/>
);
}
export default TextSelector;

View File

@@ -0,0 +1,94 @@
import { useMemo } from 'react';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import type { OptionData } from 'components/NewSelect/types';
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
interface ValueSelectorProps {
options: string[];
multiSelect: boolean;
showAllOption: boolean;
loading?: boolean;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
testId?: string;
}
/**
* Single/multi value picker for Custom/Query/Dynamic variables. Reuses the
* shared NewSelect components, which provide search, the "ALL" option and
* apply-on-close batching (so multi-select edits don't cascade per toggle).
*/
function ValueSelector({
options,
multiSelect,
showAllOption,
loading,
selection,
onChange,
testId,
}: ValueSelectorProps): JSX.Element {
const optionData = useMemo<OptionData[]>(
() => options.map((option) => ({ label: option, value: option })),
[options],
);
if (multiSelect) {
const value = selection.allSelected
? ALL_SELECT_VALUE
: (Array.isArray(selection.value) ? selection.value : []).map(String);
return (
<CustomMultiSelect
className={styles.select}
data-testid={testId}
options={optionData}
value={value}
loading={loading}
showSearch
placeholder="Select value"
enableAllSelection={showAllOption}
onChange={(next): void => {
const values = Array.isArray(next)
? next.map(String)
: next
? [String(next)]
: [];
if (values.length === 0) {
onChange({ value: [], allSelected: false });
return;
}
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
showAllOption &&
options.length > 0 &&
options.every((option) => values.includes(option));
onChange({ value: values, allSelected: isAll });
}}
onClear={(): void => onChange({ value: [], allSelected: false })}
/>
);
}
return (
<CustomSelect
className={styles.select}
data-testid={testId}
options={optionData}
value={
selection.value == null || Array.isArray(selection.value)
? undefined
: String(selection.value)
}
loading={loading}
showSearch
placeholder="Select value"
onChange={(next): void =>
onChange({ value: next == null ? '' : String(next), allSelected: false })
}
/>
);
}
export default ValueSelector;

View File

@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
import type { VariableSelection } from './selectionTypes';
/**
* When fetched options arrive and the current selection isn't one of them,
* auto-pick the variable's default (if present in the options) or the first
* option — so dependent children always have a usable parent value.
*/
export function useAutoSelect(
variable: VariableFormModel,
options: string[],
selection: VariableSelection,
onChange: (selection: VariableSelection) => void,
): void {
useEffect(() => {
if (options.length === 0 || selection.allSelected) {
return;
}
const current = selection.value;
const isValid = Array.isArray(current)
? current.length > 0 && current.every((c) => options.includes(String(c)))
: current !== '' &&
current !== null &&
current !== undefined &&
options.includes(String(current));
if (isValid) {
return;
}
const fallback = (variable.defaultValue as { value?: string } | undefined)
?.value;
const initial =
fallback && options.includes(fallback) ? fallback : options[0];
onChange({
value: variable.multiSelect ? [initial] : initial,
allSelected: false,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]);
}

View File

@@ -0,0 +1,116 @@
import { useCallback, useEffect, useMemo } from 'react';
import { parseAsJson, useQueryState } from 'nuqs';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from './selectionTypes';
import {
computeVariableDependencies,
type VariableDependencyData,
} from './variableDependencies';
/** URL sentinel for an "ALL values selected" state (matches V1). */
export const ALL_SELECTED = '__ALL__';
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
(v) =>
typeof v === 'object' && v !== null
? (v as Record<string, SelectedVariableValue>)
: null,
);
function defaultSelection(model: VariableFormModel): VariableSelection {
const def = (
model.defaultValue as { value?: SelectedVariableValue } | undefined
)?.value;
if (def !== undefined && def !== null && def !== '') {
return { value: def, allSelected: false };
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
return raw === ALL_SELECTED
? { value: null, allSelected: true }
: { value: raw, allSelected: false };
}
interface UseVariableSelection {
variables: VariableFormModel[];
dependencyData: VariableDependencyData;
selection: VariableSelectionMap;
setSelection: (name: string, selection: VariableSelection) => void;
}
/**
* Runtime variable selection: derives the variable list from the spec, seeds
* each value from URL → localStorage(store) → default, and persists changes to
* both the store and the URL. Never writes to the dashboard spec.
*/
export function useVariableSelection(
dashboard: DashboardtypesGettableDashboardV2DTO,
): UseVariableSelection {
const dashboardId = dashboard.id ?? '';
const variables = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const dependencyData = useMemo(
() => computeVariableDependencies(variables),
[variables],
);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
const [urlValues, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
// Seed selections for this dashboard: URL wins, then persisted store, then default.
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
}
// `selection` here is the persisted (localStorage) map on mount — the
// effect deliberately doesn't depend on it, so seeding runs once per set.
const stored = selection;
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
if (urlValue !== undefined) {
seeded[variable.name] = fromUrlValue(urlValue);
} else if (stored[variable.name]) {
seeded[variable.name] = stored[variable.name];
} else {
seeded[variable.name] = defaultSelection(variable);
}
});
setVariableValues(dashboardId, seeded);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, variables]);
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
setVariableValue(dashboardId, name, next);
void setUrlValues((prev) => ({
...(prev ?? {}),
[name]: next.allSelected ? ALL_SELECTED : next.value,
}));
},
[dashboardId, setVariableValue, setUrlValues],
);
return { variables, dependencyData, selection, setSelection };
}

View File

@@ -0,0 +1,199 @@
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
/**
* Inter-variable dependency graph for runtime selection. A QUERY variable
* "depends on" another variable when its query text references that variable
* (`{{.name}}`, `{{name}}`, `$name`, `[[name]]`). When a variable's value
* changes, its dependent QUERY variables must refetch. Ported from the V1
* dashboard-variables runtime; operates on the V2 flat variable model.
*/
export type VariableGraph = Record<string, string[]>;
export interface VariableDependencyData {
/** Topological order of variables (parents before children). */
order: string[];
/** Direct children (dependents) of each variable. */
graph: VariableGraph;
/** Direct parents of each variable. */
parentGraph: VariableGraph;
/** All transitive descendants of each variable (precomputed). */
transitiveDescendants: VariableGraph;
hasCycle: boolean;
cycleNodes?: string[];
}
/** Names of QUERY variables whose query references `variableName`. */
function getDependents(
variableName: string,
variables: VariableFormModel[],
): string[] {
return variables
.filter(
(v) =>
v.type === 'QUERY' &&
!!v.name &&
textContainsVariableReference(v.queryValue || '', variableName),
)
.map((v) => v.name);
}
/** variable name → its direct dependents (children). */
export function buildDependencies(
variables: VariableFormModel[],
): VariableGraph {
const graph: VariableGraph = {};
variables.forEach((v) => {
if (v.name) {
graph[v.name] = getDependents(v.name, variables);
}
});
return graph;
}
/** Invert a child graph into a parent graph. */
export function buildParentGraph(graph: VariableGraph): VariableGraph {
const parents: VariableGraph = {};
Object.keys(graph).forEach((node) => {
parents[node] = parents[node] ?? [];
});
Object.entries(graph).forEach(([node, children]) => {
children.forEach((child) => {
parents[child] = parents[child] ?? [];
parents[child].push(node);
});
});
return parents;
}
function collectCyclePath(
graph: VariableGraph,
start: string,
end: string,
): string[] {
const path: string[] = [];
let current = start;
const findParent = (node: string): string | undefined =>
Object.keys(graph).find((key) => graph[key]?.includes(node));
while (current !== end) {
const parent = findParent(current);
if (!parent) {
break;
}
path.push(parent);
current = parent;
}
return [start, ...path];
}
function detectCycle(
graph: VariableGraph,
node: string,
visited: Set<string>,
recStack: Set<string>,
): string[] | null {
if (!visited.has(node)) {
visited.add(node);
recStack.add(node);
let cycleNodes: string[] | null = null;
(graph[node] || []).some((neighbor) => {
if (!visited.has(neighbor)) {
const found = detectCycle(graph, neighbor, visited, recStack);
if (found) {
cycleNodes = found;
return true;
}
} else if (recStack.has(neighbor)) {
cycleNodes = collectCyclePath(graph, node, neighbor);
return true;
}
return false;
});
if (cycleNodes) {
return cycleNodes;
}
}
recStack.delete(node);
return null;
}
/** Build the full dependency data (topo order, parents, transitive descendants, cycle info). */
export function buildDependencyData(
dependencies: VariableGraph,
): VariableDependencyData {
const inDegree: Record<string, number> = {};
const adjList: VariableGraph = {};
Object.keys(dependencies).forEach((node) => {
inDegree[node] = inDegree[node] ?? 0;
adjList[node] = adjList[node] ?? [];
(dependencies[node] || []).forEach((child) => {
inDegree[child] = inDegree[child] ?? 0;
inDegree[child] += 1;
adjList[node].push(child);
});
});
const visited = new Set<string>();
const recStack = new Set<string>();
let cycleNodes: string[] | undefined;
Object.keys(dependencies).some((node) => {
if (!visited.has(node)) {
const found = detectCycle(dependencies, node, visited, recStack);
if (found) {
cycleNodes = found;
return true;
}
}
return false;
});
// Topological sort (Kahn's algorithm).
const queue = Object.keys(inDegree).filter((n) => inDegree[n] === 0);
const order: string[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) {
break;
}
order.push(current);
(adjList[current] || []).forEach((neighbor) => {
inDegree[neighbor] -= 1;
if (inDegree[neighbor] === 0) {
queue.push(neighbor);
}
});
}
const hasCycle = order.length !== Object.keys(dependencies).length;
// Transitive descendants: walk topo order in reverse.
const transitiveDescendants: VariableGraph = {};
for (let i = order.length - 1; i >= 0; i--) {
const node = order[i];
const desc = new Set<string>();
(adjList[node] || []).forEach((child) => {
desc.add(child);
(transitiveDescendants[child] || []).forEach((d) => desc.add(d));
});
transitiveDescendants[node] = Array.from(desc);
}
return {
order,
graph: adjList,
parentGraph: buildParentGraph(adjList),
transitiveDescendants,
hasCycle,
cycleNodes,
};
}
/** Compute the full dependency data straight from the variable list. */
export function computeVariableDependencies(
variables: VariableFormModel[],
): VariableDependencyData {
return buildDependencyData(buildDependencies(variables));
}

View File

@@ -9,6 +9,7 @@ import { useAppContext } from 'providers/App/App';
import DashboardDescription from './DashboardDescription';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useDashboardStore } from './store/useDashboardStore';
import VariablesBar from './VariablesBar/VariablesBar';
import styles from './DashboardContainer.module.scss';
interface DashboardContainerProps {
@@ -45,6 +46,7 @@ function DashboardContainer({
handle={fullScreenHandle}
refetch={refetch}
/>
<VariablesBar dashboard={dashboard} />
<PanelsAndSectionsLayout layouts={layouts} panels={panels} />
</div>
{/* Shared panel-type picker (V1 component): opened from any "New Panel"

View File

@@ -4,7 +4,7 @@ import type {
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesJSONPatchOperationDTOOp } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import type { GridItem } from './utils';
@@ -16,7 +16,7 @@ import type { GridItem } from './utils';
* patches in DashboardSettings/General and DashboardDescription).
*/
const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp;
const { add, replace, remove } = DashboardtypesPatchOpDTO;
const PANEL_REF_PREFIX = '#/spec/panels/';

View File

@@ -0,0 +1,55 @@
import type { StateCreator } from 'zustand';
import type {
VariableSelection,
VariableSelectionMap,
} from '../../VariablesBar/selectionTypes';
import type { DashboardStore } from '../useDashboardStore';
/**
* Runtime variable selection — the values the user picks in the variable bar.
* Keyed by dashboardId → variable name. Frontend-only and persisted to
* localStorage (mirrored to the URL by the bar for shareable links); it is
* deliberately NOT part of the dashboard spec, so selecting a value never
* patches the dashboard.
*/
export interface VariableSelectionSlice {
variableValues: Record<string, VariableSelectionMap>;
setVariableValue: (
dashboardId: string,
name: string,
selection: VariableSelection,
) => void;
/** Bulk set (used to seed from URL/localStorage/defaults on load). */
setVariableValues: (dashboardId: string, values: VariableSelectionMap) => void;
}
export const createVariableSelectionSlice: StateCreator<
DashboardStore,
[['zustand/persist', unknown]],
[],
VariableSelectionSlice
> = (set, get) => ({
variableValues: {},
setVariableValue: (dashboardId, name, selection): void => {
const { variableValues } = get();
set({
variableValues: {
...variableValues,
[dashboardId]: { ...variableValues[dashboardId], [name]: selection },
},
});
},
setVariableValues: (dashboardId, values): void => {
const { variableValues } = get();
set({
variableValues: { ...variableValues, [dashboardId]: values },
});
},
});
/** Selector: the selection map for a dashboard (empty if none). */
export const selectVariableValues =
(dashboardId: string) =>
(state: DashboardStore): VariableSelectionMap =>
state.variableValues[dashboardId] ?? {};

View File

@@ -9,25 +9,36 @@ import {
createCollapseSlice,
type CollapseSlice,
} from './slices/collapseSlice';
import {
createVariableSelectionSlice,
type VariableSelectionSlice,
} from './slices/variableSelectionSlice';
export type DashboardStore = EditContextSlice & CollapseSlice;
export type DashboardStore = EditContextSlice &
CollapseSlice &
VariableSelectionSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
* dashboard spec (that stays in react-query via useGetDashboardV2). Two slices:
* dashboard spec (that stays in react-query via useGetDashboardV2). Slices:
* - edit-context: dashboardId / isEditable / refetch (set once, not persisted).
* - collapse: per-section open state (frontend-only, persisted to localStorage).
* - variable-selection: runtime variable values (frontend-only, persisted).
*/
export const useDashboardStore = create<DashboardStore>()(
persist(
(...a) => ({
...createEditContextSlice(...a),
...createCollapseSlice(...a),
...createVariableSelectionSlice(...a),
}),
{
name: '@signoz/dashboard-v2',
// Persist only the collapse map — context (incl. the refetch fn) is transient.
partialize: (state) => ({ collapsed: state.collapsed }),
// Persist UI-only state (context incl. the refetch fn is transient).
partialize: (state) => ({
collapsed: state.collapsed,
variableValues: state.variableValues,
}),
},
),
);

View File

@@ -1,3 +1,15 @@
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardsListPageV2 from 'pages/DashboardsListPageV2';
import DashboardsListPage from './DashboardsListPage';
export default DashboardsListPage;
// Serves the V2 dashboards list when the `use_dashboard_v2` flag is active;
// otherwise the existing V1 list. Lets V2 dark-ship behind the flag without
// changing route definitions.
function DashboardsListPageEntry(): JSX.Element {
const isDashboardV2 = useIsDashboardV2();
return isDashboardV2 ? <DashboardsListPageV2 /> : <DashboardsListPage />;
}
export default DashboardsListPageEntry;

View File

@@ -8,6 +8,10 @@ import {
createDashboardV2,
useListDashboardsV2,
} from 'api/generated/services/dashboard';
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
import useComponentPermission from 'hooks/useComponentPermission';
@@ -24,8 +28,6 @@ import {
useSearch,
useSortColumn,
useSortOrder,
type SortColumn,
type SortOrder,
} from '../../hooks/useDashboardsListQueryParams';
import type { DashboardListItem } from '../../utils';
import ConfigureMetadataModal from '../ConfigureMetadataModal/ConfigureMetadataModal';
@@ -131,6 +133,10 @@ function DashboardsList(): JSX.Element {
tags: null,
spec: {
display: { name: t('new_dashboard_title', { ns: 'dashboard' }) },
layouts: [],
panels: {},
variables: [],
// TODO(@AshwinBhatkal): duration and refresh interval need to be integrated
},
});
safeNavigate(
@@ -150,7 +156,7 @@ function DashboardsList(): JSX.Element {
}, []);
const onSortChange = useCallback(
(column: SortColumn): void => {
(column: DashboardtypesListSortDTO): void => {
void setSortColumn(column);
void setPage(1);
},
@@ -158,7 +164,7 @@ function DashboardsList(): JSX.Element {
);
const onOrderChange = useCallback(
(order: SortOrder): void => {
(order: DashboardtypesListOrderDTO): void => {
void setSortOrder(order);
void setPage(1);
},

View File

@@ -7,18 +7,18 @@ import {
HdmiPort,
} from '@signozhq/icons';
import type {
SortColumn,
SortOrder,
} from '../../hooks/useDashboardsListQueryParams';
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import styles from './ListHeader.module.scss';
interface Props {
sortColumn: SortColumn;
onSortChange: (column: SortColumn) => void;
sortOrder: SortOrder;
onOrderChange: (order: SortOrder) => void;
sortColumn: DashboardtypesListSortDTO;
onSortChange: (column: DashboardtypesListSortDTO) => void;
sortOrder: DashboardtypesListOrderDTO;
onOrderChange: (order: DashboardtypesListOrderDTO) => void;
onConfigureMetadata: () => void;
}
@@ -44,49 +44,57 @@ function ListHeader({
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onSortChange('name')}
onClick={(): void => onSortChange(DashboardtypesListSortDTO.name)}
data-testid="sort-by-name"
>
Name
{sortColumn === 'name' && <Check size={14} />}
{sortColumn === DashboardtypesListSortDTO.name && <Check size={14} />}
</Button>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onSortChange('created_at')}
onClick={(): void =>
onSortChange(DashboardtypesListSortDTO.created_at)
}
data-testid="sort-by-last-created"
>
Last created
{sortColumn === 'created_at' && <Check size={14} />}
{sortColumn === DashboardtypesListSortDTO.created_at && (
<Check size={14} />
)}
</Button>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onSortChange('updated_at')}
onClick={(): void =>
onSortChange(DashboardtypesListSortDTO.updated_at)
}
data-testid="sort-by-last-updated"
>
Last updated
{sortColumn === 'updated_at' && <Check size={14} />}
{sortColumn === DashboardtypesListSortDTO.updated_at && (
<Check size={14} />
)}
</Button>
<div className={styles.sortDivider} />
<Typography.Text className={styles.sortHeading}>Order</Typography.Text>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onOrderChange('asc')}
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.asc)}
data-testid="sort-order-asc"
>
Ascending
{sortOrder === 'asc' && <Check size={14} />}
{sortOrder === DashboardtypesListOrderDTO.asc && <Check size={14} />}
</Button>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onOrderChange('desc')}
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.desc)}
data-testid="sort-order-desc"
>
Descending
{sortOrder === 'desc' && <Check size={14} />}
{sortOrder === DashboardtypesListOrderDTO.desc && <Check size={14} />}
</Button>
</div>
}

View File

@@ -1,5 +1,5 @@
// Shared building blocks for the dashboards-list view states.
// Composed via CSS-modules `composes:` from each state's own SCSS.
/* Shared building blocks for the dashboards-list view states. */
/* Composed via CSS-modules `composes:` from each state's own SCSS. */
.cardWrapper {
display: flex;

View File

@@ -1,3 +1,7 @@
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
parseAsInteger,
parseAsString,
@@ -7,26 +11,31 @@ import {
type UseQueryStateReturn,
} from 'nuqs';
export const SORT_COLUMNS = ['updated_at', 'created_at', 'name'] as const;
export type SortColumn = (typeof SORT_COLUMNS)[number];
export const SORT_ORDERS = ['asc', 'desc'] as const;
export type SortOrder = (typeof SORT_ORDERS)[number];
export const SORT_COLUMNS = Object.values(DashboardtypesListSortDTO);
export const SORT_ORDERS = Object.values(DashboardtypesListOrderDTO);
const opts: Options = { history: 'push' };
export const useSortColumn = (): UseQueryStateReturn<SortColumn, SortColumn> =>
export const useSortColumn = (): UseQueryStateReturn<
DashboardtypesListSortDTO,
DashboardtypesListSortDTO
> =>
useQueryState(
'sort',
parseAsStringLiteral(SORT_COLUMNS)
.withDefault('updated_at')
.withDefault(DashboardtypesListSortDTO.updated_at)
.withOptions(opts),
);
export const useSortOrder = (): UseQueryStateReturn<SortOrder, SortOrder> =>
export const useSortOrder = (): UseQueryStateReturn<
DashboardtypesListOrderDTO,
DashboardtypesListOrderDTO
> =>
useQueryState(
'order',
parseAsStringLiteral(SORT_ORDERS).withDefault('desc').withOptions(opts),
parseAsStringLiteral(SORT_ORDERS)
.withDefault(DashboardtypesListOrderDTO.desc)
.withOptions(opts),
);
export const usePage = (): UseQueryStateReturn<number, number> =>

View File

@@ -1,8 +1,8 @@
import dayjs from 'dayjs';
import { isEmpty } from 'lodash-es';
import type { DashboardtypesGettableDashboardWithPinDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesListedDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
export type DashboardListItem = DashboardtypesGettableDashboardWithPinDTO;
export type DashboardListItem = DashboardtypesListedDashboardV2DTO;
export const tagsToStrings = (
tags: { key: string; value: string }[] | null | undefined,

View File

@@ -2,7 +2,7 @@ import { Logout } from 'api/utils';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { createErrorResponse, rest, server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import { render, screen, waitFor, fireEvent } from 'tests/test-utils';
import ResetPassword from '../index';
@@ -103,6 +103,7 @@ describe('ResetPassword Page', () => {
expect(
screen.getByText(/reset password token does not exist/i),
).toBeInTheDocument();
expect(screen.getByTestId('back-to-login')).toBeInTheDocument();
});
it('shows "token is expired" when token is expired (401) without redirecting to login', async () => {
@@ -137,6 +138,32 @@ describe('ResetPassword Page', () => {
// 401 from this endpoint must NOT trigger logout/redirect
expect(mockHistoryPush).not.toHaveBeenCalledWith(ROUTES.LOGIN);
expect(Logout).not.toHaveBeenCalled();
expect(screen.getByTestId('back-to-login')).toBeInTheDocument();
});
it('navigates to login when "Back to login" is clicked on error screen', async () => {
server.use(
rest.post(
VERIFY_TOKEN_ENDPOINT,
createErrorResponse(
404,
'reset_password_token_not_found',
'reset password token does not exist',
),
),
);
window.history.pushState({}, '', '/password-reset?token=invalid-token');
render(<ResetPassword />, undefined, {
initialRoute: '/password-reset?token=invalid-token',
});
await waitFor(() => {
expect(screen.getByTestId('back-to-login')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('back-to-login'));
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
});
it('redirects to login when no token is in the URL', async () => {

View File

@@ -41,6 +41,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
import { saveRecentQuery } from 'lib/recentQueries/saveRecentQuery';
import { replaceIncorrectObjectFields } from 'lib/replaceIncorrectObjectFields';
import { cloneDeep, get, isEqual, set } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -1031,6 +1032,8 @@ export function QueryBuilderProvider({
if (isExplorer) {
setCalledFromHandleRunQuery(true);
}
saveRecentQuery(currentQuery);
const currentQueryData = {
...currentQuery,
builder: {

View File

@@ -2,6 +2,7 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
@@ -9,6 +10,7 @@ dayjs.extend(utc);
dayjs.extend(customParseFormat);
dayjs.extend(duration);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
export function toUTCEpoch(time: number): number {
const x = new Date();

View File

@@ -48,9 +48,7 @@
"node_modules",
"src/parser/*.ts",
"src/parser/TraceOperatorParser/*.ts",
"orval.config.ts",
"src/pages/DashboardsListPageV2/**/*",
"src/pages/DashboardPageV2/**/*"
"orval.config.ts"
],
"include": [
"./src",

View File

@@ -14,6 +14,42 @@ import (
)
func (provider *provider) addDashboardRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListV2), handler.OpenAPIDef{
ID: "ListDashboardsV2",
Tags: []string{"dashboard"},
Summary: "List dashboards (v2)",
Description: "Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).",
Request: nil,
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
RequestContentType: "",
Response: new(dashboardtypes.ListableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/me/dashboards", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListForUserV2), handler.OpenAPIDef{
ID: "ListDashboardsForUserV2",
Tags: []string{"dashboard"},
Summary: "List dashboards for the current user (v2)",
Description: "Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.",
Request: nil,
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
RequestContentType: "",
Response: new(dashboardtypes.ListableDashboardForUserV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CreateV2), handler.OpenAPIDef{
ID: "CreateDashboardV2",
Tags: []string{"dashboard"},
@@ -89,6 +125,23 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.DeleteV2), handler.OpenAPIDef{
ID: "DeleteDashboardV2",
Tags: []string{"dashboard"},
Summary: "Delete dashboard (v2)",
Description: "This endpoint deletes a v2-shape dashboard along with its tag relations. Locked dashboards are rejected.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.LockV2), handler.OpenAPIDef{
ID: "LockDashboardV2",
Tags: []string{"dashboard"},
@@ -123,6 +176,42 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return err
}
// ViewAccess: pinning only mutates the calling user's pin list, not the
// dashboard itself — anyone who can view a dashboard can bookmark it.
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.PinV2), handler.OpenAPIDef{
ID: "PinDashboardV2",
Tags: []string{"dashboard"},
Summary: "Pin a dashboard for the current user (v2)",
Description: "Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.UnpinV2), handler.OpenAPIDef{
ID: "UnpinDashboardV2",
Tags: []string{"dashboard"},
Summary: "Unpin a dashboard for the current user (v2)",
Description: "Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
ID: "CreatePublicDashboard",
Tags: []string{"dashboard"},

View File

@@ -50,8 +50,8 @@ func (handler *healthOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext
)
}
func (handler *healthOpenAPIHandler) AuditDef() *pkghandler.AuditDef {
// Health endpoints are not audited since they don't represent user actions and are called frequently by monitoring systems, which would create noise in the audit logs.
func (handler *healthOpenAPIHandler) ResourceDefs() []pkghandler.ResourceDef {
// Health endpoints don't act on resources.
return nil
}

View File

@@ -7,166 +7,197 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (provider *provider) addRoleRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/roles", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Create, authtypes.Relation{Verb: coretypes.VerbCreate}, coretypes.ResourceRole, roleCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "CreateRole",
Tags: []string{"role"},
Summary: "Create role",
Description: "This endpoint creates a role",
Request: new(authtypes.PostableRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbCreate)}),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Create, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateRole",
Tags: []string{"role"},
Summary: "Create role",
Description: "This endpoint creates a role",
Request: new(authtypes.PostableRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles", handler.New(provider.authzMiddleware.Check(provider.authzHandler.List, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceRole, roleCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "ListRoles",
Tags: []string{"role"},
Summary: "List roles",
Description: "This endpoint lists all roles",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbList)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.List, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListRoles",
Tags: []string{"role"},
Summary: "List roles",
Description: "This endpoint lists all roles",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Get, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetRole",
Tags: []string{"role"},
Summary: "Get role",
Description: "This endpoint gets a role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Get, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetRole",
Tags: []string{"role"},
Summary: "Get role",
Description: "This endpoint gets a role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authzMiddleware.Check(provider.authzHandler.GetObjects, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetObjects",
Tags: []string{"role"},
Summary: "Get objects for a role by relation",
Description: "Gets all objects connected to the specified role via a given relation type",
Request: nil,
RequestContentType: "",
Response: make([]*coretypes.ObjectGroup, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.GetObjects, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetObjects",
Tags: []string{"role"},
Summary: "Get objects for a role by relation",
Description: "Gets all objects connected to the specified role via a given relation type",
Request: nil,
RequestContentType: "",
Response: make([]*coretypes.ObjectGroup, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Patch, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "PatchRole",
Tags: []string{"role"},
Summary: "Patch role",
Description: "This endpoint patches a role",
Request: new(authtypes.PatchableRole),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPatch).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Patch, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "PatchRole",
Tags: []string{"role"},
Summary: "Patch role",
Description: "This endpoint patches a role",
Request: new(authtypes.PatchableRole),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authzMiddleware.Check(provider.authzHandler.PatchObjects, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "PatchObjects",
Tags: []string{"role"},
Summary: "Patch objects for a role by relation",
Description: "Patches the objects connected to the specified role via a given relation type",
Request: new(coretypes.PatchableObjects),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPatch).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.PatchObjects, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "PatchObjects",
Tags: []string{"role"},
Summary: "Patch objects for a role by relation",
Description: "Patches the objects connected to the specified role via a given relation type",
Request: new(coretypes.PatchableObjects),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Delete, authtypes.Relation{Verb: coretypes.VerbDelete}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "DeleteRole",
Tags: []string{"role"},
Summary: "Delete role",
Description: "This endpoint deletes a role",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteRole",
Tags: []string{"role"},
Summary: "Delete role",
Description: "This endpoint deletes a role",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
func roleCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func (provider *provider) roleInstanceSelectorCallback(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, err
}
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), roleID)
if err != nil {
return nil, err
}
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(role.Name),
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}

View File

@@ -1,13 +1,10 @@
package signozapiserver
import (
"bytes"
"encoding/json"
"io"
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -17,41 +14,56 @@ import (
)
func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Create, authtypes.Relation{Verb: coretypes.VerbCreate}, coretypes.ResourceServiceAccount, serviceAccountCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "CreateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Create service account",
Description: "This endpoint creates a service account",
Request: new(serviceaccounttypes.PostableServiceAccount),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Create, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Create service account",
Description: "This endpoint creates a service account",
Request: new(serviceaccounttypes.PostableServiceAccount),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.List, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceServiceAccount, serviceAccountCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "ListServiceAccounts",
Tags: []string{"serviceaccount"},
Summary: "List service accounts",
Description: "This endpoint lists the service accounts for an organisation",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.ServiceAccount, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbList)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.List, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListServiceAccounts",
Tags: []string{"serviceaccount"},
Summary: "List service accounts",
Description: "This endpoint lists the service accounts for an organisation",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.ServiceAccount, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
@@ -72,89 +84,117 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Get, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Gets a service account",
Description: "This endpoint gets an existing service account",
Request: nil,
RequestContentType: "",
Response: new(serviceaccounttypes.ServiceAccountWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Get, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Gets a service account",
Description: "This endpoint gets an existing service account",
Request: nil,
RequestContentType: "",
Response: new(serviceaccounttypes.ServiceAccountWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.GetRoles, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetServiceAccountRoles",
Tags: []string{"serviceaccount"},
Summary: "Gets service account roles",
Description: "This endpoint gets all the roles for the existing service account",
Request: nil,
RequestContentType: "",
Response: new([]*authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.GetRoles, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetServiceAccountRoles",
Tags: []string{"serviceaccount"},
Summary: "Gets service account roles",
Description: "This endpoint gets all the roles for the existing service account",
Request: nil,
RequestContentType: "",
Response: new([]*authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.SetRole, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceRole, SelectorCallback: provider.roleAttachSelectorFromBody, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
Description: "This endpoint assigns a role to a service account",
Request: new(serviceaccounttypes.PostableServiceAccountRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.SetRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
Description: "This endpoint assigns a role to a service account",
Request: new(serviceaccounttypes.PostableServiceAccountRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceServiceAccount,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("id")),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.DeleteRole, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceRole, SelectorCallback: provider.roleDetachSelectorFromPath, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
Description: "This endpoint revokes a role from service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
Description: "This endpoint revokes a role from service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceServiceAccount,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.PathParam("rid")),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
@@ -175,208 +215,209 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Update, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "UpdateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account",
Description: "This endpoint updates an existing service account",
Request: new(serviceaccounttypes.UpdatableServiceAccount),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Update, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account",
Description: "This endpoint updates an existing service account",
Request: new(serviceaccounttypes.UpdatableServiceAccount),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Delete, authtypes.Relation{Verb: coretypes.VerbDelete}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "DeleteServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Deletes a service account",
Description: "This endpoint deletes an existing service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDelete)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Deletes a service account",
Description: "This endpoint deletes an existing service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.CreateFactorAPIKey, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbCreate}, Resource: coretypes.ResourceMetaResourceFactorAPIKey, SelectorCallback: factorAPIKeyCollectionSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "CreateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Create a service account key",
Description: "This endpoint creates a service account key",
Request: new(serviceaccounttypes.PostableFactorAPIKey),
RequestContentType: "",
Response: new(serviceaccounttypes.GettableFactorAPIKeyWithKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbCreate), coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach)}),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.CreateFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Create a service account key",
Description: "This endpoint creates a service account key",
Request: new(serviceaccounttypes.PostableFactorAPIKey),
RequestContentType: "",
Response: new(serviceaccounttypes.GettableFactorAPIKeyWithKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbCreate), coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachParentChildResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
ParentResource: coretypes.ResourceServiceAccount,
ParentID: coretypes.PathParam("id"),
ParentSelector: coretypes.IDSelector,
ChildResource: coretypes.ResourceMetaResourceFactorAPIKey,
ChildIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceMetaResourceFactorAPIKey, factorAPIKeyCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "ListServiceAccountKeys",
Tags: []string{"serviceaccount"},
Summary: "List service account keys",
Description: "This endpoint lists the service account keys",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.GettableFactorAPIKey, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbList)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListServiceAccountKeys",
Tags: []string{"serviceaccount"},
Summary: "List service account keys",
Description: "This endpoint lists the service account keys",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.GettableFactorAPIKey, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceMetaResourceFactorAPIKey, factorAPIKeyInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "UpdateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account key",
Description: "This endpoint updates an existing service account key",
Request: new(serviceaccounttypes.UpdatableFactorAPIKey),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account key",
Description: "This endpoint updates an existing service account key",
Request: new(serviceaccounttypes.UpdatableFactorAPIKey),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("fid"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.RevokeFactorAPIKey, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbDelete}, Resource: coretypes.ResourceMetaResourceFactorAPIKey, SelectorCallback: factorAPIKeyInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "RevokeServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Revoke a service account key",
Description: "This endpoint revokes an existing service account key",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbDelete), coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.RevokeFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "RevokeServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Revoke a service account key",
Description: "This endpoint revokes an existing service account key",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbDelete), coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("fid"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachParentChildResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryAccessControl,
ParentResource: coretypes.ResourceServiceAccount,
ParentID: coretypes.PathParam("id"),
ParentSelector: coretypes.IDSelector,
ChildResource: coretypes.ResourceMetaResourceFactorAPIKey,
ChildIDs: coretypes.OneID(coretypes.PathParam("fid")),
},
),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
func (provider *provider) roleDetachSelectorFromPath(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(mux.Vars(req)["rid"])
// roleSelector resolves the FGA selectors for a role from its UUID. The id is
// already extracted by the ResourceDef (path or body); this only does the
// UUID -> name lookup the FGA object string requires. Shared by service account
// and role routes.
func (provider *provider) roleSelector(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(id)
if err != nil {
return nil, err
}
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), roleID)
role, err := provider.authzService.Get(ctx, orgID, roleID)
if err != nil {
return nil, err
}
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(role.Name),
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func (provider *provider) roleAttachSelectorFromBody(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
req.Body = io.NopCloser(bytes.NewReader(body))
postableRole := new(serviceaccounttypes.PostableServiceAccountRole)
if err := json.Unmarshal(body, postableRole); err != nil {
return nil, err
}
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), postableRole.ID)
if err != nil {
return nil, err
}
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(role.Name),
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func factorAPIKeyCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func factorAPIKeyInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
fid := mux.Vars(req)["fid"]
fidSelector, err := coretypes.TypeMetaResource.Selector(fid)
if err != nil {
return nil, err
}
return []coretypes.Selector{
fidSelector,
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func serviceAccountCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeServiceAccount.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func serviceAccountInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
id := mux.Vars(req)["id"]
idSelector, err := coretypes.TypeServiceAccount.Selector(id)
if err != nil {
return nil, err
}
return []coretypes.Selector{
idSelector,
coretypes.TypeServiceAccount.MustSelector(coretypes.WildCardSelectorString),
resource.Type().MustSelector(role.Name),
resource.Type().MustSelector(coretypes.WildCardSelectorString),
}, nil
}

View File

@@ -20,16 +20,16 @@ func newTestSettings() factory.ScopedProviderSettings {
return factory.NewScopedProviderSettings(instrumentationtest.New().ToProviderSettings(), "auditorserver_test")
}
func newTestEvent(resource string, action coretypes.Verb) audittypes.AuditEvent {
func newTestEvent(resource coretypes.Resource, action coretypes.Verb) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
EventName: audittypes.NewEventName(coretypes.MustNewKind(resource), action),
EventName: audittypes.NewEventName(resource.Kind(), action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,
},
ResourceAttributes: audittypes.ResourceAttributes{
ResourceKind: coretypes.MustNewKind(resource),
Resource: resource,
},
}
}
@@ -84,7 +84,7 @@ func TestAdd_FlushesOnBatchSize(t *testing.T) {
go func() { _ = server.Start(ctx) }()
for i := 0; i < 3; i++ {
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbCreate))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate))
}
assert.Eventually(t, func() bool {
@@ -113,7 +113,7 @@ func TestAdd_FlushesOnInterval(t *testing.T) {
go func() { _ = server.Start(ctx) }()
server.Add(ctx, newTestEvent("user", coretypes.VerbUpdate))
server.Add(ctx, newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate))
assert.Eventually(t, func() bool {
return exported.Load() == 1
@@ -131,9 +131,9 @@ func TestAdd_DropsWhenBufferFull(t *testing.T) {
ctx := context.Background()
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbCreate))
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbUpdate))
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbDelete))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbUpdate))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbDelete))
assert.Equal(t, 2, server.queueLen())
}
@@ -156,7 +156,7 @@ func TestStop_DrainsRemainingEvents(t *testing.T) {
go func() { _ = server.Start(ctx) }()
for i := 0; i < 5; i++ {
server.Add(ctx, newTestEvent("alert-rule", coretypes.VerbCreate))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceRule, coretypes.VerbCreate))
}
require.NoError(t, server.Stop(ctx))
@@ -181,8 +181,8 @@ func TestAdd_ContinuesAfterExportFailure(t *testing.T) {
go func() { _ = server.Start(ctx) }()
server.Add(ctx, newTestEvent("user", coretypes.VerbDelete))
server.Add(ctx, newTestEvent("user", coretypes.VerbDelete))
server.Add(ctx, newTestEvent(coretypes.ResourceUser, coretypes.VerbDelete))
server.Add(ctx, newTestEvent(coretypes.ResourceUser, coretypes.VerbDelete))
assert.Eventually(t, func() bool {
return calls.Load() >= 1
@@ -213,7 +213,7 @@ func TestAdd_ConcurrentSafety(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbCreate))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate))
}()
}
wg.Wait()

View File

@@ -15,13 +15,13 @@ type ServeOpenAPIFunc func(openapi.OperationContext)
type Handler interface {
http.Handler
ServeOpenAPI(openapi.OperationContext)
AuditDef() *AuditDef
ResourceDefs() []ResourceDef
}
type handler struct {
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
auditDef *AuditDef
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
resourceDefs []ResourceDef
}
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
@@ -130,6 +130,6 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
}
}
func (handler *handler) AuditDef() *AuditDef {
return handler.auditDef
func (handler *handler) ResourceDefs() []ResourceDef {
return handler.resourceDefs
}

View File

@@ -1,25 +1,9 @@
package handler
import (
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
)
// Option configures optional behaviour on a handler created by New.
type Option func(*handler)
type AuditDef struct {
ResourceKind coretypes.Kind // Typeable.Kind() value, e.g. "dashboard", "user".
Action coretypes.Verb // create, update, delete, etc.
Category audittypes.ActionCategory // access_control, configuration_change, etc.
ResourceIDParam string // Gorilla mux path param name for the resource ID.
}
// WithAudit attaches an AuditDef to the handler. The actual audit event
// emission is handled by the middleware layer, which reads the AuditDef
// from the matched route's handler.
func WithAuditDef(def AuditDef) Option {
func WithResourceDefs(defs ...ResourceDef) Option {
return func(h *handler) {
h.auditDef = &def
h.resourceDefs = append(h.resourceDefs, defs...)
}
}

View File

@@ -0,0 +1,99 @@
package handler
import "github.com/SigNoz/signoz/pkg/types/coretypes"
type ResourceDef interface {
// resolveRequest is unexported to seal the interface. It returns a slice so a
// single def can fan out (e.g. a telemetry query touching multiple signals).
resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource
}
func ResolveRequest(defs []ResourceDef, ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
resolved := make([]coretypes.ResolvedResource, 0, len(defs))
for _, def := range defs {
resolved = append(resolved, def.resolveRequest(ec)...)
}
return resolved
}
// BasicResourceDef checks a single resource for one verb.
type BasicResourceDef struct {
Resource coretypes.Resource
Verb coretypes.Verb
Category coretypes.ActionCategory
ID coretypes.ResourceIDExtractor
Selector coretypes.SelectorFunc
}
func (def BasicResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
return []coretypes.ResolvedResource{
coretypes.NewResolvedResource(
def.Verb,
def.Category,
def.Resource,
def.ID,
def.Selector,
ec,
),
}
}
// AttachDetachSiblingResourceDef checks an attach/detach between peer resources;
// both source and target are authz-checked.
type AttachDetachSiblingResourceDef struct {
Verb coretypes.Verb
Category coretypes.ActionCategory
SourceResource coretypes.Resource
SourceIDs coretypes.ResourceIDsExtractor
SourceSelector coretypes.SelectorFunc
TargetResource coretypes.Resource
TargetIDs coretypes.ResourceIDsExtractor
TargetSelector coretypes.SelectorFunc
}
func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
return []coretypes.ResolvedResource{
coretypes.NewResolvedResourceWithTarget(
def.Verb,
def.Category,
def.SourceResource,
def.SourceIDs,
def.SourceSelector,
def.TargetResource,
def.TargetIDs,
def.TargetSelector,
false,
ec,
),
}
}
// AttachDetachParentChildResourceDef authz-checks only the parent; the child
// rides along for audit context.
type AttachDetachParentChildResourceDef struct {
Verb coretypes.Verb
Category coretypes.ActionCategory
ParentResource coretypes.Resource
ParentID coretypes.ResourceIDExtractor
ParentSelector coretypes.SelectorFunc
ChildResource coretypes.Resource
ChildIDs coretypes.ResourceIDsExtractor
}
func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
return []coretypes.ResolvedResource{
coretypes.NewResolvedResourceWithTarget(
def.Verb,
def.Category,
def.ParentResource,
coretypes.OneID(def.ParentID),
def.ParentSelector,
def.ChildResource,
def.ChildIDs,
nil,
true,
ec,
),
}
}

View File

@@ -12,10 +12,10 @@ import (
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
)
const (
@@ -61,6 +61,12 @@ func (middleware *Audit) Wrap(next http.Handler) http.Handler {
responseBuffer := &byteBuffer{}
writer := newResponseCapture(rw, responseBuffer)
// Capture the body only when a resolved resource derives an id from it (e.g. a create).
if coretypes.ShouldCaptureResponseBody(req.Context()) {
writer.EnableBodyCapture()
}
next.ServeHTTP(writer, req)
statusCode, writeErr := writer.StatusCode(), writer.WriteError()
@@ -80,7 +86,7 @@ func (middleware *Audit) Wrap(next http.Handler) http.Handler {
fields = append(fields, errors.Attr(writeErr))
middleware.logger.ErrorContext(req.Context(), logMessage, fields...)
} else {
if responseBuffer.Len() != 0 {
if statusCode >= 400 && responseBuffer.Len() != 0 {
fields = append(fields, "response.body", responseBuffer.String())
}
@@ -94,76 +100,85 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
return
}
def := auditDefFromRequest(req)
if def == nil {
resolved, err := coretypes.ResolvedResourcesFromContext(req.Context())
if err != nil || len(resolved) == 0 {
return
}
// extract claims
claims, _ := authtypes.ClaimsFromContext(req.Context())
// extract status code
statusCode := writer.StatusCode()
// extract traces.
span := trace.SpanFromContext(req.Context())
// extract error details.
var errorType, errorCode string
if statusCode >= 400 {
errorType = render.ErrorTypeFromStatusCode(statusCode)
errorCode = render.ErrorCodeFromBody(writer.BodyBytes())
}
event := audittypes.NewAuditEventFromHTTPRequest(
req,
routeTemplate,
statusCode,
span.SpanContext().TraceID(),
span.SpanContext().SpanID(),
def.Action,
def.Category,
claims,
resourceIDFromRequest(req, def.ResourceIDParam),
def.ResourceKind,
errorType,
errorCode,
)
extractorCtx := coretypes.ExtractorContext{Request: req, ResponseBody: writer.BodyBytes()}
middleware.auditor.Audit(req.Context(), event)
}
func auditDefFromRequest(req *http.Request) *handler.AuditDef {
route := mux.CurrentRoute(req)
if route == nil {
return nil
}
actualHandler := route.GetHandler()
if actualHandler == nil {
return nil
}
// The type assertion is necessary because route.GetHandler() returns
// http.Handler, and not every http.Handler on the mux is a handler.Handler
// (e.g. middleware wrappers, raw http.HandlerFunc registrations).
provider, ok := actualHandler.(handler.Handler)
if !ok {
return nil
}
return provider.AuditDef()
}
func resourceIDFromRequest(req *http.Request, param string) string {
if param == "" {
return ""
}
vars := mux.Vars(req)
if vars == nil {
return ""
}
return vars[param]
for _, resource := range resolved {
resource.ResolveResponse(extractorCtx)
verb, category := resource.Verb(), resource.Category()
switch typed := resource.(type) {
case coretypes.ResolvedResourceWithTargetResource:
for _, sourceID := range typed.SourceIDs() {
for _, targetID := range typed.TargetIDs() {
attributesList := []audittypes.ResourceAttributes{
audittypes.NewRelatedResourceAttributes(
typed.SourceResource(),
sourceID,
typed.TargetResource(),
targetID,
),
}
// Sibling peers are symmetric, so mirror the event from the target's side too.
if !typed.IsParentChild() {
attributesList = append(attributesList, audittypes.NewRelatedResourceAttributes(
typed.TargetResource(),
targetID,
typed.SourceResource(),
sourceID,
))
}
for _, attributes := range attributesList {
middleware.auditor.Audit(req.Context(), audittypes.NewAuditEventFromHTTPRequest(
req,
routeTemplate,
statusCode,
span.SpanContext().TraceID(),
span.SpanContext().SpanID(),
verb,
category,
claims,
attributes,
errorType,
errorCode,
))
}
}
}
default:
for _, id := range resource.SourceIDs() {
attributes := audittypes.NewResourceAttributes(resource.SourceResource(), id)
middleware.auditor.Audit(req.Context(), audittypes.NewAuditEventFromHTTPRequest(
req,
routeTemplate,
statusCode,
span.SpanContext().TraceID(),
span.SpanContext().SpanID(),
verb,
category,
claims,
attributes,
errorType,
errorCode,
))
}
}
}
}

View File

@@ -1,6 +1,8 @@
package middleware
import (
"context"
"fmt"
"log/slog"
"net/http"
@@ -19,18 +21,6 @@ const (
authzDeniedMessage string = "::AUTHZ-DENIED::"
)
type AuthZCheckDef struct {
Relation authtypes.Relation
Resource coretypes.Resource
SelectorCallback selectorCallbackWithClaimsFn
Roles []string
}
// AuthZCheckGroup is a set of checks OR'd together.
// At least one check in the group must pass for the group to pass.
type AuthZCheckGroup []AuthZCheckDef
type selectorCallbackWithClaimsFn func(*http.Request, authtypes.Claims) ([]coretypes.Selector, error)
type selectorCallbackWithoutClaimsFn func(*http.Request, []*types.Organization) ([]coretypes.Selector, valuer.UUID, error)
type AuthZ struct {
@@ -201,7 +191,9 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
})
}
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, typeable coretypes.Resource, cb selectorCallbackWithClaimsFn, roles []string) http.HandlerFunc {
// CheckResources authorizes every resolved resource for the route. roles are the
// allowed role names (the OSS role-gate); the resource selectors drive the EE check.
func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
@@ -210,40 +202,7 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relatio
return
}
selectors, err := cb(req, claims)
if err != nil {
render.Error(rw, err)
return
}
roleSelectors := []coretypes.Selector{}
for _, role := range roles {
roleSelectors = append(roleSelectors, coretypes.TypeRole.MustSelector(role))
}
err = middleware.authzService.CheckWithTupleCreation(ctx, claims, valuer.MustNewUUID(claims.OrgID), relation, typeable, selectors, roleSelectors)
if err != nil {
render.Error(rw, err)
return
}
next(rw, req)
})
}
// CheckAll verifies groups of permission checks.
// Within each group, checks are OR'd (any check passing = group passes).
// Across groups, results are AND'd (all groups must pass).
//
// This model expresses any combination:
// - Single check: []AuthZCheckGroup{{checkA}}
// - Pure AND: []AuthZCheckGroup{{checkA}, {checkB}}
// - Cross-resource OR: []AuthZCheckGroup{{checkA, checkB}}
// - Mixed (A OR B) AND C: []AuthZCheckGroup{{checkA, checkB}, {checkC}}
func (middleware *AuthZ) CheckAll(next http.HandlerFunc, groups []AuthZCheckGroup) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
resolved, err := coretypes.ResolvedResourcesFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
@@ -251,33 +210,23 @@ func (middleware *AuthZ) CheckAll(next http.HandlerFunc, groups []AuthZCheckGrou
orgID := valuer.MustNewUUID(claims.OrgID)
for _, group := range groups {
groupPassed := false
var lastErr error
roleSelectors := make([]coretypes.Selector, len(roles))
for idx, role := range roles {
roleSelectors[idx] = coretypes.TypeRole.MustSelector(role)
}
for _, check := range group {
selectors, err := check.SelectorCallback(req, claims)
if err != nil {
for _, resource := range resolved {
if err := middleware.checkResource(ctx, claims, orgID, resource.Verb(), resource.SourceResource(), resource.SourceIDs(), resource.SourceSelector(), roleSelectors); err != nil {
render.Error(rw, err)
return
}
target, ok := resource.(coretypes.ResolvedResourceWithTargetResource)
if ok && !target.IsParentChild() {
if err := middleware.checkResource(ctx, claims, orgID, target.Verb(), target.TargetResource(), target.TargetIDs(), target.TargetSelector(), roleSelectors); err != nil {
render.Error(rw, err)
return
}
roleSelectors := make([]coretypes.Selector, len(check.Roles))
for idx, role := range check.Roles {
roleSelectors[idx] = coretypes.TypeRole.MustSelector(role)
}
err = middleware.authzService.CheckWithTupleCreation(ctx, claims, orgID, check.Relation, check.Resource, selectors, roleSelectors)
if err == nil {
groupPassed = true
break
}
lastErr = err
}
if !groupPassed {
render.Error(rw, lastErr)
return
}
}
@@ -285,6 +234,68 @@ func (middleware *AuthZ) CheckAll(next http.HandlerFunc, groups []AuthZCheckGrou
})
}
func (middleware *AuthZ) checkResource(
ctx context.Context,
claims authtypes.Claims,
orgID valuer.UUID,
verb coretypes.Verb,
resource coretypes.Resource,
ids []string,
selector coretypes.SelectorFunc,
roleSelectors []coretypes.Selector,
) error {
if selector == nil {
return errors.New(errors.TypeInternal, errors.CodeInternal, "resolved resource is missing a selector")
}
for _, id := range ids {
selectors, err := selector(ctx, resource, id, orgID)
if err != nil {
return err
}
err = middleware.authzService.CheckWithTupleCreation(
ctx,
claims,
orgID,
authtypes.Relation{Verb: verb},
resource,
selectors,
roleSelectors,
)
if err == nil {
continue
}
if !errors.Asc(err, authtypes.ErrCodeAuthZForbidden) {
return err
}
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
principal := fmt.Sprintf("%s/%s", claims.Principal.StringValue(), claims.IdentityID())
if id != "" {
return errors.Newf(
errors.TypeForbidden,
authtypes.ErrCodeAuthZForbidden,
"%s is not authorized to perform %s on resource %q",
principal,
resource.Scope(verb),
id,
)
}
return errors.Newf(
errors.TypeForbidden,
authtypes.ErrCodeAuthZForbidden,
"%s is not authorized to perform %s",
principal,
resource.Scope(verb),
)
}
return nil
}
func (middleware *AuthZ) CheckWithoutClaims(next http.HandlerFunc, relation authtypes.Relation, typeable coretypes.Resource, cb selectorCallbackWithoutClaimsFn, roles []string) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()

View File

@@ -0,0 +1,67 @@
package middleware
import (
"bytes"
"io"
"log/slog"
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/gorilla/mux"
)
// Resource resolves a route's declared ResourceDefs and stashes the result in
// the request context for authz and audit to read.
type Resource struct {
logger *slog.Logger
}
func NewResource(logger *slog.Logger) *Resource {
return &Resource{logger: logger.With(slog.String("pkg", pkgname))}
}
func (middleware *Resource) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
defs := resourceDefsFromRequest(req)
if len(defs) == 0 {
next.ServeHTTP(rw, req)
return
}
// Buffer the body once so extractors can read it and the handler still sees a fresh reader.
var body []byte
if req.Body != nil {
body, _ = io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(body))
}
extractorCtx := coretypes.ExtractorContext{
Request: req,
RequestBody: body,
}
resolved := handler.ResolveRequest(defs, extractorCtx)
ctx := coretypes.NewContextWithResolvedResources(req.Context(), resolved)
next.ServeHTTP(rw, req.WithContext(ctx))
})
}
func resourceDefsFromRequest(req *http.Request) []handler.ResourceDef {
route := mux.CurrentRoute(req)
if route == nil {
return nil
}
actualHandler := route.GetHandler()
if actualHandler == nil {
return nil
}
provider, ok := actualHandler.(handler.Handler)
if !ok {
return nil
}
return provider.ResourceDefs()
}

View File

@@ -23,9 +23,14 @@ type responseCapture interface {
// WriteError returns the error (if any) from the downstream Write call.
WriteError() error
// BodyBytes returns the captured response body bytes. Only populated
// for error responses (status >= 400).
// BodyBytes returns the captured response body bytes. Populated for error
// responses (status >= 400), or for any response once EnableBodyCapture is called.
BodyBytes() []byte
// EnableBodyCapture forces capture of the response body regardless of status
// code (still bounded by maxResponseBodyCapture). Must be called before the
// handler writes the response.
EnableBodyCapture()
}
func newResponseCapture(rw http.ResponseWriter, buffer *byteBuffer) responseCapture {
@@ -72,12 +77,13 @@ func (b *byteBuffer) String() string {
}
type nonFlushingResponseCapture struct {
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
bodyBytesLeft int
statusCode int
writeError error
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
forceCaptureBody bool
bodyBytesLeft int
statusCode int
writeError error
}
type flushingResponseCapture struct {
@@ -98,13 +104,17 @@ func (writer *nonFlushingResponseCapture) Header() http.Header {
// WriteHeader writes the HTTP response header.
func (writer *nonFlushingResponseCapture) WriteHeader(statusCode int) {
writer.statusCode = statusCode
if statusCode >= 400 {
if statusCode >= 400 || writer.forceCaptureBody {
writer.captureBody = true
}
writer.rw.WriteHeader(statusCode)
}
func (writer *nonFlushingResponseCapture) EnableBodyCapture() {
writer.forceCaptureBody = true
}
// Write writes HTTP response data.
func (writer *nonFlushingResponseCapture) Write(data []byte) (int, error) {
if writer.statusCode == 0 {

View File

@@ -61,11 +61,23 @@ type Module interface {
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
ListV2(ctx context.Context, orgID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error)
ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error)
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error)
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)
PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error
UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error
DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
}
type Handler interface {
@@ -96,6 +108,10 @@ type Handler interface {
GetV2(http.ResponseWriter, *http.Request)
ListV2(http.ResponseWriter, *http.Request)
ListForUserV2(http.ResponseWriter, *http.Request)
UpdateV2(http.ResponseWriter, *http.Request)
LockV2(http.ResponseWriter, *http.Request)
@@ -103,4 +119,10 @@ type Handler interface {
UnlockV2(http.ResponseWriter, *http.Request)
PatchV2(http.ResponseWriter, *http.Request)
PinV2(http.ResponseWriter, *http.Request)
UnpinV2(http.ResponseWriter, *http.Request)
DeleteV2(http.ResponseWriter, *http.Request)
}

View File

@@ -0,0 +1,44 @@
package impldashboard
import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
)
type Compiled struct {
SQL string
Args []any
}
func (c Compiled) IsEmpty() bool {
return c.SQL == ""
}
// Compile always returns a non-nil *Compiled. An empty query (or one that
// produces no SQL) yields a Compiled with an empty SQL — callers gate on
// SQL != "" rather than a nil check.
func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
if len(query) == 0 {
return &Compiled{}, nil
}
queryVisitor := newVisitor(formatter)
sql, args, syntaxErrs := queryVisitor.compile(query)
if len(syntaxErrs) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(syntaxErrs, "; "))
}
if len(queryVisitor.errors) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(queryVisitor.errors, "; "))
}
return &Compiled{
SQL: sql,
Args: args,
}, nil
}

View File

@@ -0,0 +1,526 @@
package impldashboard
import (
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
)
type compileCase struct {
subtestName string
dslQueryToCompile string
emptyQueryExpected bool
expectedSQL string
expectedArgs []any
expectedErrShouldContain string
}
// kindArg is the tag_relation.kind value bound into every tag EXISTS subquery
// (stored double-encoded, hence the embedded quotes). It leads each tag
// predicate's args, ahead of the tag key.
const kindArg = `"dashboard"`
func runCompileCases(t *testing.T, cases []compileCase) {
t.Helper()
for _, c := range cases {
t.Run(c.subtestName, func(t *testing.T) {
out, err := Compile(c.dslQueryToCompile, formatter(t))
if c.expectedErrShouldContain != "" {
require.Error(t, err)
assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(c.expectedErrShouldContain))
return
}
require.NoError(t, err)
if c.emptyQueryExpected {
assert.True(t, out.IsEmpty())
return
}
require.NotNil(t, out)
if c.expectedSQL != "" {
assert.Equal(t, normalizeSQL(c.expectedSQL), normalizeSQL(out.SQL))
}
if c.expectedArgs != nil {
require.Len(t, out.Args, len(c.expectedArgs))
for i, want := range c.expectedArgs {
// time.Time values can carry semantically-equal instants
// in different *Location representations (UTC vs Local vs
// FixedZone). Compare via .Equal() instead of DeepEqual.
if wantT, ok := want.(time.Time); ok {
gotT, ok := out.Args[i].(time.Time)
require.True(t, ok, "arg[%d]: want time.Time, got %T", i, out.Args[i])
assert.True(t, wantT.Equal(gotT), "arg[%d]: want %s, got %s", i, wantT, gotT)
continue
}
assert.Equal(t, want, out.Args[i], "arg[%d]", i)
}
}
})
}
}
func TestCompile_Empty(t *testing.T) {
runCompileCases(t, []compileCase{
{subtestName: "empty query yields nil", dslQueryToCompile: "", emptyQueryExpected: true},
})
}
func TestCompile_Name(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "name =",
dslQueryToCompile: `name = 'overview'`,
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') = ?`,
expectedArgs: []any{"overview"},
},
{
// QUOTED_TEXT in the grammar covers both '…' and "…" — visitor
// strips whichever quote pair surrounds the value.
subtestName: "name = with double-quoted value",
dslQueryToCompile: `name = "something"`,
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') = ?`,
expectedArgs: []any{"something"},
},
{
subtestName: "name CONTAINS",
dslQueryToCompile: `name CONTAINS 'overview'`,
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') LIKE ? ESCAPE '\'`,
expectedArgs: []any{"%overview%"},
},
{
subtestName: "name ILIKE — emitted as LOWER(col) LIKE LOWER(?) for dialect parity",
dslQueryToCompile: `name ILIKE 'Prod%'`,
expectedSQL: `lower(json_extract("dashboard"."data", '$.spec.display.name')) LIKE LOWER(?) ESCAPE '\'`,
expectedArgs: []any{"Prod%"},
},
{
subtestName: "CONTAINS escapes % in user input",
dslQueryToCompile: `name CONTAINS '50%'`,
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') LIKE ? ESCAPE '\'`,
expectedArgs: []any{`%50\%%`},
},
})
}
func TestCompile_CreatedByLocked(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "created_by LIKE",
dslQueryToCompile: `created_by LIKE '%@signoz.io'`,
expectedSQL: `dashboard.created_by LIKE ? ESCAPE '\'`,
expectedArgs: []any{"%@signoz.io"},
},
{
subtestName: "locked = true",
dslQueryToCompile: `locked = true`,
expectedSQL: `dashboard.locked = ?`,
expectedArgs: []any{true},
},
})
}
func TestCompile_Timestamps(t *testing.T) {
ist := time.FixedZone("+05:30", 5*60*60+30*60)
runCompileCases(t, []compileCase{
{
subtestName: "created_at >= RFC3339",
dslQueryToCompile: `created_at >= '2026-03-10T00:00:00Z'`,
expectedSQL: `dashboard.created_at >= ?`,
expectedArgs: []any{time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)},
},
{
subtestName: "updated_at BETWEEN",
dslQueryToCompile: `updated_at BETWEEN '2026-03-10T00:00:00Z' AND '2026-03-20T00:00:00Z'`,
expectedSQL: `dashboard.updated_at BETWEEN ? AND ?`,
expectedArgs: []any{
time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC),
time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
},
},
{
subtestName: "created_at >= IST timestamp",
dslQueryToCompile: `created_at >= '2026-03-10T05:30:00+05:30'`,
expectedSQL: `dashboard.created_at >= ?`,
expectedArgs: []any{time.Date(2026, 3, 10, 5, 30, 0, 0, ist)},
},
})
}
// Tag operators wrap each predicate in EXISTS / NOT EXISTS. Any non-reserved
// key is a tag key — `team = 'pulse'` matches a tag with key=team value=pulse,
// `tag = 'prod'` matches a tag with key=tag value=prod, and so on.
func TestCompile_Tag(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "team = wraps in EXISTS",
dslQueryToCompile: `team = 'pulse'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{kindArg, "team", "pulse"},
},
{
subtestName: "tag = is just a regular tag-key filter",
dslQueryToCompile: `tag = 'database'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{kindArg, "tag", "database"},
},
{
subtestName: "team != wraps in NOT EXISTS with positive inner",
dslQueryToCompile: `team != 'pulse'`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{kindArg, "team", "pulse"},
},
{
subtestName: "team IN — inner is single placeholder list on t.value",
dslQueryToCompile: `team IN ['pulse', 'events']`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?)
)`,
expectedArgs: []any{kindArg, "team", "pulse", "events"},
},
{
subtestName: "team NOT IN",
dslQueryToCompile: `team NOT IN ['pulse', 'events']`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?)
)`,
expectedArgs: []any{kindArg, "team", "pulse", "events"},
},
{
subtestName: "team LIKE — wildcard on value",
dslQueryToCompile: `team LIKE 'pulse%'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value LIKE ? ESCAPE '\'
)`,
expectedArgs: []any{kindArg, "team", "pulse%"},
},
{
subtestName: "team NOT LIKE",
dslQueryToCompile: `team NOT LIKE 'staging%'`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value LIKE ? ESCAPE '\'
)`,
expectedArgs: []any{kindArg, "team", "staging%"},
},
{
subtestName: "database EXISTS — asserts a tag with key=database is present",
dslQueryToCompile: `database EXISTS`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
)`,
expectedArgs: []any{kindArg, "database"},
},
{
subtestName: "database NOT EXISTS",
dslQueryToCompile: `database NOT EXISTS`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
)`,
expectedArgs: []any{kindArg, "database"},
},
{
subtestName: "tag-key matching is case-insensitive — TEAM lowercased",
dslQueryToCompile: `TEAM = 'pulse'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{kindArg, "team", "pulse"},
},
})
}
func TestCompile_BooleanComposition(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "AND chain — flat arg list",
dslQueryToCompile: `locked = true AND created_by = 'a@b.com'`,
expectedSQL: `(dashboard.locked = ? AND dashboard.created_by = ?)`,
expectedArgs: []any{true, "a@b.com"},
},
{
subtestName: "OR chain",
dslQueryToCompile: `locked = true OR created_by = 'a@b.com'`,
expectedSQL: `(dashboard.locked = ? OR dashboard.created_by = ?)`,
expectedArgs: []any{true, "a@b.com"},
},
{
subtestName: "parens preserve precedence",
dslQueryToCompile: `(locked = true OR locked = false) AND created_by = 'a@b.com'`,
expectedSQL: `((dashboard.locked = ? OR dashboard.locked = ?) AND dashboard.created_by = ?)`,
expectedArgs: []any{true, false, "a@b.com"},
},
})
}
// Distinct from operator-suffix negation (NOT IN / NOT LIKE / NOT EXISTS).
// Driven by the unaryExpression rule (`NOT? primary`), so NOT binds to
// exactly one primary and only widens via parens.
func TestCompile_NOT(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "NOT on a single comparison",
dslQueryToCompile: `NOT name = 'foo'`,
expectedSQL: `NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?)`,
expectedArgs: []any{"foo"},
},
{
subtestName: "NOT binds tightly to its primary in an AND chain",
dslQueryToCompile: `NOT name = 'foo' AND created_by = 'alice'`,
expectedSQL: `(NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?) AND dashboard.created_by = ?)`,
expectedArgs: []any{"foo", "alice"},
},
{
subtestName: "NOT applied to the second term in an AND chain",
dslQueryToCompile: `locked = true AND NOT name = 'foo'`,
expectedSQL: `(dashboard.locked = ? AND NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?))`,
expectedArgs: []any{true, "foo"},
},
{
subtestName: "NOT around a parenthesized OR",
dslQueryToCompile: `NOT (locked = true OR created_by = 'a@b.com')`,
expectedSQL: `NOT ((dashboard.locked = ? OR dashboard.created_by = ?))`,
expectedArgs: []any{true, "a@b.com"},
},
{
subtestName: "double NOT via parens",
dslQueryToCompile: `NOT (NOT name = 'foo')`,
expectedSQL: `NOT (NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?))`,
expectedArgs: []any{"foo"},
},
{
subtestName: "NOT on a tag equality",
dslQueryToCompile: `NOT team = 'pulse'`,
expectedSQL: `
NOT (
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)
)`,
expectedArgs: []any{kindArg, "team", "pulse"},
},
{
subtestName: "NOT team = ... AND name = ...",
dslQueryToCompile: `NOT team = 'pulse' AND name = 'overview'`,
expectedSQL: `
(
NOT (
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)
)
AND json_extract("dashboard"."data", '$.spec.display.name') = ?)`,
expectedArgs: []any{kindArg, "team", "pulse", "overview"},
},
})
}
func TestCompile_ComplexExamples(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "name CONTAINS + tag LIKE + created_by + database =",
dslQueryToCompile: `name CONTAINS 'overview' AND tag LIKE 'prod%' AND created_by = 'naman.verma@signoz.io' AND database = 'mongo'`,
expectedSQL: `
(
json_extract("dashboard"."data", '$.spec.display.name') LIKE ? ESCAPE '\'
AND EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value LIKE ? ESCAPE '\'
)
AND dashboard.created_by = ?
AND EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
))`,
expectedArgs: []any{"%overview%", kindArg, "tag", "prod%", "naman.verma@signoz.io", kindArg, "database", "mongo"},
},
{
subtestName: "team IN AND database EXISTS",
dslQueryToCompile: `team IN ['pulse', 'events'] AND database EXISTS`,
expectedSQL: `
(
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?)
)
AND EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
))`,
expectedArgs: []any{kindArg, "team", "pulse", "events", kindArg, "database"},
},
{
subtestName: "nested OR / AND with parens",
dslQueryToCompile: `(database IN ['sql', 'redis', 'mongo'] OR name LIKE '%database%') AND (team = 'pulse' OR name LIKE '%pulse%')`,
expectedSQL: `
(
(
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?, ?)
)
OR json_extract("dashboard"."data", '$.spec.display.name') LIKE ? ESCAPE '\'
)
AND (
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)
OR json_extract("dashboard"."data", '$.spec.display.name') LIKE ? ESCAPE '\'
))`,
expectedArgs: []any{kindArg, "database", "sql", "redis", "mongo", "%database%", kindArg, "team", "pulse", "%pulse%"},
},
})
}
func TestCompile_Rejections(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "rejects op outside per-reserved-key allowlist",
dslQueryToCompile: `name BETWEEN 'a' AND 'z'`,
expectedErrShouldContain: "operator",
},
{
subtestName: "rejects BETWEEN on a tag key",
dslQueryToCompile: `team BETWEEN 'a' AND 'z'`,
expectedErrShouldContain: "operator",
},
{
subtestName: "rejects non-bool on locked",
dslQueryToCompile: `locked = 'yes'`,
expectedErrShouldContain: "boolean",
},
{
subtestName: "rejects non-RFC3339 timestamp",
dslQueryToCompile: `created_at >= 'not-a-date'`,
expectedErrShouldContain: "RFC3339",
},
{
subtestName: "rejects REGEXP — not yet supported",
dslQueryToCompile: `name REGEXP '.*'`,
expectedErrShouldContain: "REGEXP",
},
{
subtestName: "rejects syntax error from grammar",
dslQueryToCompile: `name = `,
expectedErrShouldContain: "syntax",
},
})
}
// Every key in dashboardtypes.ReservedOps must have a matching case in
// visitComparisonForReservedKeys; a key that's reserved but unhandled falls
// through to the "no handler for reserved key" error. Equal is accepted by all
// reserved keys, so `key = 'x'` always reaches the dispatch switch — a missing
// handler surfaces as that error regardless of whether the value type-checks.
func TestCompileReservedKeysAllHandled(t *testing.T) {
for key := range dashboardtypes.ReservedOps {
t.Run(string(key), func(t *testing.T) {
_, err := Compile(string(key)+` = 'x'`, formatter(t))
if err != nil {
assert.NotContains(t, err.Error(), "no handler for reserved key",
"reserved key %q has no handler in visitComparisonForReservedKeys", key)
}
})
}
}
func formatter(t *testing.T) sqlstore.SQLFormatter {
t.Helper()
p := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
return p.Formatter()
}
func normalizeSQL(s string) string {
s = strings.Join(strings.Fields(s), " ")
s = strings.ReplaceAll(s, "( ", "(")
s = strings.ReplaceAll(s, " )", ")")
return s
}

View File

@@ -0,0 +1,581 @@
package impldashboard
import (
"fmt"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/parser/filterquery"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/antlr4-go/antlr/v4"
sqlbuilder "github.com/huandu/go-sqlbuilder"
)
// bunPlaceholderFlavor is any flavor that renders `?` placeholders, which bun
// re-binds to the actual backend (e.g. `$1` for Postgres) at query time.
const bunPlaceholderFlavor = sqlbuilder.SQLite
type visitor struct {
grammar.BaseFilterQueryVisitor
selectBuilder *sqlbuilder.SelectBuilder
formatter sqlstore.SQLFormatter
errors []string
}
func newVisitor(formatter sqlstore.SQLFormatter) *visitor {
return &visitor{
selectBuilder: sqlbuilder.NewSelectBuilder(),
formatter: formatter,
}
}
// compile turns the parse tree into `?`-placeholder WHERE SQL + arguments for bun.
func (v *visitor) compile(query string) (string, []any, []string) {
tree, _, collector := filterquery.Parse(query)
if len(collector.Errors) > 0 {
return "", nil, collector.Errors
}
condition, _ := v.visit(tree).(string)
if condition == "" {
return "", nil, nil
}
sql, arguments := v.selectBuilder.Args.CompileWithFlavor(condition, bunPlaceholderFlavor)
return sql, arguments, nil
}
func (v *visitor) visit(tree antlr.ParseTree) any {
if tree == nil {
return nil
}
return tree.Accept(v)
}
// ════════════════════════════════════════════════════════════════════════
// methods from grammar.BaseFilterQueryVisitor that are overridden
// ════════════════════════════════════════════════════════════════════════
func (v *visitor) VisitQuery(ctx *grammar.QueryContext) any {
return v.visit(ctx.Expression())
}
func (v *visitor) VisitExpression(ctx *grammar.ExpressionContext) any {
return v.visit(ctx.OrExpression())
}
func (v *visitor) VisitOrExpression(ctx *grammar.OrExpressionContext) any {
parts := ctx.AllAndExpression()
conditions := make([]string, 0, len(parts))
for _, part := range parts {
if condition, ok := v.visit(part).(string); ok && condition != "" {
conditions = append(conditions, condition)
}
}
switch len(conditions) {
case 0:
return ""
case 1:
return conditions[0]
default:
return v.selectBuilder.Or(conditions...)
}
}
func (v *visitor) VisitAndExpression(ctx *grammar.AndExpressionContext) any {
parts := ctx.AllUnaryExpression()
conditions := make([]string, 0, len(parts))
for _, part := range parts {
if condition, ok := v.visit(part).(string); ok && condition != "" {
conditions = append(conditions, condition)
}
}
switch len(conditions) {
case 0:
return ""
case 1:
return conditions[0]
default:
return v.selectBuilder.And(conditions...)
}
}
func (v *visitor) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) any {
condition, _ := v.visit(ctx.Primary()).(string)
if condition == "" {
return ""
}
if ctx.NOT() != nil {
return fmt.Sprintf("NOT (%s)", condition)
}
return condition
}
func (v *visitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
if ctx.OrExpression() != nil {
return v.visit(ctx.OrExpression())
}
if ctx.Comparison() != nil {
return v.visit(ctx.Comparison())
}
// Bare keys, values, full text, and function calls are not part of the
// dashboard list DSL.
v.addError("unsupported expression %q — every term must be of the form `key OP value`", ctx.GetText())
return ""
}
// VisitComparison dispatches a single `key OP value` term. A key that matches
// a reserved DSL key (name, description, etc.) becomes a column-level
// predicate; any other identifier is treated as a tag key — the operator
// applies to the tag's value, with a case-insensitive match on the tag's key.
func (v *visitor) VisitComparison(ctx *grammar.ComparisonContext) any {
key := strings.ToLower(strings.TrimSpace(ctx.Key().GetText()))
operation, ok := v.extractOperation(ctx)
if !ok {
return ""
}
if allowedOperations, isReserved := dashboardtypes.ReservedOps[dashboardtypes.DSLKey(key)]; isReserved {
return v.visitComparisonForReservedKeys(ctx, operation, dashboardtypes.DSLKey(key), allowedOperations)
}
return v.visitComparisonForTags(ctx, operation, key)
}
func (v *visitor) visitComparisonForReservedKeys(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key dashboardtypes.DSLKey, allowedOperations map[qbtypesv5.FilterOperator]struct{}) string {
if _, allowed := allowedOperations[operation]; !allowed {
v.addError("operator %s is not allowed for key %q", operationName(operation), key)
return ""
}
switch key {
case dashboardtypes.DSLKeyName:
return v.buildJSONStringComparison(ctx, operation, dashboardtypes.DSLKeyName, "$.spec.display.name")
case dashboardtypes.DSLKeyDescription:
return v.buildJSONStringComparison(ctx, operation, dashboardtypes.DSLKeyDescription, "$.spec.display.description")
case dashboardtypes.DSLKeyCreatedAt:
return v.buildTimestampComparison(ctx, operation, "dashboard.created_at")
case dashboardtypes.DSLKeyUpdatedAt:
return v.buildTimestampComparison(ctx, operation, "dashboard.updated_at")
case dashboardtypes.DSLKeyCreatedBy:
return v.buildStringComparison(ctx, operation, dashboardtypes.DSLKeyCreatedBy, "dashboard.created_by")
case dashboardtypes.DSLKeyLocked:
return v.buildBoolComparison(ctx, operation, "dashboard.locked")
}
// Unreachable for real input: every dashboardtypes.ReservedOps key has a case above, and
// TestCompileReservedKeysAllHandled guards that the two stay in sync.
v.addError("no handler for reserved key %q", key)
return ""
}
func (v *visitor) visitComparisonForTags(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, tagKey string) string {
if _, allowed := dashboardtypes.TagKeyOps[operation]; !allowed {
v.addError("operator %s is not allowed on a tag-key filter", operationName(operation))
return ""
}
return v.buildTagComparison(ctx, operation, tagKey)
}
func (v *visitor) extractOperation(ctx *grammar.ComparisonContext) (qbtypesv5.FilterOperator, bool) {
// For operators that take an optional leading NOT, Inverse() maps each to
// its Not<X> counterpart.
maybeNot := func(operation qbtypesv5.FilterOperator) qbtypesv5.FilterOperator {
if ctx.NOT() != nil {
return operation.Inverse()
}
return operation
}
switch {
case ctx.EQUALS() != nil:
return qbtypesv5.FilterOperatorEqual, true
case ctx.NOT_EQUALS() != nil, ctx.NEQ() != nil:
return qbtypesv5.FilterOperatorNotEqual, true
case ctx.LT() != nil:
return qbtypesv5.FilterOperatorLessThan, true
case ctx.LE() != nil:
return qbtypesv5.FilterOperatorLessThanOrEq, true
case ctx.GT() != nil:
return qbtypesv5.FilterOperatorGreaterThan, true
case ctx.GE() != nil:
return qbtypesv5.FilterOperatorGreaterThanOrEq, true
case ctx.BETWEEN() != nil:
return maybeNot(qbtypesv5.FilterOperatorBetween), true
case ctx.LIKE() != nil:
return maybeNot(qbtypesv5.FilterOperatorLike), true
case ctx.ILIKE() != nil:
return maybeNot(qbtypesv5.FilterOperatorILike), true
case ctx.CONTAINS() != nil:
return maybeNot(qbtypesv5.FilterOperatorContains), true
case ctx.REGEXP() != nil:
return maybeNot(qbtypesv5.FilterOperatorRegexp), true
case ctx.InClause() != nil:
return qbtypesv5.FilterOperatorIn, true
case ctx.NotInClause() != nil:
return qbtypesv5.FilterOperatorNotIn, true
case ctx.EXISTS() != nil:
return maybeNot(qbtypesv5.FilterOperatorExists), true
}
v.addError("could not determine operator in expression %q", ctx.GetText())
return qbtypesv5.FilterOperatorUnknown, false
}
// ─── per-key emitters ────────────────────────────────────────────────────────
func (v *visitor) buildJSONStringComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key dashboardtypes.DSLKey, jsonPath string) string {
columnExpression := string(v.formatter.JSONExtractString("dashboard.data", jsonPath))
return v.buildStringOperation(v.selectBuilder, ctx, operation, columnExpression, string(key))
}
func (v *visitor) buildStringComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key dashboardtypes.DSLKey, columnExpression string) string {
return v.buildStringOperation(v.selectBuilder, ctx, operation, columnExpression, string(key))
}
// buildStringOperation covers all the operators the spec allows on text-shaped keys
// (name, description, created_by, and a tag's value). Placeholders are interned
// into builder — the outer builder for column predicates, the subquery builder for
// tag-value predicates — so nested EXISTS arguments thread correctly.
func (v *visitor) buildStringOperation(builder *sqlbuilder.SelectBuilder, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression, keyForError string) string {
switch operation {
case qbtypesv5.FilterOperatorEqual:
val, ok := v.extractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
return builder.Equal(columnExpression, val)
case qbtypesv5.FilterOperatorNotEqual:
val, ok := v.extractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
return builder.NotEqual(columnExpression, val)
case qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike:
val, ok := v.extractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
like := "LIKE"
if operation == qbtypesv5.FilterOperatorNotLike {
like = "NOT LIKE"
}
// The user's % and _ stay as wildcards; ESCAPE pins backslash as the escape
// char so a literal `\` in the pattern is read the same on both dialects —
// Postgres defaults to `\`, SQLite has no default escape.
return fmt.Sprintf("%s %s %s ESCAPE '\\'", columnExpression, like, builder.Var(val))
case qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike:
val, ok := v.extractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
// SQLite has no ILIKE keyword and Postgres LIKE is case-sensitive — emit
// LOWER(col) LIKE LOWER(?) so behavior is identical on both dialects. ESCAPE
// pins backslash as the escape char (Postgres default; SQLite has none).
lowerColumn := string(v.formatter.LowerExpression(columnExpression))
like := "LIKE"
if operation == qbtypesv5.FilterOperatorNotILike {
like = "NOT LIKE"
}
return fmt.Sprintf("%s %s LOWER(%s) ESCAPE '\\'", lowerColumn, like, builder.Var(val))
case qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains:
val, ok := v.extractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
like := "LIKE"
if operation == qbtypesv5.FilterOperatorNotContains {
like = "NOT LIKE"
}
// Escape the user's % and _ so they match literally, then wrap in wildcards.
// ESCAPE declares the backslash we just injected as the escape char — needed
// on SQLite (no default) and a harmless restatement of the Postgres default.
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(val)
return fmt.Sprintf("%s %s %s ESCAPE '\\'", columnExpression, like, builder.Var("%"+escaped+"%"))
case qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp:
v.addError("REGEXP filtering on %q is not yet supported", keyForError)
return ""
case qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn:
values, ok := v.extractStringValueList(ctx, keyForError)
if !ok {
return ""
}
arguments := make([]any, len(values))
for i, s := range values {
arguments[i] = s
}
if operation == qbtypesv5.FilterOperatorNotIn {
return builder.NotIn(columnExpression, arguments...)
}
return builder.In(columnExpression, arguments...)
}
v.addError("operator %s on %q is not implemented", operationName(operation), keyForError)
return ""
}
func (v *visitor) buildTimestampComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression string) string {
switch operation {
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq:
t, ok := v.extractSingleTimestampValue(ctx)
if !ok {
return ""
}
switch operation {
case qbtypesv5.FilterOperatorEqual:
return v.selectBuilder.Equal(columnExpression, t)
case qbtypesv5.FilterOperatorNotEqual:
return v.selectBuilder.NotEqual(columnExpression, t)
case qbtypesv5.FilterOperatorLessThan:
return v.selectBuilder.LessThan(columnExpression, t)
case qbtypesv5.FilterOperatorLessThanOrEq:
return v.selectBuilder.LessEqualThan(columnExpression, t)
case qbtypesv5.FilterOperatorGreaterThan:
return v.selectBuilder.GreaterThan(columnExpression, t)
case qbtypesv5.FilterOperatorGreaterThanOrEq:
return v.selectBuilder.GreaterEqualThan(columnExpression, t)
}
case qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween:
timestamps, ok := v.extractTwoTimestampValues(ctx)
if !ok {
return ""
}
if operation == qbtypesv5.FilterOperatorNotBetween {
return v.selectBuilder.NotBetween(columnExpression, timestamps[0], timestamps[1])
}
return v.selectBuilder.Between(columnExpression, timestamps[0], timestamps[1])
}
v.addError("operator %s on timestamp is not implemented", operationName(operation))
return ""
}
func (v *visitor) buildBoolComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression string) string {
b, ok := v.extractSingleBoolValue(ctx)
if !ok {
return ""
}
if operation == qbtypesv5.FilterOperatorNotEqual {
return v.selectBuilder.NotEqual(columnExpression, b)
}
return v.selectBuilder.Equal(columnExpression, b)
}
func (v *visitor) buildTagComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, tagKey string) string {
subqueryBuilder := sqlbuilder.NewSelectBuilder()
if operation == qbtypesv5.FilterOperatorExists || operation == qbtypesv5.FilterOperatorNotExists {
buildSubqueryForTagKey(subqueryBuilder, tagKey)
} else {
// All other tag operators take the positive form of the value predicate
// and toggle the EXISTS wrapper for negation. Inverse() flips Not<X> → <X>.
positiveOperation := operation
if operation.IsNegativeOperator() {
positiveOperation = operation.Inverse()
}
valuePredicate := v.buildStringOperation(subqueryBuilder, ctx, positiveOperation, "t.value", tagKey)
if valuePredicate == "" {
return ""
}
buildSubqueryForTagKeyAndValue(subqueryBuilder, tagKey, valuePredicate)
}
if operation.IsNegativeOperator() {
return v.selectBuilder.NotExists(subqueryBuilder)
}
return v.selectBuilder.Exists(subqueryBuilder)
}
func buildSubqueryForTagKey(subqueryBuilder *sqlbuilder.SelectBuilder, tagKey string) *sqlbuilder.SelectBuilder {
const dashboardTagKind = `"dashboard"`
return subqueryBuilder.
Select("1").
From("tag_relation tr").
Join("tag t", "t.id = tr.tag_id").
Where(
subqueryBuilder.Equal("tr.kind", dashboardTagKind),
"tr.resource_id = dashboard.id",
"LOWER(t.key) = LOWER("+subqueryBuilder.Var(tagKey)+")",
)
}
func buildSubqueryForTagKeyAndValue(subqueryBuilder *sqlbuilder.SelectBuilder, tagKey, valuePredicate string) *sqlbuilder.SelectBuilder {
return buildSubqueryForTagKey(subqueryBuilder, tagKey).Where(valuePredicate)
}
// ─── value extraction helpers ───────────────────────────────────────────────
func (v *visitor) addError(format string, arguments ...any) {
v.errors = append(v.errors, fmt.Sprintf(format, arguments...))
}
func (v *visitor) extractSingleStringValue(ctx *grammar.ComparisonContext, keyForError string) (string, bool) {
values := ctx.AllValue()
if len(values) != 1 {
v.addError("expected exactly one value for %q", keyForError)
return "", false
}
return v.extractStringValue(values[0], keyForError)
}
func (v *visitor) extractSingleBoolValue(ctx *grammar.ComparisonContext) (bool, bool) {
values := ctx.AllValue()
if len(values) != 1 {
v.addError("expected a single boolean (true/false)")
return false, false
}
return v.extractBoolValue(values[0])
}
func (v *visitor) extractSingleTimestampValue(ctx *grammar.ComparisonContext) (time.Time, bool) {
values := ctx.AllValue()
if len(values) != 1 {
v.addError("expected a single RFC3339 timestamp")
return time.Time{}, false
}
return v.extractTimestampValue(values[0])
}
func (v *visitor) extractTwoTimestampValues(ctx *grammar.ComparisonContext) ([2]time.Time, bool) {
values := ctx.AllValue()
if len(values) != 2 {
v.addError("BETWEEN expects two RFC3339 timestamps")
return [2]time.Time{}, false
}
a, ok1 := v.extractTimestampValue(values[0])
b, ok2 := v.extractTimestampValue(values[1])
if !ok1 || !ok2 {
return [2]time.Time{}, false
}
return [2]time.Time{a, b}, true
}
func (v *visitor) extractStringValueList(ctx *grammar.ComparisonContext, keyForError string) ([]string, bool) {
var valuesCtx []grammar.IValueContext
switch {
case ctx.InClause() != nil:
inClause := ctx.InClause()
if inClause.ValueList() != nil {
valuesCtx = inClause.ValueList().AllValue()
} else {
valuesCtx = []grammar.IValueContext{inClause.Value()}
}
case ctx.NotInClause() != nil:
notInClause := ctx.NotInClause()
if notInClause.ValueList() != nil {
valuesCtx = notInClause.ValueList().AllValue()
} else {
valuesCtx = []grammar.IValueContext{notInClause.Value()}
}
default:
v.addError("IN clause is missing for %q", keyForError)
return nil, false
}
if len(valuesCtx) == 0 {
v.addError("IN list for %q is empty", keyForError)
return nil, false
}
out := make([]string, 0, len(valuesCtx))
for _, valueContext := range valuesCtx {
s, ok := v.extractStringValue(valueContext, keyForError)
if !ok {
return nil, false
}
out = append(out, s)
}
return out, true
}
func (v *visitor) extractStringValue(ctx grammar.IValueContext, keyForError string) (string, bool) {
if ctx.QUOTED_TEXT() != nil {
return trimQuotes(ctx.QUOTED_TEXT().GetText()), true
}
if ctx.KEY() != nil {
// Bare tokens are accepted as strings, mirroring the FilterQuery lexer's
// treatment of unquoted identifiers on the value side.
return ctx.KEY().GetText(), true
}
v.addError("expected a string value for %q, got %q", keyForError, ctx.GetText())
return "", false
}
func (v *visitor) extractBoolValue(ctx grammar.IValueContext) (bool, bool) {
if ctx.BOOL() == nil {
v.addError("expected a boolean (true/false), got %q", ctx.GetText())
return false, false
}
return strings.EqualFold(ctx.BOOL().GetText(), "true"), true
}
func (v *visitor) extractTimestampValue(ctx grammar.IValueContext) (time.Time, bool) {
if ctx.QUOTED_TEXT() == nil {
v.addError("expected an RFC3339 timestamp string, got %q", ctx.GetText())
return time.Time{}, false
}
raw := trimQuotes(ctx.QUOTED_TEXT().GetText())
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
v.addError("invalid RFC3339 timestamp %q: %s", raw, err.Error())
return time.Time{}, false
}
return t, true
}
// ─── operator spelling ───────────────────────────────────────────────────────
// operationName returns the user-facing spelling of a FilterOperator, used only in
// error messages — go-sqlbuilder's Cond helpers emit the SQL keywords.
func operationName(operation qbtypesv5.FilterOperator) string {
switch operation {
case qbtypesv5.FilterOperatorEqual:
return "="
case qbtypesv5.FilterOperatorNotEqual:
return "!="
case qbtypesv5.FilterOperatorLessThan:
return "<"
case qbtypesv5.FilterOperatorLessThanOrEq:
return "<="
case qbtypesv5.FilterOperatorGreaterThan:
return ">"
case qbtypesv5.FilterOperatorGreaterThanOrEq:
return ">="
case qbtypesv5.FilterOperatorBetween:
return "BETWEEN"
case qbtypesv5.FilterOperatorNotBetween:
return "NOT BETWEEN"
case qbtypesv5.FilterOperatorLike:
return "LIKE"
case qbtypesv5.FilterOperatorNotLike:
return "NOT LIKE"
case qbtypesv5.FilterOperatorILike:
return "ILIKE"
case qbtypesv5.FilterOperatorNotILike:
return "NOT ILIKE"
case qbtypesv5.FilterOperatorContains:
return "CONTAINS"
case qbtypesv5.FilterOperatorNotContains:
return "NOT CONTAINS"
case qbtypesv5.FilterOperatorRegexp:
return "REGEXP"
case qbtypesv5.FilterOperatorNotRegexp:
return "NOT REGEXP"
case qbtypesv5.FilterOperatorIn:
return "IN"
case qbtypesv5.FilterOperatorNotIn:
return "NOT IN"
case qbtypesv5.FilterOperatorExists:
return "EXISTS"
case qbtypesv5.FilterOperatorNotExists:
return "NOT EXISTS"
}
return "?"
}
func trimQuotes(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
s = s[1 : len(s)-1]
}
}
s = strings.ReplaceAll(s, `\\`, `\`)
s = strings.ReplaceAll(s, `\'`, `'`)
return s
}

View File

@@ -2,6 +2,7 @@ package impldashboard
import (
"context"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -63,6 +64,155 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
return storableDashboard, nil
}
// ListForUser emits the joined dashboard ⨝ user_dashboard_preference query the
// spec calls for. Aliases:
//
// dashboard — the visitor expects this
// user_dashboard_preference AS preference — only used inside this query
//
// Sort is "is_pinned DESC, <sort> <order>" so pinned dashboards float to the
// top inside the requested ordering. Name-sort goes through the same
// JSONExtractString path the visitor uses for name/description filtering.
func (store *store) ListForUser(
ctx context.Context,
orgID valuer.UUID,
userID valuer.UUID,
params *dashboardtypes.ListDashboardsV2Params,
) ([]*dashboardtypes.StorableDashboardWithPinInfo, int64, error) {
compiled, err := Compile(params.Query, store.sqlstore.Formatter())
if err != nil {
return nil, 0, err
}
type listedRow struct {
*dashboardtypes.StorableDashboard `bun:",extend"`
IsPinned bool `bun:"is_pinned"`
Total int64 `bun:"total"`
}
rows := make([]*listedRow, 0)
q := store.sqlstore.
BunDB().
NewSelect().
Model(&rows).
ColumnExpr("dashboard.id, dashboard.org_id, dashboard.name, dashboard.data, dashboard.locked, dashboard.source, dashboard.created_at, dashboard.created_by, dashboard.updated_at, dashboard.updated_by").
ColumnExpr("CASE WHEN preference.is_pinned THEN 1 ELSE 0 END AS is_pinned").
ColumnExpr("COUNT(*) OVER () AS total").
Join("LEFT JOIN user_dashboard_preference AS preference ON preference.user_id = ? AND preference.dashboard_id = dashboard.id", userID).
Where("dashboard.org_id = ?", orgID).
Where("dashboard.source != ?", dashboardtypes.SourceSystem)
if !compiled.IsEmpty() {
q = q.Where(compiled.SQL, compiled.Args...)
}
sortExpr, err := store.sortExprForListV2(params.Sort)
if err != nil {
return nil, 0, err
}
q = q.
OrderExpr("is_pinned DESC").
OrderExpr(sortExpr + " " + strings.ToUpper(params.Order.StringValue())).
Limit(params.Limit).
Offset(params.Offset)
if err := q.Scan(ctx); err != nil {
return nil, 0, errors.WrapInternalf(err, errors.CodeInternal, "couldn't list dashboards")
}
// COUNT(*) OVER () is computed pre-LIMIT, so any returned row carries the
// full filter total. Empty result page => zero matches.
var total int64
if len(rows) > 0 {
total = rows[0].Total
}
out := make([]*dashboardtypes.StorableDashboardWithPinInfo, len(rows))
for i, r := range rows {
out[i] = &dashboardtypes.StorableDashboardWithPinInfo{
Dashboard: r.StorableDashboard,
Pinned: r.IsPinned,
}
}
return out, total, nil
}
// ListV2 is the pure (user-independent) list: the same filter/sort/pagination as
// ListForUser, but without the per-user pin join or pin-first ordering.
func (store *store) ListV2(
ctx context.Context,
orgID valuer.UUID,
params *dashboardtypes.ListDashboardsV2Params,
) ([]*dashboardtypes.StorableDashboard, int64, error) {
compiled, err := Compile(params.Query, store.sqlstore.Formatter())
if err != nil {
return nil, 0, err
}
type listedRow struct {
*dashboardtypes.StorableDashboard `bun:",extend"`
Total int64 `bun:"total"`
}
rows := make([]*listedRow, 0)
q := store.sqlstore.
BunDB().
NewSelect().
Model(&rows).
ColumnExpr("dashboard.id, dashboard.org_id, dashboard.name, dashboard.data, dashboard.locked, dashboard.source, dashboard.created_at, dashboard.created_by, dashboard.updated_at, dashboard.updated_by").
ColumnExpr("COUNT(*) OVER () AS total").
Where("dashboard.org_id = ?", orgID).
Where("dashboard.source != ?", dashboardtypes.SourceSystem)
if !compiled.IsEmpty() {
q = q.Where(compiled.SQL, compiled.Args...)
}
sortExpr, err := store.sortExprForListV2(params.Sort)
if err != nil {
return nil, 0, err
}
q = q.
OrderExpr(sortExpr + " " + strings.ToUpper(params.Order.StringValue())).
Limit(params.Limit).
Offset(params.Offset)
if err := q.Scan(ctx); err != nil {
return nil, 0, errors.WrapInternalf(err, errors.CodeInternal, "couldn't list dashboards")
}
// COUNT(*) OVER () is computed pre-LIMIT, so any returned row carries the
// full filter total. Empty result page => zero matches.
var total int64
if len(rows) > 0 {
total = rows[0].Total
}
out := make([]*dashboardtypes.StorableDashboard, len(rows))
for i, r := range rows {
out[i] = r.StorableDashboard
}
return out, total, nil
}
// sortExprForListV2 maps a sort enum to the SQL expression to plug into
// ORDER BY. Title-sort routes through the SQLFormatter so it stays
// dialect-aware (matches what the filter visitor does for the name filter).
func (store *store) sortExprForListV2(sort dashboardtypes.ListSort) (string, error) {
switch sort {
case dashboardtypes.ListSortUpdatedAt:
return "dashboard.updated_at", nil
case dashboardtypes.ListSortCreatedAt:
return "dashboard.created_at", nil
case dashboardtypes.ListSortName:
return string(store.sqlstore.Formatter().JSONExtractString("dashboard.data", "$.spec.display.name")), nil
}
return "", errors.Newf(errors.TypeInvalidInput, dashboardtypes.ErrCodeDashboardListInvalid,
"unsupported sort field %q", sort)
}
func (store *store) GetPublic(ctx context.Context, dashboardID string) (*dashboardtypes.StorablePublicDashboard, error) {
storable := new(dashboardtypes.StorablePublicDashboard)
err := store.
@@ -217,3 +367,108 @@ func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) er
return cb(ctx)
})
}
// PinForUser combines the count check, the existence check, and the upsert in
// a single statement so the limit gate and the insert can't drift between two
// round-trips. The count and existence checks gate on is_pinned = true so they
// stay correct once the row carries preferences other than the pin.
//
// pin exists? | pinned count < 10? | WHERE passes? | effect | rows
// ------------|--------------------|-------------------------|-------------------------------------|-----
// no | yes | yes (count branch) | INSERT new pinned row | 1
// no | no | no | nothing (limit hit) | 0
// yes | yes | yes (count branch) | INSERT → conflict → UPDATE is_pinned| 1
// yes | no | yes (EXISTS OR branch) | INSERT → conflict → UPDATE is_pinned| 1
//
// rows = 0 is the only signal of a real limit hit.
func (store *store) PinForUser(ctx context.Context, preference *dashboardtypes.UserDashboardPreference) error {
res, err := store.sqlstore.BunDBCtx(ctx).NewRaw(`
INSERT INTO user_dashboard_preference (id, user_id, dashboard_id, is_pinned, created_at, updated_at)
SELECT ?, ?, ?, true, ?, ?
WHERE (SELECT COUNT(*) FROM user_dashboard_preference WHERE user_id = ? AND is_pinned = true) < ?
OR EXISTS (SELECT 1 FROM user_dashboard_preference WHERE user_id = ? AND dashboard_id = ? AND is_pinned = true)
ON CONFLICT (user_id, dashboard_id) DO UPDATE SET is_pinned = true, updated_at = ?
`,
preference.ID, preference.UserID, preference.DashboardID, preference.CreatedAt, preference.UpdatedAt,
preference.UserID, dashboardtypes.MaxPinnedDashboardsPerUser,
preference.UserID, preference.DashboardID,
preference.UpdatedAt,
).Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't pin dashboard for user")
}
rows, err := res.RowsAffected()
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't read pin result")
}
if rows == 0 {
return errors.Newf(errors.TypeAlreadyExists, dashboardtypes.ErrCodePinnedDashboardLimitHit,
"cannot pin more than %d dashboards", dashboardtypes.MaxPinnedDashboardsPerUser)
}
return nil
}
// UnpinForUser deletes the user's preference row. This is fine while is_pinned
// is the only preference stored; once the row carries other preferences this
// must become an UPDATE that clears is_pinned instead of dropping the row.
func (store *store) UnpinForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, dashboardID valuer.UUID) error {
// No org_id on the preference table, so scope by org via a subquery on the
// parent (DELETE-with-JOIN isn't portable across Postgres/SQLite).
dashboardIDsInOrgSubQuery := store.sqlstore.BunDBCtx(ctx).
NewSelect().
TableExpr("dashboard").
Column("id").
Where("org_id = ?", orgID)
_, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.UserDashboardPreference)(nil)).
Where("user_id = ?", userID).
Where("dashboard_id = ?", dashboardID).
Where("dashboard_id IN (?)", dashboardIDsInOrgSubQuery).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't unpin dashboard for user")
}
return nil
}
func (store *store) DeletePreferencesForDashboard(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error {
// No org_id on the preference table, so scope by org via a subquery on the
// parent (DELETE-with-JOIN isn't portable across Postgres/SQLite).
dashboardIDsInOrgSubQuery := store.sqlstore.BunDBCtx(ctx).
NewSelect().
TableExpr("dashboard").
Column("id").
Where("org_id = ?", orgID)
_, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.UserDashboardPreference)(nil)).
Where("dashboard_id = ?", dashboardID).
Where("dashboard_id IN (?)", dashboardIDsInOrgSubQuery).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't delete dashboard preferences")
}
return nil
}
func (store *store) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
// No org_id on the preference table, so scope by org via a subquery on the
// parent (DELETE-with-JOIN isn't portable across Postgres/SQLite).
userIDsInOrgSubQuery := store.sqlstore.BunDBCtx(ctx).
NewSelect().
TableExpr("users").
Column("id").
Where("org_id = ?", orgID)
_, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.UserDashboardPreference)(nil)).
Where("user_id = ?", userID).
Where("user_id IN (?)", userIDsInOrgSubQuery).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't delete dashboard preferences")
}
return nil
}

View File

@@ -42,6 +42,69 @@ func (handler *handler) CreateV2(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusCreated, dashboard.ToGettableDashboardV2())
}
func (handler *handler) ListV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
params := new(dashboardtypes.ListDashboardsV2Params)
if err := binding.Query.BindQuery(r.URL.Query(), params); err != nil {
render.Error(rw, err)
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
out, err := handler.module.ListV2(ctx, orgID, params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, out)
}
func (handler *handler) ListForUserV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
userID := valuer.MustNewUUID(claims.IdentityID())
params := new(dashboardtypes.ListDashboardsV2Params)
if err := binding.Query.BindQuery(r.URL.Query(), params); err != nil {
render.Error(rw, err)
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
out, err := handler.module.ListForUserV2(ctx, orgID, userID, params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, out)
}
func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -205,3 +268,79 @@ func (handler *handler) PatchV2(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) PinV2(rw http.ResponseWriter, r *http.Request) {
handler.pinUnpinV2(rw, r, true)
}
func (handler *handler) UnpinV2(rw http.ResponseWriter, r *http.Request) {
handler.pinUnpinV2(rw, r, false)
}
func (handler *handler) pinUnpinV2(rw http.ResponseWriter, r *http.Request, pin bool) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
userID := valuer.MustNewUUID(claims.IdentityID())
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
if pin {
err = handler.module.PinV2(ctx, orgID, userID, dashboardID)
} else {
err = handler.module.UnpinV2(ctx, orgID, userID, dashboardID)
}
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) DeleteV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
if err := handler.module.DeleteV2(ctx, orgID, dashboardID); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -42,6 +43,58 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
return dashboard, nil
}
func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error) {
dashboards, total, err := module.store.ListV2(ctx, orgID, params)
if err != nil {
return nil, err
}
dashboardIDs := make([]valuer.UUID, len(dashboards))
for i, d := range dashboards {
dashboardIDs[i] = d.ID
}
tagsByDashboard, allTags, err := module.fetchDashboardTags(ctx, orgID, dashboardIDs)
if err != nil {
return nil, err
}
return dashboardtypes.NewListableDashboardV2(dashboards, total, tagsByDashboard, allTags)
}
func (module *module) ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error) {
rows, total, err := module.store.ListForUser(ctx, orgID, userID, params)
if err != nil {
return nil, err
}
dashboardIDs := make([]valuer.UUID, len(rows))
for i, r := range rows {
dashboardIDs[i] = r.Dashboard.ID
}
tagsByDashboard, allTags, err := module.fetchDashboardTags(ctx, orgID, dashboardIDs)
if err != nil {
return nil, err
}
return dashboardtypes.NewListableDashboardForUserV2(rows, total, tagsByDashboard, allTags)
}
func (module *module) fetchDashboardTags(ctx context.Context, orgID valuer.UUID, dashboardIDs []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, []*tagtypes.Tag, error) {
tagsByDashboard, err := module.tagModule.ListForResources(ctx, orgID, coretypes.KindDashboard, dashboardIDs)
if err != nil {
return nil, nil, err
}
allTags, err := module.tagModule.List(ctx, orgID, coretypes.KindDashboard)
if err != nil {
return nil, nil, err
}
return tagsByDashboard, allTags, nil
}
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
storable, err := module.store.Get(ctx, orgID, id)
if err != nil {
@@ -66,7 +119,7 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
if err := existing.ErrIfNotUpdatable(); err != nil {
return nil, err
}
@@ -101,7 +154,7 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
if err := existing.ErrIfNotUpdatable(); err != nil {
return nil, err
}
@@ -135,6 +188,27 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
return existing, nil
}
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return err
}
if err := existing.ErrIfNotDeletable(); err != nil {
return err
}
return module.store.RunInTx(ctx, func(ctx context.Context) error {
// Syncing to an empty tag set drops every tag link for the dashboard.
if _, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, nil); err != nil {
return err
}
if err := module.store.DeletePreferencesForDashboard(ctx, orgID, id); err != nil {
return err
}
return module.store.Delete(ctx, orgID, id)
})
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
@@ -149,3 +223,18 @@ func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id va
}
return module.store.Update(ctx, orgID, storable)
}
func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
if _, err := module.GetV2(ctx, orgID, id); err != nil {
return err
}
return module.store.PinForUser(ctx, dashboardtypes.NewUserDashboardPreference(userID, id))
}
func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.store.UnpinForUser(ctx, orgID, userID, id)
}
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
return module.store.DeletePreferencesForUser(ctx, orgID, userID)
}

View File

@@ -67,6 +67,10 @@ func (m *module) syncLinksForResource(ctx context.Context, orgID valuer.UUID, ki
})
}
func (m *module) List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind) ([]*tagtypes.Tag, error) {
return m.store.List(ctx, orgID, kind)
}
func (m *module) ListForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*tagtypes.Tag, error) {
return m.store.ListByResource(ctx, orgID, kind, resourceID)
}

View File

@@ -13,6 +13,9 @@ type Module interface {
// and reconciles the resource's links to exactly that set, all in one transaction.
SyncTags(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, error)
// List returns every tag of the given kind in the org.
List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind) ([]*tagtypes.Tag, error)
ListForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*tagtypes.Tag, error)
// Resources with no tags are absent from the returned map.

View File

@@ -34,10 +34,11 @@ type setter struct {
analytics analytics.Analytics
config root.Config
getter root.Getter
onDeleteUser []root.OnDeleteUser
}
// This module is a WIP, don't take inspiration from this.
func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, authz authz.AuthZ, analytics analytics.Analytics, config root.Config, userRoleStore authtypes.UserRoleStore, getter root.Getter) root.Setter {
func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, authz authz.AuthZ, analytics analytics.Analytics, config root.Config, userRoleStore authtypes.UserRoleStore, getter root.Getter, onDeleteUser []root.OnDeleteUser) root.Setter {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user/impluser")
return &setter{
store: store,
@@ -50,6 +51,7 @@ func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em
authz: authz,
config: config,
getter: getter,
onDeleteUser: onDeleteUser,
}
}
@@ -406,6 +408,12 @@ func (module *setter) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return err
}
for _, onDeleteUser := range module.onDeleteUser {
if err := onDeleteUser(ctx, orgID, user.ID); err != nil {
return err
}
}
traitsOrProperties := types.NewTraitsFromUser(user)
module.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traitsOrProperties)
module.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Deleted", map[string]any{

View File

@@ -129,3 +129,6 @@ type Handler interface {
ChangePassword(http.ResponseWriter, *http.Request)
ForgotPassword(http.ResponseWriter, *http.Request)
}
// OnDeleteUser lets other modules clean up data tied to a deleted user.
type OnDeleteUser func(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error

View File

@@ -0,0 +1,33 @@
package filterquery
import (
"fmt"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/antlr4-go/antlr/v4"
)
func Parse(query string) (antlr.ParseTree, *antlr.CommonTokenStream, *ErrorCollector) {
collector := NewErrorCollector()
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(query))
lexer.RemoveErrorListeners()
lexer.AddErrorListener(collector)
tokens := antlr.NewCommonTokenStream(lexer, 0)
parser := grammar.NewFilterQueryParser(tokens)
parser.RemoveErrorListeners()
parser.AddErrorListener(collector)
return parser.Query(), tokens, collector
}
type ErrorCollector struct {
*antlr.DefaultErrorListener
Errors []string
}
func NewErrorCollector() *ErrorCollector {
return &ErrorCollector{}
}
func (c *ErrorCollector) SyntaxError(_ antlr.Recognizer, _ any, line, column int, msg string, _ antlr.RecognitionException) {
c.Errors = append(c.Errors, fmt.Sprintf("syntax error at %d:%d — %s", line, column, msg))
}

View File

@@ -361,6 +361,10 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
missingMetrics = append(missingMetrics, spec.Aggregations[i].MetricName)
continue
}
// Type is resolved now; validate aggregation compatibility against it.
if err := spec.Aggregations[i].ValidateForType(); err != nil {
return nil, "", err
}
presentAggregations = append(presentAggregations, spec.Aggregations[i])
}
if len(presentAggregations) == 0 {

View File

@@ -168,6 +168,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)

View File

@@ -122,7 +122,11 @@ func NewModules(
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, userRoleStore, userGetter)
// Cleanup callbacks from other modules, invoked when a user is deleted.
onDeleteUser := []user.OnDeleteUser{
dashboard.DeletePreferencesForUser,
}
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, userRoleStore, userGetter, onDeleteUser)
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
return Modules{

View File

@@ -211,6 +211,8 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddDashboardNameFactory(sqlstore, sqlschema),
sqlmigration.NewFixChangelogOperationTypeFactory(sqlstore, sqlschema),
sqlmigration.NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema),
sqlmigration.NewAddUserDashboardPreferenceFactory(sqlstore, sqlschema),
sqlmigration.NewRecreateUserDashboardPreferenceFactory(sqlstore, sqlschema),
)
}

View File

@@ -0,0 +1,71 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addUserDashboardPreference struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddUserDashboardPreferenceFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_user_dashboard_preference"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addUserDashboardPreference{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addUserDashboardPreference) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addUserDashboardPreference) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "user_dashboard_preference",
Columns: []*sqlschema.Column{
{Name: "user_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "is_pinned", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "false"},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"user_id", "dashboard_id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("user_id"),
ReferencedTableName: sqlschema.TableName("users"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
{
ReferencingColumnName: sqlschema.ColumnName("dashboard_id"),
ReferencedTableName: sqlschema.TableName("dashboard"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addUserDashboardPreference) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,84 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type recreateUserDashboardPreference struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewRecreateUserDashboardPreferenceFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("recreate_user_dashboard_pref"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &recreateUserDashboardPreference{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *recreateUserDashboardPreference) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up replaces the composite (user_id, dashboard_id) primary key with a surrogate
// id primary key, demotes the pair to a unique index, and adds created_at /
// updated_at. The table is dropped and recreated since it carries no data yet.
func (migration *recreateUserDashboardPreference) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
sqls := migration.sqlschema.Operator().DropTable(&sqlschema.Table{Name: "user_dashboard_preference"})
sqls = append(sqls, migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "user_dashboard_preference",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "user_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "is_pinned", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "false"},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("user_id"),
ReferencedTableName: sqlschema.TableName("users"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
{
ReferencingColumnName: sqlschema.ColumnName("dashboard_id"),
ReferencedTableName: sqlschema.TableName("dashboard"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})...)
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{
TableName: "user_dashboard_preference",
ColumnNames: []sqlschema.ColumnName{"user_id", "dashboard_id"},
})...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *recreateUserDashboardPreference) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -13,13 +13,13 @@ import (
// Audit attributes — Action (What).
type AuditAttributes struct {
Action coretypes.Verb // guaranteed to be present
ActionCategory ActionCategory // guaranteed to be present
Outcome Outcome // guaranteed to be present
Action coretypes.Verb // guaranteed to be present
ActionCategory coretypes.ActionCategory // guaranteed to be present
Outcome Outcome // guaranteed to be present
IdentNProvider authtypes.IdentNProvider
}
func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category ActionCategory, claims authtypes.Claims) AuditAttributes {
func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category coretypes.ActionCategory, claims authtypes.Claims) AuditAttributes {
outcome := OutcomeFailure
if statusCode >= 200 && statusCode < 400 {
outcome = OutcomeSuccess
@@ -71,23 +71,50 @@ func (attributes PrincipalAttributes) Put(dest pcommon.Map) {
// Audit attributes — Resource (On What).
// These are OTel resource attributes (placed on the Resource, not event attributes).
type ResourceAttributes struct {
ResourceID string
ResourceKind coretypes.Kind // guaranteed to be present
Resource coretypes.Resource // guaranteed to be present
ResourceID string
// TargetResource names the counterpart of an attach/detach event (audit
// context only). nil when there is no relationship.
TargetResource coretypes.Resource
TargetResourceID string
}
func NewResourceAttributes(resourceID string, resourceKind coretypes.Kind) ResourceAttributes {
func NewResourceAttributes(resource coretypes.Resource, resourceID string) ResourceAttributes {
return ResourceAttributes{
ResourceID: resourceID,
ResourceKind: resourceKind,
Resource: resource,
ResourceID: resourceID,
}
}
// NewAttachResourceAttributes builds resource attributes that additionally name
// the target counterpart (used for attach/detach audit events).
func NewRelatedResourceAttributes(resource coretypes.Resource, resourceID string, targetResource coretypes.Resource, targetResourceID string) ResourceAttributes {
return ResourceAttributes{
Resource: resource,
ResourceID: resourceID,
TargetResource: targetResource,
TargetResourceID: targetResourceID,
}
}
// PutResource writes the resource attributes to an OTel Resource's attribute map.
// These are resource-level attributes (stored in the resource JSON column),
// not event-level attributes (stored in attributes_string).
func (attributes ResourceAttributes) PutResource(dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.resource.kind", attributes.ResourceKind.String())
func (attributes ResourceAttributes) PutResource(orgID valuer.UUID, dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.resource.kind", attributes.Resource.Kind().String())
putStrIfNotEmpty(dest, "signoz.audit.resource.id", attributes.ResourceID)
if attributes.ResourceID != "" {
putStrIfNotEmpty(dest, "signoz.audit.resource.object", attributes.Resource.Object(orgID, attributes.ResourceID))
}
if attributes.TargetResource != nil {
putStrIfNotEmpty(dest, "signoz.audit.resource.target.kind", attributes.TargetResource.Kind().String())
putStrIfNotEmpty(dest, "signoz.audit.resource.target.id", attributes.TargetResourceID)
if attributes.TargetResourceID != "" {
putStrIfNotEmpty(dest, "signoz.audit.resource.target.object", attributes.TargetResource.Object(orgID, attributes.TargetResourceID))
}
}
}
// Audit attributes — Error (When outcome is failure)
@@ -193,13 +220,24 @@ func newBody(auditAttributes AuditAttributes, principalAttributes PrincipalAttri
// Resource: " kind (id)" or " kind".
b.WriteString(" ")
b.WriteString(resourceAttributes.ResourceKind.String())
b.WriteString(resourceAttributes.Resource.Kind().String())
if resourceAttributes.ResourceID != "" {
b.WriteString(" (")
b.WriteString(resourceAttributes.ResourceID)
b.WriteString(")")
}
// Target (attach/detach context): " · target kind (id)" or " · target kind".
if resourceAttributes.TargetResource != nil {
b.WriteString(" to ")
b.WriteString(resourceAttributes.TargetResource.Kind().String())
if resourceAttributes.TargetResourceID != "" {
b.WriteString(" (")
b.WriteString(resourceAttributes.TargetResourceID)
b.WriteString(")")
}
}
// Error suffix (failure only): ": type (code)" or ": type" or ": (code)" or omitted.
if auditAttributes.Outcome == OutcomeFailure {
errorType := errorAttributes.ErrorType

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