mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-15 02:50:25 +01:00
Compare commits
5 Commits
feat/llm-a
...
feat/expor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0854b34dc8 | ||
|
|
8c6b5f0145 | ||
|
|
2170e1f022 | ||
|
|
75dc5f6195 | ||
|
|
8b47513a06 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -189,9 +189,7 @@ go.mod @therealpandey
|
||||
## Infrastructure Monitoring
|
||||
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringHosts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringHostsV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringK8s/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringK8sV2/ @SigNoz/pulse-frontend
|
||||
|
||||
## Alerts
|
||||
/frontend/src/pages/AlertList/ @SigNoz/pulse-frontend
|
||||
|
||||
@@ -543,31 +543,6 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesGettableRole:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- description
|
||||
- type
|
||||
- orgId
|
||||
type: object
|
||||
AuthtypesGettableToken:
|
||||
properties:
|
||||
accessToken:
|
||||
@@ -719,6 +694,43 @@ components:
|
||||
- detach
|
||||
type: string
|
||||
AuthtypesRole:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- description
|
||||
- type
|
||||
- orgId
|
||||
type: object
|
||||
AuthtypesRoleMapping:
|
||||
properties:
|
||||
defaultRole:
|
||||
type: string
|
||||
groupMappings:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
useRoleAttribute:
|
||||
type: boolean
|
||||
type: object
|
||||
AuthtypesRoleWithTransactionGroups:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
@@ -746,18 +758,6 @@ components:
|
||||
- orgId
|
||||
- transactionGroups
|
||||
type: object
|
||||
AuthtypesRoleMapping:
|
||||
properties:
|
||||
defaultRole:
|
||||
type: string
|
||||
groupMappings:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
useRoleAttribute:
|
||||
type: boolean
|
||||
type: object
|
||||
AuthtypesSamlConfig:
|
||||
properties:
|
||||
attributeMapping:
|
||||
@@ -4259,34 +4259,6 @@ components:
|
||||
type: number
|
||||
clusterName:
|
||||
type: string
|
||||
counts:
|
||||
properties:
|
||||
daemonSets:
|
||||
format: int64
|
||||
type: integer
|
||||
deployments:
|
||||
format: int64
|
||||
type: integer
|
||||
jobs:
|
||||
format: int64
|
||||
type: integer
|
||||
namespaces:
|
||||
format: int64
|
||||
type: integer
|
||||
nodes:
|
||||
format: int64
|
||||
type: integer
|
||||
statefulSets:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- nodes
|
||||
- namespaces
|
||||
- deployments
|
||||
- daemonSets
|
||||
- jobs
|
||||
- statefulSets
|
||||
type: object
|
||||
meta:
|
||||
additionalProperties:
|
||||
type: string
|
||||
@@ -4307,7 +4279,6 @@ components:
|
||||
- nodeCountsByReadiness
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- counts
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesClusters:
|
||||
@@ -4829,26 +4800,6 @@ components:
|
||||
type: object
|
||||
InframonitoringtypesNamespaceRecord:
|
||||
properties:
|
||||
counts:
|
||||
properties:
|
||||
daemonSets:
|
||||
format: int64
|
||||
type: integer
|
||||
deployments:
|
||||
format: int64
|
||||
type: integer
|
||||
jobs:
|
||||
format: int64
|
||||
type: integer
|
||||
statefulSets:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- deployments
|
||||
- daemonSets
|
||||
- jobs
|
||||
- statefulSets
|
||||
type: object
|
||||
meta:
|
||||
additionalProperties:
|
||||
type: string
|
||||
@@ -4872,7 +4823,6 @@ components:
|
||||
- namespaceMemory
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- counts
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesNamespaces:
|
||||
@@ -12072,7 +12022,7 @@ paths:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesGettableRole'
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
@@ -12256,7 +12206,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
|
||||
@@ -119,6 +119,10 @@ func (provider *provider) CheckTransactions(ctx context.Context, subject string,
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
|
||||
return provider.openfgaServer.ListObjects(ctx, subject, relation, objectType)
|
||||
}
|
||||
|
||||
func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
|
||||
return provider.openfgaServer.Write(ctx, additions, deletions)
|
||||
}
|
||||
@@ -127,6 +131,10 @@ func (provider *provider) ReadTuples(ctx context.Context, tupleKey *openfgav1.Re
|
||||
return provider.openfgaServer.ReadTuples(ctx, tupleKey)
|
||||
}
|
||||
|
||||
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
|
||||
return provider.pkgAuthzService.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
|
||||
return provider.pkgAuthzService.GetByOrgIDAndName(ctx, orgID, name)
|
||||
}
|
||||
@@ -175,7 +183,7 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
|
||||
return provider.Write(ctx, tuples, nil)
|
||||
}
|
||||
|
||||
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
|
||||
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.RoleWithTransactionGroups) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
@@ -200,31 +208,70 @@ func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *a
|
||||
return err
|
||||
}
|
||||
|
||||
return provider.store.Create(ctx, role)
|
||||
if err := provider.store.Create(ctx, role.Role); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
|
||||
return provider.store.Get(ctx, orgID, id)
|
||||
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, error) {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
existingRole, err := provider.store.GetByOrgIDAndName(ctx, role.OrgID, role.Name)
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if existingRole != nil {
|
||||
return existingRole, nil
|
||||
}
|
||||
|
||||
err = provider.store.Create(ctx, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.Role) error {
|
||||
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
role, err := provider.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transactionGroups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
|
||||
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
|
||||
}
|
||||
|
||||
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
existingRole, err := provider.Get(ctx, orgID, updatedRole.ID)
|
||||
existingRole, err := provider.GetWithTransactionGroups(ctx, orgID, updatedRole.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingTuples, err := provider.readAllTuplesForRole(ctx, existingRole.Name, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
|
||||
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
|
||||
additions, deletions := existingRole.TransactionGroups.Diff(updatedRole.TransactionGroups)
|
||||
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -240,7 +287,7 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
|
||||
return err
|
||||
}
|
||||
|
||||
return provider.store.Update(ctx, orgID, updatedRole)
|
||||
return provider.store.Update(ctx, orgID, updatedRole.Role)
|
||||
}
|
||||
|
||||
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
@@ -249,7 +296,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
role, err := provider.Get(ctx, orgID, id)
|
||||
role, err := provider.GetWithTransactionGroups(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -265,7 +312,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
}
|
||||
}
|
||||
|
||||
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
|
||||
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -277,24 +324,6 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return provider.store.Delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
|
||||
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
|
||||
|
||||
tuples := make([]*openfgav1.TupleKey, 0)
|
||||
for _, objectType := range provider.registry.Types() {
|
||||
typeTuples, err := provider.openfgaServer.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
|
||||
User: subject,
|
||||
Object: objectType.StringValue() + ":",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tuples = append(tuples, typeTuples...)
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) []*openfgav1.TupleKey {
|
||||
tuples := []*openfgav1.TupleKey{}
|
||||
|
||||
@@ -346,3 +375,21 @@ func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*
|
||||
|
||||
return tuples
|
||||
}
|
||||
|
||||
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
|
||||
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
|
||||
|
||||
tuples := make([]*openfgav1.TupleKey, 0)
|
||||
for _, objectType := range provider.registry.Types() {
|
||||
typeTuples, err := provider.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
|
||||
User: subject,
|
||||
Object: objectType.StringValue() + ":",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tuples = append(tuples, typeTuples...)
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
|
||||
return server.pkgAuthzService.BatchCheck(ctx, tupleReq)
|
||||
}
|
||||
|
||||
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
|
||||
return server.pkgAuthzService.ListObjects(ctx, subject, relation, objectType)
|
||||
}
|
||||
|
||||
func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
|
||||
return server.pkgAuthzService.Write(ctx, additions, deletions)
|
||||
}
|
||||
|
||||
29
frontend/__mocks__/resizableMock.tsx
Normal file
29
frontend/__mocks__/resizableMock.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
type CommonProps = PropsWithChildren<{
|
||||
className?: string;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
defaultSize?: number;
|
||||
direction?: 'horizontal' | 'vertical';
|
||||
autoSaveId?: string;
|
||||
withHandle?: boolean;
|
||||
}>;
|
||||
|
||||
export function ResizablePanelGroup({
|
||||
children,
|
||||
className,
|
||||
}: CommonProps): JSX.Element {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
export function ResizablePanel({
|
||||
children,
|
||||
className,
|
||||
}: CommonProps): JSX.Element {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
export function ResizableHandle({ className }: CommonProps): JSX.Element {
|
||||
return <div className={className} />;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const config: Config.InitialOptions = {
|
||||
'\\.md$': '<rootDir>/__mocks__/cssMock.ts',
|
||||
'^uplot$': '<rootDir>/__mocks__/uplotMock.ts',
|
||||
'^motion/react$': '<rootDir>/__mocks__/motionMock.tsx',
|
||||
'^@signozhq/resizable$': '<rootDir>/__mocks__/resizableMock.tsx',
|
||||
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/icons": "0.4.0",
|
||||
"@signozhq/resizable": "0.0.2",
|
||||
"@signozhq/ui": "0.0.23",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
@@ -127,7 +128,7 @@
|
||||
"timestamp-nano": "^1.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"uplot": "1.6.31",
|
||||
"uuid": "14.0.1",
|
||||
"uuid": "^8.3.2",
|
||||
"vite": "npm:rolldown-vite@7.3.1",
|
||||
"vite-plugin-html": "3.2.2",
|
||||
"zod": "4.3.6",
|
||||
@@ -221,5 +222,34 @@
|
||||
"*.(scss|css)": [
|
||||
"stylelint"
|
||||
]
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"debug": "4.3.4",
|
||||
"semver": "7.5.4",
|
||||
"xml2js": "0.5.0",
|
||||
"phin": "^3.7.1",
|
||||
"body-parser": "1.20.3",
|
||||
"http-proxy-middleware": "4.1.1",
|
||||
"cross-spawn": "7.0.5",
|
||||
"cookie": "^0.7.1",
|
||||
"serialize-javascript": "6.0.2",
|
||||
"prismjs": "1.30.0",
|
||||
"got": "11.8.5",
|
||||
"form-data": "4.0.6",
|
||||
"brace-expansion": "^2.0.3",
|
||||
"on-headers": "^1.1.0",
|
||||
"js-cookie": "^3.0.7",
|
||||
"tmp": "0.2.7",
|
||||
"vite": "npm:rolldown-vite@7.3.1",
|
||||
"dompurify": "3.4.11",
|
||||
"js-yaml@3": "3.15.0",
|
||||
"js-yaml@4": "4.2.0",
|
||||
"yaml@1": "1.10.3",
|
||||
"react-router@6": "6.30.4",
|
||||
"markdown-it": "14.2.0",
|
||||
"mdast-util-to-hast@13": "13.2.1",
|
||||
"protocol-buffers-schema": "3.6.1"
|
||||
}
|
||||
}
|
||||
1864
frontend/pnpm-lock.yaml
generated
1864
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,53 +1,6 @@
|
||||
blockExoticSubdeps: true
|
||||
trustPolicy: no-downgrade
|
||||
minimumReleaseAge: 2880 # 2d
|
||||
minimumReleaseAgeStrict: true
|
||||
minimumReleaseAgeExclude:
|
||||
- '@signozhq/*'
|
||||
minimumReleaseAgeStrict: true
|
||||
# Security floors for vulnerable transitive deps. Where possible, targets are
|
||||
# capped to avoid crossing breaking versions (major; and minor for 0.x).
|
||||
# Some entries may still force a breaking bump when no safe release exists
|
||||
# within the consumer’s constraint—see per-entry notes.
|
||||
overrides:
|
||||
# via: direct devDep @babel/core ^7.22.11 (+ babel plugin peers)
|
||||
# remove: bump @babel/core in package.json to ^7.29.6
|
||||
'@babel/core@<=7.29.0': '>=7.29.6 <8'
|
||||
# via: jest > babel-plugin-istanbul > @istanbuljs/load-nyc-config@1.1.0 (js-yaml ^3.13.1)
|
||||
# remove: blocked — 1.1.0 is latest and still depends on js-yaml 3.x
|
||||
'@istanbuljs/load-nyc-config>js-yaml': '>=4.2.0 <5'
|
||||
# via: msw@1.3.2 (devDep) > cookie ^0.4.2
|
||||
# remove: upgrade msw to >=2 (ships cookie ^1). Do NOT open the cap: cookie >=1 is
|
||||
# ESM-only and breaks msw under jest's CJS sandbox (kills every test suite)
|
||||
cookie@<0.7.0: '>=0.7.1 <1'
|
||||
# via: direct dep dompurify 3.4.0; @grafana/data@11.6.15 (3.4.0/3.2.4 exact);
|
||||
# @monaco-editor/react > monaco-editor@0.55.1 (3.2.7 exact)
|
||||
# remove: bump direct dep to 3.4.11; @grafana/data (latest 13.1.0) and
|
||||
# monaco-editor (latest 0.55.1) still pin vulnerable versions — blocked
|
||||
dompurify@<=3.4.10: '>=3.4.11 <4'
|
||||
# via: rolldown-vite@7.3.1 (esbuild ^0.27.0); orval@8.9.1 (^0.27.4); ts-jest@29.4.9 (~0.27.4)
|
||||
# remove: blocked on rolldown-vite (7.3.1 is latest, still ^0.27.0);
|
||||
# orval >=8.20.0 and ts-jest >=29.4.11 already fixed on their side
|
||||
esbuild@>=0.27.3 <0.28.1: '>=0.28.1 <0.29.0'
|
||||
# via: react-use@17.5.1 (direct, js-cookie ^2.2.1); @grafana/data > react-use@17.6.0
|
||||
# remove: bump react-use to >=17.6.1 (js-cookie ^3); @grafana/data side blocked
|
||||
js-cookie@<=3.0.5: '>=3.0.7 <4'
|
||||
# via: @orval/core@8.9.1 (devDep, js-yaml 4.1.1 EXACT pin — not deletable);
|
||||
# json-schema-to-typescript@15 > @apidevtools/json-schema-ref-parser (^4.1.0)
|
||||
# remove: upgrade orval to >=8.20.0 (drops js-yaml dependency entirely)
|
||||
js-yaml@>=4.0.0 <=4.1.1: '>=4.2.0 <5'
|
||||
# via: react-syntax-highlighter@15.5.0 (prismjs ^1.27.0 + refractor@3 ~1.27.0 tilde-pinned)
|
||||
# remove: bump react-syntax-highlighter to >=16.1.1 (prismjs ^1.30.0, refractor@5)
|
||||
prismjs@<1.30.0: '>=1.30.0 <2'
|
||||
# via: direct dep react-router-dom-v5-compat@6.30.3 (react-router 6.30.3 exact)
|
||||
# remove: bump react-router-dom-v5-compat to 6.30.4. Do NOT open the cap:
|
||||
# react-router >=7 requires React 19 and breaks the app-wide CompatRouter
|
||||
react-router@>=6.7.0 <6.30.4: '>=6.30.4 <7'
|
||||
# via: msw@1.3.2 (devDep) > inquirer@8 > external-editor@3.1.0 (tmp ^0.0.33)
|
||||
# remove: upgrade msw to >=2 (drops the inquirer/external-editor chain)
|
||||
tmp@<0.2.6: '>=0.2.6 <0.3.0'
|
||||
# via: jest > babel-plugin-macros > cosmiconfig@7 (yaml ^1.10.0);
|
||||
# typescript-plugin-css-modules > postcss-load-config@3 (^1.10.2)
|
||||
# remove: blocked — babel-plugin-macros 3.1.0 (latest) still uses cosmiconfig@7
|
||||
yaml@>=1.0.0 <1.10.3: '>=1.10.3 <2'
|
||||
trustPolicy: no-downgrade
|
||||
trustPolicyExclude:
|
||||
- 'semver@6.3.1 || 5.7.2'
|
||||
blockExoticSubdeps: true
|
||||
|
||||
@@ -63,6 +63,5 @@
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
|
||||
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
}
|
||||
@@ -88,6 +88,5 @@
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
|
||||
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
}
|
||||
@@ -2082,39 +2082,6 @@ export interface AuthtypesGettableAuthDomainDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableTokenDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2358,6 +2325,39 @@ export interface AuthtypesPostableUserRoleDTO {
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleWithTransactionGroupsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -5669,39 +5669,6 @@ export interface InframonitoringtypesChecksDTO {
|
||||
type: InframonitoringtypesCheckTypeDTO;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesClusterRecordDTOCounts = {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
daemonSets: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
deployments: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
jobs: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
namespaces: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
nodes: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
statefulSets: number;
|
||||
};
|
||||
|
||||
export type InframonitoringtypesClusterRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -5846,10 +5813,6 @@ export interface InframonitoringtypesClusterRecordDTO {
|
||||
* @type string
|
||||
*/
|
||||
clusterName: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
counts: InframonitoringtypesClusterRecordDTOCounts;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
@@ -6405,29 +6368,6 @@ export interface InframonitoringtypesJobsDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesNamespaceRecordDTOCounts = {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
daemonSets: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
deployments: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
jobs: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
statefulSets: number;
|
||||
};
|
||||
|
||||
export type InframonitoringtypesNamespaceRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -6439,10 +6379,6 @@ export type InframonitoringtypesNamespaceRecordDTOMeta =
|
||||
InframonitoringtypesNamespaceRecordDTOMetaAnyOf | null;
|
||||
|
||||
export interface InframonitoringtypesNamespaceRecordDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
counts: InframonitoringtypesNamespaceRecordDTOCounts;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
@@ -9258,44 +9194,6 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
items: SpantypesSpanMapperGroupDTO[];
|
||||
}
|
||||
|
||||
export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesSpanMapperTestSpanDTOAttributes =
|
||||
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
|
||||
|
||||
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesSpanMapperTestSpanDTOResource =
|
||||
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
|
||||
|
||||
export interface SpantypesSpanMapperTestSpanDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
attributes?: SpantypesSpanMapperTestSpanDTOAttributes;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
resource?: SpantypesSpanMapperTestSpanDTOResource;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableSpanMapperTestDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanAggregationTypeDTO {
|
||||
span_count = 'span_count',
|
||||
execution_time_percentage = 'execution_time_percentage',
|
||||
@@ -9591,33 +9489,6 @@ export interface SpantypesPostableSpanMapperGroupDTO {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperTestGroupDTO {
|
||||
condition: SpantypesSpanMapperGroupConditionDTO | null;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
mappers?: SpantypesPostableSpanMapperDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperTestDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
groups: SpantypesPostableSpanMapperTestGroupDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanAggregationDTO {
|
||||
aggregation: SpantypesSpanAggregationTypeDTO;
|
||||
field: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
@@ -10743,7 +10614,7 @@ export type ListRoles200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: AuthtypesGettableRoleDTO[];
|
||||
data: AuthtypesRoleDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -10765,7 +10636,7 @@ export type GetRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetRole200 = {
|
||||
data: AuthtypesRoleDTO;
|
||||
data: AuthtypesRoleWithTransactionGroupsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -11007,14 +10878,6 @@ export type UpdateSpanMapperPathParameters = {
|
||||
groupId: string;
|
||||
mapperId: string;
|
||||
};
|
||||
export type TestSpanMappers200 = {
|
||||
data: SpantypesGettableSpanMapperTestDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetStats200Data = { [key: string]: unknown };
|
||||
|
||||
export type GetStats200 = {
|
||||
|
||||
@@ -30,10 +30,8 @@ import type {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
TestSpanMappers200,
|
||||
UpdateSpanMapperGroupPathParameters,
|
||||
UpdateSpanMapperPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -782,86 +780,3 @@ export const useUpdateSpanMapper = <
|
||||
> => {
|
||||
return useMutation(getUpdateSpanMapperMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Tests how span mappers would transform sample spans
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const testSpanMappers = (
|
||||
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<TestSpanMappers200>({
|
||||
url: `/api/v1/span_mapper_groups/test`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableSpanMapperTestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTestSpanMappersMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testSpanMappers'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return testSpanMappers(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type TestSpanMappersMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>
|
||||
>;
|
||||
export type TestSpanMappersMutationBody =
|
||||
| BodyType<SpantypesPostableSpanMapperTestDTO>
|
||||
| undefined;
|
||||
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const useTestSpanMappers = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getTestSpanMappersMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import { ExportRawDataProps } from 'types/api/exportRawData/getExportRawData';
|
||||
|
||||
export interface FetchExportDataProps extends ExportRawDataProps {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
async function postExportRawData({
|
||||
format,
|
||||
body,
|
||||
signal,
|
||||
}: FetchExportDataProps): Promise<AxiosResponse<Blob>> {
|
||||
return axios.post<Blob>(
|
||||
`export_raw_data?format=${encodeURIComponent(format)}`,
|
||||
body,
|
||||
{
|
||||
responseType: 'blob',
|
||||
decompress: true,
|
||||
headers: {
|
||||
Accept: 'application/octet-stream',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 0,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single export_raw_data page and returns it as a Blob.
|
||||
* Callers own retry/cancel/error UX.
|
||||
*/
|
||||
export async function fetchExportData(
|
||||
props: FetchExportDataProps,
|
||||
): Promise<Blob> {
|
||||
const response = await postExportRawData(props);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const downloadExportData = async (
|
||||
props: ExportRawDataProps,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const response = await axios.post<Blob>(
|
||||
`export_raw_data?format=${encodeURIComponent(props.format)}`,
|
||||
props.body,
|
||||
{
|
||||
responseType: 'blob',
|
||||
decompress: true,
|
||||
headers: {
|
||||
Accept: 'application/octet-stream',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 0,
|
||||
},
|
||||
);
|
||||
const response = await postExportRawData(props);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(
|
||||
|
||||
1
frontend/src/auto-import-registry.d.ts
vendored
1
frontend/src/auto-import-registry.d.ts
vendored
@@ -12,4 +12,5 @@
|
||||
|
||||
import '@signozhq/design-tokens';
|
||||
import '@signozhq/icons';
|
||||
import '@signozhq/resizable';
|
||||
import '@signozhq/ui';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import APIError from 'types/api/error';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
@@ -16,7 +16,7 @@ export interface RoleOption {
|
||||
}
|
||||
|
||||
export function useRoles(): {
|
||||
roles: AuthtypesGettableRoleDTO[];
|
||||
roles: AuthtypesRoleDTO[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: APIError | undefined;
|
||||
@@ -33,7 +33,7 @@ export function useRoles(): {
|
||||
}
|
||||
|
||||
export function getRoleOptions(
|
||||
roles: AuthtypesGettableRoleDTO[],
|
||||
roles: AuthtypesRoleDTO[],
|
||||
valueField: 'id' | 'name',
|
||||
): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
@@ -79,7 +79,7 @@ interface BaseProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
|
||||
roles?: AuthtypesGettableRoleDTO[];
|
||||
roles?: AuthtypesRoleDTO[];
|
||||
loading?: boolean;
|
||||
isError?: boolean;
|
||||
error?: APIError;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
@@ -28,7 +28,7 @@ interface OverviewTabProps {
|
||||
localRoles: string[];
|
||||
onRolesChange: (v: string[]) => void;
|
||||
isDisabled: boolean;
|
||||
availableRoles: AuthtypesGettableRoleDTO[];
|
||||
availableRoles: AuthtypesRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
rolesError?: boolean;
|
||||
rolesErrorObj?: APIError | undefined;
|
||||
|
||||
@@ -139,31 +139,21 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.currentNodes,
|
||||
width: { min: 210 },
|
||||
width: { min: 180 },
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
cell: ({ row }): React.ReactNode => (
|
||||
<GroupedStatusCounts
|
||||
items={[
|
||||
{
|
||||
value: row.readyNodes,
|
||||
label: 'Ready',
|
||||
color: Color.BG_FOREST_500,
|
||||
},
|
||||
{
|
||||
value: row.currentNodes,
|
||||
label: 'Current',
|
||||
color: Color.BG_ROBIN_500,
|
||||
color: Color.BG_FOREST_500,
|
||||
},
|
||||
{
|
||||
value: row.desiredNodes,
|
||||
label: 'Desired',
|
||||
color: Color.BG_SAKURA_400,
|
||||
},
|
||||
{
|
||||
value: row.misscheduledNodes,
|
||||
label: 'Misscheduled',
|
||||
color: Color.BG_AMBER_500,
|
||||
color: Color.BG_ROBIN_500,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -322,30 +312,6 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ready_nodes',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#ready">
|
||||
Ready Nodes
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.readyNodes,
|
||||
width: { min: 140 },
|
||||
enableSort: true,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const readyNodes = value as number;
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={readyNodes}
|
||||
entity={InfraMonitoringEntity.DAEMONSETS}
|
||||
attribute="ready node"
|
||||
>
|
||||
<TanStackTable.Text>{readyNodes}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'current_nodes',
|
||||
header: (): React.ReactNode => (
|
||||
@@ -394,28 +360,4 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'misscheduled_nodes',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#misscheduled">
|
||||
Misscheduled Nodes
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.misscheduledNodes,
|
||||
width: { min: 140 },
|
||||
enableSort: true,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const misscheduledNodes = value as number;
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={misscheduledNodes}
|
||||
entity={InfraMonitoringEntity.DAEMONSETS}
|
||||
attribute="misscheduled node"
|
||||
>
|
||||
<TanStackTable.Text>{misscheduledNodes}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -14,28 +14,11 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.entityMetricsTitleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entityMetricsTitle {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.metricsExplorerLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.metricsHeader {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
@@ -21,7 +19,6 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { getMetricsExplorerUrl } from 'utils/explorerUtils';
|
||||
|
||||
import { buildEntityMetricsChartConfig } from './configBuilder';
|
||||
|
||||
@@ -207,31 +204,9 @@ function EntityMetrics<T>({
|
||||
key={entityWidgetInfo[idx].title}
|
||||
className={styles.entityMetricsCol}
|
||||
>
|
||||
<div className={styles.entityMetricsTitleContainer}>
|
||||
<span className={styles.entityMetricsTitle}>
|
||||
{entityWidgetInfo[idx].title}
|
||||
</span>
|
||||
{queryPayloads[idx] &&
|
||||
queryPayloads[idx].graphType !== PANEL_TYPES.TABLE && (
|
||||
<Tooltip title="Open in Metrics Explorer">
|
||||
<Link
|
||||
to={getMetricsExplorerUrl({
|
||||
query: queryPayloads[idx].query,
|
||||
...(selectedInterval && selectedInterval !== 'custom'
|
||||
? { relativeTime: selectedInterval }
|
||||
: {
|
||||
startTimeMs: timeRange.startTime * 1000,
|
||||
endTimeMs: timeRange.endTime * 1000,
|
||||
}),
|
||||
})}
|
||||
className={styles.metricsExplorerLink}
|
||||
data-testid={`open-metrics-explorer-${idx}`}
|
||||
>
|
||||
<Compass size={14} />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<span className={styles.entityMetricsTitle}>
|
||||
{entityWidgetInfo[idx].title}
|
||||
</span>
|
||||
<div className={styles.entityMetricsCard} ref={graphRef}>
|
||||
{renderCardContent(query, idx)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import * as appContextHooks from 'providers/App/App';
|
||||
@@ -296,19 +295,17 @@ const renderEntityMetrics = (overrides = {}): any => {
|
||||
};
|
||||
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<EntityMetrics
|
||||
timeRange={defaultProps.timeRange}
|
||||
isModalTimeSelection={defaultProps.isModalTimeSelection}
|
||||
handleTimeChange={defaultProps.handleTimeChange}
|
||||
selectedInterval={defaultProps.selectedInterval}
|
||||
entity={defaultProps.entity}
|
||||
entityWidgetInfo={defaultProps.entityWidgetInfo}
|
||||
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
|
||||
queryKey={defaultProps.queryKey}
|
||||
category={defaultProps.category}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
<EntityMetrics
|
||||
timeRange={defaultProps.timeRange}
|
||||
isModalTimeSelection={defaultProps.isModalTimeSelection}
|
||||
handleTimeChange={defaultProps.handleTimeChange}
|
||||
selectedInterval={defaultProps.selectedInterval}
|
||||
entity={defaultProps.entity}
|
||||
entityWidgetInfo={defaultProps.entityWidgetInfo}
|
||||
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
|
||||
queryKey={defaultProps.queryKey}
|
||||
category={defaultProps.category}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -337,8 +334,8 @@ const mockTableData: (import('../utils').MetricsTableData[] | null)[] = [
|
||||
];
|
||||
|
||||
const mockQueryPayloads = [
|
||||
{ graphType: 'graph', query: { queryType: 'builder' } }, // time_series
|
||||
{ graphType: 'table', query: { queryType: 'builder' } }, // table
|
||||
{ graphType: 'graph' }, // time_series
|
||||
{ graphType: 'table' }, // table
|
||||
];
|
||||
|
||||
describe('EntityMetrics', () => {
|
||||
@@ -445,34 +442,6 @@ describe('EntityMetrics', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders metrics explorer link only for non-table panels', () => {
|
||||
renderEntityMetrics();
|
||||
expect(screen.getByTestId('open-metrics-explorer-0')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('open-metrics-explorer-1'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('builds metrics explorer link with relativeTime when a relative interval is selected', () => {
|
||||
renderEntityMetrics({ selectedInterval: '5m' as Time });
|
||||
const href = screen
|
||||
.getByTestId('open-metrics-explorer-0')
|
||||
.getAttribute('href');
|
||||
expect(href).toContain('relativeTime=5m');
|
||||
expect(href).not.toContain('startTime=');
|
||||
expect(href).not.toContain('endTime=');
|
||||
});
|
||||
|
||||
it('builds metrics explorer link with absolute time range in milliseconds for custom interval', () => {
|
||||
renderEntityMetrics({ selectedInterval: 'custom' as Time });
|
||||
const href = screen
|
||||
.getByTestId('open-metrics-explorer-0')
|
||||
.getAttribute('href');
|
||||
expect(href).toContain(`startTime=${mockTimeRange.startTime * 1000}`);
|
||||
expect(href).toContain(`endTime=${mockTimeRange.endTime * 1000}`);
|
||||
expect(href).not.toContain('relativeTime=');
|
||||
});
|
||||
|
||||
it('passes correct parameters to useEntityMetrics hook', () => {
|
||||
renderEntityMetrics();
|
||||
expect(mockUseEntityMetrics).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import { DraftGroup } from '../types';
|
||||
import styles from './AttributeMappingsTab.module.scss';
|
||||
import MappingsTable from './components/MappingsTable/MappingsTable';
|
||||
import { AttributeMappingStore } from './hooks/useAttributeMappingStore';
|
||||
import { useAttributeMappingStore } from './hooks/useAttributeMappingStore';
|
||||
|
||||
interface AttributeMappingsTabProps {
|
||||
store: AttributeMappingStore;
|
||||
onEditGroup: (group: DraftGroup) => void;
|
||||
onAddGroup: () => void;
|
||||
}
|
||||
function AttributeMappingsTab(): JSX.Element {
|
||||
const store = useAttributeMappingStore();
|
||||
|
||||
// "Attribute mappings" tab: the mapping-groups listing and its error state.
|
||||
// The store is owned by the container (the header's save/discard share it),
|
||||
// so it's passed in rather than created here.
|
||||
function AttributeMappingsTab({
|
||||
store,
|
||||
onEditGroup,
|
||||
onAddGroup,
|
||||
}: AttributeMappingsTabProps): JSX.Element {
|
||||
return (
|
||||
<div data-testid="attribute-mappings-tab">
|
||||
{store.isError ? (
|
||||
@@ -24,11 +12,7 @@ function AttributeMappingsTab({
|
||||
Failed to load mapping groups. Please try again.
|
||||
</div>
|
||||
) : (
|
||||
<MappingsTable
|
||||
store={store}
|
||||
onEditGroup={onEditGroup}
|
||||
onAddGroup={onAddGroup}
|
||||
/>
|
||||
<MappingsTable store={store} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,19 +2,9 @@ import {
|
||||
SpantypesFieldContextDTO as FieldContext,
|
||||
SpantypesSpanMapperOperationDTO as MapperOperation,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
GROUPS_ENDPOINT,
|
||||
makeGroupsResponse,
|
||||
@@ -24,9 +14,7 @@ import {
|
||||
mockGroups,
|
||||
mockMappers,
|
||||
} from 'container/LLMObservability/AttributeMapping/__tests__/fixtures';
|
||||
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import AttributeMappingsTab from '../AttributeMappingsTab';
|
||||
import { useAttributeMappingStore } from '../hooks/useAttributeMappingStore';
|
||||
|
||||
function setupGroups(groups = mockGroups): void {
|
||||
server.use(
|
||||
@@ -51,62 +39,6 @@ async function expandGroup(
|
||||
await user.click(screen.getByTestId(`group-expand-${groupId}`));
|
||||
}
|
||||
|
||||
async function openGroupActionsMenu(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
groupId: string,
|
||||
): Promise<void> {
|
||||
const row = screen
|
||||
.getByTestId(`group-name-${groupId}`)
|
||||
.closest('.ant-collapse-item') as HTMLElement;
|
||||
await user.click(within(row).getByRole('button', { name: 'Group actions' }));
|
||||
}
|
||||
|
||||
interface AttributeMappingsTabWithStoreProps {
|
||||
onEditGroup?: (group: DraftGroup) => void;
|
||||
onAddGroup?: () => void;
|
||||
}
|
||||
|
||||
// The tab is a presentational view over a store owned by the container, so this
|
||||
// wrapper creates the store (via the hook, backed by the mocked API) and wires
|
||||
// the edit/add callbacks the tab needs.
|
||||
function AttributeMappingsTabWithStore({
|
||||
onEditGroup,
|
||||
onAddGroup,
|
||||
}: AttributeMappingsTabWithStoreProps): JSX.Element {
|
||||
const store = useAttributeMappingStore();
|
||||
return (
|
||||
<AttributeMappingsTab
|
||||
store={store}
|
||||
onEditGroup={onEditGroup ?? jest.fn()}
|
||||
onAddGroup={onAddGroup ?? jest.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The real Save button lives in the page header; this harness exposes the same
|
||||
// store.save() path the header wires up, so the tab suite can exercise it.
|
||||
function SaveableHarness(): JSX.Element {
|
||||
const store = useAttributeMappingStore();
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="save-button"
|
||||
onClick={(): void => {
|
||||
void store.save();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<AttributeMappingsTab
|
||||
store={store}
|
||||
onEditGroup={jest.fn()}
|
||||
onAddGroup={jest.fn()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AttributeMappingsTab (integration)', () => {
|
||||
beforeEach(() => {
|
||||
// Reset URL state between tests — jsdom shares window.location across a file.
|
||||
@@ -119,7 +51,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
|
||||
it('renders no error banner on a successful load', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
|
||||
@@ -131,7 +63,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await expect(screen.findByRole('alert')).resolves.toHaveTextContent(
|
||||
'Failed to load mapping groups. Please try again.',
|
||||
@@ -140,7 +72,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
|
||||
it('shows the empty state when there are no groups', async () => {
|
||||
setupGroups([]);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('mapper-groups-empty'),
|
||||
@@ -149,10 +81,10 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
|
||||
it('renders each group header row with its name, condition count and status', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
// Condition filters are no longer shown inline as clauses — the header
|
||||
// carries a count instead (the keys surface in the group drawer).
|
||||
// carries a count instead (the keys surface in the group drawer, later PR).
|
||||
// Group headers are antd Collapse panels, so rows scope to the panel item.
|
||||
// group-1: enabled, with attribute + resource condition keys.
|
||||
const enabledRow = (await screen.findByTestId('group-name-group-1')).closest(
|
||||
@@ -166,7 +98,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
).toHaveTextContent('2 conditions');
|
||||
expect(within(enabledRow).getByTestId('group-enabled-group-1')).toBeChecked();
|
||||
|
||||
// group-2: disabled.
|
||||
// group-2: disabled, with no condition keys.
|
||||
const disabledRow = screen
|
||||
.getByTestId('group-name-group-2')
|
||||
.closest('.ant-collapse-item') as HTMLElement;
|
||||
@@ -179,70 +111,22 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("stages a toggle of the group's enabled state via the header switch", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
it('renders the group enable state as a read-only switch', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
// The status switch reflects enabled state but is non-interactive in this
|
||||
// read-only listing — editing lands in a later PR.
|
||||
const toggle = await screen.findByTestId('group-enabled-group-1');
|
||||
expect(toggle).toBeChecked();
|
||||
|
||||
await user.click(toggle);
|
||||
// The flip lands in the store's working copy (persisted on page save).
|
||||
expect(screen.getByTestId('group-enabled-group-1')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('invokes onAddGroup from the toolbar button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onAddGroup = jest.fn();
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTabWithStore onAddGroup={onAddGroup} />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await user.click(screen.getByTestId('add-group-row'));
|
||||
|
||||
expect(onAddGroup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('invokes onEditGroup with the group when Edit is chosen from the actions menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onEditGroup = jest.fn();
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTabWithStore onEditGroup={onEditGroup} />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await openGroupActionsMenu(user, 'group-1');
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Edit' }));
|
||||
|
||||
expect(onEditGroup).toHaveBeenCalledTimes(1);
|
||||
expect(onEditGroup.mock.calls[0][0]).toMatchObject({
|
||||
localId: 'group-1',
|
||||
name: 'demo',
|
||||
});
|
||||
});
|
||||
|
||||
it('stages a group removal when Delete is chosen from the actions menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await openGroupActionsMenu(user, 'group-1');
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Delete' }));
|
||||
|
||||
// The row leaves the working copy immediately; the delete is persisted on
|
||||
// the page-level save.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('group-name-group-1')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByTestId('group-name-group-2')).toBeInTheDocument();
|
||||
expect(toggle).toBeDisabled();
|
||||
});
|
||||
|
||||
it("reveals a group's mappers on expand and hides them on collapse", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1' })]);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
// The toggle is the antd Collapse header, which owns the expanded state.
|
||||
@@ -271,7 +155,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
setupMappers([
|
||||
makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model', enabled: true }),
|
||||
]);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
// Mappers are not fetched until the row is expanded.
|
||||
@@ -288,14 +172,29 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
const sources = within(mapperRow).getByTestId('mapper-sources-mapper-1');
|
||||
expect(sources).toHaveTextContent('genai.model');
|
||||
expect(sources).toHaveTextContent('llm.model');
|
||||
// Field-context badge (beside the target) + enabled status (an inline
|
||||
// Switch, not text).
|
||||
// Writes-to field context + enabled status (an inline Switch, not text).
|
||||
expect(within(mapperRow).getByText('attribute')).toBeInTheDocument();
|
||||
expect(
|
||||
within(mapperRow).getByTestId('mapper-enabled-mapper-1'),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
it("renders a mapper's enable state as a read-only switch", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1', enabled: true })]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
|
||||
// Like the group switch, a mapper's status switch reflects state without
|
||||
// accepting flips in this read-only listing.
|
||||
const toggle = await screen.findByTestId('mapper-enabled-mapper-1');
|
||||
expect(toggle).toBeChecked();
|
||||
expect(toggle).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows the mappers error state when the mappers request fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
@@ -304,7 +203,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
res(ctx.status(500)),
|
||||
),
|
||||
);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
@@ -318,7 +217,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([]);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
@@ -344,7 +243,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
},
|
||||
}),
|
||||
]);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
@@ -356,7 +255,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1', config: { sources: [] } })]);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
@@ -365,193 +264,4 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
expect(screen.getByTestId('mapper-sources-mapper-1')).toHaveTextContent('—'),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-fetches an open group's mappers on save instead of showing stale rows", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
|
||||
// The mapper list changes server-side between the first open and the save
|
||||
// (e.g. another edit landed, or the save reconciled ids). The open table
|
||||
// must reflect the latest list — not the list cached on first expand.
|
||||
let mappers = mockMappers;
|
||||
server.use(
|
||||
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeMappersResponse(mappers))),
|
||||
),
|
||||
);
|
||||
render(<SaveableHarness />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('group-expand-group-1'));
|
||||
await expect(
|
||||
screen.findByTestId('mapper-target-mapper-1'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// Server now returns a different mapper for the group.
|
||||
mappers = [makeMapper({ id: 'mapper-2', name: 'gen_ai.response.model' })];
|
||||
|
||||
await user.click(screen.getByTestId('save-button'));
|
||||
|
||||
// Fresh row appears; the stale one is gone. Without removeQueries the table
|
||||
// would keep showing mapper-1 (the hydrate once-guard blocks the update).
|
||||
await expect(
|
||||
screen.findByTestId('mapper-target-mapper-2'),
|
||||
).resolves.toHaveTextContent('gen_ai.response.model');
|
||||
expect(
|
||||
screen.queryByTestId('mapper-target-mapper-1'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns and keeps the working copy when the post-save refresh fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
(toast.success as jest.Mock).mockClear();
|
||||
(toast.warning as jest.Mock).mockClear();
|
||||
|
||||
// The initial load succeeds; the post-save refetch fails. persistDraft
|
||||
// itself succeeds (the PATCH below), so the changes are on the server —
|
||||
// only the refresh that follows them errors.
|
||||
let failRefresh = false;
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
|
||||
failRefresh
|
||||
? res(ctx.status(500), ctx.json({ status: 'error' }))
|
||||
: res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
|
||||
),
|
||||
rest.patch(`${GROUPS_ENDPOINT}/:groupId`, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'ok' })),
|
||||
),
|
||||
);
|
||||
render(<SaveableHarness />);
|
||||
|
||||
const toggle = await screen.findByTestId('group-enabled-group-1');
|
||||
expect(toggle).toBeChecked();
|
||||
|
||||
// Stage a change so persistDraft issues a PATCH and the draft is dirty.
|
||||
await user.click(toggle);
|
||||
expect(screen.getByTestId('group-enabled-group-1')).not.toBeChecked();
|
||||
|
||||
failRefresh = true;
|
||||
await user.click(screen.getByTestId('save-button'));
|
||||
|
||||
// The refresh failed, so the user is warned rather than told the save
|
||||
// succeeded. Crucially the working copy is not reset — the guard skips the
|
||||
// setDraft(null) reset that would otherwise re-seed from the stale
|
||||
// pre-save cache and silently drop the just-saved change.
|
||||
await waitFor(() => expect(toast.warning).toHaveBeenCalled());
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The mapper drawer is owned by MappingsTable (a single useMapperFormDrawer +
|
||||
// MapperFormDrawer instance, targeted at whichever group triggered it), so
|
||||
// these flows reach it through the same store-backed harness by expanding a
|
||||
// group first. Persistence is staged into the draft store, so the assertions
|
||||
// are on the resulting table rows and the store's dirty state rather than any
|
||||
// network call.
|
||||
describe('mapper drawer', () => {
|
||||
async function openGroupWithMapper(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> {
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model' })]);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
await screen.findByTestId('mapper-target-mapper-1');
|
||||
}
|
||||
|
||||
it('opens the add-mapping drawer from a group\'s "Add mapping" button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await openGroupWithMapper(user);
|
||||
|
||||
expect(screen.queryByTestId('mapper-form-drawer')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByTestId('add-mapper-group-1'));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('mapper-form-drawer'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('New custom mapping')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the edit drawer prefilled and locks the target attribute', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await openGroupWithMapper(user);
|
||||
|
||||
// DropdownMenuSimple drops the trigger's testId, so target it by its label.
|
||||
await user.click(screen.getByRole('button', { name: 'Mapping actions' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Edit' }));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('mapper-form-drawer'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('Edit mapping')).toBeInTheDocument();
|
||||
const target = screen.getByTestId('mapper-form-target');
|
||||
expect(target).toHaveValue('gen_ai.request.model');
|
||||
// The target attribute is the mapper's identity — immutable after create.
|
||||
expect(target).toBeDisabled();
|
||||
});
|
||||
|
||||
it("toggles a mapper's enabled state through the store", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await openGroupWithMapper(user);
|
||||
|
||||
const toggle = screen.getByTestId('mapper-enabled-mapper-1');
|
||||
expect(toggle).toBeChecked();
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
// The switch is driven by the draft, so a flip proves the store round-trip.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('mapper-enabled-mapper-1')).not.toBeChecked(),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a mapper via the row action menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await openGroupWithMapper(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Mapping actions' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Delete' }));
|
||||
|
||||
// The row is dropped from the draft; the group falls back to its empty state.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId('mapper-target-mapper-1'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByTestId('mappers-empty-group-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a new mapping through the drawer and appends it to the group', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await openGroupWithMapper(user);
|
||||
|
||||
await user.click(screen.getByTestId('add-mapper-group-1'));
|
||||
await screen.findByTestId('mapper-form-drawer');
|
||||
|
||||
// Create stays disabled until the draft has a target and a non-empty source.
|
||||
expect(screen.getByTestId('mapper-form-save')).toBeDisabled();
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId('mapper-form-target'),
|
||||
'gen_ai.response.model',
|
||||
);
|
||||
await user.type(screen.getByTestId('mapper-form-source-0'), 'raw.model');
|
||||
|
||||
const create = screen.getByTestId('mapper-form-save');
|
||||
await waitFor(() => expect(create).toBeEnabled());
|
||||
await user.click(create);
|
||||
|
||||
// Drawer closes and the new mapping shows up as a row in the group.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('mapper-form-drawer')).not.toBeInTheDocument(),
|
||||
);
|
||||
await expect(
|
||||
screen.findByText('gen_ai.response.model'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, Pencil, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
interface GroupActionsMenuProps {
|
||||
group: DraftGroup;
|
||||
onEdit: (group: DraftGroup) => void;
|
||||
onRemove: (localId: string) => void;
|
||||
}
|
||||
|
||||
// Per-group overflow menu: Edit opens the group drawer, Delete stages the
|
||||
// removal in the working copy (persisted on the page-level save).
|
||||
function GroupActionsMenu({
|
||||
group,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: GroupActionsMenuProps): JSX.Element {
|
||||
const menuItems = useMemo<MenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
icon: <Pencil size={14} />,
|
||||
onClick: (): void => onEdit(group),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
onClick: (): void => onRemove(group.localId),
|
||||
},
|
||||
],
|
||||
[onEdit, onRemove, group],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Group actions"
|
||||
testId={`group-actions-${group.localId}`}
|
||||
>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupActionsMenu;
|
||||
@@ -1,42 +0,0 @@
|
||||
.conditionsTooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
max-width: 320px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.keyList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.key {
|
||||
max-width: 100%;
|
||||
padding: 0 var(--spacing-1);
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--l1-foreground);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.emptyHint {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import styles from './ConditionsTooltip.module.scss';
|
||||
|
||||
interface ConditionsTooltipProps {
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
}
|
||||
|
||||
// Rich hover content for a group's condition count: lists the span-attribute and
|
||||
// resource keys that gate the group, mirroring the matching logic spelled out in
|
||||
// the group form drawer.
|
||||
function ConditionsTooltip({
|
||||
attributes,
|
||||
resource,
|
||||
}: ConditionsTooltipProps): JSX.Element {
|
||||
const hasConditions = attributes.length > 0 || resource.length > 0;
|
||||
|
||||
if (!hasConditions) {
|
||||
return <span className={styles.emptyHint}>No conditions set up</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.conditionsTooltip}
|
||||
data-testid="group-conditions-tooltip"
|
||||
>
|
||||
{attributes.length > 0 && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionTitle}>
|
||||
Runs when a span attribute key contains
|
||||
</span>
|
||||
<div className={styles.keyList}>
|
||||
{attributes.map((key) => (
|
||||
<code key={key} className={styles.key}>
|
||||
{key}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{resource.length > 0 && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionTitle}>
|
||||
{attributes.length > 0 ? 'or when' : 'Runs when'} a resource key contains
|
||||
</span>
|
||||
<div className={styles.keyList}>
|
||||
{resource.map((key) => (
|
||||
<code key={key} className={styles.key}>
|
||||
{key}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConditionsTooltip;
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
.groupName {
|
||||
color: var(--l1-foreground);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -18,15 +17,3 @@
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conditionCount {
|
||||
composes: groupCount;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.conditionInfoIcon {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
import ConditionsTooltip from './ConditionsTooltip';
|
||||
import styles from './GroupHeader.module.scss';
|
||||
|
||||
interface GroupHeaderProps {
|
||||
group: DraftGroup;
|
||||
group: MappingGroup;
|
||||
}
|
||||
|
||||
function GroupHeader({ group }: GroupHeaderProps): JSX.Element {
|
||||
@@ -17,33 +13,22 @@ function GroupHeader({ group }: GroupHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.groupHeaderLabel}
|
||||
data-testid={`group-expand-${group.localId}`}
|
||||
data-testid={`group-expand-${group.id}`}
|
||||
>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
className={styles.groupName}
|
||||
testId={`group-name-${group.localId}`}
|
||||
testId={`group-name-${group.id}`}
|
||||
>
|
||||
{group.name}
|
||||
</Typography.Text>
|
||||
<TooltipSimple
|
||||
title={
|
||||
<ConditionsTooltip
|
||||
attributes={group.attributes}
|
||||
resource={group.resource}
|
||||
/>
|
||||
}
|
||||
side="bottom"
|
||||
align="start"
|
||||
<Typography.Text
|
||||
as="span"
|
||||
className={styles.groupCount}
|
||||
testId={`group-condition-count-${group.id}`}
|
||||
>
|
||||
<span
|
||||
className={styles.conditionCount}
|
||||
data-testid={`group-condition-count-${group.localId}`}
|
||||
>
|
||||
· {conditionCount} {conditionCount === 1 ? 'condition' : 'conditions'}
|
||||
<Info size={12} className={styles.conditionInfoIcon} />
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
· {conditionCount} {conditionCount === 1 ? 'condition' : 'conditions'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
|
||||
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import GroupActionsMenu from '../GroupActionsMenu/GroupActionsMenu';
|
||||
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import styles from './GroupHeaderActions.module.scss';
|
||||
|
||||
interface GroupHeaderActionsProps {
|
||||
group: DraftGroup;
|
||||
onToggle: (localId: string, enabled: boolean) => void;
|
||||
onEdit: (group: DraftGroup) => void;
|
||||
onRemove: (localId: string) => void;
|
||||
group: MappingGroup;
|
||||
}
|
||||
|
||||
function GroupHeaderActions({
|
||||
group,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: GroupHeaderActionsProps): JSX.Element {
|
||||
function GroupHeaderActions({ group }: GroupHeaderActionsProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.actions}
|
||||
@@ -24,10 +15,10 @@ function GroupHeaderActions({
|
||||
>
|
||||
<Switch
|
||||
value={group.enabled}
|
||||
onChange={(checked): void => onToggle(group.localId, checked)}
|
||||
testId={`group-enabled-${group.localId}`}
|
||||
// We don't yet support toggling a group's enabled state in this read-only PR, so disable the switch. A later PR will add the toggle handler and its drawer.
|
||||
disabled
|
||||
testId={`group-enabled-${group.id}`}
|
||||
/>
|
||||
<GroupActionsMenu group={group} onEdit={onEdit} onRemove={onRemove} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
// Must mirror the header table's layout in MappingsTable.module.scss so a
|
||||
// panel's rows align to the shared colgroup (see MappingsColgroup).
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
// ---- per-group state rows (loading / empty / error / add) ----
|
||||
|
||||
.mapperStateRow .stateCell {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.addMapperRow .stateCell {
|
||||
padding-top: var(--spacing-2);
|
||||
padding-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.stateCell {
|
||||
padding: var(--spacing-4) var(--spacing-6) var(--spacing-4) var(--spacing-12);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.addMapperCell {
|
||||
padding: var(--spacing-4) var(--spacing-6) var(--spacing-4) var(--spacing-6);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { useListSpanMappers } from 'api/generated/services/spanmapper';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import {
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
Mapper,
|
||||
MappingGroup,
|
||||
Mapping,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { AttributeMappingStore } from 'container/LLMObservability/AttributeMapping/AttributeMappingsTab/hooks/useAttributeMappingStore';
|
||||
import { buildMappingsFromListResponse } from 'container/LLMObservability/AttributeMapping/utils';
|
||||
import { COLUMN_COUNT } from '../constants';
|
||||
import MapperRow from '../MapperRow/MapperRow';
|
||||
import MapperRowSkeleton from '../MapperRow/MapperRowSkeleton';
|
||||
import MappingsColgroup from '../MappingsColgroup/MappingsColgroup';
|
||||
import MapperRow, { MapperRowSkeleton } from '../MapperRow';
|
||||
import MappingsColgroup from '../MappingsColgroup';
|
||||
import styles from './GroupMappers.module.scss';
|
||||
|
||||
const MAPPER_SKELETON_ROWS = 1;
|
||||
@@ -24,125 +19,72 @@ const STATE_ROW_MOTION = {
|
||||
transition: { duration: 0.18, ease: 'easeOut' },
|
||||
} as const;
|
||||
|
||||
interface GroupMappersProps {
|
||||
group: DraftGroup;
|
||||
store: AttributeMappingStore;
|
||||
onAddMapper: (groupLocalId: string) => void;
|
||||
onEditMapper: (groupLocalId: string, mapper: DraftMapper) => void;
|
||||
interface StateRowProps {
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
function GroupMappers({
|
||||
group,
|
||||
store,
|
||||
onAddMapper,
|
||||
onEditMapper,
|
||||
}: GroupMappersProps): JSX.Element {
|
||||
const { hydrateGroupMappers, removeMapper, toggleMapper } = store;
|
||||
|
||||
const hasServerId = group.serverId !== null;
|
||||
const { data, isLoading, isError } = useListSpanMappers(
|
||||
{ groupId: group.serverId ?? '' },
|
||||
// refetchOnMount: false — the Collapse destroys inactive panels, so a
|
||||
// group's mappers would otherwise refetch on every expand. Cached data is
|
||||
// reused instead; the save flow evicts these caches so post-edit expands
|
||||
// still fetch fresh.
|
||||
{ query: { enabled: hasServerId, refetchOnMount: false } },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const items = data?.data?.items;
|
||||
if (group.serverId && items) {
|
||||
hydrateGroupMappers(group.serverId, items as unknown as Mapper[]);
|
||||
}
|
||||
}, [group.serverId, data, hydrateGroupMappers]);
|
||||
|
||||
const isLoadingMappers = hasServerId && isLoading;
|
||||
const isErrorMappers = hasServerId && isError;
|
||||
const mapperCount = group.mappers.length;
|
||||
|
||||
const skeletonRows = Array.from({ length: MAPPER_SKELETON_ROWS }).map(
|
||||
(_, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<MapperRowSkeleton key={`mapper-skeleton-${index}`} />
|
||||
),
|
||||
);
|
||||
|
||||
const errorRow = (
|
||||
<motion.tr
|
||||
key="error"
|
||||
className={styles.mapperStateRow}
|
||||
{...STATE_ROW_MOTION}
|
||||
>
|
||||
function ErrorRow({ groupId }: StateRowProps): JSX.Element {
|
||||
return (
|
||||
<motion.tr className={styles.mapperStateRow} {...STATE_ROW_MOTION}>
|
||||
<td
|
||||
colSpan={COLUMN_COUNT}
|
||||
className={styles.stateCell}
|
||||
data-testid={`mappers-error-${group.localId}`}
|
||||
data-testid={`mappers-error-${groupId}`}
|
||||
>
|
||||
Failed to load mappings. Please try again.
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyRow = (
|
||||
<motion.tr
|
||||
key="empty"
|
||||
className={styles.mapperStateRow}
|
||||
{...STATE_ROW_MOTION}
|
||||
>
|
||||
function EmptyRow({ groupId }: StateRowProps): JSX.Element {
|
||||
return (
|
||||
<motion.tr className={styles.mapperStateRow} {...STATE_ROW_MOTION}>
|
||||
<td
|
||||
colSpan={COLUMN_COUNT}
|
||||
className={styles.stateCell}
|
||||
data-testid={`mappers-empty-${group.localId}`}
|
||||
data-testid={`mappers-empty-${groupId}`}
|
||||
>
|
||||
No mappings in this group yet.
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
}
|
||||
|
||||
const addMapperRow = (
|
||||
<motion.tr
|
||||
key="add-mapper"
|
||||
className={styles.addMapperRow}
|
||||
{...STATE_ROW_MOTION}
|
||||
>
|
||||
<td colSpan={COLUMN_COUNT} className={styles.addMapperCell}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => onAddMapper(group.localId)}
|
||||
testId={`add-mapper-${group.localId}`}
|
||||
>
|
||||
Add mapping
|
||||
</Button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
interface GroupMappersProps {
|
||||
group: MappingGroup;
|
||||
}
|
||||
|
||||
function GroupMappers({ group }: GroupMappersProps): JSX.Element {
|
||||
const {
|
||||
data: mappers = [],
|
||||
isLoading,
|
||||
isError,
|
||||
} = useListSpanMappers<Mapping[]>(
|
||||
{
|
||||
groupId: group.id,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
refetchOnMount: false,
|
||||
select: buildMappingsFromListResponse,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const mapperRows = group.mappers.map((mapper, index) => (
|
||||
<MapperRow
|
||||
key={mapper.localId}
|
||||
mapper={mapper}
|
||||
index={index}
|
||||
onEdit={(next): void => onEditMapper(group.localId, next)}
|
||||
onRemove={(localId): void => removeMapper(group.localId, localId)}
|
||||
onToggle={(localId, enabled): void =>
|
||||
toggleMapper(group.localId, localId, enabled)
|
||||
}
|
||||
/>
|
||||
));
|
||||
|
||||
// The add-mapping row trails every non-error state (including loading/empty).
|
||||
let rows: JSX.Element[];
|
||||
if (isErrorMappers) {
|
||||
rows = [errorRow];
|
||||
} else if (isLoadingMappers && mapperCount === 0) {
|
||||
rows = [...skeletonRows, addMapperRow];
|
||||
} else if (mapperCount === 0) {
|
||||
rows = [emptyRow, addMapperRow];
|
||||
if (isError) {
|
||||
rows = [<ErrorRow key="error" groupId={group.id} />];
|
||||
} else if (isLoading) {
|
||||
rows = Array.from({ length: MAPPER_SKELETON_ROWS }).map((_, index) => (
|
||||
<MapperRowSkeleton key={`mapper-skeleton-${index}`} />
|
||||
));
|
||||
} else if (mappers.length === 0) {
|
||||
rows = [<EmptyRow key="empty" groupId={group.id} />];
|
||||
} else {
|
||||
rows = [...mapperRows, addMapperRow];
|
||||
rows = mappers.map((mapper, index) => (
|
||||
<MapperRow key={mapper.id} mapper={mapper} index={index} />
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, Pencil, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { DraftMapper } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
interface MapperActionsMenuProps {
|
||||
mapper: DraftMapper;
|
||||
onEdit: (mapper: DraftMapper) => void;
|
||||
onRemove: (localId: string) => void;
|
||||
}
|
||||
|
||||
function MapperActionsMenu({
|
||||
mapper,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: MapperActionsMenuProps): JSX.Element {
|
||||
const menuItems = useMemo<MenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
icon: <Pencil size={14} />,
|
||||
onClick: (): void => onEdit(mapper),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
onClick: (): void => onRemove(mapper.localId),
|
||||
},
|
||||
],
|
||||
[onEdit, onRemove, mapper],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Mapping actions"
|
||||
testId={`mapper-actions-${mapper.localId}`}
|
||||
>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperActionsMenu;
|
||||
@@ -11,22 +11,11 @@
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
// Indent the first cell so mapper rows read as nested under their group.
|
||||
.targetCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
padding-left: var(--spacing-12);
|
||||
}
|
||||
|
||||
.targetName {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.targetContextBadge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Shorter vertical padding so the loading state reads as a compact placeholder.
|
||||
.skeletonCell {
|
||||
composes: cell;
|
||||
@@ -40,7 +29,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.actionsCell {
|
||||
.statusCell {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -58,10 +47,6 @@
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.sourceChip {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.sourceChipText {
|
||||
display: block;
|
||||
max-width: 220px;
|
||||
|
||||
@@ -5,8 +5,7 @@ import { SpantypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas'
|
||||
import cx from 'classnames';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import { DraftMapper } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import MapperActionsMenu from '../MapperActionsMenu/MapperActionsMenu';
|
||||
import { Mapping } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import styles from './MapperRow.module.scss';
|
||||
|
||||
const MAX_VISIBLE_SOURCES = 3;
|
||||
@@ -16,20 +15,11 @@ const MAX_STAGGERED_ROWS = 6;
|
||||
const STAGGER_STEP = 0.03;
|
||||
|
||||
interface MapperRowProps {
|
||||
mapper: DraftMapper;
|
||||
mapper: Mapping;
|
||||
index: number;
|
||||
onEdit: (mapper: DraftMapper) => void;
|
||||
onRemove: (localId: string) => void;
|
||||
onToggle: (localId: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function MapperRow({
|
||||
mapper,
|
||||
index,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onToggle,
|
||||
}: MapperRowProps): JSX.Element {
|
||||
function MapperRow({ mapper, index }: MapperRowProps): JSX.Element {
|
||||
const sources = mapper.sources ?? [];
|
||||
const visibleSources = sources.slice(0, MAX_VISIBLE_SOURCES);
|
||||
const remainingSources = sources.length - visibleSources.length;
|
||||
@@ -37,7 +27,7 @@ function MapperRow({
|
||||
return (
|
||||
<motion.tr
|
||||
className={styles.mapperRow}
|
||||
data-testid={`mapper-row-${mapper.localId}`}
|
||||
data-testid={`mapper-row-${mapper.id}`}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
@@ -47,38 +37,22 @@ function MapperRow({
|
||||
>
|
||||
<td className={cx(styles.cell, styles.targetCell)}>
|
||||
<Typography.Text
|
||||
weight="semibold"
|
||||
truncate={1}
|
||||
title={mapper.name}
|
||||
className={styles.targetName}
|
||||
data-testid={`mapper-target-${mapper.localId}`}
|
||||
data-testid={`mapper-target-${mapper.id}`}
|
||||
>
|
||||
{mapper.name}
|
||||
</Typography.Text>
|
||||
<Badge
|
||||
color={
|
||||
mapper.fieldContext === SpantypesFieldContextDTO.resource
|
||||
? 'amber'
|
||||
: 'robin'
|
||||
}
|
||||
variant="outline"
|
||||
className={styles.targetContextBadge}
|
||||
>
|
||||
{mapper.fieldContext}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className={styles.cell}>
|
||||
{sources.length === 0 ? (
|
||||
<span
|
||||
className={styles.muted}
|
||||
data-testid={`mapper-sources-${mapper.localId}`}
|
||||
>
|
||||
<span className={styles.muted} data-testid={`mapper-sources-${mapper.id}`}>
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
className={styles.sources}
|
||||
data-testid={`mapper-sources-${mapper.localId}`}
|
||||
data-testid={`mapper-sources-${mapper.id}`}
|
||||
>
|
||||
{visibleSources.map((source) => (
|
||||
<Badge
|
||||
@@ -100,14 +74,25 @@ function MapperRow({
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className={cx(styles.cell, styles.actionsCell)}>
|
||||
<td className={styles.cell}>
|
||||
<Badge
|
||||
color={
|
||||
mapper.fieldContext === SpantypesFieldContextDTO.resource
|
||||
? 'amber'
|
||||
: 'robin'
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{mapper.fieldContext}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className={cx(styles.cell, styles.statusCell)}>
|
||||
<div className={styles.rowActions}>
|
||||
<Switch
|
||||
value={mapper.enabled}
|
||||
onChange={(checked): void => onToggle(mapper.localId, checked)}
|
||||
testId={`mapper-enabled-${mapper.localId}`}
|
||||
disabled
|
||||
testId={`mapper-enabled-${mapper.id}`}
|
||||
/>
|
||||
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
|
||||
@@ -8,7 +8,6 @@ function MapperRowSkeleton(): JSX.Element {
|
||||
<tr className={styles.mapperRow}>
|
||||
<td className={cx(styles.skeletonCell, styles.targetCell)}>
|
||||
<Skeleton.Input active size="small" style={{ width: '55%' }} />
|
||||
<Skeleton.Button active size="small" style={{ width: 72 }} />
|
||||
</td>
|
||||
<td className={styles.skeletonCell}>
|
||||
<div className={styles.sources}>
|
||||
@@ -16,7 +15,10 @@ function MapperRowSkeleton(): JSX.Element {
|
||||
<Skeleton.Button active size="small" style={{ width: 56 }} />
|
||||
</div>
|
||||
</td>
|
||||
<td className={cx(styles.skeletonCell, styles.actionsCell)}>
|
||||
<td className={styles.skeletonCell}>
|
||||
<Skeleton.Button active size="small" style={{ width: 72 }} />
|
||||
</td>
|
||||
<td className={cx(styles.skeletonCell, styles.statusCell)}>
|
||||
<div className={styles.rowActions}>
|
||||
<Skeleton.Button active size="small" shape="round" />
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
width: 32%;
|
||||
}
|
||||
|
||||
.colActions {
|
||||
width: 176px;
|
||||
.colWritesTo {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.colStatus {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ function MappingsColgroup(): JSX.Element {
|
||||
<colgroup>
|
||||
<col className={styles.colTarget} />
|
||||
<col />
|
||||
<col className={styles.colActions} />
|
||||
<col className={styles.colWritesTo} />
|
||||
<col className={styles.colStatus} />
|
||||
</colgroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
.tableWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -18,7 +5,7 @@
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
border-top: 1px solid var(--l2-border);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.headerCell {
|
||||
|
||||
@@ -1,95 +1,30 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ChevronDown, ChevronRight, Plus } from '@signozhq/icons';
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { Collapse, type CollapseProps, Skeleton } from 'antd';
|
||||
|
||||
import MapperFormDrawer from 'container/LLMObservability/AttributeMapping/components/MapperFormDrawer/MapperFormDrawer';
|
||||
import { useMapperFormDrawer } from 'container/LLMObservability/AttributeMapping/components/MapperFormDrawer/hooks/useMapperFormDrawer';
|
||||
import {
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { AttributeMappingStore } from 'container/LLMObservability/AttributeMapping/AttributeMappingsTab/hooks/useAttributeMappingStore';
|
||||
import GroupHeader from './GroupHeader/GroupHeader';
|
||||
import GroupHeaderActions from './GroupHeaderActions/GroupHeaderActions';
|
||||
import GroupMappers from './GroupMappers/GroupMappers';
|
||||
import MappingsColgroup from './MappingsColgroup/MappingsColgroup';
|
||||
import GroupHeader from './GroupHeader';
|
||||
import GroupHeaderActions from './GroupHeaderActions';
|
||||
import GroupMappers from './GroupMappers';
|
||||
import MappingsColgroup from './MappingsColgroup';
|
||||
import styles from './MappingsTable.module.scss';
|
||||
|
||||
const SKELETON_ROW_COUNT = 3;
|
||||
|
||||
interface MappingsTableProps {
|
||||
store: AttributeMappingStore;
|
||||
onEditGroup: (group: DraftGroup) => void;
|
||||
onAddGroup: () => void;
|
||||
}
|
||||
|
||||
function MappingsTable({
|
||||
store,
|
||||
onEditGroup,
|
||||
onAddGroup,
|
||||
}: MappingsTableProps): JSX.Element {
|
||||
function MappingsTable({ store }: MappingsTableProps): JSX.Element {
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||
const [targetGroupId, setTargetGroupId] = useState<string | null>(null);
|
||||
const drawer = useMapperFormDrawer();
|
||||
|
||||
const { upsertMapper, removeMapper } = store;
|
||||
|
||||
const handleAddMapper = useCallback(
|
||||
(groupLocalId: string): void => {
|
||||
setTargetGroupId(groupLocalId);
|
||||
// Keep the group open so the staged row is visible after save.
|
||||
setExpandedGroups((prev) =>
|
||||
prev.includes(groupLocalId) ? prev : [...prev, groupLocalId],
|
||||
);
|
||||
drawer.openForAdd();
|
||||
},
|
||||
[drawer],
|
||||
);
|
||||
|
||||
const handleEditMapper = useCallback(
|
||||
(groupLocalId: string, mapper: DraftMapper): void => {
|
||||
setTargetGroupId(groupLocalId);
|
||||
drawer.openForEdit(mapper);
|
||||
},
|
||||
[drawer],
|
||||
);
|
||||
|
||||
const handleSaveMapper = useCallback((): void => {
|
||||
if (targetGroupId) {
|
||||
upsertMapper(targetGroupId, drawer.draft);
|
||||
}
|
||||
drawer.close();
|
||||
}, [targetGroupId, upsertMapper, drawer]);
|
||||
|
||||
const handleDeleteMapper = useCallback((): void => {
|
||||
if (targetGroupId && drawer.draft.id) {
|
||||
removeMapper(targetGroupId, drawer.draft.id);
|
||||
}
|
||||
drawer.close();
|
||||
}, [targetGroupId, removeMapper, drawer]);
|
||||
|
||||
const isEmpty = !store.isLoading && store.groups.length === 0;
|
||||
|
||||
const items: CollapseProps['items'] = store.groups.map((group) => ({
|
||||
key: group.localId,
|
||||
key: group.id,
|
||||
label: <GroupHeader group={group} />,
|
||||
extra: (
|
||||
<GroupHeaderActions
|
||||
group={group}
|
||||
onToggle={store.toggleGroup}
|
||||
onEdit={onEditGroup}
|
||||
onRemove={store.removeGroup}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<GroupMappers
|
||||
group={group}
|
||||
store={store}
|
||||
onAddMapper={handleAddMapper}
|
||||
onEditMapper={handleEditMapper}
|
||||
/>
|
||||
),
|
||||
extra: <GroupHeaderActions group={group} />,
|
||||
children: <GroupMappers group={group} />,
|
||||
}));
|
||||
|
||||
const skeletonBanners = (
|
||||
@@ -110,76 +45,48 @@ function MappingsTable({
|
||||
</div>
|
||||
<div className={styles.skeletonGroupRight}>
|
||||
<Skeleton.Button active size="small" shape="round" />
|
||||
<Skeleton.Avatar active size={16} shape="square" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrapper}>
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onAddGroup}
|
||||
testId="add-group-row"
|
||||
disabled={store.isLoading}
|
||||
>
|
||||
Add a new group
|
||||
</Button>
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
|
||||
No mapping groups yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{isEmpty ? (
|
||||
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
|
||||
No mapping groups yet.
|
||||
</div>
|
||||
return (
|
||||
<div data-testid="mappings-table">
|
||||
<table className={styles.table}>
|
||||
<MappingsColgroup />
|
||||
<thead>
|
||||
<tr className={styles.headerRow}>
|
||||
<th className={styles.headerCell}>Target</th>
|
||||
<th className={styles.headerCell}>Sources</th>
|
||||
<th className={styles.headerCell}>Writes to</th>
|
||||
<th className={styles.headerCell}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
{store.isLoading ? (
|
||||
skeletonBanners
|
||||
) : (
|
||||
<div data-testid="mappings-table">
|
||||
<table className={styles.table}>
|
||||
<MappingsColgroup />
|
||||
<thead>
|
||||
<tr className={styles.headerRow}>
|
||||
<th className={styles.headerCell}>Target</th>
|
||||
<th className={styles.headerCell}>Sources</th>
|
||||
<th className={styles.headerCell}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
{store.isLoading ? (
|
||||
skeletonBanners
|
||||
) : (
|
||||
<Collapse
|
||||
className={styles.groupsCollapse}
|
||||
activeKey={expandedGroups}
|
||||
onChange={(keys): void =>
|
||||
setExpandedGroups(Array.isArray(keys) ? keys : [keys])
|
||||
}
|
||||
bordered={false}
|
||||
destroyInactivePanel
|
||||
expandIcon={({ isActive }): JSX.Element =>
|
||||
isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
items={items}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{drawer.isOpen && (
|
||||
<MapperFormDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
mode={drawer.mode}
|
||||
draft={drawer.draft}
|
||||
setDraft={drawer.setDraft}
|
||||
onClose={drawer.close}
|
||||
onSave={handleSaveMapper}
|
||||
onDelete={handleDeleteMapper}
|
||||
isSaving={false}
|
||||
isDeleting={false}
|
||||
saveError={null}
|
||||
<Collapse
|
||||
className={styles.groupsCollapse}
|
||||
activeKey={expandedGroups}
|
||||
onChange={(keys): void =>
|
||||
setExpandedGroups(Array.isArray(keys) ? keys : [keys])
|
||||
}
|
||||
bordered={false}
|
||||
destroyInactivePanel
|
||||
expandIcon={({ isActive }): JSX.Element =>
|
||||
isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
items={items}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const COLUMN_COUNT = 3;
|
||||
export const COLUMN_COUNT = 4;
|
||||
|
||||
@@ -1,326 +1,33 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { cloneDeep, isEqual } from 'lodash-es';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
useCreateSpanMapper,
|
||||
useCreateSpanMapperGroup,
|
||||
useDeleteSpanMapper,
|
||||
useDeleteSpanMapperGroup,
|
||||
useListSpanMapperGroups,
|
||||
useUpdateSpanMapper,
|
||||
useUpdateSpanMapperGroup,
|
||||
} from 'api/generated/services/spanmapper';
|
||||
import { useMemo } from 'react';
|
||||
import { SpantypesSpanMapperGroupDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useListSpanMapperGroups } from 'api/generated/services/spanmapper';
|
||||
|
||||
import { persistDraft, SaveMutations } from '../../saveDraft';
|
||||
import {
|
||||
DraftGroup,
|
||||
GroupDraft,
|
||||
Mapper,
|
||||
MapperDraft,
|
||||
MapperGroup,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildDraftGroup,
|
||||
buildDraftMapper,
|
||||
nodeFromGroupDraft,
|
||||
nodeFromMapperDraft,
|
||||
} from '../../utils';
|
||||
|
||||
const GROUPS_KEY_PREFIX = '/api/v1/span_mapper_groups';
|
||||
|
||||
function clone(groups: DraftGroup[]): DraftGroup[] {
|
||||
return cloneDeep(groups);
|
||||
}
|
||||
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { buildMappingGroup } from 'container/LLMObservability/AttributeMapping/utils';
|
||||
|
||||
export interface AttributeMappingStore {
|
||||
groups: DraftGroup[];
|
||||
// Server baseline (last-saved state) the working `groups` diff against.
|
||||
// Consumed by the Test tab to send only changed groups' mappers.
|
||||
snapshot: DraftGroup[];
|
||||
groups: MappingGroup[];
|
||||
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;
|
||||
hydrateGroupMappers: (groupServerId: string, mappers: Mapper[]) => 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;
|
||||
}
|
||||
|
||||
// Read-only store for the listing view: loads the server groups only. Each
|
||||
// group's mappers are fetched lazily when its panel is expanded (see
|
||||
// GroupMappers), so page load is a single request instead of an N+1 fan-out
|
||||
// across every group. Editing (enabled toggles, save/discard) and its drawers
|
||||
// land in a later PR — this PR only lists.
|
||||
export function useAttributeMappingStore(): AttributeMappingStore {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const groupsQuery = useListSpanMapperGroups();
|
||||
const { refetch: refetchGroups } = groupsQuery;
|
||||
const serverGroups: MapperGroup[] = useMemo(
|
||||
() => groupsQuery.data?.data?.items ?? [],
|
||||
[groupsQuery.data],
|
||||
);
|
||||
|
||||
const ready = !groupsQuery.isLoading;
|
||||
|
||||
const [loadedMappers, setLoadedMappers] = useState<Record<string, Mapper[]>>(
|
||||
{},
|
||||
);
|
||||
const loadedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const snapshot = useMemo<DraftGroup[]>(() => {
|
||||
if (!ready) {
|
||||
return [];
|
||||
}
|
||||
return serverGroups.map((group) =>
|
||||
buildDraftGroup(group, loadedMappers[group.id] ?? []),
|
||||
);
|
||||
}, [ready, serverGroups, loadedMappers]);
|
||||
|
||||
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 hydrateGroupMappers = useCallback(
|
||||
(groupServerId: string, mappers: Mapper[]): void => {
|
||||
if (loadedRef.current.has(groupServerId)) {
|
||||
return;
|
||||
}
|
||||
loadedRef.current.add(groupServerId);
|
||||
setLoadedMappers((prev) => ({ ...prev, [groupServerId]: mappers }));
|
||||
setDraft((prev) =>
|
||||
prev === null
|
||||
? prev
|
||||
: prev.map((group) =>
|
||||
group.serverId === groupServerId
|
||||
? {
|
||||
...group,
|
||||
mappers: [
|
||||
...mappers.map(buildDraftMapper),
|
||||
...group.mappers.filter((mapper) => mapper.serverId === null),
|
||||
],
|
||||
}
|
||||
: 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);
|
||||
// Refresh the groups list in place — it stays mounted, so this just
|
||||
// swaps in fresh data without a loading flash. Using the query's own
|
||||
// refetch keeps it scoped to the groups list; the per-group mapper
|
||||
// caches are handled separately below (mappers can change here, so
|
||||
// their cached lists must be dropped rather than left stale).
|
||||
const { isError: refreshFailed } = await refetchGroups();
|
||||
if (refreshFailed) {
|
||||
toast.warning(
|
||||
'Changes saved, but the list failed to refresh. Reload to see the latest.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Reset the mapper-load tracking so expanded groups re-hydrate from
|
||||
// the fresh server data.
|
||||
loadedRef.current = new Set();
|
||||
setLoadedMappers({});
|
||||
// Reset the working copy so the effect above re-seeds it from the
|
||||
// fresh snapshot (new server ids included).
|
||||
setDraft(null);
|
||||
// Evict the cached per-group mapper lists so an expanded group
|
||||
// refetches its mappers against the fresh server state.
|
||||
queryClient.removeQueries({
|
||||
predicate: (query) =>
|
||||
typeof query.queryKey?.[0] === 'string' &&
|
||||
(query.queryKey[0] as string).startsWith(`${GROUPS_KEY_PREFIX}/`),
|
||||
});
|
||||
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, refetchGroups, queryClient]);
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => draft !== null && !isEqual(draft, snapshot),
|
||||
[draft, snapshot],
|
||||
);
|
||||
const groups = useMemo<MappingGroup[]>(() => {
|
||||
const serverGroups: SpantypesSpanMapperGroupDTO[] =
|
||||
groupsQuery.data?.data?.items ?? [];
|
||||
return serverGroups.map((group) => buildMappingGroup(group));
|
||||
}, [groupsQuery.data]);
|
||||
|
||||
return {
|
||||
groups: draft ?? [],
|
||||
snapshot,
|
||||
isLoading: !ready || draft === null,
|
||||
groups,
|
||||
isLoading: groupsQuery.isLoading,
|
||||
isError: groupsQuery.isError,
|
||||
isDirty,
|
||||
isSaving,
|
||||
saveError,
|
||||
upsertGroup,
|
||||
removeGroup,
|
||||
toggleGroup,
|
||||
hydrateGroupMappers,
|
||||
upsertMapper,
|
||||
removeMapper,
|
||||
toggleMapper,
|
||||
save,
|
||||
discard,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
.llmObservabilityAttributeMapping {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--spacing-2);
|
||||
|
||||
--tab-content-padding: 0;
|
||||
--tab-text-color: var(--l1-foreground);
|
||||
--tab-active-text-color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.pageError {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-0);
|
||||
}
|
||||
|
||||
@@ -1,51 +1,24 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { useConfirmableAction } from 'hooks/useConfirmableAction';
|
||||
|
||||
import AttributeMappingHeader from './components/AttributeMappingHeader/AttributeMappingHeader';
|
||||
import AttributeMappingHeader from './components/AttributeMappingHeader';
|
||||
import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
|
||||
import DiscardChangesDialog from './components/DiscardChangesDialog/DiscardChangesDialog';
|
||||
import GroupFormDrawer from './components/GroupFormDrawer/GroupFormDrawer';
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import TestTab from './TestTab';
|
||||
import { useAttributeMappingStore } from './AttributeMappingsTab/hooks/useAttributeMappingStore';
|
||||
import { useGroupFormDrawer } from './components/GroupFormDrawer/hooks/useGroupFormDrawer';
|
||||
|
||||
const noop = (): void => undefined;
|
||||
|
||||
function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
const store = useAttributeMappingStore();
|
||||
const groupDrawer = useGroupFormDrawer();
|
||||
|
||||
const { discard } = store;
|
||||
// Discarding wipes the whole working copy, so gate it behind a confirm
|
||||
// prompt rather than firing straight from the button.
|
||||
const discardConfirm = useConfirmableAction(
|
||||
useCallback(async (): Promise<void> => {
|
||||
discard();
|
||||
}, [discard]),
|
||||
);
|
||||
|
||||
const handleGroupSave = useCallback((): void => {
|
||||
store.upsertGroup(groupDrawer.draft);
|
||||
groupDrawer.close();
|
||||
}, [store, groupDrawer]);
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'attribute-mappings',
|
||||
label: 'Attribute Mappings',
|
||||
children: (
|
||||
<AttributeMappingsTab
|
||||
store={store}
|
||||
onEditGroup={groupDrawer.openForEdit}
|
||||
onAddGroup={groupDrawer.openForAdd}
|
||||
/>
|
||||
),
|
||||
label: 'Attribute mappings',
|
||||
children: <AttributeMappingsTab />,
|
||||
},
|
||||
{
|
||||
key: 'test',
|
||||
label: 'Test',
|
||||
children: <TestTab store={store} />,
|
||||
disabled: true,
|
||||
disabledReason: 'Coming soon',
|
||||
children: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -55,39 +28,17 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
data-testid="llm-observability-attribute-mapping-page"
|
||||
>
|
||||
<AttributeMappingHeader
|
||||
isDirty={store.isDirty}
|
||||
isSaving={store.isSaving}
|
||||
onDiscard={discardConfirm.request}
|
||||
onSave={store.save}
|
||||
isDirty={false}
|
||||
isSaving={false}
|
||||
onDiscard={noop}
|
||||
onSave={noop}
|
||||
/>
|
||||
|
||||
{store.saveError && (
|
||||
<div className={styles.pageError} role="alert">
|
||||
{store.saveError}
|
||||
</div>
|
||||
)}
|
||||
<Divider />
|
||||
|
||||
<Tabs
|
||||
testId="attribute-mapping-tabs"
|
||||
defaultValue="attribute-mappings"
|
||||
items={tabItems}
|
||||
/>
|
||||
{groupDrawer.isOpen && (
|
||||
<GroupFormDrawer
|
||||
isOpen={groupDrawer.isOpen}
|
||||
mode={groupDrawer.mode}
|
||||
draft={groupDrawer.draft}
|
||||
setDraft={groupDrawer.setDraft}
|
||||
onClose={groupDrawer.close}
|
||||
onSave={handleGroupSave}
|
||||
/>
|
||||
)}
|
||||
<DiscardChangesDialog
|
||||
open={discardConfirm.open}
|
||||
onConfirm={discardConfirm.confirm}
|
||||
onCancel={discardConfirm.cancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { SpantypesSpanMapperTestSpanDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { AttrChangeStatus, diffSpanAttributes } from './testPayload';
|
||||
import styles from './TestTab.module.scss';
|
||||
|
||||
interface TestResultProps {
|
||||
index: number;
|
||||
span: SpantypesSpanMapperTestSpanDTO;
|
||||
inputAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Partial<
|
||||
Record<
|
||||
AttrChangeStatus,
|
||||
{ color: 'success' | 'robin' | 'sienna'; label: string }
|
||||
>
|
||||
> = {
|
||||
added: { color: 'success', label: 'populated' },
|
||||
changed: { color: 'robin', label: 'remapped' },
|
||||
removed: { color: 'sienna', label: 'moved out' },
|
||||
};
|
||||
|
||||
// Only added/removed rows carry a background treatment; the rest render plain.
|
||||
// Kept as an explicit map so we never do dynamic `styles[...]` access.
|
||||
const ROW_CLASS: Partial<Record<AttrChangeStatus, string>> = {
|
||||
added: styles.added,
|
||||
removed: styles.removed,
|
||||
};
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// Renders one transformed span as a key/value list, highlighting which target
|
||||
// attributes the mappers populated and which source keys a move consumed.
|
||||
function TestResult({
|
||||
index,
|
||||
span,
|
||||
inputAttributes,
|
||||
}: TestResultProps): JSX.Element {
|
||||
const entries = useMemo(
|
||||
() => diffSpanAttributes(inputAttributes, span.attributes ?? {}),
|
||||
[inputAttributes, span.attributes],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.resultCard} data-testid={`test-result-${index}`}>
|
||||
<div className={styles.resultTitle}>Resulting attributes</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className={styles.resultEmpty}>This span has no attributes.</div>
|
||||
) : (
|
||||
<div className={styles.attrRows}>
|
||||
{entries.map((entry) => {
|
||||
const badge = STATUS_BADGE[entry.status];
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
className={`${styles.attrRow} ${ROW_CLASS[entry.status] ?? ''}`}
|
||||
>
|
||||
<span className={styles.attrKey} title={entry.key}>
|
||||
{entry.key}
|
||||
</span>
|
||||
<span className={styles.attrValue} title={formatValue(entry.value)}>
|
||||
{formatValue(entry.value)}
|
||||
</span>
|
||||
{badge ? (
|
||||
<Badge color={badge.color} variant="outline">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestResult;
|
||||
@@ -1,161 +0,0 @@
|
||||
.testTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
margin-top: var(--spacing-6);
|
||||
}
|
||||
|
||||
// Heading + description on the left, Run button pinned to the top-right.
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.headerText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-6);
|
||||
height: calc(100vh - 320px);
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resultsPanel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: var(--padding-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
// Pre-run empty state that fills the blank right half.
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-2);
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.placeholderTitle {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.resultCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.resultTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.resultEmpty {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.attrRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.attrRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--padding-3);
|
||||
border-radius: var(--radius-2);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
&.added {
|
||||
background: var(--callout-success-background);
|
||||
}
|
||||
|
||||
&.removed {
|
||||
opacity: 0.65;
|
||||
|
||||
.attrKey,
|
||||
.attrValue {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attrKey,
|
||||
.attrValue {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.attrKey {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.attrValue {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { Play } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import Editor from 'components/Editor';
|
||||
|
||||
import styles from './TestTab.module.scss';
|
||||
import TestResult from './TestResult';
|
||||
import { AttributeMappingStore } from './AttributeMappingsTab/hooks/useAttributeMappingStore';
|
||||
import { useTestSpanMapper } from './useTestSpanMapper';
|
||||
|
||||
interface TestTabProps {
|
||||
store: AttributeMappingStore;
|
||||
}
|
||||
|
||||
// "Test" tab: paste a sample span, run it through the working draft's mappers,
|
||||
// and see which target attributes get populated. Only groups whose mappers
|
||||
// changed are sent in full — unchanged groups are backfilled server-side.
|
||||
function TestTab({ store }: TestTabProps): JSX.Element {
|
||||
const { input, setInput, run, isRunning, result, testedAttributes, error } =
|
||||
useTestSpanMapper(store.snapshot, store.groups);
|
||||
|
||||
function renderResults(): JSX.Element {
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.error} role="alert" data-testid="test-error">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (result) {
|
||||
if (result.length === 0) {
|
||||
return (
|
||||
<div className={styles.resultEmpty} data-testid="test-results-empty">
|
||||
No spans returned. The mappers produced no output for this input.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.results} data-testid="test-results">
|
||||
{result.map((span, index) => (
|
||||
<TestResult
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
index={index}
|
||||
span={span}
|
||||
inputAttributes={testedAttributes ?? {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.placeholder} data-testid="test-results-placeholder">
|
||||
<span className={styles.placeholderTitle}>No results yet</span>
|
||||
<span>
|
||||
Run the test to see which target attributes your mappers populate.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.testTab} data-testid="test-tab">
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerText}>
|
||||
<h3 className={styles.heading}>Test with sample span</h3>
|
||||
<p className={styles.description}>
|
||||
Paste a JSON span object to see which target attributes get populated and
|
||||
which source key matched.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
testId="run-test-button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Play size={14} />}
|
||||
onClick={run}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}
|
||||
>
|
||||
Run Test
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div
|
||||
className={styles.editor}
|
||||
data-testid="test-span-input"
|
||||
aria-label="Sample span JSON"
|
||||
>
|
||||
<Editor
|
||||
language="json"
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
height="100%"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
formatOnPaste: true,
|
||||
tabSize: 2,
|
||||
automaticLayout: true,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.resultsPanel} data-testid="test-results-panel">
|
||||
{renderResults()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestTab;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
|
||||
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
|
||||
@@ -34,7 +34,7 @@ describe('LLMObservabilityAttributeMapping', () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Attribute Mappings' }),
|
||||
screen.getByRole('tab', { name: 'Attribute mappings' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Test' })).toBeInTheDocument();
|
||||
});
|
||||
@@ -43,7 +43,7 @@ describe('LLMObservabilityAttributeMapping', () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
const attributeMappingsTab = screen.getByRole('tab', {
|
||||
name: 'Attribute Mappings',
|
||||
name: 'Attribute mappings',
|
||||
});
|
||||
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
|
||||
await expect(
|
||||
@@ -64,49 +64,4 @@ describe('LLMObservabilityAttributeMapping', () => {
|
||||
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('unsaved-changes')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prompts before discarding, then reverts staged changes on confirm', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
// Stage a change (toggle a group) so the Discard action appears.
|
||||
const toggle = await screen.findByTestId('group-enabled-group-1');
|
||||
expect(toggle).toBeChecked();
|
||||
await user.click(toggle);
|
||||
expect(screen.getByTestId('group-enabled-group-1')).not.toBeChecked();
|
||||
|
||||
// Discard opens a confirm prompt rather than reverting immediately — the
|
||||
// change is still staged while the prompt is open.
|
||||
await user.click(screen.getByTestId('discard-changes-btn'));
|
||||
const confirmBtn = await screen.findByTestId('discard-changes-confirm-btn');
|
||||
expect(screen.getByTestId('group-enabled-group-1')).not.toBeChecked();
|
||||
|
||||
await user.click(confirmBtn);
|
||||
|
||||
// Confirmed: the change is reverted and the dirty-only actions disappear.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('group-enabled-group-1')).toBeChecked(),
|
||||
);
|
||||
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps staged changes when the discard prompt is dismissed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
const toggle = await screen.findByTestId('group-enabled-group-1');
|
||||
await user.click(toggle);
|
||||
|
||||
await user.click(screen.getByTestId('discard-changes-btn'));
|
||||
await user.click(await screen.findByTestId('discard-changes-cancel-btn'));
|
||||
|
||||
// Prompt closes and the staged change is preserved.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId('discard-changes-confirm-btn'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByTestId('group-enabled-group-1')).not.toBeChecked();
|
||||
expect(screen.getByTestId('discard-changes-btn')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-left: var(--spacing-2);
|
||||
margin-top: var(--spacing-4);
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderActions {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AttributeMappingHeader';
|
||||
@@ -1,60 +0,0 @@
|
||||
import { AlertDialog } from '@signozhq/ui/alert-dialog';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
|
||||
interface DiscardChangesDialogProps {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Confirmation before discarding staged attribute-mapping edits. Discard throws
|
||||
// away every unsaved group/mapper change in the working copy and can't be
|
||||
// undone, so it's gated behind an explicit confirm. AlertDialog blocks
|
||||
// outside-click dismissal to force a deliberate choice.
|
||||
function DiscardChangesDialog({
|
||||
open,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DiscardChangesDialogProps): JSX.Element {
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
width="narrow"
|
||||
title="Discard unsaved changes?"
|
||||
titleIcon={<Trash2 size={16} />}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
prefix={<X size={12} />}
|
||||
testId="discard-changes-cancel-btn"
|
||||
>
|
||||
Keep editing
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
onClick={onConfirm}
|
||||
prefix={<Trash2 size={12} />}
|
||||
testId="discard-changes-confirm-btn"
|
||||
>
|
||||
Discard changes
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
This reverts every unsaved group and mapping change back to the last saved
|
||||
state. This action cannot be undone.
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default DiscardChangesDialog;
|
||||
@@ -1,39 +0,0 @@
|
||||
.groupForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
padding: var(--spacing-2) 0;
|
||||
}
|
||||
|
||||
.groupFormField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.groupFormFieldRow {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.groupFormLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.groupFormHint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.groupFormFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
@@ -1,117 +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 ConditionKeyList from './components/ConditionKeyList/ConditionKeyList';
|
||||
import styles from './GroupFormDrawer.module.scss';
|
||||
import { FieldContext, GroupDraft, MapperDraftMode } from '../../types';
|
||||
import { isGroupDraftValid } from '../../utils';
|
||||
|
||||
interface GroupFormDrawerProps {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: GroupDraft;
|
||||
setDraft: (next: GroupDraft) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
function GroupFormDrawer({
|
||||
isOpen,
|
||||
mode,
|
||||
draft,
|
||||
setDraft,
|
||||
onClose,
|
||||
onSave,
|
||||
}: 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={styles.groupFormFooter}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
testId="group-form-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
disabled={!isValid}
|
||||
testId="group-form-save"
|
||||
>
|
||||
{isEdit ? 'Save group' : 'Create group'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.groupForm}>
|
||||
<div className={styles.groupFormField}>
|
||||
<span className={styles.groupFormLabel}>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={`${styles.groupFormField} ${styles.groupFormFieldRow}`}>
|
||||
<span className={styles.groupFormLabel}>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={styles.groupFormHint}>
|
||||
Leave both empty to run this group on every span.
|
||||
</span>
|
||||
</div>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupFormDrawer;
|
||||
@@ -1,37 +0,0 @@
|
||||
// Mirrors the drawer's field/label rhythm (see GroupFormDrawer.module.scss)
|
||||
// so the key lists sit flush with the other form fields.
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.labelHint {
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.keys {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.keyRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.keyInput {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus, X } from '@signozhq/icons';
|
||||
|
||||
import { FieldContextValue } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
|
||||
import styles from './ConditionKeyList.module.scss';
|
||||
|
||||
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={styles.field}>
|
||||
<span className={styles.label}>
|
||||
{label}
|
||||
{labelHint && <span className={styles.labelHint}> {labelHint}</span>}
|
||||
</span>
|
||||
|
||||
{keys.length > 0 && (
|
||||
<div className={styles.keys}>
|
||||
{keys.map((key, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div className={styles.keyRow} key={index}>
|
||||
<KeySearchInput
|
||||
className={styles.keyInput}
|
||||
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;
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { DraftGroup, GroupDraft, MapperDraftMode } from '../../../types';
|
||||
import { EMPTY_GROUP_DRAFT, groupDraftFromNode } from '../../../utils';
|
||||
|
||||
interface UseGroupFormDrawer {
|
||||
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(): UseGroupFormDrawer {
|
||||
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 };
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
.keySearch {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keySearchDropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--spacing-2));
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
// Match the trigger input's width — long keys truncate per-option.
|
||||
width: 100%;
|
||||
max-height: 240px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-2);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 8px 24px
|
||||
color-mix(in srgb, var(--bg-base-black) 40%, transparent);
|
||||
}
|
||||
|
||||
.keySearchDropdownEmpty {
|
||||
padding: var(--spacing-4) var(--spacing-5);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.keySearchOption {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: var(--spacing-3) var(--spacing-5);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +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 Spinner from 'components/Spinner';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
import {
|
||||
FieldContext,
|
||||
FieldContextValue,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import styles from './KeySearchInput.module.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(styles.keySearch, 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={styles.keySearchDropdown}
|
||||
data-testid={`${testId}-dropdown`}
|
||||
>
|
||||
{suggestions.map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
className={styles.keySearchOption}
|
||||
title={name}
|
||||
// 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={cx(styles.keySearchDropdown, styles.keySearchDropdownEmpty)}
|
||||
>
|
||||
<Spinner size="small" height="auto" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeySearchInput;
|
||||
@@ -1,63 +0,0 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
padding: var(--spacing-2) 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.labelHint {
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.fieldContext {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--spacing-5) var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import { 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/KeySearchInput';
|
||||
import styles from './MapperFormDrawer.module.scss';
|
||||
import SourceAttributeRow from './components/SourceAttributeRow/SourceAttributeRow';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldContextValue,
|
||||
MapperDraft,
|
||||
MapperDraftMode,
|
||||
SourceConfig,
|
||||
} from '../../types';
|
||||
import { createEmptySource, isMapperDraftValid } from '../../utils';
|
||||
|
||||
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);
|
||||
|
||||
const [rowIds, setRowIds] = useState<string[]>(() =>
|
||||
draft.sources.map(() => uuid()),
|
||||
);
|
||||
const sourceIds = draft.sources.map(
|
||||
(_, index) => rowIds[index] ?? `pending-${index}`,
|
||||
);
|
||||
|
||||
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={styles.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={styles.footerActions}>
|
||||
<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={styles.form}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.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={styles.hint}>
|
||||
The target attribute can't be changed after creation.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Write target to</span>
|
||||
<SelectSimple
|
||||
className={styles.fieldContext}
|
||||
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={styles.hint}>
|
||||
Where the standardized attribute is written.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>
|
||||
Source attributes
|
||||
<span className={styles.labelHint}>
|
||||
{' '}
|
||||
· priority: top → bottom · drag to reorder
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={sourceIds} strategy={verticalListSortingStrategy}>
|
||||
<div className={styles.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={styles.error} role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperFormDrawer;
|
||||
@@ -1,40 +0,0 @@
|
||||
.source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.sourceHandle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-2);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--l3-foreground);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.sourceIndex {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.sourceInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
.sourceSelect {
|
||||
flex: 0 0 auto;
|
||||
width: 120px;
|
||||
}
|
||||
@@ -1,112 +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 {
|
||||
FieldContext,
|
||||
FieldContextValue,
|
||||
MapperOperation,
|
||||
MapperOperationValue,
|
||||
SourceConfig,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
|
||||
import styles from './SourceAttributeRow.module.scss';
|
||||
|
||||
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;
|
||||
}
|
||||
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={styles.source} ref={setNodeRef} style={style}>
|
||||
<div
|
||||
className={styles.sourceHandle}
|
||||
data-testid={`mapper-form-source-handle-${index}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
<span className={styles.sourceIndex}>{index + 1}</span>
|
||||
<KeySearchInput
|
||||
className={styles.sourceInput}
|
||||
placeholder="Source attribute key"
|
||||
value={value.key}
|
||||
fieldContext={value.context}
|
||||
onChange={(key): void => onChange(index, { key })}
|
||||
testId={`mapper-form-source-${index}`}
|
||||
/>
|
||||
<SelectSimple
|
||||
className={styles.sourceSelect}
|
||||
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={styles.sourceSelect}
|
||||
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;
|
||||
@@ -1,38 +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;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
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 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 ||
|
||||
!isEqual(snapshot.attributes, draft.attributes) ||
|
||||
!isEqual(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),
|
||||
);
|
||||
|
||||
// Creates/updates before deletes (see persistDraft). Sequential for
|
||||
// deterministic ordering.
|
||||
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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deletes last.
|
||||
await Promise.all(
|
||||
snapshotMappers
|
||||
.filter((mapper) => mapper.serverId && !draftServerIds.has(mapper.serverId))
|
||||
.map((mapper) => m.deleteMapper(groupServerId, mapper.serverId as string)),
|
||||
);
|
||||
}
|
||||
|
||||
// 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),
|
||||
);
|
||||
|
||||
// Creates/updates before deletes: a mid-save failure then leaves incomplete
|
||||
// additions rather than groups deleted with no replacement. Deletes are
|
||||
// irreversible (cascade mappers server-side), so they run last.
|
||||
//
|
||||
// Names are unique per scope, so deleting and recreating the same name in
|
||||
// one save collides and errors — recoverable by splitting across two saves,
|
||||
// which we favour over the data loss delete-first would risk.
|
||||
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);
|
||||
}
|
||||
|
||||
// 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)),
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesPostableSpanMapperTestGroupDTO,
|
||||
SpantypesSpanMapperTestSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { DraftGroup } from './types';
|
||||
import {
|
||||
buildPostableGroup,
|
||||
buildPostableMapper,
|
||||
groupDraftFromNode,
|
||||
mapperDraftFromNode,
|
||||
} from './utils';
|
||||
|
||||
// A group's mappers must be sent in full when the group is new (the backend has
|
||||
// no saved group of that name to backfill from) or when its mapper set has
|
||||
// diverged from the saved baseline. Otherwise we omit them and let the backend
|
||||
// load the saved mappers by group name — which also covers groups whose rows
|
||||
// were never expanded (their draft carries no mappers yet).
|
||||
function shouldSendMappers(
|
||||
snap: DraftGroup | undefined,
|
||||
group: DraftGroup,
|
||||
): boolean {
|
||||
if (!snap) {
|
||||
return true;
|
||||
}
|
||||
return JSON.stringify(snap.mappers) !== JSON.stringify(group.mappers);
|
||||
}
|
||||
|
||||
// Builds the `groups` portion of the test request from the working draft,
|
||||
// sending each group's name/enabled/condition from the current draft and its
|
||||
// `mappers` only when they changed (null otherwise → backend backfills).
|
||||
export function buildTestGroups(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
): SpantypesPostableSpanMapperTestGroupDTO[] {
|
||||
const snapById = new Map(
|
||||
snapshot
|
||||
.filter((group) => group.serverId)
|
||||
.map((group) => [group.serverId as string, group]),
|
||||
);
|
||||
|
||||
return draft.map((group) => {
|
||||
const base = buildPostableGroup(groupDraftFromNode(group));
|
||||
const snap = group.serverId ? snapById.get(group.serverId) : undefined;
|
||||
return {
|
||||
...base,
|
||||
mappers: shouldSendMappers(snap, group)
|
||||
? group.mappers.map((mapper) =>
|
||||
buildPostableMapper(mapperDraftFromNode(mapper)),
|
||||
)
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Parses the pasted JSON into a single test span. The pasted object is treated
|
||||
// as the span's attribute map (matching the sample shown to the user); resource
|
||||
// is left empty. Throws a friendly error on anything that isn't a JSON object.
|
||||
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Paste a JSON span object to run the test.');
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Invalid JSON — check for trailing commas or missing quotes.',
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Span must be a JSON object of attribute key-value pairs.');
|
||||
}
|
||||
|
||||
return { attributes: parsed as Record<string, unknown>, resource: {} };
|
||||
}
|
||||
|
||||
export function buildTestRequest(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
input: string,
|
||||
): SpantypesPostableSpanMapperTestDTO {
|
||||
return {
|
||||
groups: buildTestGroups(snapshot, draft),
|
||||
spans: [parseSpanInput(input)],
|
||||
};
|
||||
}
|
||||
|
||||
export type AttrChangeStatus = 'added' | 'changed' | 'unchanged' | 'removed';
|
||||
|
||||
export interface AttrDiffEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
status: AttrChangeStatus;
|
||||
}
|
||||
|
||||
function valuesEqual(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
// Diffs the span attributes a user pasted against the attributes the mappers
|
||||
// produced, so the UI can highlight which target keys got populated ('added'),
|
||||
// which source keys were consumed by a move ('removed'), and what stayed.
|
||||
// Added keys (the populated targets) sort first as the primary signal.
|
||||
export function diffSpanAttributes(
|
||||
inputAttributes: Record<string, unknown>,
|
||||
resultAttributes: Record<string, unknown>,
|
||||
): AttrDiffEntry[] {
|
||||
const added: AttrDiffEntry[] = [];
|
||||
const changed: AttrDiffEntry[] = [];
|
||||
const unchanged: AttrDiffEntry[] = [];
|
||||
const removed: AttrDiffEntry[] = [];
|
||||
|
||||
Object.entries(resultAttributes).forEach(([key, value]) => {
|
||||
if (!(key in inputAttributes)) {
|
||||
added.push({ key, value, status: 'added' });
|
||||
} else if (!valuesEqual(inputAttributes[key], value)) {
|
||||
changed.push({ key, value, status: 'changed' });
|
||||
} else {
|
||||
unchanged.push({ key, value, status: 'unchanged' });
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(inputAttributes).forEach(([key, value]) => {
|
||||
if (!(key in resultAttributes)) {
|
||||
removed.push({ key, value, status: 'removed' });
|
||||
}
|
||||
});
|
||||
|
||||
return [...added, ...changed, ...unchanged, ...removed];
|
||||
}
|
||||
@@ -1,58 +1,26 @@
|
||||
import {
|
||||
SpantypesFieldContextDTO,
|
||||
SpantypesSpanMapperDTO,
|
||||
SpantypesSpanMapperGroupDTO,
|
||||
SpantypesSpanMapperOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type MapperGroup = SpantypesSpanMapperGroupDTO;
|
||||
export type Mapper = SpantypesSpanMapperDTO;
|
||||
export const FieldContext = SpantypesFieldContextDTO;
|
||||
export type FieldContextValue = SpantypesFieldContextDTO;
|
||||
export const MapperOperation = SpantypesSpanMapperOperationDTO;
|
||||
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
|
||||
|
||||
export type MapperDraftMode = 'add' | 'edit';
|
||||
|
||||
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;
|
||||
export interface Mapping {
|
||||
id: string;
|
||||
name: string;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
sources: SourceConfig[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface GroupDraft {
|
||||
id: string | null;
|
||||
export interface MappingGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DraftMapper {
|
||||
localId: string;
|
||||
serverId: string | null;
|
||||
name: string;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
sources: SourceConfig[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DraftGroup {
|
||||
localId: string;
|
||||
serverId: string | null;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
enabled: boolean;
|
||||
mappers: DraftMapper[];
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesSpanMapperTestSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useTestSpanMappers } from 'api/generated/services/spanmapper';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { buildTestRequest } from './testPayload';
|
||||
import { DraftGroup } from './types';
|
||||
|
||||
// Pre-filled sample so the tab is runnable on first open (mirrors the design).
|
||||
export const SAMPLE_SPAN_JSON = `{
|
||||
"my_company.llm.input": "What is quantum computing?",
|
||||
"llm.input_messages": "What is quantum computing?",
|
||||
"gen_ai.request.model": "gpt-4",
|
||||
"gen_ai.usage.total_tokens": 1250,
|
||||
"gen_ai.content.completion": "Quantum computing leverages..."
|
||||
}`;
|
||||
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
const axiosError = error as AxiosError<RenderErrorResponseDTO>;
|
||||
return (
|
||||
axiosError?.response?.data?.error?.message ??
|
||||
(error instanceof Error ? error.message : 'Test failed. Please try again.')
|
||||
);
|
||||
}
|
||||
|
||||
export interface UseTestSpanMapper {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
run: () => void;
|
||||
reset: () => void;
|
||||
isRunning: boolean;
|
||||
result: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
// The attributes that were actually submitted with the last successful run,
|
||||
// so the result diff stays stable even if the textarea is edited afterwards.
|
||||
testedAttributes: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Owns the Test tab's local state: the pasted span JSON, the parsed/built
|
||||
// request, and the result/error of the test mutation. Builds the request from
|
||||
// the working draft (sending only changed groups' mappers — see testPayload).
|
||||
export function useTestSpanMapper(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
): UseTestSpanMapper {
|
||||
const [input, setInput] = useState<string>(SAMPLE_SPAN_JSON);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<SpantypesSpanMapperTestSpanDTO[] | null>(
|
||||
null,
|
||||
);
|
||||
const [testedAttributes, setTestedAttributes] = useState<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null>(null);
|
||||
|
||||
const { mutate, isLoading } = useTestSpanMappers();
|
||||
|
||||
const run = useCallback((): void => {
|
||||
setError(null);
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = buildTestRequest(snapshot, draft, input);
|
||||
} catch (parseError) {
|
||||
setResult(null);
|
||||
setError(apiErrorMessage(parseError));
|
||||
return;
|
||||
}
|
||||
|
||||
const submittedAttributes = (body.spans?.[0]?.attributes ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
mutate(
|
||||
{ data: body },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
setTestedAttributes(submittedAttributes);
|
||||
setResult(response.data?.spans ?? []);
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setResult(null);
|
||||
setError(apiErrorMessage(mutationError));
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [snapshot, draft, input, mutate]);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setTestedAttributes(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
input,
|
||||
setInput,
|
||||
run,
|
||||
reset,
|
||||
isRunning: isLoading,
|
||||
result,
|
||||
testedAttributes,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -1,49 +1,15 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
ListSpanMappers200,
|
||||
SpantypesSpanMapperDTO,
|
||||
SpantypesSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
FieldContext,
|
||||
GroupDraft,
|
||||
Mapper,
|
||||
MapperDraft,
|
||||
MapperGroup,
|
||||
MapperOperation,
|
||||
SourceConfig,
|
||||
} from './types';
|
||||
import { MappingGroup, Mapping, 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.
|
||||
function genLocalId(prefix: 'group' | 'mapper'): string {
|
||||
return `local-${prefix}-${uuid()}`;
|
||||
}
|
||||
|
||||
// Trimmed, de-duplicated, non-empty keys preserving input order.
|
||||
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;
|
||||
}
|
||||
|
||||
// Source configs for a mapper, highest priority first (first match wins at
|
||||
// evaluation time).
|
||||
function getMapperSources(mapper: Mapper): SourceConfig[] {
|
||||
function getMapperSources(mapper: SpantypesSpanMapperDTO): SourceConfig[] {
|
||||
const sources = mapper.config?.sources ?? [];
|
||||
return [...sources]
|
||||
.sort((a, b) => b.priority - a.priority)
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map((source) => ({
|
||||
key: source.key,
|
||||
context: source.context,
|
||||
@@ -51,116 +17,9 @@ function getMapperSources(mapper: Mapper): SourceConfig[] {
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- mapper form helpers ----
|
||||
|
||||
export function createEmptySource(): SourceConfig {
|
||||
export function buildMapping(mapper: SpantypesSpanMapperDTO): Mapping {
|
||||
return {
|
||||
key: '',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
};
|
||||
}
|
||||
|
||||
export const EMPTY_MAPPER_DRAFT: MapperDraft = {
|
||||
id: null,
|
||||
name: '',
|
||||
fieldContext: FieldContext.attribute,
|
||||
sources: [createEmptySource()],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
function getCleanSources(draft: MapperDraft): SourceConfig[] {
|
||||
const seen = new Set<string>();
|
||||
const result: SourceConfig[] = [];
|
||||
draft.sources.forEach((source) => {
|
||||
const key = source.key.trim();
|
||||
const dedupeKey = `${source.context}:${key}`;
|
||||
if (key && !seen.has(dedupeKey)) {
|
||||
seen.add(dedupeKey);
|
||||
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) },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- group form helpers ----
|
||||
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
id: mapper.id,
|
||||
name: mapper.name,
|
||||
fieldContext: mapper.fieldContext,
|
||||
sources: getMapperSources(mapper),
|
||||
@@ -168,71 +27,22 @@ export function buildDraftMapper(mapper: Mapper): DraftMapper {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDraftGroup(
|
||||
group: MapperGroup,
|
||||
mappers: Mapper[],
|
||||
): DraftGroup {
|
||||
export function buildMappingsFromListResponse(
|
||||
response: ListSpanMappers200,
|
||||
): Mapping[] {
|
||||
const items = (response.data?.items ??
|
||||
[]) as unknown as SpantypesSpanMapperDTO[];
|
||||
return items.map(buildMapping);
|
||||
}
|
||||
|
||||
export function buildMappingGroup(
|
||||
group: SpantypesSpanMapperGroupDTO,
|
||||
): MappingGroup {
|
||||
return {
|
||||
localId: group.id,
|
||||
serverId: group.id,
|
||||
id: 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,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,10 +4,4 @@
|
||||
height: 100%;
|
||||
margin-top: var(--spacing-2);
|
||||
margin-left: var(--spacing-2);
|
||||
--tab-text-color: var(--l1-foreground);
|
||||
--tab-active-text-color: var(--l1-foreground);
|
||||
[role='tabpanel'] {
|
||||
margin: 0;
|
||||
padding: var(--spacing-0) var(--spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,4 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
--tab-content-padding: 0;
|
||||
margin-top: var(--spacing-3);
|
||||
--tab-text-color: var(--l1-foreground);
|
||||
--tab-active-text-color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.tabLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tabBadge {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1,58 +1,30 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
|
||||
import { MODEL_COSTS_TAB, TAB_KEY, UNPRICED_MODELS_TAB } from './constants';
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
import ModelCostTabPanel from './ModelCostTabPanel';
|
||||
import UnpricedModelsTab from './UnpricedModelsTab';
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
|
||||
function LLMObservabilityModelPricing(): JSX.Element {
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
TAB_KEY,
|
||||
parseAsStringEnum([MODEL_COSTS_TAB, UNPRICED_MODELS_TAB]).withDefault(
|
||||
MODEL_COSTS_TAB,
|
||||
),
|
||||
);
|
||||
|
||||
// Count powers the tab badge; deduped with the tab's own fetch by react-query.
|
||||
const { data } = useListUnmappedLLMModels();
|
||||
const unpricedCount = data?.data?.items?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.llmObservabilityModelPricing}
|
||||
data-testid="llm-observability-model-pricing-page"
|
||||
>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(key): void => {
|
||||
void setActiveTab(key as typeof activeTab);
|
||||
}}
|
||||
// Model costs is the only enabled tab for now, so default to it. When
|
||||
// the unpriced-models tab lands in a later PR.
|
||||
defaultValue="model-costs"
|
||||
items={[
|
||||
{
|
||||
key: MODEL_COSTS_TAB,
|
||||
key: 'model-costs',
|
||||
label: 'Model costs',
|
||||
children: <ModelCostTabPanel />,
|
||||
},
|
||||
{
|
||||
key: UNPRICED_MODELS_TAB,
|
||||
label: (
|
||||
<span className={styles.tabLabel}>
|
||||
Unpriced models
|
||||
{unpricedCount > 0 && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className={styles.tabBadge}
|
||||
data-testid="unpriced-models-count"
|
||||
>
|
||||
{unpricedCount}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
children: <UnpricedModelsTab />,
|
||||
// Unpriced-models tab lands in a later PR.
|
||||
key: 'unpriced-models',
|
||||
label: 'Unpriced models',
|
||||
disabled: true,
|
||||
children: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
gap: var(--spacing-6);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.filtersBarLeft {
|
||||
|
||||
@@ -110,7 +110,7 @@ function ModelCostDrawer({
|
||||
>
|
||||
<div className={styles.drawerSection}>
|
||||
<label htmlFor="billing-model-id">
|
||||
Billing Model ID{' '}
|
||||
Billing model ID{' '}
|
||||
<span className={styles.required} aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
|
||||
@@ -68,10 +68,10 @@ function ExtraPricingBuckets({
|
||||
<div className={cx(styles.extraBucketsSection, styles.drawerSection)}>
|
||||
<div className={styles.extraBucketsSectionHead}>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
Extra Pricing Buckets
|
||||
Extra pricing buckets
|
||||
</Typography.Text>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
Optional
|
||||
optional
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ function PricingFields({
|
||||
<div className={styles.pricingGrid}>
|
||||
<div className={styles.pricingField}>
|
||||
<label htmlFor="input-cost">
|
||||
Input Cost{' '}
|
||||
Input cost{' '}
|
||||
<span className={styles.required} aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
@@ -59,7 +59,7 @@ function PricingFields({
|
||||
</div>
|
||||
<div className={styles.pricingField}>
|
||||
<label htmlFor="output-cost">
|
||||
Output Cost{' '}
|
||||
Output cost{' '}
|
||||
<span className={styles.required} aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getListLLMPricingRulesQueryKey,
|
||||
getListUnmappedLLMModelsQueryKey,
|
||||
useCreateOrUpdateLLMPricingRules,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
@@ -11,16 +10,9 @@ import {
|
||||
EMPTY_DRAFT,
|
||||
TOAST_MODEL_COST_ADDED,
|
||||
TOAST_MODEL_COST_UPDATED,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import type {
|
||||
DrawerDraft,
|
||||
DrawerMode,
|
||||
PricingRule,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/types';
|
||||
import {
|
||||
buildRulePayload,
|
||||
draftFromRule,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/utils';
|
||||
} from '../../../../constants';
|
||||
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
|
||||
import { buildRulePayload, draftFromRule } from '../../../../utils';
|
||||
|
||||
interface UseModelCostDrawerResult {
|
||||
isOpen: boolean;
|
||||
@@ -46,26 +38,17 @@ export function useModelCostDrawer(): UseModelCostDrawerResult {
|
||||
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
|
||||
useCreateOrUpdateLLMPricingRules();
|
||||
|
||||
// Adding pricing can also resolve a model that was showing up as unpriced, so
|
||||
// refresh the unmapped list (and its tab-badge count) alongside the rules list.
|
||||
const invalidateList = useCallback(async (): Promise<void> => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListUnmappedLLMModelsQueryKey(),
|
||||
}),
|
||||
]);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
// prefillModelName seeds the billing model ID when adding from an unpriced
|
||||
// model row, so the user only has to fill in pricing.
|
||||
const openForAdd = useCallback((prefillModelName?: string): void => {
|
||||
const openForAdd = useCallback((): void => {
|
||||
setMode('add');
|
||||
setInitialDraft({
|
||||
...EMPTY_DRAFT,
|
||||
modelName: prefillModelName ?? '',
|
||||
modelName: '',
|
||||
patterns: [],
|
||||
});
|
||||
setSelectedRuleId(null);
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
.drawerSurface {
|
||||
padding: var(--spacing-7);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,6 @@
|
||||
:global(table) tbody tr {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// Column headings use the l2 foreground token.
|
||||
:global(.tanstack-header-title) {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.modelCostsEmpty {
|
||||
|
||||
@@ -42,9 +42,9 @@ export function getModelCostsColumns({
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<div className={styles.modelCell}>
|
||||
<Typography.Text
|
||||
weight="semibold"
|
||||
truncate={1}
|
||||
testId={`model-cell-name-${row.id}`}
|
||||
className={styles.tableCellStyles}
|
||||
>
|
||||
{row.modelName}
|
||||
</Typography.Text>
|
||||
@@ -59,11 +59,7 @@ export function getModelCostsColumns({
|
||||
accessorKey: 'provider',
|
||||
width: { min: 140 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Typography.Text className={styles.tableCellStyles}>
|
||||
{row.provider ?? ''}
|
||||
</Typography.Text>
|
||||
),
|
||||
cell: ({ row }): string => row.provider ?? '',
|
||||
},
|
||||
{
|
||||
id: 'input',
|
||||
@@ -71,7 +67,7 @@ export function getModelCostsColumns({
|
||||
width: { min: 120 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Typography.Text className={styles.tableCellStyles}>
|
||||
<Typography.Text>
|
||||
{formatPricePerMillion(row.pricing?.input)}
|
||||
</Typography.Text>
|
||||
),
|
||||
@@ -82,7 +78,7 @@ export function getModelCostsColumns({
|
||||
width: { min: 120 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Typography.Text className={styles.tableCellStyles}>
|
||||
<Typography.Text>
|
||||
{formatPricePerMillion(row.pricing?.output)}
|
||||
</Typography.Text>
|
||||
),
|
||||
@@ -96,7 +92,7 @@ export function getModelCostsColumns({
|
||||
const buckets = getExtraBuckets(row);
|
||||
if (buckets.length === 0) {
|
||||
return (
|
||||
<Typography.Text as="span" className={styles.tableCellStyles}>
|
||||
<Typography.Text color="muted" as="span">
|
||||
—
|
||||
</Typography.Text>
|
||||
);
|
||||
@@ -110,19 +106,10 @@ export function getModelCostsColumns({
|
||||
variant="outline"
|
||||
className={styles.extraBucketsChip}
|
||||
>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
className={styles.tableCellStyles}
|
||||
>
|
||||
<Typography.Text as="span" size="small">
|
||||
{startCase(bucket.key)}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
weight="semibold"
|
||||
className={styles.tableCellStyles}
|
||||
>
|
||||
<Typography.Text as="span" size="small" weight="semibold">
|
||||
{formatPricePerMillion(bucket.pricePerMillion)}
|
||||
</Typography.Text>
|
||||
</Badge>
|
||||
@@ -152,11 +139,7 @@ export function getModelCostsColumns({
|
||||
header: 'Last seen',
|
||||
width: { min: 120 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Typography.Text className={styles.tableCellStyles}>
|
||||
{getRelativeLastSeen(row)}
|
||||
</Typography.Text>
|
||||
),
|
||||
cell: ({ row }): string => getRelativeLastSeen(row),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
|
||||
@@ -24,7 +24,3 @@
|
||||
.sourceBadge {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tableCellStyles {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
.unpricedModelsTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
border-radius: var(--radius-2);
|
||||
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-amber-400) 8%, transparent);
|
||||
color: var(--bg-amber-300, var(--bg-amber-400));
|
||||
}
|
||||
|
||||
.bannerIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
border-radius: var(--radius-2);
|
||||
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TriangleAlert } from '@signozhq/icons';
|
||||
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import styles from './UnpricedModelsTab.module.scss';
|
||||
import ModelCostDrawer, {
|
||||
useModelCostDrawer,
|
||||
} from '../ModelCostTabPanel/components/ModelCostDrawer';
|
||||
import type { PricingRule, UnpricedModel } from '../types';
|
||||
import MapConfirmDialog from './components/MapConfirmDialog';
|
||||
import type { UnpricedColumnsConfig } from './components/UnpricedModelsTable/TableConfig';
|
||||
import UnpricedModelsTable from './components/UnpricedModelsTable';
|
||||
import { useUnpricedModelMapping } from './hooks/useUnpricedModelMapping';
|
||||
import { usePendingMappingStore } from './usePendingMappingStore';
|
||||
|
||||
function UnpricedModelsTab(): JSX.Element {
|
||||
const { data, isLoading, isError } = useListUnmappedLLMModels();
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [canManagePricing] = useComponentPermission(
|
||||
['manage_llm_pricing'],
|
||||
user.role,
|
||||
);
|
||||
|
||||
const models: UnpricedModel[] = useMemo(() => data?.data?.items || [], [data]);
|
||||
|
||||
// Picking a billing model stages a single mapping for confirmation; the
|
||||
// mapping only commits once the user confirms in the dialog. Kept in a store
|
||||
// (not local state) so the row's memoized select trigger can mirror the pick —
|
||||
// see usePendingMappingStore.
|
||||
const pendingMapping = usePendingMappingStore((state) => state.pending);
|
||||
const setPending = usePendingMappingStore((state) => state.setPending);
|
||||
const clearPending = usePendingMappingStore((state) => state.clearPending);
|
||||
const { mapModel, isSaving } = useUnpricedModelMapping();
|
||||
|
||||
// Reset any staged mapping when leaving the tab so a stale pick doesn't reopen
|
||||
// the dialog on remount (the store outlives this component).
|
||||
useEffect(() => (): void => clearPending(), [clearPending]);
|
||||
|
||||
// Reuses the "Model costs" add/edit drawer to define brand-new pricing for a
|
||||
// model that has no matching billing model to map onto. Saving resolves the
|
||||
// model, so it drops off this tab (the drawer invalidates the unmapped list).
|
||||
const drawer = useModelCostDrawer();
|
||||
|
||||
const onRequestMap = useCallback(
|
||||
(model: UnpricedModel, rule: PricingRule): void => {
|
||||
setPending({ model, rule });
|
||||
},
|
||||
[setPending],
|
||||
);
|
||||
|
||||
const onConfirmMap = useCallback(async (): Promise<void> => {
|
||||
if (!pendingMapping) {
|
||||
return;
|
||||
}
|
||||
const didSave = await mapModel(pendingMapping);
|
||||
if (didSave) {
|
||||
clearPending();
|
||||
}
|
||||
}, [mapModel, pendingMapping, clearPending]);
|
||||
|
||||
// openForAdd is stable (useCallback with no deps); drawer itself is a fresh
|
||||
// object each render, so depend on the method, not the object.
|
||||
const { openForAdd } = drawer;
|
||||
const onCreateNew = useCallback(
|
||||
(modelName: string): void => openForAdd(modelName),
|
||||
[openForAdd],
|
||||
);
|
||||
const columnsConfig = useMemo<UnpricedColumnsConfig>(
|
||||
() => ({
|
||||
canManage: canManagePricing,
|
||||
onRequestMap,
|
||||
onCreateNew,
|
||||
}),
|
||||
[canManagePricing, onRequestMap, onCreateNew],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.unpricedModelsTab}>
|
||||
<div className={styles.banner}>
|
||||
<TriangleAlert size="sm" className={styles.bannerIcon} />
|
||||
<Typography.Text as="span" size="small" color="warning">
|
||||
Models detected in traces without pricing. Map each to a billing model or
|
||||
create pricing so estimated cost can be computed.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className={styles.error}>
|
||||
<Typography.Text as="p" size="small" color="danger" role="alert">
|
||||
Failed to load unpriced models. Please try again.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UnpricedModelsTable
|
||||
models={models}
|
||||
isLoading={isLoading}
|
||||
columnsConfig={columnsConfig}
|
||||
/>
|
||||
|
||||
{pendingMapping && (
|
||||
<MapConfirmDialog
|
||||
open
|
||||
model={pendingMapping.model}
|
||||
rule={pendingMapping.rule}
|
||||
isSaving={isSaving}
|
||||
onConfirm={onConfirmMap}
|
||||
onCancel={clearPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{drawer.isOpen && (
|
||||
<ModelCostDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
mode={drawer.mode}
|
||||
initialDraft={drawer.initialDraft}
|
||||
onClose={drawer.close}
|
||||
onSave={drawer.save}
|
||||
isSaving={drawer.isSaving}
|
||||
saveError={drawer.saveError}
|
||||
canManage={canManagePricing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnpricedModelsTab;
|
||||
@@ -1,180 +0,0 @@
|
||||
import type { LlmpricingruletypesUpdatableLLMPricingRulesDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
LLM_PRICING_ENDPOINT,
|
||||
LLM_UNMAPPED_ENDPOINT,
|
||||
makeListResponse,
|
||||
makeUnmappedResponse,
|
||||
mockRules,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import UnpricedModelsTab from '../UnpricedModelsTab';
|
||||
|
||||
const toastSuccess = jest.fn();
|
||||
const toastError = jest.fn();
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: (...args: unknown[]): void => toastSuccess(...args),
|
||||
error: (...args: unknown[]): void => toastError(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const MODEL = 'gpt-4o-mini-2024-07-18';
|
||||
|
||||
function setup(): void {
|
||||
server.use(
|
||||
rest.get(LLM_UNMAPPED_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeUnmappedResponse())),
|
||||
),
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeListResponse(mockRules))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Picks a billing model in a row's dropdown: open the combobox, then click the
|
||||
// option. The dropdown fetches its options from the rules list (mocked above).
|
||||
async function selectRule(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
modelName: string,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
await user.click(screen.getByTestId(`map-to-select-${modelName}`));
|
||||
await user.click(await screen.findByTestId(`map-to-option-${ruleId}`));
|
||||
}
|
||||
|
||||
describe('UnpricedModelsTab (integration)', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
setup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
toastSuccess.mockClear();
|
||||
toastError.mockClear();
|
||||
});
|
||||
|
||||
it('opens the confirm dialog with the target rule pricing when a model is picked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<UnpricedModelsTab />);
|
||||
|
||||
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
|
||||
|
||||
// Picking a rule stages the mapping in the confirm dialog.
|
||||
await selectRule(user, MODEL, 'rule-openai');
|
||||
|
||||
const confirmItem = await screen.findByTestId(
|
||||
`unpriced-map-confirm-item-${MODEL}`,
|
||||
);
|
||||
expect(confirmItem).toBeInTheDocument();
|
||||
// Shows the target billing model + its pricing so the user can eyeball it.
|
||||
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.getByText('$3.00')).toBeInTheDocument();
|
||||
expect(screen.getByText('$9.00')).toBeInTheDocument();
|
||||
// While the dialog is open, the row's trigger mirrors the staged pick
|
||||
// instead of the placeholder.
|
||||
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
|
||||
expect(
|
||||
within(trigger).getByText('openai:gpt-4o ($3.00/$9.00)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reverts the row trigger to the placeholder when the mapping is cancelled', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<UnpricedModelsTab />);
|
||||
|
||||
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
|
||||
await selectRule(user, MODEL, 'rule-openai');
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId(`map-to-select-${MODEL}`)).getByText(
|
||||
'openai:gpt-4o ($3.00/$9.00)',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.findByTestId('unpriced-map-cancel-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
|
||||
expect(
|
||||
within(trigger).getByText('Select / Create a pricing model'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('commits the mapping in one request when confirmed', async () => {
|
||||
const sent: LlmpricingruletypesUpdatableLLMPricingRulesDTO[] = [];
|
||||
server.use(
|
||||
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
|
||||
sent.push(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<UnpricedModelsTab />);
|
||||
|
||||
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
|
||||
await selectRule(user, MODEL, 'rule-openai');
|
||||
|
||||
await user.click(await screen.findByTestId('unpriced-map-confirm-btn'));
|
||||
|
||||
await waitFor(() => expect(sent).toHaveLength(1));
|
||||
expect(sent[0].rules).toHaveLength(1);
|
||||
expect(sent[0].rules?.[0].modelPattern).toContain(MODEL);
|
||||
await waitFor(() =>
|
||||
expect(toastSuccess).toHaveBeenCalledWith('Mapped model'),
|
||||
);
|
||||
// Dialog closes on success.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId(`unpriced-map-confirm-item-${MODEL}`),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels the mapping without committing', async () => {
|
||||
const sent: LlmpricingruletypesUpdatableLLMPricingRulesDTO[] = [];
|
||||
server.use(
|
||||
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
|
||||
sent.push(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<UnpricedModelsTab />);
|
||||
|
||||
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
|
||||
await selectRule(user, MODEL, 'rule-openai');
|
||||
|
||||
await user.click(await screen.findByTestId('unpriced-map-cancel-btn'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId(`unpriced-map-confirm-item-${MODEL}`),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('opens the add-cost drawer prefilled when creating pricing for a model', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<UnpricedModelsTab />);
|
||||
|
||||
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
|
||||
|
||||
// Open the row's dropdown and take the "Create pricing for …" escape hatch
|
||||
// instead of mapping onto an existing billing model.
|
||||
await user.click(screen.getByTestId(`map-to-select-${MODEL}`));
|
||||
await user.click(await screen.findByTestId(`map-to-create-${MODEL}`));
|
||||
|
||||
// The shared add-cost drawer opens with the model name prefilled.
|
||||
const drawerTitle = await screen.findByText('Add model cost');
|
||||
expect(drawerTitle).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-model-id-input')).toHaveValue(MODEL);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.mapping {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pricing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.pricingRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ArrowRight, Check, Link2, X } from '@signozhq/icons';
|
||||
import { startCase } from 'lodash-es';
|
||||
|
||||
import styles from './MapConfirmDialog.module.scss';
|
||||
import type {
|
||||
PricingRule,
|
||||
UnpricedModel,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/types';
|
||||
import {
|
||||
formatPricePerMillion,
|
||||
getCanonicalId,
|
||||
getExtraBuckets,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/utils';
|
||||
|
||||
interface MapConfirmDialogProps {
|
||||
open: boolean;
|
||||
model: UnpricedModel;
|
||||
rule: PricingRule;
|
||||
isSaving: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Per-row confirm step before mapping an unpriced model onto an existing billing
|
||||
// model. Mapping appends the span's model name as a match pattern on the chosen
|
||||
// rule, so the model inherits that rule's pricing — shown here so the user can
|
||||
// eyeball the rates before committing. Dismissible (outside click / close), since
|
||||
// mapping is reversible (re-map or edit the rule's patterns afterwards).
|
||||
function MapConfirmDialog({
|
||||
open,
|
||||
model,
|
||||
rule,
|
||||
isSaving,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: MapConfirmDialogProps): JSX.Element {
|
||||
const extraBuckets = getExtraBuckets(rule);
|
||||
|
||||
const pricingRows = [
|
||||
{ key: 'input', label: 'Input / 1M', value: rule.pricing?.input },
|
||||
{ key: 'output', label: 'Output / 1M', value: rule.pricing?.output },
|
||||
...extraBuckets.map((bucket) => ({
|
||||
key: bucket.key,
|
||||
label: startCase(bucket.key),
|
||||
value: bucket.pricePerMillion,
|
||||
})),
|
||||
];
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
prefix={<X size={12} />}
|
||||
testId="unpriced-map-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
onClick={onConfirm}
|
||||
prefix={<Check size={12} />}
|
||||
testId="unpriced-map-confirm-btn"
|
||||
>
|
||||
Map model
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
width="base"
|
||||
title="Map to billing model"
|
||||
titleIcon={<Link2 size={16} />}
|
||||
footer={footer}
|
||||
testId="unpriced-map-confirm-dialog"
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<Typography.Text as="p" size="small" color="muted">
|
||||
Spans from this model will be priced using the selected billing model.
|
||||
</Typography.Text>
|
||||
|
||||
<div className={styles.mapping}>
|
||||
<Typography.Text
|
||||
weight="semibold"
|
||||
testId={`unpriced-map-confirm-item-${model.modelName}`}
|
||||
>
|
||||
{model.modelName}
|
||||
</Typography.Text>
|
||||
<ArrowRight size={14} className={styles.arrow} />
|
||||
<Typography.Text weight="semibold">{getCanonicalId(rule)}</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.pricing}>
|
||||
{pricingRows.map((row) => (
|
||||
<div className={styles.pricingRow} key={row.key}>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
{row.label}
|
||||
</Typography.Text>
|
||||
<Typography.Text as="span" size="small" weight="semibold">
|
||||
{formatPricePerMillion(row.value)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapConfirmDialog;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './MapConfirmDialog';
|
||||
@@ -1,27 +0,0 @@
|
||||
.mapToCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mapToSelect {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.mapToDropdown {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.skeletonList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
}
|
||||
|
||||
.skeletonRow {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxCommand,
|
||||
ComboboxContent,
|
||||
ComboboxCreateItem,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxSeparator,
|
||||
ComboboxTrigger,
|
||||
} from '@signozhq/ui/combobox';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Skeleton } from 'antd';
|
||||
|
||||
import styles from './MapToBillingModelSelect.module.scss';
|
||||
import { RULE_OPTIONS_LIMIT } from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import type { PricingRule } from 'container/LLMObservability/Settings/ModelPricing/types';
|
||||
import { getRuleOptionLabel } from 'container/LLMObservability/Settings/ModelPricing/utils';
|
||||
import { usePendingMappingLabel } from 'container/LLMObservability/Settings/ModelPricing/UnpricedModelsTab/usePendingMappingStore';
|
||||
import { useMapToBillingModelSearch } from './useMapToBillingModelSearch';
|
||||
|
||||
// One placeholder row per fetched option, so the skeleton height matches the
|
||||
// loaded list. Stable keys derived from the fetch limit.
|
||||
const SKELETON_ROW_KEYS = Array.from(
|
||||
{ length: RULE_OPTIONS_LIMIT },
|
||||
(_, index) => `skeleton-${index}`,
|
||||
);
|
||||
|
||||
interface MapToBillingModelSelectProps {
|
||||
modelName: string;
|
||||
disabled: boolean;
|
||||
onSelect: (rule: PricingRule) => void;
|
||||
onCreateNew: () => void;
|
||||
}
|
||||
|
||||
// Searchable, server-paged dropdown for picking the billing model an unpriced
|
||||
// model maps onto. Only RULE_OPTIONS_LIMIT rules are fetched at a time; typing
|
||||
// narrows the set via the rules API rather than client-side filtering, so cmdk's
|
||||
// own filter is disabled (shouldFilter={false}). The dropdown is a pure picker —
|
||||
// choosing a rule hands it up to the confirm dialog rather than persisting a
|
||||
// selection here. The trigger only mirrors the staged pick (read from the
|
||||
// pending-mapping store) while that dialog is open, reverting on confirm/cancel.
|
||||
function MapToBillingModelSelect({
|
||||
modelName,
|
||||
disabled,
|
||||
onSelect,
|
||||
onCreateNew,
|
||||
}: MapToBillingModelSelectProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { searchText, setSearchText, rules, rulesById, isFetching } =
|
||||
useMapToBillingModelSearch(open);
|
||||
const selectedLabel = usePendingMappingLabel(modelName);
|
||||
|
||||
const handleSelect = (ruleId: string): void => {
|
||||
const rule = rulesById.get(ruleId);
|
||||
if (rule) {
|
||||
onSelect(rule);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateNew = (): void => {
|
||||
setOpen(false);
|
||||
onCreateNew();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.mapToCell}>
|
||||
<Combobox open={open} onOpenChange={setOpen}>
|
||||
<ComboboxTrigger
|
||||
className={styles.mapToSelect}
|
||||
disabled={disabled}
|
||||
placeholder="Select / Create a pricing model"
|
||||
value={selectedLabel}
|
||||
testId={`map-to-select-${modelName}`}
|
||||
/>
|
||||
<ComboboxContent className={styles.mapToDropdown}>
|
||||
<ComboboxCommand shouldFilter={false}>
|
||||
<ComboboxInput
|
||||
value={searchText}
|
||||
onValueChange={setSearchText}
|
||||
placeholder="Search billing models"
|
||||
testId={`map-to-search-${modelName}`}
|
||||
/>
|
||||
<ComboboxList>
|
||||
{rules.map((rule) => (
|
||||
<ComboboxItem
|
||||
key={rule.id}
|
||||
value={rule.id}
|
||||
onSelect={(): void => handleSelect(rule.id)}
|
||||
data-testid={`map-to-option-${rule.id}`}
|
||||
>
|
||||
{getRuleOptionLabel(rule)}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
{isFetching && (
|
||||
<div
|
||||
className={styles.skeletonList}
|
||||
data-testid={`map-to-loading-${modelName}`}
|
||||
>
|
||||
{SKELETON_ROW_KEYS.map((key) => (
|
||||
<Skeleton.Input
|
||||
key={key}
|
||||
active
|
||||
block
|
||||
size="small"
|
||||
className={styles.skeletonRow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isFetching && rules.length === 0 && (
|
||||
<ComboboxEmpty>No billing models found</ComboboxEmpty>
|
||||
)}
|
||||
</ComboboxList>
|
||||
{/* Kept outside ComboboxList so it stays pinned as a footer while the
|
||||
options scroll. Escape hatch when no existing billing model fits:
|
||||
define this model's own pricing rather than mapping onto another. */}
|
||||
<ComboboxSeparator alwaysRender />
|
||||
<ComboboxCreateItem
|
||||
inputValue={modelName}
|
||||
value={`create-pricing-${modelName}`}
|
||||
prefix={<Plus size={14} />}
|
||||
onSelect={handleCreateNew}
|
||||
testId={`map-to-create-${modelName}`}
|
||||
>
|
||||
Create pricing for "{modelName}"
|
||||
</ComboboxCreateItem>
|
||||
</ComboboxCommand>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapToBillingModelSelect;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './MapToBillingModelSelect';
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
import {
|
||||
RULE_OPTIONS_LIMIT,
|
||||
SEARCH_DEBOUNCE_MS,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import type { PricingRule } from 'container/LLMObservability/Settings/ModelPricing/types';
|
||||
|
||||
interface UseMapToBillingModelSearch {
|
||||
searchText: string;
|
||||
setSearchText: (value: string) => void;
|
||||
rules: PricingRule[];
|
||||
// Fetched rules keyed by id, so a pick can resolve to its full rule object.
|
||||
rulesById: Map<string, PricingRule>;
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
// Server-side search for the per-row "Map to billing model" dropdown. Only
|
||||
// RULE_OPTIONS_LIMIT rules are fetched at a time; typing narrows the set via the
|
||||
// rules API's `q` param instead of filtering client-side. The fetch is gated on
|
||||
// `enabled` (the dropdown's open state) so closed rows don't fetch, and
|
||||
// react-query dedupes identical query keys so rows sharing a term hit the network
|
||||
// once.
|
||||
export function useMapToBillingModelSearch(
|
||||
enabled: boolean,
|
||||
): UseMapToBillingModelSearch {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const debouncedSearch = useDebounce(searchText, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
const { data, isFetching } = useListLLMPricingRules(
|
||||
{ offset: 0, limit: RULE_OPTIONS_LIMIT, q: debouncedSearch || undefined },
|
||||
{ query: { enabled } },
|
||||
);
|
||||
|
||||
const rules = useMemo<PricingRule[]>(() => data?.data?.items || [], [data]);
|
||||
const rulesById = useMemo(
|
||||
() => new Map(rules.map((rule) => [rule.id, rule])),
|
||||
[rules],
|
||||
);
|
||||
|
||||
return { searchText, setSearchText, rules, rulesById, isFetching };
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { getUnpricedModelsColumns } from './table.config';
|
||||
export type { UnpricedColumnsConfig } from './table.config';
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
|
||||
import MapToBillingModelSelect from 'container/LLMObservability/Settings/ModelPricing/UnpricedModelsTab/components/MapToBillingModelSelect';
|
||||
import type {
|
||||
PricingRule,
|
||||
UnpricedModel,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/types';
|
||||
import { formatSpanCount } from 'container/LLMObservability/Settings/ModelPricing/utils';
|
||||
import styles from './tableConfig.module.scss';
|
||||
|
||||
export interface UnpricedColumnsConfig {
|
||||
canManage: boolean;
|
||||
// Picking a billing model requests a mapping — the tab opens a confirm dialog
|
||||
// and commits that single mapping there (no batch/selection state here).
|
||||
onRequestMap: (model: UnpricedModel, rule: PricingRule) => void;
|
||||
// Opens the add-cost drawer prefilled with the row's model name.
|
||||
onCreateNew: (modelName: string) => void;
|
||||
}
|
||||
|
||||
// Column definitions for the unpriced-models TanStackTable. Sorting is off — the
|
||||
// unmapped-models endpoint returns the full set in one shot with no ordering knob.
|
||||
// Each row's action is self-contained: pick a billing model to map onto (confirmed
|
||||
// in a dialog) or create pricing for the model, so there's no bulk-save column.
|
||||
export function getUnpricedModelsColumns({
|
||||
canManage,
|
||||
onRequestMap,
|
||||
onCreateNew,
|
||||
}: UnpricedColumnsConfig): TableColumnDef<UnpricedModel>[] {
|
||||
return [
|
||||
{
|
||||
id: 'model',
|
||||
header: 'Model (from spans)',
|
||||
accessorFn: (row): string => row.modelName,
|
||||
width: { min: 240, default: '100%' },
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Typography.Text
|
||||
weight="semibold"
|
||||
truncate={1}
|
||||
testId={`unpriced-model-name-${row.modelName}`}
|
||||
>
|
||||
{row.modelName}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'provider',
|
||||
header: 'Provider',
|
||||
width: { min: 140 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): string => row.provider || 'Unknown',
|
||||
},
|
||||
{
|
||||
id: 'spans',
|
||||
header: 'Spans',
|
||||
width: { min: 100 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Badge
|
||||
color="cherry"
|
||||
variant="outline"
|
||||
className={styles.spansBadge}
|
||||
data-testid={`unpriced-spans-${row.modelName}`}
|
||||
>
|
||||
{formatSpanCount(row.spanCount)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'mapTo',
|
||||
header: 'Map to billing model',
|
||||
width: { min: 280, default: '100%' },
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<MapToBillingModelSelect
|
||||
modelName={row.modelName}
|
||||
disabled={!canManage}
|
||||
onSelect={(rule): void => onRequestMap(row, rule)}
|
||||
onCreateNew={(): void => onCreateNew(row.modelName)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
.spansBadge {
|
||||
margin: 0;
|
||||
font-family: var(--code-font-family, monospace);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Capped scroll viewport — mirrors ModelCostsTable so the unpriced tab doesn't
|
||||
// shift on load, but uses max-height so a short list stays compact instead of
|
||||
// reserving the full viewport. The extra offset over the model-costs table
|
||||
// accounts for the banner toolbar above.
|
||||
.unpricedModelsTable {
|
||||
--tanstack-table-row-height: 48px;
|
||||
max-height: calc(100vh - 220px);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(table) tbody tr {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.unpricedModelsEmpty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
|
||||
import { SKELETON_ROW_COUNT } from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import type { UnpricedModel } from 'container/LLMObservability/Settings/ModelPricing/types';
|
||||
import styles from './UnpricedModelsTable.module.scss';
|
||||
import {
|
||||
getUnpricedModelsColumns,
|
||||
type UnpricedColumnsConfig,
|
||||
} from './TableConfig';
|
||||
|
||||
interface UnpricedModelsTableProps {
|
||||
models: UnpricedModel[];
|
||||
isLoading: boolean;
|
||||
columnsConfig: UnpricedColumnsConfig;
|
||||
}
|
||||
|
||||
// The unmapped-models endpoint returns the full set in one response, so there's
|
||||
// no pagination here — just a content-height list. Virtual scroll is disabled
|
||||
// because the set is small and bounded.
|
||||
function UnpricedModelsTable({
|
||||
models,
|
||||
isLoading,
|
||||
columnsConfig,
|
||||
}: UnpricedModelsTableProps): JSX.Element {
|
||||
const columns = useMemo(
|
||||
() => getUnpricedModelsColumns(columnsConfig),
|
||||
[columnsConfig],
|
||||
);
|
||||
|
||||
if (!isLoading && models.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={styles.unpricedModelsEmpty}
|
||||
data-testid="unpriced-models-empty"
|
||||
>
|
||||
All models in your traces are priced.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TanStackTable<UnpricedModel>
|
||||
className={styles.unpricedModelsTable}
|
||||
data={models}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
skeletonRowCount={SKELETON_ROW_COUNT}
|
||||
getRowKey={(row): string => row.modelName}
|
||||
disableVirtualScroll
|
||||
testId="unpriced-models-table"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnpricedModelsTable;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './UnpricedModelsTable';
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getListLLMPricingRulesQueryKey,
|
||||
getListUnmappedLLMModelsQueryKey,
|
||||
useCreateOrUpdateLLMPricingRules,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import type {
|
||||
PricingRule,
|
||||
UnpricedModel,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/types';
|
||||
import { buildPatternMappingPayload } from 'container/LLMObservability/Settings/ModelPricing/utils';
|
||||
|
||||
// A single row's choice: map this unpriced model onto this billing rule.
|
||||
export interface UnpricedModelMapping {
|
||||
model: UnpricedModel;
|
||||
rule: PricingRule;
|
||||
}
|
||||
|
||||
interface UseUnpricedModelMappingResult {
|
||||
// Commits a single mapping in one request. Resolves true on success so the
|
||||
// caller can close the confirm dialog and clear its staged pick.
|
||||
mapModel: (mapping: UnpricedModelMapping) => Promise<boolean>;
|
||||
// True while the save is in flight, so the confirm button can spin.
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
// Maps an unpriced model onto an existing pricing rule. There's no dedicated
|
||||
// "edit" endpoint — mapping reuses CreateOrUpdate (PUT), appending the model name
|
||||
// as a match pattern on the chosen rule so the model inherits its pricing. Both
|
||||
// the unmapped list and the rules list are invalidated on success so the mapped
|
||||
// model drops out of this tab immediately.
|
||||
export function useUnpricedModelMapping(): UseUnpricedModelMappingResult {
|
||||
const queryClient = useQueryClient();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const { mutateAsync: createOrUpdate } = useCreateOrUpdateLLMPricingRules();
|
||||
|
||||
const mapModel = useCallback(
|
||||
async ({ model, rule }: UnpricedModelMapping): Promise<boolean> => {
|
||||
const payload = buildPatternMappingPayload(rule, model.modelName);
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await createOrUpdate({ data: { rules: [payload] } });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListUnmappedLLMModelsQueryKey(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
}),
|
||||
]);
|
||||
toast.success('Mapped model');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Mapping failed';
|
||||
toast.error(message);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[createOrUpdate, queryClient],
|
||||
);
|
||||
|
||||
return { mapModel, isSaving };
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './UnpricedModelsTab';
|
||||
@@ -1,39 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { PricingRule, UnpricedModel } from '../types';
|
||||
import { getRuleOptionLabel } from '../utils';
|
||||
|
||||
// A model + the billing rule it's about to be mapped onto, held while the confirm
|
||||
// dialog is open.
|
||||
export interface PendingMapping {
|
||||
model: UnpricedModel;
|
||||
rule: PricingRule;
|
||||
}
|
||||
|
||||
interface PendingMappingState {
|
||||
pending: PendingMapping | null;
|
||||
setPending: (mapping: PendingMapping) => void;
|
||||
clearPending: () => void;
|
||||
}
|
||||
|
||||
// The staged mapping lives in a store (not component state/props) because the
|
||||
// row's select trigger, which mirrors the staged pick, sits inside a memoized
|
||||
// TanStackTable cell whose value is cached per row — a prop/context change would
|
||||
// not re-render it, but a store subscription does. Picking a billing model stages
|
||||
// a single mapping for confirmation; it only commits once confirmed in the dialog.
|
||||
export const usePendingMappingStore = create<PendingMappingState>((set) => ({
|
||||
pending: null,
|
||||
setPending: (mapping): void => set({ pending: mapping }),
|
||||
clearPending: (): void => set({ pending: null }),
|
||||
}));
|
||||
|
||||
// Label to show on the given row's select trigger while its mapping is staged, or
|
||||
// undefined when nothing is staged for that row (falls back to the placeholder).
|
||||
export const usePendingMappingLabel = (modelName: string): string | undefined =>
|
||||
usePendingMappingStore((state) => {
|
||||
const { pending } = state;
|
||||
if (!pending || pending.model.modelName !== modelName) {
|
||||
return undefined;
|
||||
}
|
||||
return getRuleOptionLabel(pending.rule);
|
||||
});
|
||||
@@ -2,19 +2,14 @@ import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
type ListLLMPricingRules200,
|
||||
type ListUnmappedLLMModels200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PricingRule, UnpricedModel } from '../types';
|
||||
import type { PricingRule } from '../types';
|
||||
|
||||
// Endpoint glob used by MSW handlers. The generated client hits a relative
|
||||
// `/api/v1/llm_pricing_rules`, so the `*` prefix matches regardless of base URL.
|
||||
export const LLM_PRICING_ENDPOINT = '*/api/v1/llm_pricing_rules';
|
||||
export const LLM_PRICING_RULE_ENDPOINT = '*/api/v1/llm_pricing_rules/:id';
|
||||
// Distinct path (extra segment), so it needs its own handler — the list glob
|
||||
// above does not match it.
|
||||
export const LLM_UNMAPPED_ENDPOINT =
|
||||
'*/api/v1/llm_pricing_rules/unmapped_models';
|
||||
|
||||
// Builds a valid pricing rule, with overrides merged shallowly. Pricing is
|
||||
// replaced wholesale when provided so callers can shape cache buckets freely.
|
||||
@@ -65,26 +60,6 @@ export const mockRules: PricingRule[] = [
|
||||
}),
|
||||
];
|
||||
|
||||
// Unpriced models seen in traces with no matching pricing rule.
|
||||
export const mockUnpricedModels: UnpricedModel[] = [
|
||||
{ modelName: 'gpt-4o-mini-2024-07-18', provider: 'openai', spanCount: 18400 },
|
||||
{
|
||||
modelName: 'claude-3-7-sonnet-20250219',
|
||||
provider: 'anthropic',
|
||||
spanCount: 9200,
|
||||
},
|
||||
];
|
||||
|
||||
// Wraps unpriced models in the envelope the unmapped-models query reads.
|
||||
export function makeUnmappedResponse(
|
||||
items: UnpricedModel[] = mockUnpricedModels,
|
||||
): ListUnmappedLLMModels200 {
|
||||
return {
|
||||
status: 'success',
|
||||
data: { items },
|
||||
};
|
||||
}
|
||||
|
||||
// Wraps items in the list response envelope the list query reads
|
||||
// (`data.data.items` / `data.data.total`).
|
||||
export function makeListResponse(
|
||||
|
||||
@@ -34,13 +34,6 @@ export const SOURCE_FILTER_TO_IS_OVERRIDE: Record<
|
||||
// loaded page renders — otherwise the table height jumps on load.
|
||||
export const SKELETON_ROW_COUNT = PAGE_SIZE;
|
||||
|
||||
export const RULE_OPTIONS_LIMIT = 10;
|
||||
|
||||
// URL-backed key for the active tab on the model-pricing page.
|
||||
export const TAB_KEY = 'tab';
|
||||
export const MODEL_COSTS_TAB = 'model-costs';
|
||||
export const UNPRICED_MODELS_TAB = 'unpriced-models';
|
||||
|
||||
export const PROVIDER_OPTIONS = [
|
||||
{ value: 'OpenAI', label: 'OpenAI' },
|
||||
{ value: 'Anthropic', label: 'Anthropic' },
|
||||
@@ -69,7 +62,6 @@ export const EMPTY_DRAFT: DrawerDraft = {
|
||||
provider: 'OpenAI',
|
||||
patterns: [],
|
||||
isOverride: true,
|
||||
enabled: true,
|
||||
pricing: {
|
||||
input: null,
|
||||
output: null,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user