Compare commits

..

13 Commits

Author SHA1 Message Date
Ashwin Bhatkal
c491856c4b chore(dashboard-v2): apply formatter to variable selection slice 2026-06-12 11:27:01 +05:30
Ashwin Bhatkal
5cab720d3d 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-12 11:27:01 +05:30
Ashwin Bhatkal
0dea90fb65 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-12 11:27:01 +05:30
Ashwin Bhatkal
6416a808b2 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-12 11:27:01 +05:30
Ashwin Bhatkal
ca049f5d71 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-12 11:27:01 +05:30
Ashwin Bhatkal
37fcd01489 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-12 11:27:01 +05:30
Naman Verma
7eb0095133 fix: proper definition of user dashboard preferences (#11643)
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: 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
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
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
135 changed files with 3221 additions and 8077 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:
@@ -2570,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:
@@ -2585,7 +2577,6 @@ components:
panels:
additionalProperties:
$ref: '#/components/schemas/DashboardtypesPanel'
nullable: true
type: object
refreshInterval:
type: string
@@ -2593,6 +2584,11 @@ components:
items:
$ref: '#/components/schemas/DashboardtypesVariable'
type: array
required:
- display
- variables
- panels
- layouts
type: object
DashboardtypesDatasourcePlugin:
discriminator:
@@ -2628,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:
@@ -2822,7 +2827,7 @@ components:
defaultValue:
$ref: '#/components/schemas/VariableDefaultValue'
display:
$ref: '#/components/schemas/VariableDisplay'
$ref: '#/components/schemas/DashboardtypesDisplay'
name:
type: string
plugin:
@@ -2830,6 +2835,8 @@ components:
sort:
nullable: true
type: string
required:
- display
type: object
DashboardtypesListableDashboardForUserV2:
properties:
@@ -2957,7 +2964,7 @@ components:
DashboardtypesListedDashboardV2Spec:
properties:
display:
$ref: '#/components/schemas/CommonDisplay'
$ref: '#/components/schemas/DashboardtypesDisplay'
type: object
DashboardtypesNumberPanelSpec:
properties:
@@ -2977,6 +2984,9 @@ components:
$ref: '#/components/schemas/DashboardtypesPanelKind'
spec:
$ref: '#/components/schemas/DashboardtypesPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelFormatting:
properties:
@@ -3106,7 +3116,7 @@ components:
DashboardtypesPanelSpec:
properties:
display:
$ref: '#/components/schemas/DashboardPanelDisplay'
$ref: '#/components/schemas/DashboardtypesDisplay'
links:
items:
$ref: '#/components/schemas/DashboardLink'
@@ -3116,7 +3126,12 @@ components:
queries:
items:
$ref: '#/components/schemas/DashboardtypesQuery'
nullable: true
type: array
required:
- display
- plugin
- queries
type: object
DashboardtypesPatchOp:
enum:
@@ -3185,6 +3200,9 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
spec:
$ref: '#/components/schemas/DashboardtypesQuerySpec'
required:
- kind
- spec
type: object
DashboardtypesQueryPlugin:
discriminator:
@@ -3291,6 +3309,8 @@ components:
type: string
plugin:
$ref: '#/components/schemas/DashboardtypesQueryPlugin'
required:
- plugin
type: object
DashboardtypesQueryVariableSpec:
properties:

View File

@@ -254,12 +254,12 @@ func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID value
return module.pkgDashboardModule.PinV2(ctx, orgID, userID, id)
}
func (module *module) UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.UnpinV2(ctx, 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, userID valuer.UUID) error {
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, userID)
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) {

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

@@ -323,17 +323,3 @@ export const AIAssistantPage = Loadable(
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
),
);
export const LLMObservabilityModelPricingPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
),
);
export const LLMObservabilityAttributeMappingPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Attribute Mapping Page" */ 'pages/LLMObservabilityAttributeMapping'
),
);

View File

@@ -22,8 +22,6 @@ import {
IntegrationsDetailsPage,
LicensePage,
ListAllALertsPage,
LLMObservabilityAttributeMappingPage,
LLMObservabilityModelPricingPage,
LiveLogs,
Login,
Logs,
@@ -509,20 +507,6 @@ const routes: AppRoutes[] = [
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
exact: true,
component: LLMObservabilityModelPricingPage,
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
exact: true,
component: LLMObservabilityAttributeMappingPage,
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
isPrivate: true,
},
];
export const SUPPORT_ROUTE: AppRoutes = {

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
@@ -3892,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',
}
@@ -4440,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',
}
@@ -4572,7 +4566,7 @@ export interface DashboardtypesListVariableSpecDTO {
*/
customAllValue?: string;
defaultValue?: VariableDefaultValueDTO;
display?: VariableDisplayDTO;
display: DashboardtypesDisplayDTO;
/**
* @type string
*/
@@ -4614,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
*/
@@ -4638,7 +4632,7 @@ export interface DashboardtypesDashboardSpecDTO {
/**
* @type array
*/
variables?: DashboardtypesVariableDTO[];
variables: DashboardtypesVariableDTO[];
}
export enum DashboardtypesDatasourcePluginKindDTO {
@@ -4762,7 +4756,7 @@ export enum DashboardtypesListSortDTO {
name = 'name',
}
export interface DashboardtypesListedDashboardV2SpecDTO {
display?: CommonDisplayDTO;
display?: DashboardtypesDisplayDTO;
}
export interface DashboardtypesListedDashboardForUserV2DTO {

View File

@@ -1,9 +1,7 @@
export const ENVIRONMENT = {
// baseURL:
// process?.env?.FRONTEND_API_ENDPOINT ||
// process?.env?.GITPOD_WORKSPACE_URL?.replace('://', '://8080-') ||
// '',
// wsURL: process?.env?.WEBSOCKET_API_ENDPOINT || '',
baseURL: 'https://app.us.staging.signoz.cloud',
wsURL: 'ws://app.us.staging.signoz.cloud',
baseURL:
process?.env?.FRONTEND_API_ENDPOINT ||
process?.env?.GITPOD_WORKSPACE_URL?.replace('://', '://8080-') ||
'',
wsURL: process?.env?.WEBSOCKET_API_ENDPOINT || '',
};

View File

@@ -91,9 +91,6 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING:
'/llm-observability/settings/attribute-mapping',
} as const;
export default ROUTES;

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

@@ -1,52 +0,0 @@
import { Button } from '@signozhq/ui/button';
interface AttributeMappingHeaderProps {
isDirty: boolean;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function AttributeMappingHeader({
isDirty,
isSaving,
onDiscard,
onSave,
}: AttributeMappingHeaderProps): JSX.Element {
return (
<header className="page-header">
<div className="page-header__title">
<h1>Attribute Mapping</h1>
<p>Configure source-to-target attribute remapping for LLM traces</p>
</div>
<div className="page-header__actions">
{isDirty && (
<span className="page-header__unsaved" data-testid="unsaved-changes">
Unsaved changes
</span>
)}
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={!isDirty || isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={!isDirty || isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
</header>
);
}
export default AttributeMappingHeader;

View File

@@ -1,90 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { Plus, X } from '@signozhq/icons';
import KeySearchInput from './KeySearchInput';
import { FieldContextValue } from './types';
interface ConditionKeyListProps {
label: string;
labelHint?: string;
keys: string[];
placeholder: string;
addLabel: string;
testIdPrefix: string;
fieldContext: FieldContextValue;
onChange: (keys: string[]) => void;
}
// Editor for one list of condition keys (the group's span-attribute or
// resource gating keys). Substring "contains" match, order irrelevant.
function ConditionKeyList({
label,
labelHint,
keys,
placeholder,
addLabel,
testIdPrefix,
fieldContext,
onChange,
}: ConditionKeyListProps): JSX.Element {
const updateKey = (index: number, value: string): void => {
onChange(keys.map((key, i) => (i === index ? value : key)));
};
const addKey = (): void => {
onChange([...keys, '']);
};
const removeKey = (index: number): void => {
onChange(keys.filter((_, i) => i !== index));
};
return (
<div className="group-form__field">
<span className="group-form__label">
{label}
{labelHint && <span className="group-form__label-hint"> {labelHint}</span>}
</span>
{keys.length > 0 && (
<div className="group-form__keys">
{keys.map((key, index) => (
// eslint-disable-next-line react/no-array-index-key
<div className="group-form__key" key={index}>
<KeySearchInput
className="group-form__key-input"
placeholder={placeholder}
value={key}
fieldContext={fieldContext}
onChange={(next): void => updateKey(index, next)}
testId={`${testIdPrefix}-${index}`}
/>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Remove key"
onClick={(): void => removeKey(index)}
testId={`${testIdPrefix}-remove-${index}`}
>
<X size={14} />
</Button>
</div>
))}
</div>
)}
<Button
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
onClick={addKey}
testId={`${testIdPrefix}-add`}
>
{addLabel}
</Button>
</div>
);
}
export default ConditionKeyList;

View File

@@ -1,76 +0,0 @@
.group-form {
display: flex;
flex-direction: column;
gap: 20px;
padding: 4px 0;
&__field {
display: flex;
flex-direction: column;
gap: 8px;
&--row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
&__label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-400);
}
&__label-hint {
font-weight: 400;
text-transform: none;
letter-spacing: normal;
}
&__hint {
font-size: 12px;
color: var(--bg-vanilla-400);
}
&__keys {
display: flex;
flex-direction: column;
gap: 8px;
}
&__key {
display: flex;
align-items: center;
gap: 6px;
}
&__key-input {
flex: 1;
font-family: 'Geist Mono', monospace;
}
&__error {
padding: 10px 12px;
border-radius: 4px;
background: rgba(229, 72, 77, 0.1);
color: var(--bg-cherry-400);
font-size: 13px;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8px;
}
&__footer-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
}

View File

@@ -1,148 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Trash2 } from '@signozhq/icons';
import ConditionKeyList from './ConditionKeyList';
import { FieldContext, GroupDraft, MapperDraftMode } from './types';
import { isGroupDraftValid } from './utils';
import './GroupFormDrawer.styles.scss';
interface GroupFormDrawerProps {
isOpen: boolean;
mode: MapperDraftMode;
draft: GroupDraft;
setDraft: (next: GroupDraft) => void;
onClose: () => void;
onSave: () => void;
onDelete: () => void;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
}
function GroupFormDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
saveError,
}: GroupFormDrawerProps): JSX.Element {
const isEdit = mode === 'edit';
const isValid = isGroupDraftValid(draft);
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
title={isEdit ? 'Edit group' : 'New group'}
subTitle="A group gates which spans its mappings run on"
width="wide"
testId="group-form-drawer"
footer={
<div className="group-form__footer">
{isEdit && (
<Button
variant="ghost"
color="destructive"
prefix={<Trash2 size={14} />}
onClick={onDelete}
disabled={isDeleting}
testId="group-form-delete"
>
{isDeleting ? 'Deleting…' : 'Delete'}
</Button>
)}
<div className="group-form__footer-actions">
<Button
variant="ghost"
color="secondary"
onClick={onClose}
testId="group-form-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
disabled={!isValid || isSaving}
testId="group-form-save"
>
{/* eslint-disable-next-line no-nested-ternary */}
{isSaving ? 'Saving…' : isEdit ? 'Save group' : 'Create group'}
</Button>
</div>
</div>
}
>
<div className="group-form">
<div className="group-form__field">
<span className="group-form__label">Group name</span>
<Input
placeholder="e.g. OpenAI gateway"
value={draft.name}
onChange={(event): void =>
setDraft({ ...draft, name: event.target.value })
}
testId="group-form-name"
/>
</div>
<div className="group-form__field group-form__field--row">
<span className="group-form__label">Enabled</span>
<Switch
value={draft.enabled}
onChange={(checked): void => setDraft({ ...draft, enabled: checked })}
testId="group-form-enabled"
/>
</div>
<ConditionKeyList
label="Condition · span attribute keys"
labelHint="· runs when a span attribute key contains any of these"
keys={draft.attributes}
placeholder="e.g. gen_ai."
addLabel="Add attribute key"
testIdPrefix="group-form-attribute"
fieldContext={FieldContext.attribute}
onChange={(attributes): void => setDraft({ ...draft, attributes })}
/>
<ConditionKeyList
label="Condition · resource keys"
labelHint="· or when a resource key contains any of these"
keys={draft.resource}
placeholder="e.g. service.name"
addLabel="Add resource key"
testIdPrefix="group-form-resource"
fieldContext={FieldContext.resource}
onChange={(resource): void => setDraft({ ...draft, resource })}
/>
<span className="group-form__hint">
Leave both empty to run this group on every span.
</span>
{saveError && (
<div className="group-form__error" role="alert">
{saveError}
</div>
)}
</div>
</DrawerWrapper>
);
}
export default GroupFormDrawer;

View File

@@ -1,10 +0,0 @@
interface IndexBadgeProps {
index: number;
}
// Small positional badge mirroring the Pipelines list ordering chip.
function IndexBadge({ index }: IndexBadgeProps): JSX.Element {
return <span className="am-index-badge">{index + 1}</span>;
}
export default IndexBadge;

View File

@@ -1,51 +0,0 @@
.key-search {
position: relative;
flex: 1;
min-width: 0;
&__dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 20;
min-width: 100%;
width: max-content;
max-width: 420px;
max-height: 240px;
overflow-x: hidden;
overflow-y: auto;
padding: 4px;
border: 1px solid var(--bg-slate-400, rgba(255, 255, 255, 0.12));
border-radius: 6px;
background: var(--bg-ink-400, #121317);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
&--empty {
padding: 8px 10px;
font-size: 12px;
color: var(--bg-vanilla-400);
}
}
&__option {
display: block;
width: 100%;
max-width: 100%;
padding: 6px 10px;
border: none;
border-radius: 3px;
background: transparent;
color: var(--bg-vanilla-100);
font-family: 'Geist Mono', monospace;
font-size: 12px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
&:hover {
background: var(--bg-slate-400, rgba(255, 255, 255, 0.08));
}
}
}

View File

@@ -1,112 +0,0 @@
import { useMemo, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import useDebounce from 'hooks/useDebounce';
import { FieldContext, FieldContextValue } from './types';
import './KeySearchInput.styles.scss';
const SUGGESTION_LIMIT = 50;
const DEBOUNCE_MS = 300;
interface KeySearchInputProps {
value: string;
fieldContext: FieldContextValue;
placeholder?: string;
className?: string;
disabled?: boolean;
testId?: string;
onChange: (value: string) => void;
}
// Maps the mapper's attribute/resource context to the fields-endpoint context.
function toFieldsContext(
context: FieldContextValue,
): TelemetrytypesFieldContextDTO {
return context === FieldContext.resource
? TelemetrytypesFieldContextDTO.resource
: TelemetrytypesFieldContextDTO.attribute;
}
// Free-text input with span/resource key suggestions from /api/v1/fields/keys
// (signal=traces). Typing keeps the custom value; suggestions are assistive.
function KeySearchInput({
value,
fieldContext,
placeholder,
className,
disabled,
testId,
onChange,
}: KeySearchInputProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const debouncedSearch = useDebounce(value, DEBOUNCE_MS);
const { data, isFetching } = useGetFieldsKeys(
{
signal: TelemetrytypesSignalDTO.traces,
fieldContext: toFieldsContext(fieldContext),
searchText: debouncedSearch,
limit: SUGGESTION_LIMIT,
},
{ query: { enabled: isOpen && !disabled, keepPreviousData: true } },
);
const suggestions = useMemo(() => {
const keys = data?.data?.keys ?? {};
return Object.keys(keys)
.filter((key) => key !== value)
.slice(0, SUGGESTION_LIMIT);
}, [data, value]);
return (
<div className={cx('key-search', className)}>
<Input
placeholder={placeholder}
value={value}
disabled={disabled}
autoComplete="off"
onChange={(event): void => {
onChange(event.target.value);
setIsOpen(true);
}}
onFocus={(): void => setIsOpen(true)}
onBlur={(): void => setIsOpen(false)}
testId={testId}
/>
{isOpen && suggestions.length > 0 && (
<div className="key-search__dropdown" data-testid={`${testId}-dropdown`}>
{suggestions.map((name) => (
<button
type="button"
key={name}
className="key-search__option"
// onMouseDown (not onClick) so selection runs before the input blur.
onMouseDown={(event): void => {
event.preventDefault();
onChange(name);
setIsOpen(false);
}}
data-testid={`${testId}-option-${name}`}
>
{name}
</button>
))}
</div>
)}
{isOpen && isFetching && suggestions.length === 0 && (
<div className="key-search__dropdown key-search__dropdown--empty">
Searching
</div>
)}
</div>
);
}
export default KeySearchInput;

View File

@@ -1,183 +0,0 @@
.llm-observability-attribute-mapping {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
&__title {
h1 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
p {
margin: 4px 0 0;
font-size: 13px;
color: var(--bg-vanilla-400);
}
}
&__actions {
display: flex;
align-items: center;
gap: 12px;
}
&__unsaved {
font-size: 13px;
color: var(--bg-amber-400);
}
}
.page-error {
padding: 12px 16px;
border-radius: 4px;
background: var(--bg-cherry-500-opacity-10, rgba(229, 72, 77, 0.1));
color: var(--bg-cherry-400);
font-size: 13px;
}
.page-footer {
font-size: 12px;
color: var(--bg-vanilla-400);
}
.muted {
color: var(--bg-vanilla-400);
}
.am-index-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 999px;
background: var(--bg-robin-500);
color: var(--bg-vanilla-100);
font-size: 12px;
font-weight: 600;
}
.am-row-actions {
display: flex;
align-items: center;
gap: 6px;
}
.am-add-row {
align-self: flex-start;
}
.groups-table__wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.am-table {
&__empty {
padding: 24px 12px;
text-align: center;
color: var(--bg-vanilla-400);
font-size: 13px;
}
}
.groups-table {
&__name {
font-weight: 600;
}
&__name-cell {
display: flex;
align-items: center;
gap: 8px;
}
&__sub-row > td {
padding: 0 16px 12px;
background: var(--bg-ink-400, rgba(255, 255, 255, 0.02));
}
&__filters {
display: flex;
flex-direction: column;
gap: 6px;
}
&__filter {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
&__filter-key {
font-family: 'Geist Mono', monospace;
}
&__edited {
display: flex;
flex-direction: column;
font-size: 12px;
}
}
.mappers-table__wrapper {
display: flex;
flex-direction: column;
gap: 8px;
margin: 4px 0;
}
.mappers-table {
margin: 4px 0;
&__target {
font-family: 'Geist Mono', monospace;
font-weight: 600;
}
&__sources {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
&__source-chip {
display: inline-flex;
align-items: center;
max-width: 220px;
padding: 2px 8px;
border: 1px solid var(--bg-slate-400, rgba(255, 255, 255, 0.12));
border-radius: 6px;
background: var(--bg-ink-300, rgba(255, 255, 255, 0.04));
font-family: 'Geist Mono', monospace;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__source-more {
font-size: 12px;
white-space: nowrap;
}
&__error {
padding: 12px;
font-size: 13px;
color: var(--bg-cherry-400);
}
}
}

View File

@@ -1,77 +0,0 @@
import { useCallback } from 'react';
import AttributeMappingHeader from './AttributeMappingHeader';
import GroupFormDrawer from './GroupFormDrawer';
import MapperGroupsTable from './MapperGroupsTable';
import { useAttributeMappingStore } from './useAttributeMappingStore';
import { useGroupFormDrawer } from './useGroupFormDrawer';
import './LLMObservabilityAttributeMapping.styles.scss';
function LLMObservabilityAttributeMapping(): JSX.Element {
const store = useAttributeMappingStore();
const groupDrawer = useGroupFormDrawer();
const handleGroupSave = useCallback((): void => {
store.upsertGroup(groupDrawer.draft);
groupDrawer.close();
}, [store, groupDrawer]);
const handleGroupDelete = useCallback((): void => {
if (groupDrawer.draft.id) {
store.removeGroup(groupDrawer.draft.id);
}
groupDrawer.close();
}, [store, groupDrawer]);
return (
<div
className="llm-observability-attribute-mapping"
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={store.isDirty}
isSaving={store.isSaving}
onDiscard={store.discard}
onSave={store.save}
/>
{store.saveError && (
<div className="page-error" role="alert">
{store.saveError}
</div>
)}
{store.isError && (
<div className="page-error" role="alert">
Failed to load mapping groups. Please try again.
</div>
)}
<MapperGroupsTable
store={store}
onEditGroup={groupDrawer.openForEdit}
onAddGroup={groupDrawer.openForAdd}
/>
<footer className="page-footer">
Showing {store.groups.length} group{store.groups.length === 1 ? '' : 's'}
</footer>
<GroupFormDrawer
isOpen={groupDrawer.isOpen}
mode={groupDrawer.mode}
draft={groupDrawer.draft}
setDraft={groupDrawer.setDraft}
onClose={groupDrawer.close}
onSave={handleGroupSave}
onDelete={handleGroupDelete}
isSaving={false}
isDeleting={false}
saveError={null}
/>
</div>
);
}
export default LLMObservabilityAttributeMapping;

View File

@@ -1,104 +0,0 @@
.mapper-form {
display: flex;
flex-direction: column;
gap: 20px;
padding: 4px 0;
&__field {
display: flex;
flex-direction: column;
gap: 8px;
}
&__label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-400);
}
&__label-hint {
font-weight: 400;
text-transform: none;
letter-spacing: normal;
}
&__hint {
font-size: 12px;
color: var(--bg-vanilla-400);
}
&__sources {
display: flex;
flex-direction: column;
gap: 8px;
}
&__source {
display: flex;
align-items: center;
gap: 6px;
}
&__source-handle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: transparent;
color: var(--bg-vanilla-400);
cursor: grab;
touch-action: none;
user-select: none;
&:active {
cursor: grabbing;
}
}
&__source-index {
width: 20px;
text-align: center;
font-size: 12px;
color: var(--bg-vanilla-400);
}
&__source-input {
flex: 1;
min-width: 0;
font-family: 'Geist Mono', monospace;
}
&__source-select {
flex: 0 0 auto;
width: 120px;
}
&__field-context {
width: 200px;
}
&__error {
padding: 10px 12px;
border-radius: 4px;
background: rgba(229, 72, 77, 0.1);
color: var(--bg-cherry-400);
font-size: 13px;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8px;
}
&__footer-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
}

View File

@@ -1,267 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { SelectSimple } from '@signozhq/ui/select';
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { Plus, Trash2 } from '@signozhq/icons';
import { v4 as uuid } from 'uuid';
import KeySearchInput from './KeySearchInput';
import SourceAttributeRow from './SourceAttributeRow';
import {
FieldContext,
FieldContextValue,
MapperDraft,
MapperDraftMode,
SourceConfig,
} from './types';
import { createEmptySource, isMapperDraftValid } from './utils';
import './MapperFormDrawer.styles.scss';
const FIELD_CONTEXT_OPTIONS = [
{ value: FieldContext.attribute, label: 'Span attribute' },
{ value: FieldContext.resource, label: 'Resource' },
];
interface MapperFormDrawerProps {
isOpen: boolean;
mode: MapperDraftMode;
draft: MapperDraft;
setDraft: (next: MapperDraft) => void;
onClose: () => void;
onSave: () => void;
onDelete: () => void;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
}
function MapperFormDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
saveError,
}: MapperFormDrawerProps): JSX.Element {
const isEdit = mode === 'edit';
const isValid = isMapperDraftValid(draft);
// Stable per-row ids for the sortable list. These are UI-only (never sent to
// the API and excluded from the draft), so dnd-kit can track rows reliably
// even though sources are stored as a plain array. Re-seeded each time the
// drawer opens; kept in lockstep with the sources array on add/remove/drag.
const [rowIds, setRowIds] = useState<string[]>([]);
const wasOpen = useRef(false);
useEffect(() => {
if (isOpen && !wasOpen.current) {
setRowIds(draft.sources.map(() => uuid()));
}
wasOpen.current = isOpen;
// Only re-seed on the closed→open transition.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const sourceIds = draft.sources.map(
(_, index) => rowIds[index] ?? `pending-${index}`,
);
// 5px activation distance so clicking into the input never starts a drag.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const updateSource = (index: number, patch: Partial<SourceConfig>): void => {
const sources = draft.sources.map((source, i) =>
i === index ? { ...source, ...patch } : source,
);
setDraft({ ...draft, sources });
};
const addSource = (): void => {
setDraft({ ...draft, sources: [...draft.sources, createEmptySource()] });
setRowIds((prev) => [...prev, uuid()]);
};
const removeSource = (index: number): void => {
const sources = draft.sources.filter((_, i) => i !== index);
if (sources.length === 0) {
setDraft({ ...draft, sources: [createEmptySource()] });
setRowIds([uuid()]);
return;
}
setDraft({ ...draft, sources });
setRowIds((prev) => prev.filter((_, i) => i !== index));
};
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const from = sourceIds.indexOf(String(active.id));
const to = sourceIds.indexOf(String(over.id));
if (from === -1 || to === -1) {
return;
}
setDraft({ ...draft, sources: arrayMove(draft.sources, from, to) });
setRowIds((prev) => arrayMove(prev, from, to));
};
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
title={isEdit ? 'Edit mapping' : 'New custom mapping'}
subTitle="Map source attributes onto a canonical target attribute"
width="wide"
testId="mapper-form-drawer"
footer={
<div className="mapper-form__footer">
{isEdit && (
<Button
variant="ghost"
color="destructive"
prefix={<Trash2 size={14} />}
onClick={onDelete}
disabled={isDeleting}
testId="mapper-form-delete"
>
{isDeleting ? 'Deleting…' : 'Delete'}
</Button>
)}
<div className="mapper-form__footer-actions">
<Button
variant="ghost"
color="secondary"
onClick={onClose}
testId="mapper-form-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
disabled={!isValid || isSaving}
testId="mapper-form-save"
>
{/* eslint-disable-next-line no-nested-ternary */}
{isSaving ? 'Saving…' : isEdit ? 'Save mapping' : 'Create mapping'}
</Button>
</div>
</div>
}
>
<div className="mapper-form">
<div className="mapper-form__field">
<span className="mapper-form__label">Target attribute</span>
<KeySearchInput
placeholder="e.g. gen_ai.content.prompt"
value={draft.name}
fieldContext={draft.fieldContext}
disabled={isEdit}
onChange={(name): void => setDraft({ ...draft, name })}
testId="mapper-form-target"
/>
{isEdit && (
<span className="mapper-form__hint">
The target attribute can&apos;t be changed after creation.
</span>
)}
</div>
<div className="mapper-form__field">
<span className="mapper-form__label">Write target to</span>
<SelectSimple
className="mapper-form__field-context"
items={FIELD_CONTEXT_OPTIONS}
value={draft.fieldContext}
withPortal={false}
onChange={(next): void =>
setDraft({ ...draft, fieldContext: next as FieldContextValue })
}
testId="mapper-form-field-context"
/>
<span className="mapper-form__hint">
Where the standardized attribute is written.
</span>
</div>
<div className="mapper-form__field">
<span className="mapper-form__label">
Source attributes
<span className="mapper-form__label-hint">
{' '}
· priority: top bottom · drag to reorder
</span>
</span>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext items={sourceIds} strategy={verticalListSortingStrategy}>
<div className="mapper-form__sources">
{draft.sources.map((source, index) => (
<SourceAttributeRow
key={sourceIds[index]}
id={sourceIds[index]}
index={index}
value={source}
canRemove={draft.sources.length > 1}
onChange={updateSource}
onRemove={removeSource}
/>
))}
</div>
</SortableContext>
</DndContext>
<Button
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
onClick={addSource}
testId="mapper-form-add-source"
>
Add another source
</Button>
</div>
{saveError && (
<div className="mapper-form__error" role="alert">
{saveError}
</div>
)}
</div>
</DrawerWrapper>
);
}
export default MapperFormDrawer;

View File

@@ -1,212 +0,0 @@
import { useState } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@signozhq/ui/table';
import {
ChevronDown,
ChevronRight,
Pencil,
Plus,
Trash2,
} from '@signozhq/icons';
import MappersTable from './MappersTable';
import { DraftGroup } from './types';
import { AttributeMappingStore } from './useAttributeMappingStore';
import { conditionFiltersFromGroup } from './utils';
const COLUMN_COUNT = 4;
interface MapperGroupsTableProps {
store: AttributeMappingStore;
onEditGroup: (group: DraftGroup) => void;
onAddGroup: () => void;
}
function FiltersCell({ group }: { group: DraftGroup }): JSX.Element {
const filters = conditionFiltersFromGroup(group);
if (filters.length === 0) {
return <span className="muted">No condition · always runs</span>;
}
return (
<div
className="groups-table__filters"
data-testid={`group-filters-${group.localId}`}
>
{filters.map((filter) => (
<div
className="groups-table__filter"
key={`${filter.context}:${filter.key}`}
>
<Badge
color={filter.context === 'resource' ? 'amber' : 'robin'}
variant="outline"
>
{filter.context}
</Badge>
<span className="groups-table__filter-key">contains {filter.key}</span>
</div>
))}
</div>
);
}
interface GroupRowProps {
group: DraftGroup;
store: AttributeMappingStore;
isExpanded: boolean;
onToggleExpand: (localId: string) => void;
onEditGroup: (group: DraftGroup) => void;
}
function GroupRow({
group,
store,
isExpanded,
onToggleExpand,
onEditGroup,
}: GroupRowProps): JSX.Element {
return (
<>
<TableRow data-testid={`group-row-${group.localId}`}>
<TableCell>
<div className="groups-table__name-cell">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label={isExpanded ? 'Collapse group' : 'Expand group'}
onClick={(): void => onToggleExpand(group.localId)}
testId={`group-expand-${group.localId}`}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</Button>
<span
className="groups-table__name"
data-testid={`group-name-${group.localId}`}
>
{group.name}
</span>
</div>
</TableCell>
<TableCell>
<FiltersCell group={group} />
</TableCell>
<TableCell>
<span className="muted">{group.mappers.length} mappings</span>
</TableCell>
<TableCell>
<div className="am-row-actions">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Edit group"
onClick={(): void => onEditGroup(group)}
testId={`group-edit-${group.localId}`}
>
<Pencil size={14} />
</Button>
<Button
variant="ghost"
color="destructive"
size="icon"
aria-label="Delete group"
onClick={(): void => store.removeGroup(group.localId)}
testId={`group-delete-${group.localId}`}
>
<Trash2 size={14} />
</Button>
<Switch
value={group.enabled}
onChange={(checked): void => store.toggleGroup(group.localId, checked)}
testId={`group-enabled-${group.localId}`}
/>
</div>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow className="groups-table__sub-row">
<TableCell colSpan={COLUMN_COUNT}>
<MappersTable group={group} store={store} />
</TableCell>
</TableRow>
)}
</>
);
}
function MapperGroupsTable({
store,
onEditGroup,
onAddGroup,
}: MapperGroupsTableProps): JSX.Element {
const [expandedId, setExpandedId] = useState<string | null>(null);
const toggleExpand = (localId: string): void => {
setExpandedId((current) => (current === localId ? null : localId));
};
return (
<div className="groups-table__wrapper">
<Table testId="mapper-groups-table" className="am-table">
<TableHeader>
<TableRow>
<TableHead>Group name</TableHead>
<TableHead>Filters</TableHead>
<TableHead>Mappings</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{store.isLoading && store.groups.length === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="am-table__empty">
Loading groups
</TableCell>
</TableRow>
)}
{!store.isLoading && store.groups.length === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="am-table__empty">
No mapping groups yet.
</TableCell>
</TableRow>
)}
{store.groups.map((group) => (
<GroupRow
key={group.localId}
group={group}
store={store}
isExpanded={expandedId === group.localId}
onToggleExpand={toggleExpand}
onEditGroup={onEditGroup}
/>
))}
</TableBody>
</Table>
<Button
variant="ghost"
color="primary"
prefix={<Plus size={14} />}
className="am-add-row"
onClick={onAddGroup}
testId="add-group-row"
>
Add a new group
</Button>
</div>
);
}
export default MapperGroupsTable;

View File

@@ -1,208 +0,0 @@
import { useCallback } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@signozhq/ui/table';
import { Pencil, Plus, Trash2 } from '@signozhq/icons';
import IndexBadge from './IndexBadge';
import MapperFormDrawer from './MapperFormDrawer';
import { DraftGroup, DraftMapper, FieldContext } from './types';
import { AttributeMappingStore } from './useAttributeMappingStore';
import { useMapperFormDrawer } from './useMapperFormDrawer';
const MAX_VISIBLE_SOURCES = 3;
const COLUMN_COUNT = 5;
interface MappersTableProps {
group: DraftGroup;
store: AttributeMappingStore;
}
function SourcesCell({ mapper }: { mapper: DraftMapper }): JSX.Element {
if (mapper.sources.length === 0) {
return <span className="muted"></span>;
}
const visible = mapper.sources.slice(0, MAX_VISIBLE_SOURCES);
const remaining = mapper.sources.length - visible.length;
return (
<div
className="mappers-table__sources"
data-testid={`mapper-sources-${mapper.localId}`}
>
{visible.map((source) => (
<span
className="mappers-table__source-chip"
key={`${source.context}:${source.key}`}
title={source.key}
>
{source.key}
</span>
))}
{remaining > 0 && (
<span className="mappers-table__source-more muted">+{remaining} more</span>
)}
</div>
);
}
interface MapperRowProps {
mapper: DraftMapper;
index: number;
onEdit: (mapper: DraftMapper) => void;
onDelete: (mapperLocalId: string) => void;
onToggle: (mapperLocalId: string, enabled: boolean) => void;
}
function MapperRow({
mapper,
index,
onEdit,
onDelete,
onToggle,
}: MapperRowProps): JSX.Element {
return (
<TableRow data-testid={`mapper-row-${mapper.localId}`}>
<TableCell>
<IndexBadge index={index} />
</TableCell>
<TableCell>
<span
className="mappers-table__target"
data-testid={`mapper-target-${mapper.localId}`}
>
{mapper.name}
</span>
</TableCell>
<TableCell>
<SourcesCell mapper={mapper} />
</TableCell>
<TableCell>
<Badge
color={mapper.fieldContext === FieldContext.resource ? 'amber' : 'robin'}
variant="outline"
>
{mapper.fieldContext}
</Badge>
</TableCell>
<TableCell>
<div className="am-row-actions">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Edit mapping"
onClick={(): void => onEdit(mapper)}
testId={`mapper-edit-${mapper.localId}`}
>
<Pencil size={14} />
</Button>
<Button
variant="ghost"
color="destructive"
size="icon"
aria-label="Delete mapping"
onClick={(): void => onDelete(mapper.localId)}
testId={`mapper-delete-${mapper.localId}`}
>
<Trash2 size={14} />
</Button>
<Switch
value={mapper.enabled}
onChange={(checked): void => onToggle(mapper.localId, checked)}
testId={`mapper-enabled-${mapper.localId}`}
/>
</div>
</TableCell>
</TableRow>
);
}
function MappersTable({ group, store }: MappersTableProps): JSX.Element {
const drawer = useMapperFormDrawer();
const handleSave = useCallback((): void => {
store.upsertMapper(group.localId, drawer.draft);
drawer.close();
}, [store, group.localId, drawer]);
const handleDelete = useCallback((): void => {
if (drawer.draft.id) {
store.removeMapper(group.localId, drawer.draft.id);
}
drawer.close();
}, [store, group.localId, drawer]);
return (
<div className="mappers-table__wrapper">
<Table testId={`mappers-table-${group.localId}`} className="am-table">
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Target</TableHead>
<TableHead>Sources</TableHead>
<TableHead>Writes to</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.mappers.length === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="am-table__empty">
No mappings in this group yet.
</TableCell>
</TableRow>
)}
{group.mappers.map((mapper, index) => (
<MapperRow
key={mapper.localId}
mapper={mapper}
index={index}
onEdit={drawer.openForEdit}
onDelete={(localId): void => store.removeMapper(group.localId, localId)}
onToggle={(localId, enabled): void =>
store.toggleMapper(group.localId, localId, enabled)
}
/>
))}
</TableBody>
</Table>
<Button
variant="ghost"
color="primary"
size="sm"
prefix={<Plus size={14} />}
className="am-add-row"
onClick={drawer.openForAdd}
testId={`add-mapper-${group.localId}`}
>
Add mapping
</Button>
<MapperFormDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
draft={drawer.draft}
setDraft={drawer.setDraft}
onClose={drawer.close}
onSave={handleSave}
onDelete={handleDelete}
isSaving={false}
isDeleting={false}
saveError={null}
/>
</div>
);
}
export default MappersTable;

View File

@@ -1,115 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { SelectSimple } from '@signozhq/ui/select';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, X } from '@signozhq/icons';
import KeySearchInput from './KeySearchInput';
import {
FieldContext,
FieldContextValue,
MapperOperation,
MapperOperationValue,
SourceConfig,
} from './types';
const CONTEXT_OPTIONS = [
{ value: FieldContext.attribute, label: 'Attribute' },
{ value: FieldContext.resource, label: 'Resource' },
];
const OPERATION_OPTIONS = [
{ value: MapperOperation.move, label: 'Move' },
{ value: MapperOperation.copy, label: 'Copy' },
];
interface SourceAttributeRowProps {
id: string;
index: number;
value: SourceConfig;
canRemove: boolean;
onChange: (index: number, patch: Partial<SourceConfig>) => void;
onRemove: (index: number) => void;
}
// A single draggable source row. Order = priority (top wins). Each source can
// be read from a span attribute or the resource, and moved (delete source) or
// copied (keep source). Only the grip is a drag handle.
function SourceAttributeRow({
id,
index,
value,
canRemove,
onChange,
onRemove,
}: SourceAttributeRowProps): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
return (
<div className="mapper-form__source" ref={setNodeRef} style={style}>
<div
className="mapper-form__source-handle"
data-testid={`mapper-form-source-handle-${index}`}
{...attributes}
{...listeners}
>
<GripVertical size={14} />
</div>
<span className="mapper-form__source-index">{index + 1}</span>
<KeySearchInput
className="mapper-form__source-input"
placeholder="Source attribute key"
value={value.key}
fieldContext={value.context}
onChange={(key): void => onChange(index, { key })}
testId={`mapper-form-source-${index}`}
/>
<SelectSimple
className="mapper-form__source-select"
items={CONTEXT_OPTIONS}
value={value.context}
withPortal={false}
onChange={(next): void =>
onChange(index, { context: next as FieldContextValue })
}
testId={`mapper-form-source-context-${index}`}
/>
<SelectSimple
className="mapper-form__source-select"
items={OPERATION_OPTIONS}
value={value.operation}
withPortal={false}
onChange={(next): void =>
onChange(index, { operation: next as MapperOperationValue })
}
testId={`mapper-form-source-operation-${index}`}
/>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Remove source"
disabled={!canRemove}
onClick={(): void => onRemove(index)}
testId={`mapper-form-source-remove-${index}`}
>
<X size={14} />
</Button>
</div>
);
}
export default SourceAttributeRow;

View File

@@ -1,64 +0,0 @@
import { useState } from 'react';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen } from 'tests/test-utils';
import ConditionKeyList from '../ConditionKeyList';
import { FieldContext } from '../types';
const FIELDS_ENDPOINT = '*/api/v1/fields/keys';
function Harness({ initial = [] as string[] }): JSX.Element {
const [keys, setKeys] = useState<string[]>(initial);
return (
<ConditionKeyList
label="Attributes"
keys={keys}
placeholder="key"
addLabel="Add attribute key"
testIdPrefix="cond"
fieldContext={FieldContext.attribute}
onChange={setKeys}
/>
);
}
describe('ConditionKeyList', () => {
beforeEach(() => {
server.use(
rest.get(FIELDS_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { complete: true, keys: {} } }),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders no key rows and only the add button when empty', () => {
render(<Harness />);
expect(screen.queryByTestId('cond-0')).not.toBeInTheDocument();
expect(screen.getByTestId('cond-add')).toBeInTheDocument();
});
it('adds a key row when the add button is clicked', () => {
render(<Harness />);
fireEvent.click(screen.getByTestId('cond-add'));
expect(screen.getByTestId('cond-0')).toBeInTheDocument();
});
it('removes a key row when its remove button is clicked', () => {
render(<Harness initial={['gen_ai.', 'llm.']} />);
expect(screen.getByTestId('cond-0')).toBeInTheDocument();
expect(screen.getByTestId('cond-1')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('cond-remove-1'));
expect(screen.queryByTestId('cond-1')).not.toBeInTheDocument();
expect(screen.getByTestId('cond-0')).toBeInTheDocument();
});
});

View File

@@ -1,123 +0,0 @@
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import KeySearchInput from '../KeySearchInput';
import { FieldContext } from '../types';
const FIELDS_ENDPOINT = '*/api/v1/fields/keys';
const mockKeysResponse = {
status: 'success',
data: {
complete: true,
keys: {
'gen_ai.request.model': [
{ name: 'gen_ai.request.model', fieldContext: 'attribute' },
],
'llm.model': [{ name: 'llm.model', fieldContext: 'attribute' }],
},
},
};
describe('KeySearchInput', () => {
let lastRequestParams: Record<string, string | null> = {};
beforeEach(() => {
lastRequestParams = {};
server.use(
rest.get(FIELDS_ENDPOINT, (req, res, ctx) => {
lastRequestParams = {
signal: req.url.searchParams.get('signal'),
fieldContext: req.url.searchParams.get('fieldContext'),
searchText: req.url.searchParams.get('searchText'),
};
return res(ctx.status(200), ctx.json(mockKeysResponse));
}),
);
});
afterEach(() => {
server.resetHandlers();
});
it('does not show suggestions until the input is focused', () => {
render(
<KeySearchInput
value=""
fieldContext={FieldContext.attribute}
onChange={jest.fn()}
testId="key"
/>,
);
expect(screen.queryByTestId('key-dropdown')).not.toBeInTheDocument();
});
it('shows suggestions on focus and selecting one calls onChange with the key', async () => {
const onChange = jest.fn();
render(
<KeySearchInput
value=""
fieldContext={FieldContext.attribute}
onChange={onChange}
testId="key"
/>,
);
fireEvent.focus(screen.getByTestId('key'));
const option = await screen.findByTestId('key-option-gen_ai.request.model');
fireEvent.mouseDown(option);
expect(onChange).toHaveBeenCalledWith('gen_ai.request.model');
});
it('queries traces with the resource context when fieldContext is resource', async () => {
render(
<KeySearchInput
value=""
fieldContext={FieldContext.resource}
onChange={jest.fn()}
testId="key"
/>,
);
fireEvent.focus(screen.getByTestId('key'));
await waitFor(() => expect(lastRequestParams.fieldContext).toBe('resource'));
expect(lastRequestParams.signal).toBe('traces');
});
it('accepts free text typed by the user (custom key)', () => {
const onChange = jest.fn();
render(
<KeySearchInput
value=""
fieldContext={FieldContext.attribute}
onChange={onChange}
testId="key"
/>,
);
fireEvent.change(screen.getByTestId('key'), {
target: { value: 'my.custom.key' },
});
expect(onChange).toHaveBeenCalledWith('my.custom.key');
});
it('does not query while disabled', () => {
render(
<KeySearchInput
value=""
fieldContext={FieldContext.attribute}
disabled
onChange={jest.fn()}
testId="key"
/>,
);
fireEvent.focus(screen.getByTestId('key'));
expect(screen.queryByTestId('key-dropdown')).not.toBeInTheDocument();
});
});

View File

@@ -1,72 +0,0 @@
import { SpantypesSpanMapperGroupDTO } from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen } from 'tests/test-utils';
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
const GROUPS_ENDPOINT = '*/api/v1/span_mapper_groups';
const MAPPERS_ENDPOINT = '*/api/v1/span_mapper_groups/:groupId/span_mappers';
const mockGroups: SpantypesSpanMapperGroupDTO[] = [
{
id: 'group-openai',
orgId: 'org-1',
name: 'OpenAI gateway',
enabled: true,
condition: { attributes: ['gen_ai.'], resource: [] },
updatedBy: 'gaurav.tewari@signoz.io',
updatedAt: '2026-06-10T09:30:00Z',
},
];
describe('LLMObservabilityAttributeMapping', () => {
beforeEach(() => {
server.use(
rest.get(MAPPERS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: { items: [] } })),
),
rest.get(GROUPS_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { items: mockGroups } }),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders the page header and the group row', async () => {
render(<LLMObservabilityAttributeMapping />);
await screen.findByTestId('group-name-group-openai');
expect(screen.getByText('Attribute Mapping')).toBeInTheDocument();
expect(screen.getByText('OpenAI gateway')).toBeInTheDocument();
});
it('opens the group drawer in Add mode with empty name', async () => {
render(<LLMObservabilityAttributeMapping />);
await screen.findByTestId('group-name-group-openai');
fireEvent.click(screen.getByTestId('add-group-row'));
const input = (await screen.findByTestId(
'group-form-name',
)) as HTMLInputElement;
expect(input.value).toBe('');
});
it('opens the group drawer in Edit mode prefilled with the group name', async () => {
render(<LLMObservabilityAttributeMapping />);
await screen.findByTestId('group-name-group-openai');
fireEvent.click(screen.getByTestId('group-edit-group-openai'));
const input = (await screen.findByTestId(
'group-form-name',
)) as HTMLInputElement;
expect(input.value).toBe('OpenAI gateway');
});
});

View File

@@ -1,115 +0,0 @@
import { useState } from 'react';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen } from 'tests/test-utils';
import MapperFormDrawer from '../MapperFormDrawer';
import {
FieldContext,
MapperDraft,
MapperDraftMode,
MapperOperation,
} from '../types';
import { EMPTY_MAPPER_DRAFT } from '../utils';
const FIELDS_ENDPOINT = '*/api/v1/fields/keys';
const filledDraft: MapperDraft = {
id: null,
name: 'gen_ai.request.model',
fieldContext: FieldContext.attribute,
sources: [
{
key: 'llm.model',
context: FieldContext.attribute,
operation: MapperOperation.move,
},
],
enabled: true,
};
interface HarnessProps {
initialDraft?: MapperDraft;
mode?: MapperDraftMode;
onSave?: () => void;
onDelete?: () => void;
}
function Harness({
initialDraft = EMPTY_MAPPER_DRAFT,
mode = 'add',
onSave = jest.fn(),
onDelete = jest.fn(),
}: HarnessProps): JSX.Element {
const [draft, setDraft] = useState<MapperDraft>(initialDraft);
return (
<MapperFormDrawer
isOpen
mode={mode}
draft={draft}
setDraft={setDraft}
onClose={jest.fn()}
onSave={onSave}
onDelete={onDelete}
isSaving={false}
isDeleting={false}
saveError={null}
/>
);
}
describe('MapperFormDrawer', () => {
beforeEach(() => {
server.use(
rest.get(FIELDS_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { complete: true, keys: {} } }),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('disables Create when the target/sources are empty', () => {
render(<Harness />);
expect(screen.getByTestId('mapper-form-save')).toBeDisabled();
});
it('enables Create with a target and a source key, and calls onSave', () => {
const onSave = jest.fn();
render(<Harness initialDraft={filledDraft} onSave={onSave} />);
const save = screen.getByTestId('mapper-form-save');
expect(save).not.toBeDisabled();
fireEvent.click(save);
expect(onSave).toHaveBeenCalledTimes(1);
});
it('adds a source row when "Add another source" is clicked', () => {
render(<Harness initialDraft={filledDraft} />);
expect(screen.queryByTestId('mapper-form-source-1')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('mapper-form-add-source'));
expect(screen.getByTestId('mapper-form-source-1')).toBeInTheDocument();
});
it('makes the target read-only in edit mode and shows delete', () => {
const onDelete = jest.fn();
render(
<Harness
initialDraft={{ ...filledDraft, id: 'local-mapper-1' }}
mode="edit"
onDelete={onDelete}
/>,
);
expect(screen.getByTestId('mapper-form-target')).toBeDisabled();
fireEvent.click(screen.getByTestId('mapper-form-delete'));
expect(onDelete).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,146 +0,0 @@
import { persistDraft, SaveMutations } from '../saveDraft';
import {
DraftGroup,
DraftMapper,
FieldContext,
MapperOperation,
} from '../types';
function mapper(over: Partial<DraftMapper>): DraftMapper {
return {
localId: 'm',
serverId: 'm',
name: 'm',
fieldContext: FieldContext.attribute,
sources: [
{
key: 'x',
context: FieldContext.attribute,
operation: MapperOperation.move,
},
],
enabled: true,
...over,
};
}
function group(over: Partial<DraftGroup>): DraftGroup {
return {
localId: 'g',
serverId: 'g',
name: 'g',
attributes: [],
resource: [],
enabled: true,
mappers: [],
...over,
};
}
interface RecordedCalls {
createGroup: unknown[];
updateGroup: [string, unknown][];
deleteGroup: string[];
createMapper: [string, unknown][];
updateMapper: [string, string, unknown][];
deleteMapper: [string, string][];
}
function makeMutations(): { calls: RecordedCalls; mutations: SaveMutations } {
const calls: RecordedCalls = {
createGroup: [],
updateGroup: [],
deleteGroup: [],
createMapper: [],
updateMapper: [],
deleteMapper: [],
};
const mutations: SaveMutations = {
createGroup: async (data): Promise<string> => {
calls.createGroup.push(data);
return 'new-group-id';
},
updateGroup: async (id, data): Promise<void> => {
calls.updateGroup.push([id, data]);
},
deleteGroup: async (id): Promise<void> => {
calls.deleteGroup.push(id);
},
createMapper: async (gid, data): Promise<void> => {
calls.createMapper.push([gid, data]);
},
updateMapper: async (gid, mid, data): Promise<void> => {
calls.updateMapper.push([gid, mid, data]);
},
deleteMapper: async (gid, mid): Promise<void> => {
calls.deleteMapper.push([gid, mid]);
},
};
return { calls, mutations };
}
describe('persistDraft', () => {
it('issues the minimal set of create/update/delete calls', async () => {
const snapshot: DraftGroup[] = [
group({
localId: 'g1',
serverId: 'g1',
name: 'G1',
mappers: [
mapper({ localId: 'm1', serverId: 'm1', name: 'keep' }),
mapper({ localId: 'mdel', serverId: 'mdel', name: 'del' }),
],
}),
group({ localId: 'g2', serverId: 'g2', name: 'G2' }),
];
const draft: DraftGroup[] = [
group({
localId: 'g1',
serverId: 'g1',
name: 'G1-renamed', // changed -> update
mappers: [
mapper({ localId: 'm1', serverId: 'm1', name: 'keep' }), // unchanged
mapper({ localId: 'local-mapper-x', serverId: null, name: 'fresh' }), // new
],
}),
// g2 removed -> delete
group({
localId: 'local-group-y',
serverId: null,
name: 'G3',
mappers: [
mapper({ localId: 'local-mapper-z', serverId: null, name: 'g3map' }),
],
}),
];
const { calls, mutations } = makeMutations();
await persistDraft(snapshot, draft, mutations);
expect(calls.deleteGroup).toStrictEqual(['g2']);
expect(calls.updateGroup.map(([id]) => id)).toStrictEqual(['g1']);
expect(calls.deleteMapper).toStrictEqual([['g1', 'mdel']]);
// no-op mapper is not updated
expect(calls.updateMapper).toStrictEqual([]);
// new group created once, then its mapper created under the returned id
expect(calls.createGroup).toHaveLength(1);
const createMapperGroupIds = calls.createMapper.map(([gid]) => gid).sort();
expect(createMapperGroupIds).toStrictEqual(['g1', 'new-group-id']);
});
it('does nothing when the draft equals the snapshot', async () => {
const snapshot: DraftGroup[] = [group({ localId: 'g1', serverId: 'g1' })];
const draft: DraftGroup[] = [group({ localId: 'g1', serverId: 'g1' })];
const { calls, mutations } = makeMutations();
await persistDraft(snapshot, draft, mutations);
expect(calls.createGroup).toHaveLength(0);
expect(calls.updateGroup).toHaveLength(0);
expect(calls.deleteGroup).toHaveLength(0);
expect(calls.createMapper).toHaveLength(0);
expect(calls.updateMapper).toHaveLength(0);
expect(calls.deleteMapper).toHaveLength(0);
});
});

View File

@@ -1,330 +0,0 @@
import {
DraftGroup,
FieldContext,
Mapper,
MapperDraft,
MapperGroup,
MapperOperation,
SourceConfig,
} from '../types';
import {
buildDraftGroup,
buildDraftMapper,
buildPostableGroup,
buildPostableMapper,
buildUpdatableMapper,
cleanKeys,
conditionFiltersFromGroup,
EMPTY_GROUP_DRAFT,
EMPTY_MAPPER_DRAFT,
formatTimestamp,
getMapperSources,
groupDraftFromNode,
isGroupDraftValid,
isMapperDraftValid,
mapperDraftFromNode,
nodeFromGroupDraft,
nodeFromMapperDraft,
} from '../utils';
function src(
key: string,
operation = MapperOperation.copy,
context = FieldContext.attribute,
): SourceConfig {
return { key, context, operation };
}
function mapperDraft(over: Partial<MapperDraft>): MapperDraft {
return {
id: null,
name: 'target',
fieldContext: FieldContext.attribute,
sources: [src('a')],
enabled: true,
...over,
};
}
const mapper: Mapper = {
id: 'm1',
group_id: 'g1',
name: 'gen_ai.request.model',
enabled: true,
fieldContext: FieldContext.attribute,
config: {
sources: [
{
key: 'low',
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority: 1,
},
{
key: 'high',
context: FieldContext.resource,
operation: MapperOperation.move,
priority: 3,
},
{
key: 'mid',
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority: 2,
},
],
},
};
const group: MapperGroup = {
id: 'g1',
orgId: 'org1',
name: 'OpenAI',
enabled: true,
condition: { attributes: ['gen_ai.', 'llm.'], resource: [] },
};
describe('attribute-mapping utils', () => {
describe('cleanKeys', () => {
it('trims, drops empties, and de-duplicates preserving order', () => {
expect(cleanKeys([' a ', '', 'b', 'a', ' '])).toStrictEqual(['a', 'b']);
});
});
describe('getMapperSources', () => {
it('returns sources sorted by priority descending, preserving context/operation', () => {
expect(getMapperSources(mapper)).toStrictEqual([
{
key: 'high',
context: FieldContext.resource,
operation: MapperOperation.move,
},
{
key: 'mid',
context: FieldContext.attribute,
operation: MapperOperation.copy,
},
{
key: 'low',
context: FieldContext.attribute,
operation: MapperOperation.copy,
},
]);
});
it('returns empty array when there are no sources', () => {
expect(
getMapperSources({ ...mapper, config: { sources: null } }),
).toStrictEqual([]);
});
});
describe('conditionFiltersFromGroup', () => {
it('lists attribute clauses first, then resource clauses', () => {
expect(
conditionFiltersFromGroup({
attributes: ['gen_ai.', 'llm.'],
resource: ['service.name'],
}),
).toStrictEqual([
{ context: 'attribute', key: 'gen_ai.' },
{ context: 'attribute', key: 'llm.' },
{ context: 'resource', key: 'service.name' },
]);
});
});
describe('formatTimestamp', () => {
it('renders a dash for missing timestamps', () => {
expect(formatTimestamp(undefined)).toBe('—');
});
});
describe('draft-tree builders', () => {
it('builds a draft mapper node carrying server id, fieldContext and sources', () => {
expect(buildDraftMapper(mapper)).toStrictEqual({
localId: 'm1',
serverId: 'm1',
name: 'gen_ai.request.model',
fieldContext: FieldContext.attribute,
sources: [
{
key: 'high',
context: FieldContext.resource,
operation: MapperOperation.move,
},
{
key: 'mid',
context: FieldContext.attribute,
operation: MapperOperation.copy,
},
{
key: 'low',
context: FieldContext.attribute,
operation: MapperOperation.copy,
},
],
enabled: true,
});
});
it('builds a draft group node with nested mappers', () => {
const node = buildDraftGroup(group, [mapper]);
expect(node.localId).toBe('g1');
expect(node.attributes).toStrictEqual(['gen_ai.', 'llm.']);
expect(node.mappers).toHaveLength(1);
expect(node.mappers[0].localId).toBe('m1');
});
});
describe('node <-> form conversions', () => {
const node: DraftGroup = {
localId: 'g1',
serverId: 'g1',
name: 'OpenAI',
attributes: ['gen_ai.'],
resource: ['service.name'],
enabled: true,
mappers: [],
};
it('builds a form draft from a group node', () => {
expect(groupDraftFromNode(node)).toStrictEqual({
id: 'g1',
name: 'OpenAI',
attributes: ['gen_ai.'],
resource: ['service.name'],
enabled: true,
});
});
it('updates an existing group node, preserving its ids and mappers', () => {
const existing = { ...node, mappers: [buildDraftMapper(mapper)] };
const updated = nodeFromGroupDraft(
{
id: 'g1',
name: ' Renamed ',
attributes: ['a', '', 'a'],
resource: ['service.name', ''],
enabled: false,
},
existing,
);
expect(updated.serverId).toBe('g1');
expect(updated.name).toBe('Renamed');
expect(updated.attributes).toStrictEqual(['a']);
expect(updated.resource).toStrictEqual(['service.name']);
expect(updated.mappers).toHaveLength(1);
});
it('round-trips a mapper node through the form and de-dups sources by key', () => {
const draft = mapperDraftFromNode(buildDraftMapper(mapper));
expect(draft.id).toBe('m1');
expect(draft.fieldContext).toBe(FieldContext.attribute);
const created = nodeFromMapperDraft(
mapperDraft({
id: null,
sources: [src('a', MapperOperation.move), src(' '), src('a'), src('b')],
}),
);
expect(created.serverId).toBeNull();
expect(created.localId).toMatch(/^local-mapper-/);
expect(created.sources).toStrictEqual([
{
key: 'a',
context: FieldContext.attribute,
operation: MapperOperation.move,
},
{
key: 'b',
context: FieldContext.attribute,
operation: MapperOperation.copy,
},
]);
});
});
describe('validation', () => {
it('validates a mapper draft (name + at least one keyed source)', () => {
expect(isMapperDraftValid(EMPTY_MAPPER_DRAFT)).toBe(false);
expect(
isMapperDraftValid(mapperDraft({ name: 'x', sources: [src(' ')] })),
).toBe(false);
expect(
isMapperDraftValid(mapperDraft({ name: 'x', sources: [src('a')] })),
).toBe(true);
});
it('validates a group draft (name only)', () => {
expect(isGroupDraftValid(EMPTY_GROUP_DRAFT)).toBe(false);
expect(
isGroupDraftValid({
id: null,
name: 'g',
attributes: [],
resource: [],
enabled: true,
}),
).toBe(true);
});
});
describe('API payload builders', () => {
it('derives descending priorities and carries per-source context/operation', () => {
const payload = buildPostableMapper(
mapperDraft({
name: ' target ',
fieldContext: FieldContext.resource,
sources: [
src('a', MapperOperation.move),
src('b', MapperOperation.copy, FieldContext.resource),
],
}),
);
expect(payload.name).toBe('target');
expect(payload.fieldContext).toBe(FieldContext.resource);
expect(payload.config.sources).toStrictEqual([
{
key: 'a',
context: FieldContext.attribute,
operation: MapperOperation.move,
priority: 2,
},
{
key: 'b',
context: FieldContext.resource,
operation: MapperOperation.copy,
priority: 1,
},
]);
});
it('omits name on the mapper update payload (immutable target)', () => {
const payload = buildUpdatableMapper(
mapperDraft({
id: 'm1',
enabled: false,
fieldContext: FieldContext.resource,
}),
);
expect(payload).not.toHaveProperty('name');
expect(payload.enabled).toBe(false);
expect(payload.fieldContext).toBe(FieldContext.resource);
});
it('cleans both attribute and resource condition keys', () => {
const payload = buildPostableGroup({
id: null,
name: 'g',
attributes: ['gen_ai.', '', 'gen_ai.'],
resource: ['service.name', ' ', 'service.name'],
enabled: true,
});
expect(payload.condition).toStrictEqual({
attributes: ['gen_ai.'],
resource: ['service.name'],
});
});
});
});

View File

@@ -1,185 +0,0 @@
import {
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DraftGroup, DraftMapper, SourceConfig } from './types';
import {
buildPostableGroup,
buildPostableMapper,
buildUpdatableGroup,
buildUpdatableMapper,
} from './utils';
// Thin persistence surface the store wires to the generated mutations.
// createGroup returns the new server id so its mappers can be created under it.
export interface SaveMutations {
createGroup: (data: SpantypesPostableSpanMapperGroupDTO) => Promise<string>;
updateGroup: (
groupId: string,
data: SpantypesUpdatableSpanMapperGroupDTO,
) => Promise<void>;
deleteGroup: (groupId: string) => Promise<void>;
createMapper: (
groupId: string,
data: SpantypesPostableSpanMapperDTO,
) => Promise<void>;
updateMapper: (
groupId: string,
mapperId: string,
data: SpantypesUpdatableSpanMapperDTO,
) => Promise<void>;
deleteMapper: (groupId: string, mapperId: string) => Promise<void>;
}
function arraysEqual(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
return (
a.length === b.length &&
a.every(
(source, index) =>
source.key === b[index].key &&
source.context === b[index].context &&
source.operation === b[index].operation,
)
);
}
function groupChanged(snapshot: DraftGroup, draft: DraftGroup): boolean {
return (
snapshot.name !== draft.name ||
snapshot.enabled !== draft.enabled ||
!arraysEqual(snapshot.attributes, draft.attributes) ||
!arraysEqual(snapshot.resource, draft.resource)
);
}
function mapperChanged(snapshot: DraftMapper, draft: DraftMapper): boolean {
return (
snapshot.enabled !== draft.enabled ||
snapshot.fieldContext !== draft.fieldContext ||
!sourcesEqual(snapshot.sources, draft.sources)
);
}
function groupDraftOf(
node: DraftGroup,
): Parameters<typeof buildPostableGroup>[0] {
return {
id: node.serverId,
name: node.name,
attributes: node.attributes,
resource: node.resource,
enabled: node.enabled,
};
}
function mapperDraftOf(
node: DraftMapper,
): Parameters<typeof buildPostableMapper>[0] {
return {
id: node.serverId,
name: node.name,
fieldContext: node.fieldContext,
sources: node.sources,
enabled: node.enabled,
};
}
async function persistMappers(
groupServerId: string,
snapshotMappers: DraftMapper[],
draftMappers: DraftMapper[],
m: SaveMutations,
): Promise<void> {
const snapById = new Map(
snapshotMappers
.filter((mapper) => mapper.serverId)
.map((mapper) => [mapper.serverId as string, mapper]),
);
const draftServerIds = new Set(
draftMappers
.filter((mapper) => mapper.serverId)
.map((mapper) => mapper.serverId as string),
);
// Deleted mappers.
await Promise.all(
snapshotMappers
.filter((mapper) => mapper.serverId && !draftServerIds.has(mapper.serverId))
.map((mapper) => m.deleteMapper(groupServerId, mapper.serverId as string)),
);
// Created + updated mappers (sequential to keep ordering deterministic).
for (const mapper of draftMappers) {
if (!mapper.serverId) {
// eslint-disable-next-line no-await-in-loop
await m.createMapper(
groupServerId,
buildPostableMapper(mapperDraftOf(mapper)),
);
} else {
const snap = snapById.get(mapper.serverId);
if (!snap || mapperChanged(snap, mapper)) {
// eslint-disable-next-line no-await-in-loop
await m.updateMapper(
groupServerId,
mapper.serverId,
buildUpdatableMapper(mapperDraftOf(mapper)),
);
}
}
}
}
// Diffs the staged tree against the server snapshot and issues the minimal set
// of create/update/delete calls to reconcile them.
export async function persistDraft(
snapshot: DraftGroup[],
draft: DraftGroup[],
m: SaveMutations,
): Promise<void> {
const snapById = new Map(
snapshot
.filter((group) => group.serverId)
.map((group) => [group.serverId as string, group]),
);
const draftServerIds = new Set(
draft
.filter((group) => group.serverId)
.map((group) => group.serverId as string),
);
// Deleted groups (cascades mappers server-side).
await Promise.all(
snapshot
.filter((group) => group.serverId && !draftServerIds.has(group.serverId))
.map((group) => m.deleteGroup(group.serverId as string)),
);
for (const group of draft) {
if (!group.serverId) {
// eslint-disable-next-line no-await-in-loop
const newId = await m.createGroup(buildPostableGroup(groupDraftOf(group)));
// eslint-disable-next-line no-await-in-loop
await persistMappers(newId, [], group.mappers, m);
continue;
}
const snap = snapById.get(group.serverId);
if (!snap || groupChanged(snap, group)) {
// eslint-disable-next-line no-await-in-loop
await m.updateGroup(
group.serverId,
buildUpdatableGroup(groupDraftOf(group)),
);
}
// eslint-disable-next-line no-await-in-loop
await persistMappers(group.serverId, snap?.mappers ?? [], group.mappers, m);
}
}

View File

@@ -1,84 +0,0 @@
import {
SpantypesFieldContextDTO,
SpantypesSpanMapperConfigDTO,
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupConditionDTO,
SpantypesSpanMapperGroupDTO,
SpantypesSpanMapperOperationDTO,
SpantypesSpanMapperSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
export type MapperGroup = SpantypesSpanMapperGroupDTO;
export type MapperGroupCondition =
NonNullable<SpantypesSpanMapperGroupConditionDTO>;
export type Mapper = SpantypesSpanMapperDTO;
export type MapperConfig = SpantypesSpanMapperConfigDTO;
export type MapperSource = SpantypesSpanMapperSourceDTO;
export const FieldContext = SpantypesFieldContextDTO;
export const MapperOperation = SpantypesSpanMapperOperationDTO;
export type FieldContextValue = SpantypesFieldContextDTO;
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
// A single human-readable condition clause shown in the group's Filters column.
export interface ConditionFilter {
context: 'attribute' | 'resource';
key: string;
}
export type MapperDraftMode = 'add' | 'edit';
// One source candidate. `context` is where the key is read from (span
// attribute or resource); `operation` is move (delete source) or copy (keep).
// Priority is implicit in list order (top wins), derived on save.
export interface SourceConfig {
key: string;
context: SpantypesFieldContextDTO;
operation: SpantypesSpanMapperOperationDTO;
}
// Editable form state for a mapper. `sources` is ordered highest priority
// first; `fieldContext` is where the standardized target is written.
export interface MapperDraft {
id: string | null;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
// Editable form state for a group. The group runs when a span carries a
// span-attribute key matching `attributes` OR a resource key matching
// `resource` (plain substring match).
export interface GroupDraft {
id: string | null;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
}
// Working-copy node for a mapper. `localId` is a stable client key (the server
// id once persisted, or a temporary id for not-yet-saved rows). `serverId` is
// null until the row has been persisted.
export interface DraftMapper {
localId: string;
serverId: string | null;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
// Working-copy node for a group, holding its mappers inline so the whole tree
// can be staged locally and diffed against the server snapshot on save.
export interface DraftGroup {
localId: string;
serverId: string | null;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
mappers: DraftMapper[];
}

View File

@@ -1,287 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueries, useQueryClient } from 'react-query';
import {
getListSpanMappersQueryOptions,
useCreateSpanMapper,
useCreateSpanMapperGroup,
useDeleteSpanMapper,
useDeleteSpanMapperGroup,
useListSpanMapperGroups,
useUpdateSpanMapper,
useUpdateSpanMapperGroup,
} from 'api/generated/services/spanmapper';
import { persistDraft, SaveMutations } from './saveDraft';
import {
DraftGroup,
GroupDraft,
Mapper,
MapperDraft,
MapperGroup,
} from './types';
import {
buildDraftGroup,
nodeFromGroupDraft,
nodeFromMapperDraft,
} from './utils';
const GROUPS_KEY_PREFIX = '/api/v1/span_mapper_groups';
function clone(groups: DraftGroup[]): DraftGroup[] {
return JSON.parse(JSON.stringify(groups)) as DraftGroup[];
}
export interface AttributeMappingStore {
groups: DraftGroup[];
isLoading: boolean;
isError: boolean;
isDirty: boolean;
isSaving: boolean;
saveError: string | null;
upsertGroup: (draft: GroupDraft) => void;
removeGroup: (localId: string) => void;
toggleGroup: (localId: string, enabled: boolean) => void;
upsertMapper: (groupLocalId: string, draft: MapperDraft) => void;
removeMapper: (groupLocalId: string, mapperLocalId: string) => void;
toggleMapper: (
groupLocalId: string,
mapperLocalId: string,
enabled: boolean,
) => void;
save: () => Promise<void>;
discard: () => void;
}
export function useAttributeMappingStore(): AttributeMappingStore {
const queryClient = useQueryClient();
const groupsQuery = useListSpanMapperGroups();
const serverGroups: MapperGroup[] = useMemo(
() => groupsQuery.data?.data?.items ?? [],
[groupsQuery.data],
);
const mapperQueries = useQueries(
serverGroups.map((group) =>
getListSpanMappersQueryOptions({ groupId: group.id }),
),
);
const mappersReady = mapperQueries.every((query) => !query.isLoading);
const ready = !groupsQuery.isLoading && mappersReady;
// Stable signature so the snapshot only rebuilds when server data changes.
const dataSignature = useMemo(
() =>
JSON.stringify(serverGroups) +
JSON.stringify(mapperQueries.map((query) => query.data?.data?.items ?? [])),
[serverGroups, mapperQueries],
);
const snapshot = useMemo<DraftGroup[]>(() => {
if (!ready) {
return [];
}
return serverGroups.map((group, index) =>
buildDraftGroup(
group,
(mapperQueries[index]?.data?.data?.items ?? []) as unknown as Mapper[],
),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, dataSignature]);
const [draft, setDraft] = useState<DraftGroup[] | null>(null);
// Initialise the working copy once data is ready (and re-init after a save
// clears it). Never clobbers in-flight edits — only runs when draft is null.
useEffect(() => {
if (ready && draft === null) {
setDraft(clone(snapshot));
}
}, [ready, draft, snapshot]);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { mutateAsync: createGroup } = useCreateSpanMapperGroup();
const { mutateAsync: updateGroup } = useUpdateSpanMapperGroup();
const { mutateAsync: deleteGroup } = useDeleteSpanMapperGroup();
const { mutateAsync: createMapper } = useCreateSpanMapper();
const { mutateAsync: updateMapper } = useUpdateSpanMapper();
const { mutateAsync: deleteMapper } = useDeleteSpanMapper();
const mutations: SaveMutations = useMemo(
() => ({
createGroup: async (data): Promise<string> => {
const result = await createGroup({ data });
return result.data.id;
},
updateGroup: async (groupId, data): Promise<void> => {
await updateGroup({ pathParams: { groupId }, data });
},
deleteGroup: async (groupId): Promise<void> => {
await deleteGroup({ pathParams: { groupId } });
},
createMapper: async (groupId, data): Promise<void> => {
await createMapper({ pathParams: { groupId }, data });
},
updateMapper: async (groupId, mapperId, data): Promise<void> => {
await updateMapper({ pathParams: { groupId, mapperId }, data });
},
deleteMapper: async (groupId, mapperId): Promise<void> => {
await deleteMapper({ pathParams: { groupId, mapperId } });
},
}),
[
createGroup,
updateGroup,
deleteGroup,
createMapper,
updateMapper,
deleteMapper,
],
);
const upsertGroup = useCallback((groupDraft: GroupDraft): void => {
setDraft((prev) => {
const groups = prev ?? [];
if (groupDraft.id) {
return groups.map((group) =>
group.localId === groupDraft.id
? nodeFromGroupDraft(groupDraft, group)
: group,
);
}
return [...groups, nodeFromGroupDraft(groupDraft)];
});
}, []);
const removeGroup = useCallback((localId: string): void => {
setDraft((prev) => (prev ?? []).filter((group) => group.localId !== localId));
}, []);
const toggleGroup = useCallback((localId: string, enabled: boolean): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === localId ? { ...group, enabled } : group,
),
);
}, []);
const upsertMapper = useCallback(
(groupLocalId: string, mapperDraft: MapperDraft): void => {
setDraft((prev) =>
(prev ?? []).map((group) => {
if (group.localId !== groupLocalId) {
return group;
}
if (mapperDraft.id) {
return {
...group,
mappers: group.mappers.map((mapper) =>
mapper.localId === mapperDraft.id
? nodeFromMapperDraft(mapperDraft, mapper)
: mapper,
),
};
}
return {
...group,
mappers: [...group.mappers, nodeFromMapperDraft(mapperDraft)],
};
}),
);
},
[],
);
const removeMapper = useCallback(
(groupLocalId: string, mapperLocalId: string): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === groupLocalId
? {
...group,
mappers: group.mappers.filter(
(mapper) => mapper.localId !== mapperLocalId,
),
}
: group,
),
);
},
[],
);
const toggleMapper = useCallback(
(groupLocalId: string, mapperLocalId: string, enabled: boolean): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === groupLocalId
? {
...group,
mappers: group.mappers.map((mapper) =>
mapper.localId === mapperLocalId ? { ...mapper, enabled } : mapper,
),
}
: group,
),
);
},
[],
);
const discard = useCallback((): void => {
setSaveError(null);
setDraft(clone(snapshot));
}, [snapshot]);
const save = useCallback(async (): Promise<void> => {
if (!draft) {
return;
}
setIsSaving(true);
setSaveError(null);
try {
await persistDraft(snapshot, draft, mutations);
await queryClient.invalidateQueries({
predicate: (query) =>
typeof query.queryKey?.[0] === 'string' &&
(query.queryKey[0] as string).startsWith(GROUPS_KEY_PREFIX),
});
// Re-initialise the working copy from the freshly-fetched server data.
setDraft(null);
toast.success('Attribute mapping changes saved');
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);
toast.error(`Failed to save changes: ${message}`);
} finally {
setIsSaving(false);
}
}, [draft, snapshot, mutations, queryClient]);
const isDirty = useMemo(
() => draft !== null && JSON.stringify(draft) !== JSON.stringify(snapshot),
[draft, snapshot],
);
return {
groups: draft ?? [],
isLoading: !ready || draft === null,
isError: groupsQuery.isError,
isDirty,
isSaving,
saveError,
upsertGroup,
removeGroup,
toggleGroup,
upsertMapper,
removeMapper,
toggleMapper,
save,
discard,
};
}

View File

@@ -1,40 +0,0 @@
import { useCallback, useState } from 'react';
import { DraftGroup, GroupDraft, MapperDraftMode } from './types';
import { EMPTY_GROUP_DRAFT, groupDraftFromNode } from './utils';
interface UseGroupFormDrawerResult {
isOpen: boolean;
mode: MapperDraftMode;
draft: GroupDraft;
setDraft: (next: GroupDraft) => void;
openForAdd: () => void;
openForEdit: (group: DraftGroup) => void;
close: () => void;
}
// Form state for the group drawer. Persistence is staged through the store,
// so this hook only owns open/draft/mode.
export function useGroupFormDrawer(): UseGroupFormDrawerResult {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<MapperDraftMode>('add');
const [draft, setDraft] = useState<GroupDraft>(EMPTY_GROUP_DRAFT);
const openForAdd = useCallback((): void => {
setMode('add');
setDraft(EMPTY_GROUP_DRAFT);
setIsOpen(true);
}, []);
const openForEdit = useCallback((group: DraftGroup): void => {
setMode('edit');
setDraft(groupDraftFromNode(group));
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
}, []);
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
}

View File

@@ -1,40 +0,0 @@
import { useCallback, useState } from 'react';
import { DraftMapper, MapperDraft, MapperDraftMode } from './types';
import { EMPTY_MAPPER_DRAFT, mapperDraftFromNode } from './utils';
interface UseMapperFormDrawerResult {
isOpen: boolean;
mode: MapperDraftMode;
draft: MapperDraft;
setDraft: (next: MapperDraft) => void;
openForAdd: () => void;
openForEdit: (mapper: DraftMapper) => void;
close: () => void;
}
// Form state for the mapper drawer. Persistence is staged through the store,
// so this hook only owns open/draft/mode.
export function useMapperFormDrawer(): UseMapperFormDrawerResult {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<MapperDraftMode>('add');
const [draft, setDraft] = useState<MapperDraft>(EMPTY_MAPPER_DRAFT);
const openForAdd = useCallback((): void => {
setMode('add');
setDraft(EMPTY_MAPPER_DRAFT);
setIsOpen(true);
}, []);
const openForEdit = useCallback((mapper: DraftMapper): void => {
setMode('edit');
setDraft(mapperDraftFromNode(mapper));
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
}, []);
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
}

View File

@@ -1,262 +0,0 @@
import {
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import dayjs from 'dayjs';
import { v4 as uuid } from 'uuid';
import {
ConditionFilter,
DraftGroup,
DraftMapper,
FieldContext,
GroupDraft,
Mapper,
MapperDraft,
MapperGroup,
MapperOperation,
SourceConfig,
} from './types';
// Client-side id for not-yet-persisted rows. Prefixed so it never collides
// with a server UUID and is easy to spot in logs.
export function genLocalId(prefix: 'group' | 'mapper'): string {
return `local-${prefix}-${uuid()}`;
}
// Trimmed, de-duplicated, non-empty keys preserving input order.
export function cleanKeys(keys: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
keys.forEach((raw) => {
const key = raw.trim();
if (key && !seen.has(key)) {
seen.add(key);
result.push(key);
}
});
return result;
}
// Display clauses for a group's staged condition keys (span attribute keys
// first, then resource keys).
export function conditionFiltersFromGroup(group: {
attributes: string[];
resource: string[];
}): ConditionFilter[] {
return [
...group.attributes.map((key) => ({ context: 'attribute' as const, key })),
...group.resource.map((key) => ({ context: 'resource' as const, key })),
];
}
// Source configs for a mapper, highest priority first (first match wins at
// evaluation time).
export function getMapperSources(mapper: Mapper): SourceConfig[] {
const sources = mapper.config?.sources ?? [];
return [...sources]
.sort((a, b) => b.priority - a.priority)
.map((source) => ({
key: source.key,
context: source.context,
operation: source.operation,
}));
}
// A blank source row. New sources default to `move` so the original key is
// removed once standardized (the PRD default — minimizes duplication).
export function createEmptySource(): SourceConfig {
return {
key: '',
context: FieldContext.attribute,
operation: MapperOperation.move,
};
}
export function formatTimestamp(iso?: string): string {
if (!iso) {
return '—';
}
return dayjs(iso).format('MMM D, YYYY HH:mm');
}
export const EMPTY_MAPPER_DRAFT: MapperDraft = {
id: null,
name: '',
fieldContext: FieldContext.attribute,
sources: [createEmptySource()],
enabled: true,
};
// Trimmed, de-duplicated (by key), non-empty sources in priority order,
// preserving each source's context and operation.
export function getCleanSources(draft: MapperDraft): SourceConfig[] {
const seen = new Set<string>();
const result: SourceConfig[] = [];
draft.sources.forEach((source) => {
const key = source.key.trim();
if (key && !seen.has(key)) {
seen.add(key);
result.push({ ...source, key });
}
});
return result;
}
export function isMapperDraftValid(draft: MapperDraft): boolean {
return draft.name.trim().length > 0 && getCleanSources(draft).length > 0;
}
// Priority is derived from list order so the first row wins.
function buildSources(
draft: MapperDraft,
): SpantypesPostableSpanMapperDTO['config']['sources'] {
const sources = getCleanSources(draft);
return sources.map((source, index) => ({
key: source.key,
context: source.context,
operation: source.operation,
priority: sources.length - index,
}));
}
export function buildPostableMapper(
draft: MapperDraft,
): SpantypesPostableSpanMapperDTO {
return {
name: draft.name.trim(),
fieldContext: draft.fieldContext,
enabled: draft.enabled,
config: { sources: buildSources(draft) },
};
}
// The target name is immutable on update (UpdatableSpanMapper has no name).
export function buildUpdatableMapper(
draft: MapperDraft,
): SpantypesUpdatableSpanMapperDTO {
return {
fieldContext: draft.fieldContext,
enabled: draft.enabled,
config: { sources: buildSources(draft) },
};
}
export const EMPTY_GROUP_DRAFT: GroupDraft = {
id: null,
name: '',
attributes: [''],
resource: [],
enabled: true,
};
export function isGroupDraftValid(draft: GroupDraft): boolean {
return draft.name.trim().length > 0;
}
export function buildPostableGroup(
draft: GroupDraft,
): SpantypesPostableSpanMapperGroupDTO {
return {
name: draft.name.trim(),
enabled: draft.enabled,
condition: {
attributes: cleanKeys(draft.attributes),
resource: cleanKeys(draft.resource),
},
};
}
// A full group payload is also a valid partial-update payload (all updatable
// fields are present), so we reuse the postable builder.
export function buildUpdatableGroup(
draft: GroupDraft,
): SpantypesUpdatableSpanMapperGroupDTO {
return buildPostableGroup(draft);
}
// ---- working-copy (draft tree) helpers ----
export function buildDraftMapper(mapper: Mapper): DraftMapper {
return {
localId: mapper.id,
serverId: mapper.id,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources: getMapperSources(mapper),
enabled: mapper.enabled,
};
}
export function buildDraftGroup(
group: MapperGroup,
mappers: Mapper[],
): DraftGroup {
return {
localId: group.id,
serverId: group.id,
name: group.name,
attributes: group.condition?.attributes ?? [],
resource: group.condition?.resource ?? [],
enabled: group.enabled,
mappers: mappers.map(buildDraftMapper),
};
}
// DraftGroup -> editable form state (id carries the localId).
export function groupDraftFromNode(group: DraftGroup): GroupDraft {
return {
id: group.localId,
name: group.name,
attributes: group.attributes.length > 0 ? group.attributes : [''],
resource: group.resource,
enabled: group.enabled,
};
}
// DraftMapper -> editable form state (id carries the localId).
export function mapperDraftFromNode(mapper: DraftMapper): MapperDraft {
return {
id: mapper.localId,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources:
mapper.sources.length > 0
? mapper.sources.map((source) => ({ ...source }))
: [createEmptySource()],
enabled: mapper.enabled,
};
}
// Form state -> working-copy node. Reuses cleanKeys/getCleanSourceKeys so the
// staged tree already holds normalized values.
export function nodeFromGroupDraft(
draft: GroupDraft,
existing?: DraftGroup,
): DraftGroup {
return {
localId: existing?.localId ?? genLocalId('group'),
serverId: existing?.serverId ?? null,
name: draft.name.trim(),
attributes: cleanKeys(draft.attributes),
resource: cleanKeys(draft.resource),
enabled: draft.enabled,
mappers: existing?.mappers ?? [],
};
}
export function nodeFromMapperDraft(
draft: MapperDraft,
existing?: DraftMapper,
): DraftMapper {
return {
localId: existing?.localId ?? genLocalId('mapper'),
serverId: existing?.serverId ?? null,
name: draft.name.trim(),
fieldContext: draft.fieldContext,
sources: getCleanSources(draft),
enabled: draft.enabled,
};
}

View File

@@ -1,172 +0,0 @@
.llm-observability-model-pricing {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px 32px;
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
&__title {
h1 {
margin: 0;
font-size: 22px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: var(--bg-vanilla-400);
font-size: 13px;
}
}
&__actions {
display: flex;
gap: 8px;
}
}
.page-tabs {
.ant-tabs-nav {
margin-bottom: 0;
}
}
.filters-bar {
display: flex;
gap: 12px;
align-items: center;
&__search {
flex: 1;
max-width: 360px;
}
&__source,
&__currency {
min-width: 160px;
}
&__add {
margin-left: auto;
}
}
.page-error {
padding: 12px 16px;
border-radius: 4px;
background: rgba(255, 90, 90, 0.08);
color: var(--bg-cherry-400);
font-size: 13px;
}
.page-pagination {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.page-footer {
color: var(--bg-vanilla-400);
font-size: 12px;
}
}
.model-costs-table {
.ant-table-thead > tr > th {
color: var(--bg-vanilla-400) !important;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ant-table-tbody > tr > td {
color: var(--bg-vanilla-400);
}
.model-cell {
display: flex;
flex-direction: column;
gap: 2px;
// Allow the flex children to shrink below their content width so the
// table's fixed-layout / nowrap cells truncate instead of overflowing
// into the Provider column.
min-width: 0;
&__name {
color: var(--bg-vanilla-100);
font-weight: 600;
}
&__name,
&__canonical-id {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__canonical-id {
color: var(--bg-vanilla-400);
font-family: var(--code-font-family, monospace);
font-size: 12px;
}
}
.price-cell {
font-family: var(--code-font-family, monospace);
color: var(--bg-vanilla-400);
}
.extra-buckets {
display: flex;
flex-wrap: wrap;
gap: 6px;
&__chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
}
&__key {
font-family: var(--code-font-family, monospace);
font-size: 12px;
}
&__price {
font-family: var(--code-font-family, monospace);
font-weight: 600;
}
}
.muted {
color: var(--bg-vanilla-400);
}
.source-badge {
margin: 0;
&--auto {
background: rgba(78, 116, 248, 0.12);
color: var(--bg-robin-400);
border-color: rgba(78, 116, 248, 0.24);
}
&--override {
background: rgba(245, 175, 25, 0.12);
color: var(--bg-amber-400);
border-color: rgba(245, 175, 25, 0.24);
}
}
&__row--selected {
background: rgba(78, 116, 248, 0.06);
}
}

View File

@@ -1,180 +0,0 @@
import { useMemo, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Pagination } from '@signozhq/ui/pagination';
import { SelectSimple } from '@signozhq/ui/select';
import { Tabs } from '@signozhq/ui/tabs';
import { Plus, Search } from '@signozhq/icons';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import ModelCostDrawer from './ModelCostDrawer';
import ModelCostsTable from './ModelCostsTable';
import { useModelCostDrawer } from './useModelCostDrawer';
import type { PricingRule, SourceFilter } from './types';
import { filterRules } from './utils';
import './LLMObservabilityModelPricing.styles.scss';
const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [
{ value: 'all', label: 'Source: All' },
{ value: 'auto', label: 'Auto-populated' },
{ value: 'override', label: 'User override' },
];
const CURRENCY_OPTIONS = [
{ value: 'USD', label: 'USD' },
{ value: 'EUR', label: 'EUR', disabled: true },
{ value: 'INR', label: 'INR', disabled: true },
];
const PAGE_SIZE = 20;
function LLMObservabilityModelPricing(): JSX.Element {
const [search, setSearch] = useState<string>('');
const [source, setSource] = useState<SourceFilter>('all');
const [currency, setCurrency] = useState<string>('USD');
const [page, setPage] = useState<number>(1);
const { data, isLoading, isError } = useListLLMPricingRules({
offset: (page - 1) * PAGE_SIZE,
limit: PAGE_SIZE,
});
const { user } = useAppContext();
const [canManagePricing] = useComponentPermission(
['manage_llm_pricing'],
user.role,
);
const rules: PricingRule[] = useMemo(() => data?.data?.items || [], [data]);
const total = data?.data?.total ?? 0;
const filteredRules = useMemo(
() => filterRules(rules, search, source),
[rules, search, source],
);
const drawer = useModelCostDrawer();
// Search/source filter the current page client-side (the list endpoint only
// supports offset/limit), so reset to the first page when they change.
const resetToFirstPage = (): void => setPage(1);
return (
<div
className="llm-observability-model-pricing"
data-testid="llm-observability-model-pricing-page"
>
<header className="page-header">
<div className="page-header__title">
<h1>Configuration</h1>
<p>Model pricing and cost estimation settings</p>
</div>
</header>
<Tabs
className="page-tabs"
defaultValue="model-costs"
items={[
{ key: 'model-costs', label: 'Model costs', children: null },
{
key: 'unpriced-models',
label: 'Unpriced models',
disabled: true,
children: null,
},
]}
/>
<div className="filters-bar">
<Input
className="filters-bar__search"
placeholder="Search by model or provider…"
prefix={<Search size={14} />}
value={search}
onChange={(event): void => {
setSearch(event.target.value);
resetToFirstPage();
}}
testId="search-input"
/>
<SelectSimple
className="filters-bar__source"
value={source}
onChange={(value): void => {
setSource(value as SourceFilter);
resetToFirstPage();
}}
items={SOURCE_OPTIONS}
testId="source-select"
/>
<SelectSimple
className="filters-bar__currency"
value={currency}
onChange={(value): void => setCurrency(value as string)}
items={CURRENCY_OPTIONS}
testId="currency-select"
/>
{canManagePricing && (
<Button
variant="solid"
color="primary"
className="filters-bar__add"
prefix={<Plus size={14} />}
onClick={(): void => drawer.openForAdd()}
testId="add-model-cost-btn"
>
Add model cost
</Button>
)}
</div>
{isError && (
<div className="page-error" role="alert">
Failed to load pricing rules. Please try again.
</div>
)}
<ModelCostsTable
rules={filteredRules}
isLoading={isLoading}
selectedRuleId={drawer.selectedRuleId}
canManage={canManagePricing}
onEdit={drawer.openForEdit}
/>
{total > PAGE_SIZE && (
<Pagination
className="page-pagination"
total={total}
pageSize={PAGE_SIZE}
current={page}
onPageChange={setPage}
/>
)}
<footer className="page-footer">
Showing {filteredRules.length} of {total} model{total === 1 ? '' : 's'}
{' · '}All prices per 1M tokens (USD)
</footer>
<ModelCostDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
draft={drawer.draft}
setDraft={drawer.setDraft}
onClose={drawer.close}
onSave={drawer.save}
onDelete={drawer.deleteRule}
isSaving={drawer.isSaving}
isDeleting={drawer.isDeleting}
saveError={drawer.saveError}
canManage={canManagePricing}
/>
</div>
);
}
export default LLMObservabilityModelPricing;

View File

@@ -1,313 +0,0 @@
.model-cost-drawer {
// Uniform horizontal padding across header / body / footer. The header and
// footer read these dialog vars; the body (rendered in drawer-description)
// is set directly below.
--dialog-header-padding: 20px 24px;
--dialog-footer-padding: 16px 24px;
// The drawer body — children render inside [data-slot='drawer-description']
// (this is the @signozhq drawer, not antd, so .ant-drawer-body was a no-op).
[data-slot='drawer-description'] {
display: flex;
flex-direction: column;
gap: 18px;
padding: 20px 24px;
}
[data-slot='select-content'] {
width: var(--radix-select-trigger-width);
}
display: flex;
overflow-y: auto;
&__title {
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: var(--bg-vanilla-400);
font-size: 12px;
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
// Horizontal padding is provided by the drawer-footer slot var above.
padding: 0;
width: 100%;
&-right {
display: flex;
gap: 8px;
margin-left: auto;
}
}
.drawer-section {
display: flex;
flex-direction: column;
gap: 6px;
label,
.field-label {
font-size: 12px;
font-weight: 600;
color: var(--bg-vanilla-200);
}
.help {
margin: 0;
font-size: 11px;
}
}
.muted {
color: var(--bg-vanilla-400);
}
.required {
color: var(--bg-cherry-400);
}
.full-width {
width: 100%;
}
.drawer-surface {
padding: 14px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--bg-slate-300);
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-300);
}
}
}
.managed-label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--bg-vanilla-400);
}
.pattern-box {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--bg-slate-300);
}
.pattern-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
}
.pattern-chip {
display: inline-flex;
align-items: center;
gap: 4px;
&__remove {
background: transparent;
border: none;
padding: 0;
margin-left: 2px;
cursor: pointer;
color: inherit;
display: inline-flex;
align-items: center;
&:hover {
color: var(--bg-cherry-400);
}
}
}
.pattern-add {
display: flex;
gap: 6px;
}
.help {
code {
padding: 1px 4px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
font-family: var(--code-font-family, monospace);
font-size: 10px;
}
}
.source-radio-group {
// @signozhq/ui's RadioGroupItem defaults its unchecked border to
// --l3-background, which matches the drawer surface and makes the dot
// invisible. Override with a contrasting border so users can see the
// unchecked state.
--radio-group-item-border-color: var(--bg-slate-200);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
// Layout overrides for @signozhq/ui's RadioGroupItem wrapper. The
// library injects single-class CSS at runtime (after our bundled
// stylesheet loads), so we use a two-class selector to win the
// cascade and force the wrapper to lay the dot on the left with the
// label text flush beside it.
.source-radio {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 4px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.02);
margin: 0;
width: 100%;
// Include padding + border in the 100% width so the card fits inside
// the SOURCE surface instead of overflowing its right edge.
box-sizing: border-box;
cursor: pointer;
transition:
background-color 0.12s ease,
border-color 0.12s ease;
// The radio button itself: keep it fixed-size and aligned with
// the title baseline (margin-top compensates for align-items:
// flex-start vs the title's line-box).
> button[role='radio'] {
flex: 0 0 16px;
width: 16px;
height: 16px;
margin-top: 3px;
}
// The library wraps children in a <label>. Make it grow into the
// remaining width and reset the .drawer-section label typography
// leak (set earlier in this file) so the title/desc divs use
// their own styles.
> label {
flex: 1 1 auto;
min-width: 0;
display: block;
text-align: left;
cursor: pointer;
font-size: inherit;
font-weight: inherit;
color: inherit;
}
&__title {
font-weight: 600;
font-size: 13px;
color: var(--bg-vanilla-100);
}
&__desc {
margin-top: 2px;
font-size: 12px;
color: var(--bg-vanilla-400);
}
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
// Use :has() to highlight the wrapper card when its inner button is checked.
&.source-radio--auto:has(button[data-state='checked']) {
background: rgba(78, 116, 248, 0.1);
border-color: rgba(78, 116, 248, 0.3);
}
&.source-radio--override:has(button[data-state='checked']) {
background: rgba(245, 175, 25, 0.1);
border-color: rgba(245, 175, 25, 0.3);
}
&:hover {
background: rgba(255, 255, 255, 0.04);
}
}
}
.reset-confirm {
margin-top: 12px;
padding: 12px;
border-radius: 4px;
background: rgba(78, 116, 248, 0.06);
border: 1px solid rgba(78, 116, 248, 0.2);
p {
margin: 0 0 10px;
font-size: 12px;
}
&__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
.pricing-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.pricing-field {
display: flex;
flex-direction: column;
gap: 4px;
input {
width: 100%;
}
}
.cache-mode-field {
margin-top: 10px;
}
.extras-divider {
margin-top: 14px;
margin-bottom: 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-400);
}
.drawer-error {
padding: 10px 12px;
border-radius: 4px;
background: rgba(255, 90, 90, 0.08);
color: var(--bg-cherry-400);
font-size: 12px;
}
}

View File

@@ -1,176 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Trash2 } from '@signozhq/icons';
import PatternEditor from './PatternEditor';
import PricingFields from './PricingFields';
import SourceSelector from './SourceSelector';
import { PROVIDER_OPTIONS } from './constants';
import { validateDraft } from './utils';
import type { DrawerDraft, DrawerMode } from './types';
import './ModelCostDrawer.styles.scss';
interface ModelCostDrawerProps {
isOpen: boolean;
mode: DrawerMode;
draft: DrawerDraft;
setDraft: (next: DrawerDraft) => void;
onClose: () => void;
onSave: () => void;
onDelete: () => void;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
canManage: boolean;
}
function ModelCostDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
saveError,
canManage,
}: ModelCostDrawerProps): JSX.Element {
// Metadata (model id / provider / patterns / source) is editable by any
// manager. Pricing fields are editable only once the user picks "User
// override" — auto-populated pricing is managed by SigNoz. Write APIs are
// Admin-only, so non-managers can't edit anything.
const metadataReadOnly = !canManage;
const pricingReadOnly = !canManage || !draft.isOverride;
const validation = validateDraft(draft, mode);
const showValidationTooltip =
canManage && !validation.ok && !!validation.message;
const update = (patch: Partial<DrawerDraft>): void => {
setDraft({ ...draft, ...patch });
};
const footer = (
<div className="model-cost-drawer__footer">
{mode === 'edit' && canManage && (
<Button
variant="ghost"
color="destructive"
prefix={<Trash2 size={14} />}
onClick={onDelete}
loading={isDeleting}
testId="drawer-delete-btn"
>
Delete
</Button>
)}
<div className="model-cost-drawer__footer-right">
<Button
variant="outlined"
color="secondary"
onClick={onClose}
testId="drawer-cancel-btn"
>
{canManage ? 'Cancel' : 'Close'}
</Button>
{canManage && (
<TooltipSimple
title={showValidationTooltip ? validation.message : ''}
withPortal={false}
>
{/* span wrapper so the tooltip fires even when the button is disabled */}
<span className="model-cost-drawer__save-wrap">
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={!validation.ok}
testId="drawer-save-btn"
>
Save
</Button>
</span>
</TooltipSimple>
)}
</div>
</div>
);
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
width="base"
className="model-cost-drawer"
footer={footer}
title={mode === 'edit' ? 'Edit model cost' : 'Add model cost'}
subTitle="Pricing computes gen_ai.estimated_total_cost at ingest."
drawerHeaderProps={{ className: 'model-cost-drawer__title' }}
>
<div className="drawer-section">
<label htmlFor="billing-model-id">Billing model ID</label>
<Input
id="billing-model-id"
placeholder="e.g. openai:gpt-4o"
value={draft.modelName}
disabled={mode === 'edit' || metadataReadOnly}
onChange={(e): void => update({ modelName: e.target.value })}
testId="drawer-model-id-input"
/>
</div>
<div className="drawer-section">
<label htmlFor="provider-select">Provider</label>
<SelectSimple
id="provider-select"
value={draft.provider}
onChange={(value): void => update({ provider: value as string })}
items={PROVIDER_OPTIONS}
disabled={mode === 'edit' || metadataReadOnly}
className="full-width"
withPortal={false}
testId="drawer-provider-select"
/>
</div>
<PatternEditor
patterns={draft.patterns}
isReadOnly={metadataReadOnly}
onChange={(patterns): void => update({ patterns })}
/>
<SourceSelector
isOverride={draft.isOverride}
isReadOnly={metadataReadOnly}
onChange={(isOverride): void => update({ isOverride })}
/>
<PricingFields
pricing={draft.pricing}
isReadOnly={pricingReadOnly}
onChange={(patch): void =>
setDraft({ ...draft, pricing: { ...draft.pricing, ...patch } })
}
/>
{saveError && (
<div className="drawer-error" role="alert">
{saveError}
</div>
)}
</DrawerWrapper>
);
}
export default ModelCostDrawer;

View File

@@ -1,179 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@signozhq/ui/table';
import { ChevronDown } from '@signozhq/icons';
import cx from 'classnames';
import { startCase } from 'lodash-es';
import type { PricingRule } from './types';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
} from './utils';
const COLUMN_COUNT = 8;
interface ModelCostsTableProps {
rules: PricingRule[];
isLoading: boolean;
selectedRuleId: string | null;
canManage: boolean;
onEdit: (rule: PricingRule) => void;
}
interface RowProps {
rule: PricingRule;
isSelected: boolean;
canManage: boolean;
onEdit: (rule: PricingRule) => void;
}
function ModelCostRow({
rule,
isSelected,
canManage,
onEdit,
}: RowProps): JSX.Element {
const buckets = getExtraBuckets(rule);
return (
<TableRow
className={cx({ 'model-costs-table__row--selected': isSelected })}
data-testid={`model-cost-row-${rule.id}`}
>
<TableCell>
<div className="model-cell">
<div
className="model-cell__name"
data-testid={`model-cell-name-${rule.id}`}
>
{rule.modelName}
</div>
<div
className="model-cell__canonical-id"
data-testid={`model-cell-canonical-id-${rule.id}`}
>
{getCanonicalId(rule)}
</div>
</div>
</TableCell>
<TableCell>{rule.provider}</TableCell>
<TableCell>
<span className="price-cell" data-testid={`price-cell-input-${rule.id}`}>
{formatPricePerMillion(rule.pricing?.input)}
</span>
</TableCell>
<TableCell>
<span className="price-cell" data-testid={`price-cell-output-${rule.id}`}>
{formatPricePerMillion(rule.pricing?.output)}
</span>
</TableCell>
<TableCell>
{buckets.length === 0 ? (
<span className="muted"></span>
) : (
<div className="extra-buckets">
{buckets.map((bucket) => (
<Badge
key={bucket.key}
color="vanilla"
variant="outline"
className="extra-buckets__chip"
>
<span className="extra-buckets__key">{startCase(bucket.key)}</span>
<span className="extra-buckets__price">
{formatPricePerMillion(bucket.pricePerMillion)}
</span>
</Badge>
))}
</div>
)}
</TableCell>
<TableCell>
<Badge
color={rule.isOverride ? 'amber' : 'robin'}
variant="outline"
className="source-badge"
data-testid={`source-badge-${rule.id}`}
>
{getSourceLabel(rule)}
</Badge>
</TableCell>
<TableCell>{getRelativeLastSeen(rule)}</TableCell>
<TableCell>
<Button
variant="ghost"
color="secondary"
size="sm"
suffix={<ChevronDown size={14} />}
testId={`edit-rule-${rule.id}`}
onClick={(): void => onEdit(rule)}
>
{canManage ? 'Edit' : 'View'}
</Button>
</TableCell>
</TableRow>
);
}
function ModelCostsTable({
rules,
isLoading,
selectedRuleId,
canManage,
onEdit,
}: ModelCostsTableProps): JSX.Element {
return (
<Table className="model-costs-table" testId="model-costs-table">
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Input / 1M</TableHead>
<TableHead>Output / 1M</TableHead>
<TableHead>Extra buckets</TableHead>
<TableHead>Source</TableHead>
<TableHead>Last seen</TableHead>
<TableHead aria-label="Actions" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading && rules.length === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="model-costs-table__empty">
Loading pricing rules
</TableCell>
</TableRow>
)}
{!isLoading && rules.length === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="model-costs-table__empty">
No model costs yet.
</TableCell>
</TableRow>
)}
{rules.map((rule) => (
<ModelCostRow
key={rule.id}
rule={rule}
isSelected={rule.id === selectedRuleId}
canManage={canManage}
onEdit={onEdit}
/>
))}
</TableBody>
</Table>
);
}
export default ModelCostsTable;

View File

@@ -1,96 +0,0 @@
import { useState } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { X } from '@signozhq/icons';
interface PatternEditorProps {
patterns: string[];
isReadOnly: boolean;
onChange: (patterns: string[]) => void;
}
// Model-name prefix patterns as removable chips + an add input.
function PatternEditor({
patterns,
isReadOnly,
onChange,
}: PatternEditorProps): JSX.Element {
const [patternInput, setPatternInput] = useState<string>('');
const addPattern = (): void => {
const next = patternInput.trim();
if (!next || patterns.includes(next)) {
setPatternInput('');
return;
}
onChange([...patterns, next]);
setPatternInput('');
};
const removePattern = (pattern: string): void => {
onChange(patterns.filter((p) => p !== pattern));
};
return (
<div className="drawer-section">
<span className="field-label">
Model name patterns <span className="muted">(prefix match)</span>
</span>
<div className="pattern-box">
<div className="pattern-chips">
{patterns.map((pattern) => (
<Badge
key={pattern}
color="vanilla"
variant="outline"
className="pattern-chip"
>
{pattern}*
{!isReadOnly && (
<button
type="button"
aria-label={`Remove pattern ${pattern}`}
className="pattern-chip__remove"
onClick={(): void => removePattern(pattern)}
>
<X size={10} />
</button>
)}
</Badge>
))}
</div>
{!isReadOnly && (
<div className="pattern-add">
<Input
placeholder="Add pattern…"
value={patternInput}
onChange={(e): void => setPatternInput(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
e.preventDefault();
addPattern();
}
}}
testId="drawer-pattern-input"
/>
<Button
variant="outlined"
color="secondary"
onClick={addPattern}
testId="drawer-pattern-add-btn"
>
+ Add
</Button>
</div>
)}
</div>
<p className="muted help">
Each pattern uses <strong>prefix matching</strong> against{' '}
<code>gen_ai.request.model</code>.
</p>
</div>
);
}
export default PatternEditor;

View File

@@ -1,137 +0,0 @@
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Lock } from '@signozhq/icons';
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import { CACHE_MODE_OPTIONS } from './constants';
import type { DrawerDraft } from './types';
type Pricing = DrawerDraft['pricing'];
interface PricingFieldsProps {
pricing: Pricing;
isReadOnly: boolean;
onChange: (patch: Partial<Pricing>) => void;
}
// Parses a number input's raw string. Empty → null (used by optional buckets),
// otherwise a finite number (NaN coerced to 0).
function parseAmount(raw: string): number | null {
if (raw.trim() === '') {
return null;
}
const value = Number(raw);
return Number.isFinite(value) ? value : 0;
}
function PricingFields({
pricing,
isReadOnly,
onChange,
}: PricingFieldsProps): JSX.Element {
const hasCacheBucket =
pricing.cacheRead !== null || pricing.cacheWrite !== null;
return (
<div className="drawer-section drawer-surface">
<div className="drawer-surface__head">
<h4>Pricing (per 1M tokens, USD)</h4>
{isReadOnly && (
<span className="managed-label" data-testid="drawer-readonly-label">
<Lock size={12} />
Read-only
</span>
)}
</div>
<div className="pricing-grid">
<div className="pricing-field">
<label htmlFor="input-cost">
Input cost <span className="required">*</span>
</label>
<Input
id="input-cost"
type="number"
min={0}
step={0.01}
value={pricing.input}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ input: parseAmount(e.target.value) ?? 0 })
}
testId="drawer-input-cost"
/>
</div>
<div className="pricing-field">
<label htmlFor="output-cost">
Output cost <span className="required">*</span>
</label>
<Input
id="output-cost"
type="number"
min={0}
step={0.01}
value={pricing.output}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ output: parseAmount(e.target.value) ?? 0 })
}
testId="drawer-output-cost"
/>
</div>
</div>
<div className="extras-divider">Extra pricing buckets</div>
<div className="pricing-grid">
<div className="pricing-field">
<label htmlFor="cache-read">cache_read</label>
<Input
id="cache-read"
type="number"
min={0}
step={0.01}
value={pricing.cacheRead ?? ''}
placeholder="—"
disabled={isReadOnly}
onChange={(e): void =>
onChange({ cacheRead: parseAmount(e.target.value) })
}
testId="drawer-cache-read-cost"
/>
</div>
<div className="pricing-field">
<label htmlFor="cache-write">cache_write</label>
<Input
id="cache-write"
type="number"
min={0}
step={0.01}
value={pricing.cacheWrite ?? ''}
placeholder="—"
disabled={isReadOnly}
onChange={(e): void =>
onChange({ cacheWrite: parseAmount(e.target.value) })
}
testId="drawer-cache-write-cost"
/>
</div>
</div>
{hasCacheBucket && (
<div className="pricing-field cache-mode-field">
<label htmlFor="cache-mode">Cache mode</label>
<SelectSimple
id="cache-mode"
value={pricing.cacheMode}
items={CACHE_MODE_OPTIONS}
onChange={(v): void => onChange({ cacheMode: v as CacheModeDTO })}
disabled={isReadOnly}
className="full-width"
withPortal={false}
testId="drawer-cache-mode"
/>
</div>
)}
</div>
);
}
export default PricingFields;

View File

@@ -1,103 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Lock } from '@signozhq/icons';
interface SourceSelectorProps {
isOverride: boolean;
isReadOnly: boolean;
onChange: (isOverride: boolean) => void;
}
// Auto-populated vs user-override selector, with a confirm step before
// discarding custom values back to defaults.
function SourceSelector({
isOverride,
isReadOnly,
onChange,
}: SourceSelectorProps): JSX.Element {
const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
const handleSourceChange = (value: 'auto' | 'override'): void => {
if (value === 'auto' && isOverride) {
setShowResetConfirm(true);
return;
}
if (value === 'override' && !isOverride) {
onChange(true);
}
};
const confirmReset = (): void => {
onChange(false);
setShowResetConfirm(false);
};
return (
<div className="drawer-section drawer-surface">
<div className="drawer-surface__head">
<h4>Source</h4>
{isReadOnly && (
<span className="managed-label" data-testid="drawer-managed-label">
<Lock size={12} />
Managed by SigNoz
</span>
)}
</div>
<RadioGroup
value={isOverride ? 'override' : 'auto'}
onChange={(value): void => handleSourceChange(value as 'auto' | 'override')}
className="source-radio-group"
>
<RadioGroupItem
value="auto"
containerClassName="source-radio source-radio--auto"
testId="drawer-source-auto"
>
<div className="source-radio__title">Auto-populated</div>
<div className="source-radio__desc">Default pricing from SigNoz.</div>
</RadioGroupItem>
<RadioGroupItem
value="override"
containerClassName="source-radio source-radio--override"
testId="drawer-source-override"
>
<div className="source-radio__title">User override</div>
<div className="source-radio__desc">Custom pricing. Takes precedence.</div>
</RadioGroupItem>
</RadioGroup>
{showResetConfirm && (
<div
className="reset-confirm"
role="dialog"
aria-label="Reset to default pricing"
>
<p>
Reset to default pricing? Custom values will be discarded. it might take
24 hours for changes to take effect.
</p>
<div className="reset-confirm__actions">
<Button
variant="outlined"
color="secondary"
onClick={(): void => setShowResetConfirm(false)}
testId="drawer-reset-keep-btn"
>
Keep
</Button>
<Button
variant="solid"
color="primary"
onClick={confirmReset}
testId="drawer-reset-confirm-btn"
>
Reset
</Button>
</div>
</div>
)}
</div>
);
}
export default SourceSelector;

View File

@@ -1,149 +0,0 @@
import {
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type LlmpricingruletypesLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen } from 'tests/test-utils';
import LLMObservabilityModelPricing from '../LLMObservabilityModelPricing';
const ENDPOINT = '*/api/v1/llm_pricing_rules';
const mockRules: LlmpricingruletypesLLMPricingRuleDTO[] = [
{
id: 'rule-gpt4o',
orgId: 'org-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: false,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 15, output: 60 },
},
{
id: 'rule-llama',
orgId: 'org-1',
modelName: 'llama-3.1-70b',
provider: 'Self-hosted',
modelPattern: ['llama-3.1'],
isOverride: true,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 0, output: 0 },
},
];
describe('LLMObservabilityModelPricing', () => {
beforeEach(() => {
server.use(
rest.get(ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
items: mockRules,
limit: 100,
offset: 0,
total: mockRules.length,
},
}),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders the page header and both rules', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
expect(screen.getByText('Configuration')).toBeInTheDocument();
expect(screen.getByText('llama-3.1-70b')).toBeInTheDocument();
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
});
it('filters rules by the search input', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'llama' },
});
expect(screen.queryByText('gpt-4o')).not.toBeInTheDocument();
expect(screen.getByText('llama-3.1-70b')).toBeInTheDocument();
});
it('opens the drawer in Add mode when the Add button is clicked', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
fireEvent.click(screen.getByTestId('add-model-cost-btn'));
const input = (await screen.findByTestId(
'drawer-model-id-input',
)) as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.value).toBe('');
});
it('opens the drawer in Edit mode with prefilled values when a row Edit is clicked', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
fireEvent.click(screen.getByTestId('edit-rule-rule-gpt4o'));
const input = (await screen.findByTestId(
'drawer-model-id-input',
)) as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.value).toBe('gpt-4o');
});
it('hides the Add button for non-admin users (write APIs are Admin-only)', async () => {
render(<LLMObservabilityModelPricing />, {}, { role: 'VIEWER' });
await screen.findByText('gpt-4o');
expect(screen.queryByTestId('add-model-cost-btn')).not.toBeInTheDocument();
// rows still open in read-only "View" mode
expect(screen.getByTestId('edit-rule-rule-gpt4o')).toHaveTextContent('View');
});
it('paginates server-side: selecting page 2 requests the next offset', async () => {
const requestedOffsets: number[] = [];
server.use(
rest.get(ENDPOINT, (req, res, ctx) => {
const offset = Number(req.url.searchParams.get('offset') ?? '0');
requestedOffsets.push(offset);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
items: [
{ ...mockRules[0], id: `rule-${offset}`, modelName: `model-${offset}` },
],
limit: 20,
offset,
total: 25,
},
}),
);
}),
);
render(<LLMObservabilityModelPricing />);
await screen.findByText('model-0');
fireEvent.click(screen.getByRole('button', { name: '2' }));
await screen.findByText('model-20');
expect(requestedOffsets).toContain(20);
});
});

View File

@@ -1,220 +0,0 @@
import { useState } from 'react';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from 'tests/test-utils';
import { EMPTY_DRAFT } from '../constants';
import type { DrawerDraft } from '../types';
import ModelCostDrawer from '../ModelCostDrawer';
interface HarnessProps {
initialDraft?: DrawerDraft;
mode?: 'add' | 'edit';
canManage?: boolean;
onSave?: () => void;
onDelete?: () => void;
}
function Harness({
initialDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 1 },
},
mode = 'add',
canManage = true,
onSave = jest.fn(),
onDelete = jest.fn(),
}: HarnessProps): JSX.Element {
const [draft, setDraft] = useState<DrawerDraft>(initialDraft);
return (
<ModelCostDrawer
isOpen
mode={mode}
draft={draft}
setDraft={setDraft}
onClose={jest.fn()}
onSave={onSave}
onDelete={onDelete}
isSaving={false}
isDeleting={false}
saveError={null}
canManage={canManage}
/>
);
}
describe('ModelCostDrawer', () => {
it('adds a pattern chip when the user types and presses Enter', () => {
render(<Harness />);
fireEvent.change(screen.getByTestId('drawer-pattern-input'), {
target: { value: 'gpt-4o-mini' },
});
fireEvent.keyDown(screen.getByTestId('drawer-pattern-input'), {
key: 'Enter',
code: 'Enter',
});
expect(screen.getByText('gpt-4o-mini*')).toBeInTheDocument();
});
it('disables pricing fields when isOverride is false', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: false,
}}
/>,
);
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled();
expect(screen.getByTestId('drawer-output-cost')).toBeDisabled();
});
it('enables pricing fields when isOverride is true', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: true,
}}
/>,
);
expect(screen.getByTestId('drawer-input-cost')).not.toBeDisabled();
expect(screen.getByTestId('drawer-output-cost')).not.toBeDisabled();
});
it('disables the Provider select in Edit mode but allows it in Add mode', () => {
const { unmount } = render(<Harness mode="add" />);
expect(screen.getByTestId('drawer-provider-select')).not.toHaveAttribute(
'data-disabled',
);
unmount();
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: true,
}}
/>,
);
expect(screen.getByTestId('drawer-provider-select')).toHaveAttribute(
'data-disabled',
);
});
it('keeps metadata editable but locks pricing when source is auto-populated', () => {
render(
<Harness
mode="add"
initialDraft={{ ...EMPTY_DRAFT, modelName: 'gpt-4o', isOverride: false }}
/>,
);
// Metadata stays editable while the rule is auto-populated…
expect(screen.getByTestId('drawer-model-id-input')).not.toBeDisabled();
// …but pricing is read-only until "User override" is chosen.
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled();
});
it('shows a reset confirmation when switching from Override to Auto', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Harness
initialDraft={{
...EMPTY_DRAFT,
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
await user.click(screen.getByTestId('drawer-source-auto'));
expect(screen.getByTestId('drawer-reset-confirm-btn')).toBeInTheDocument();
expect(screen.getByTestId('drawer-reset-keep-btn')).toBeInTheDocument();
});
it('hides the Delete action in Add mode', () => {
render(<Harness mode="add" />);
expect(screen.queryByTestId('drawer-delete-btn')).not.toBeInTheDocument();
});
it('shows the Delete action in Edit mode', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
expect(screen.getByTestId('drawer-delete-btn')).toBeInTheDocument();
});
it('disables the provider select in Edit mode', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: true,
}}
/>,
);
const providerNode = screen.getByTestId('drawer-provider-select');
expect(providerNode.className).toMatch(/ant-select-disabled/);
});
it('calls onSave when the Save button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSave = jest.fn();
render(<Harness onSave={onSave} />);
await user.click(screen.getByTestId('drawer-save-btn'));
expect(onSave).toHaveBeenCalledTimes(1);
});
it('is read-only when the user cannot manage pricing (hides Save/Delete)', () => {
render(
<Harness
mode="edit"
canManage={false}
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
expect(screen.queryByTestId('drawer-save-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('drawer-delete-btn')).not.toBeInTheDocument();
expect(screen.getByTestId('drawer-model-id-input')).toBeDisabled();
});
});

View File

@@ -1,149 +0,0 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EMPTY_DRAFT } from '../constants';
import type { DrawerDraft, PricingRule } from '../types';
import {
buildPricingPayload,
buildRulePayload,
draftFromRule,
validateDraft,
} from '../utils';
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
id: 'rule-1',
orgId: 'org-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: false,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 15, output: 60 },
...overrides,
});
describe('drawer draft utils', () => {
describe('draftFromRule', () => {
it('maps a rule to a draft with cache values when present', () => {
const rule = makeRule({
pricing: {
input: 3,
output: 15,
cache: {
mode: CacheModeDTO.additive,
read: 0.3,
write: 3.75,
},
},
});
const draft = draftFromRule(rule);
expect(draft.modelName).toBe('gpt-4o');
expect(draft.pricing.input).toBe(3);
expect(draft.pricing.cacheRead).toBe(0.3);
expect(draft.pricing.cacheWrite).toBe(3.75);
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.additive);
});
it('falls back to defaults when cache is missing', () => {
const draft = draftFromRule(makeRule());
expect(draft.pricing.cacheRead).toBeNull();
expect(draft.pricing.cacheWrite).toBeNull();
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.unknown);
});
});
describe('buildPricingPayload', () => {
it('omits the cache block when no cache values are set', () => {
const payload = buildPricingPayload(EMPTY_DRAFT);
expect(payload.cache).toBeUndefined();
});
it('includes only the cache values that are > 0', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
pricing: {
...EMPTY_DRAFT.pricing,
cacheRead: 1.5,
cacheWrite: 0,
cacheMode: CacheModeDTO.subtract,
},
};
const payload = buildPricingPayload(draft);
expect(payload.cache?.read).toBe(1.5);
expect(payload.cache?.write).toBeUndefined();
expect(payload.cache?.mode).toBe(CacheModeDTO.subtract);
});
});
describe('buildRulePayload', () => {
it('uses the modelName as a default pattern when no patterns are supplied', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
patterns: [],
provider: 'OpenAI',
};
const payload = buildRulePayload(draft);
expect(payload.modelPattern).toStrictEqual(['gpt-4o']);
expect(payload.unit).toBe(UnitDTO.per_million_tokens);
expect(payload.enabled).toBe(true);
});
it('omits id and sourceId for an Add draft', () => {
const payload = buildRulePayload(EMPTY_DRAFT);
expect(payload.id).toBeUndefined();
expect(payload.sourceId).toBeUndefined();
});
});
describe('validateDraft', () => {
it('requires a model name in Add mode', () => {
const result = validateDraft(EMPTY_DRAFT, 'add');
expect(result.ok).toBe(false);
expect(result.message).toMatch(/billing model id/i);
});
it('rejects negative pricing', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
pricing: { ...EMPTY_DRAFT.pricing, input: -1 },
};
expect(validateDraft(draft, 'add').ok).toBe(false);
});
it('accepts a valid Add draft', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 2 },
};
expect(validateDraft(draft, 'add').ok).toBe(true);
});
it('rejects zero input/output cost for overrides', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
isOverride: true,
pricing: { ...EMPTY_DRAFT.pricing, input: 0, output: 5 },
};
const result = validateDraft(draft, 'add');
expect(result.ok).toBe(false);
expect(result.message).toMatch(/input cost must be greater than 0/i);
});
it('skips pricing validation for auto-populated (non-override) rules', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
isOverride: false,
pricing: { ...EMPTY_DRAFT.pricing, input: 0, output: 0 },
};
expect(validateDraft(draft, 'edit').ok).toBe(true);
});
});
});

View File

@@ -1,119 +0,0 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PricingRule } from '../types';
import {
filterRules,
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
} from '../utils';
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
id: 'rule-1',
orgId: 'org-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: false,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 15, output: 60 },
...overrides,
});
describe('utils', () => {
describe('formatPricePerMillion', () => {
it('formats numbers with 2 decimals and dollar prefix', () => {
expect(formatPricePerMillion(15)).toBe('$15.00');
expect(formatPricePerMillion(0.15)).toBe('$0.15');
});
it('returns em-dash for nullish or NaN', () => {
expect(formatPricePerMillion(undefined)).toBe('—');
expect(formatPricePerMillion(Number.NaN)).toBe('—');
});
});
describe('getExtraBuckets', () => {
it('returns an empty array when there is no cache pricing', () => {
expect(getExtraBuckets(makeRule())).toStrictEqual([]);
});
it('returns only buckets with values > 0', () => {
const rule = makeRule({
pricing: {
input: 3,
output: 15,
cache: {
mode: CacheModeDTO.additive,
read: 0.3,
write: 0,
},
},
});
const buckets = getExtraBuckets(rule);
expect(buckets).toStrictEqual([{ key: 'cache_read', pricePerMillion: 0.3 }]);
});
});
describe('getSourceLabel', () => {
it('returns "Auto" for non-override and "User override" otherwise', () => {
expect(getSourceLabel(makeRule({ isOverride: false }))).toBe('Auto');
expect(getSourceLabel(makeRule({ isOverride: true }))).toBe('User override');
});
});
describe('getCanonicalId', () => {
it('lowercases the provider and joins with the model name', () => {
expect(getCanonicalId(makeRule({ provider: 'OpenAI' }))).toBe(
'openai:gpt-4o',
);
});
});
describe('getRelativeLastSeen', () => {
it('returns em-dash when no timestamp is present', () => {
expect(getRelativeLastSeen(makeRule())).toBe('—');
});
it('formats minutes-old timestamps via dayjs fromNow', () => {
const recent = new Date(Date.now() - 5 * 60 * 1000).toISOString();
expect(getRelativeLastSeen(makeRule({ updatedAt: recent }))).toMatch(
/minutes? ago/,
);
});
});
describe('filterRules', () => {
const auto = makeRule({ id: 'r1', modelName: 'gpt-4o', isOverride: false });
const override = makeRule({
id: 'r2',
modelName: 'llama-3',
provider: 'Self-hosted',
modelPattern: ['llama-3'],
isOverride: true,
});
it('returns everything when no filters are applied', () => {
expect(filterRules([auto, override], '', 'all')).toHaveLength(2);
});
it('narrows by source = override', () => {
expect(filterRules([auto, override], '', 'override')).toStrictEqual([
override,
]);
});
it('narrows by free-text search across model and provider', () => {
expect(filterRules([auto, override], 'self', 'all')).toStrictEqual([
override,
]);
expect(filterRules([auto, override], 'gpt-4', 'all')).toStrictEqual([auto]);
});
});
});

View File

@@ -1,34 +0,0 @@
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import type { DrawerDraft } from './types';
export const PROVIDER_OPTIONS = [
{ value: 'OpenAI', label: 'OpenAI' },
{ value: 'Anthropic', label: 'Anthropic' },
{ value: 'Azure OpenAI', label: 'Azure OpenAI' },
{ value: 'Google', label: 'Google' },
{ value: 'Self-hosted', label: 'Self-hosted' },
{ value: 'Other', label: 'Other' },
];
export const CACHE_MODE_OPTIONS = [
{ value: CacheModeDTO.subtract, label: 'Subtract (OpenAI style)' },
{ value: CacheModeDTO.additive, label: 'Additive (Anthropic style)' },
{ value: CacheModeDTO.unknown, label: 'Unknown' },
];
export const EMPTY_DRAFT: DrawerDraft = {
id: null,
sourceId: null,
modelName: '',
provider: 'OpenAI',
patterns: [],
isOverride: true,
pricing: {
input: 0,
output: 0,
cacheMode: CacheModeDTO.unknown,
cacheRead: null,
cacheWrite: null,
},
};

View File

@@ -1,36 +0,0 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
type LlmpricingruletypesLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
export type SourceFilter = 'all' | 'auto' | 'override';
export interface ExtraBucket {
key: string;
pricePerMillion: number;
}
export type DrawerMode = 'add' | 'edit';
export interface DrawerDraft {
id: string | null;
sourceId: string | null;
modelName: string;
provider: string;
patterns: string[];
isOverride: boolean;
pricing: {
input: number;
output: number;
cacheMode: CacheModeDTO;
cacheRead: number | null;
cacheWrite: number | null;
};
}
export interface ValidationResult {
ok: boolean;
message?: string;
}

View File

@@ -1,123 +0,0 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
useCreateOrUpdateLLMPricingRules,
useDeleteLLMPricingRule,
} from 'api/generated/services/llmpricingrules';
import { EMPTY_DRAFT } from './constants';
import type { DrawerDraft, DrawerMode, PricingRule } from './types';
import { buildRulePayload, draftFromRule } from './utils';
interface UseModelCostDrawerResult {
isOpen: boolean;
mode: DrawerMode;
draft: DrawerDraft;
setDraft: (next: DrawerDraft) => void;
openForAdd: (prefillModelName?: string) => void;
openForEdit: (rule: PricingRule) => void;
close: () => void;
save: () => Promise<void>;
deleteRule: () => Promise<void>;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
selectedRuleId: string | null;
}
export function useModelCostDrawer(): UseModelCostDrawerResult {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<DrawerMode>('add');
const [draft, setDraft] = useState<DrawerDraft>(EMPTY_DRAFT);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
useCreateOrUpdateLLMPricingRules();
const { mutateAsync: deleteRuleApi, isLoading: isDeleting } =
useDeleteLLMPricingRule();
const invalidateList = useCallback(async (): Promise<void> => {
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
}, [queryClient]);
const openForAdd = useCallback((prefillModelName?: string): void => {
setMode('add');
setDraft({
...EMPTY_DRAFT,
modelName: prefillModelName || '',
patterns: prefillModelName ? [prefillModelName] : [],
});
setSelectedRuleId(null);
setSaveError(null);
setIsOpen(true);
}, []);
const openForEdit = useCallback((rule: PricingRule): void => {
setMode('edit');
setDraft(draftFromRule(rule));
setSelectedRuleId(rule.id);
setSaveError(null);
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
setSelectedRuleId(null);
setSaveError(null);
}, []);
const save = useCallback(async (): Promise<void> => {
setSaveError(null);
try {
await createOrUpdate({
data: { rules: [buildRulePayload(draft)] },
});
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);
toast.success(mode === 'edit' ? 'Model cost updated' : 'Model cost added');
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);
}
}, [createOrUpdate, draft, invalidateList, mode]);
const deleteRule = useCallback(async (): Promise<void> => {
if (!draft.id) {
return;
}
setSaveError(null);
try {
await deleteRuleApi({ pathParams: { id: draft.id } });
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);
toast.success('Model cost deleted');
} catch (error) {
const message = error instanceof Error ? error.message : 'Delete failed';
setSaveError(message);
}
}, [deleteRuleApi, draft.id, invalidateList]);
return {
isOpen,
mode,
draft,
setDraft,
openForAdd,
openForEdit,
close,
save,
deleteRule,
isSaving,
isDeleting,
saveError,
selectedRuleId,
};
}

View File

@@ -1,175 +0,0 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type LlmpricingruletypesLLMPricingCacheCostsDTO,
type LlmpricingruletypesLLMRulePricingDTO,
type LlmpricingruletypesUpdatableLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type {
DrawerDraft,
DrawerMode,
ExtraBucket,
PricingRule,
SourceFilter,
ValidationResult,
} from './types';
// Idempotent — relativeTime is also extended globally in utils/timeUtils.
dayjs.extend(relativeTime);
const lc = (value: string): string => value.toLowerCase();
const hasCacheValue = (value: number | null): boolean =>
typeof value === 'number' && value > 0;
// ─── Display helpers ─────────────────────────────────────────────────────────
export const formatPricePerMillion = (value: number | undefined): string => {
if (value === undefined || value === null || Number.isNaN(value)) {
return '—';
}
return `$${value.toFixed(2)}`;
};
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
const cache = rule.pricing?.cache;
if (!cache) {
return [];
}
const buckets: ExtraBucket[] = [];
if (typeof cache.read === 'number' && cache.read > 0) {
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
}
if (typeof cache.write === 'number' && cache.write > 0) {
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
}
return buckets;
};
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
rule.isOverride ? 'User override' : 'Auto';
export const getRelativeLastSeen = (rule: PricingRule): string => {
const ts = rule.updatedAt || rule.syncedAt || rule.createdAt;
const parsed = ts ? dayjs(ts) : null;
return parsed?.isValid() ? parsed.fromNow() : '—';
};
export const filterRules = (
rules: PricingRule[],
search: string,
source: SourceFilter,
): PricingRule[] => {
const normalized = lc(search.trim());
return rules.filter((rule) => {
if (source === 'auto' && rule.isOverride) {
return false;
}
if (source === 'override' && !rule.isOverride) {
return false;
}
if (!normalized) {
return true;
}
return (
lc(rule.modelName).includes(normalized) ||
lc(rule.provider).includes(normalized) ||
(rule.modelPattern || []).some((pattern) => lc(pattern).includes(normalized))
);
});
};
export const getCanonicalId = (rule: PricingRule): string => {
const provider = rule.provider?.trim() || 'unknown';
return `${lc(provider)}:${rule.modelName}`;
};
// ─── Drawer draft <-> API helpers ────────────────────────────────────────────
export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
id: rule.id,
sourceId: rule.sourceId ?? null,
modelName: rule.modelName,
provider: rule.provider || 'OpenAI',
patterns: rule.modelPattern || [],
isOverride: !!rule.isOverride,
pricing: {
input: rule.pricing?.input ?? 0,
output: rule.pricing?.output ?? 0,
cacheMode: rule.pricing?.cache?.mode ?? CacheModeDTO.unknown,
cacheRead: rule.pricing?.cache?.read ?? null,
cacheWrite: rule.pricing?.cache?.write ?? null,
},
});
export const buildPricingPayload = (
draft: DrawerDraft,
): LlmpricingruletypesLLMRulePricingDTO => {
const pricing: LlmpricingruletypesLLMRulePricingDTO = {
input: draft.pricing.input,
output: draft.pricing.output,
};
if (
hasCacheValue(draft.pricing.cacheRead) ||
hasCacheValue(draft.pricing.cacheWrite)
) {
const cache: LlmpricingruletypesLLMPricingCacheCostsDTO = {
mode: draft.pricing.cacheMode,
};
if (hasCacheValue(draft.pricing.cacheRead)) {
cache.read = draft.pricing.cacheRead as number;
}
if (hasCacheValue(draft.pricing.cacheWrite)) {
cache.write = draft.pricing.cacheWrite as number;
}
pricing.cache = cache;
}
return pricing;
};
export const buildRulePayload = (
draft: DrawerDraft,
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => ({
id: draft.id || undefined,
sourceId: draft.sourceId || undefined,
modelName: draft.modelName.trim(),
provider: draft.provider.trim(),
modelPattern:
draft.patterns.length > 0 ? draft.patterns : [draft.modelName.trim()],
isOverride: draft.isOverride,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: buildPricingPayload(draft),
});
export const validateDraft = (
draft: DrawerDraft,
mode: DrawerMode,
): ValidationResult => {
if (mode === 'add' && !draft.modelName.trim()) {
return { ok: false, message: 'Billing model ID is required.' };
}
if (!draft.provider.trim()) {
return { ok: false, message: 'Provider is required.' };
}
// Pricing is only user-entered for overrides; auto-populated rules are
// managed by SigNoz (and may legitimately be 0 for self-hosted models).
if (draft.isOverride) {
if (!(draft.pricing.input > 0)) {
return { ok: false, message: 'Input cost must be greater than 0.' };
}
if (!(draft.pricing.output > 0)) {
return { ok: false, message: 'Output cost must be greater than 0.' };
}
if (
(draft.pricing.cacheRead !== null && draft.pricing.cacheRead < 0) ||
(draft.pricing.cacheWrite !== null && draft.pricing.cacheWrite < 0)
) {
return { ok: false, message: 'Cache costs must be non-negative.' };
}
}
return { ok: true };
};

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

@@ -206,8 +206,6 @@ export const routesToSkip = [
ROUTES.METER,
ROUTES.METER_EXPLORER_VIEWS,
ROUTES.SOMETHING_WENT_WRONG,
ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

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

@@ -1,7 +0,0 @@
import LLMObservabilityAttributeMapping from 'container/LLMObservabilityAttributeMapping/LLMObservabilityAttributeMapping';
function LLMObservabilityAttributeMappingPage(): JSX.Element {
return <LLMObservabilityAttributeMapping />;
}
export default LLMObservabilityAttributeMappingPage;

View File

@@ -1,7 +0,0 @@
import LLMObservabilityModelPricing from 'container/LLMObservabilityModelPricing/LLMObservabilityModelPricing';
function LLMObservabilityModelPricingPage(): JSX.Element {
return <LLMObservabilityModelPricing />;
}
export default LLMObservabilityModelPricingPage;

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

@@ -4,7 +4,6 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, RenderOptions, RenderResult } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { FeatureKeys } from 'constants/features';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { ResourceProvider } from 'hooks/useResourceAttribute';
@@ -302,7 +301,7 @@ export function AllTheProviders({
<ErrorModalProvider>
<TimezoneProvider>
<PreferenceContextProvider>
<TooltipProvider>{queryBuilderContent}</TooltipProvider>
{queryBuilderContent}
</PreferenceContextProvider>
</TimezoneProvider>
</ErrorModalProvider>

View File

@@ -20,8 +20,7 @@ export type ComponentTypes =
| 'add_panel'
| 'page_pipelines'
| 'edit_locked_dashboard'
| 'add_panel_locked_dashboard'
| 'manage_llm_pricing';
| 'add_panel_locked_dashboard';
export const componentPermission: Record<ComponentTypes, ROLES[]> = {
current_org_settings: ['ADMIN'],
@@ -43,7 +42,6 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
page_pipelines: ['ADMIN', 'EDITOR'],
edit_locked_dashboard: ['ADMIN', 'AUTHOR'],
add_panel_locked_dashboard: ['ADMIN', 'AUTHOR'],
manage_llm_pricing: ['ADMIN', 'EDITOR', 'AUTHOR'],
};
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
@@ -138,6 +136,4 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_MODEL_PRICING: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: ['ADMIN', 'EDITOR', 'VIEWER'],
};

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

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

@@ -73,11 +73,11 @@ type Module interface {
PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error
UnpinV2(ctx context.Context, 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, userID valuer.UUID) error
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
}
type Handler interface {

View File

@@ -13,9 +13,16 @@ type Compiled struct {
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 nil, nil //nolint:nilnil
return &Compiled{}, nil
}
queryVisitor := newVisitor(formatter)
@@ -29,9 +36,6 @@ func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(queryVisitor.errors, "; "))
}
if sql == "" {
return nil, nil //nolint:nilnil
}
return &Compiled{
SQL: sql,

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