Compare commits

..

20 Commits

Author SHA1 Message Date
srikanthccv
114fa157ff Merge branch 'issue-5535' of github.com:SigNoz/signoz into issue-5535 2026-07-11 13:40:30 +05:30
srikanthccv
3b959a73bd chore: update test 2026-07-11 12:24:02 +05:30
Srikanth Chekuri
9aaf348704 Merge branch 'main' into issue-5535 2026-07-11 09:09:34 +05:30
srikanthccv
15896575ea chore: fix unit tests 2026-07-11 09:08:57 +05:30
srikanthccv
ffd67b61ff Merge branch 'issue-5535' of github.com:SigNoz/signoz into issue-5535 2026-07-11 08:13:07 +05:30
srikanthccv
d1fdc5451d chore: remove __normalized and add fast path 2026-07-11 08:12:47 +05:30
Srikanth Chekuri
5c6438b26d Merge branch 'main' into issue-5535 2026-07-10 23:33:19 +05:30
srikanthccv
be2cb5a774 chore: undo the changes to tests/fixtures/http.py 2026-07-07 18:55:22 +05:30
srikanthccv
ec73030f47 Merge branch 'issue-5535' of github.com:SigNoz/signoz into issue-5535 2026-07-07 18:51:29 +05:30
srikanthccv
2329c926f9 chore: address review comments 2026-07-07 18:51:06 +05:30
Srikanth Chekuri
1ccdf9dcd0 Merge branch 'main' into issue-5535 2026-07-07 14:53:15 +05:30
srikanthccv
9af6fdcff7 chore: trigger build 2026-07-07 13:30:44 +05:30
srikanthccv
44a202fa8c Merge branch 'issue-5535' of github.com:SigNoz/signoz into issue-5535 2026-07-07 12:42:17 +05:30
srikanthccv
041b1ff121 chore: update tests 2026-07-07 12:41:59 +05:30
Srikanth Chekuri
3f7361865d Merge branch 'main' into issue-5535 2026-07-07 12:01:15 +05:30
srikanthccv
f057e84a63 chore: add todos 2026-07-07 10:47:23 +05:30
Srikanth Chekuri
b180df8e3e Merge branch 'main' into issue-5535 2026-07-06 20:55:56 +05:30
Srikanth Chekuri
c6bb7569af Merge branch 'main' into issue-5535 2026-07-06 11:51:09 +05:30
srikanthccv
5d431f9f6f chore: add to ci 2026-07-06 11:50:46 +05:30
srikanthccv
1f0113645e chore: add integration tests for metrics under reduction - query part 2026-07-06 11:19:45 +05:30
306 changed files with 4713 additions and 13185 deletions

2
.github/CODEOWNERS vendored
View File

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

View File

@@ -60,6 +60,8 @@ jobs:
- querier_json_body
- querier_skip_resource_fingerprint
- ttl
- clickhousecluster
- metricreduction
sqlstore-provider:
- postgres
- sqlite

View File

@@ -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:
@@ -1494,7 +1494,7 @@ components:
- cosmosdb
- cassandradb
- redis
- cloudsql_postgres
- cloudsql
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -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:

View File

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

View File

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

View File

@@ -1,88 +0,0 @@
// oxlint-disable-next-line no-restricted-imports
import * as React from 'react';
// In jsdom, AnimatePresence from motion/react keeps children in DOM during exit
// animations (awaiting rAF-driven completion that never fully runs in jsdom).
// This mock makes AnimatePresence render children immediately and makes motion.*
// elements render as their plain HTML equivalents without animation side-effects.
//
// IMPORTANT: motion component references are cached so React sees a stable
// component identity across re-renders and does not enter an infinite remount loop.
const MOTION_PROPS_TO_STRIP = new Set([
'initial',
'animate',
'exit',
'variants',
'transition',
'whileHover',
'whileTap',
'whileFocus',
'whileInView',
'layout',
'layoutId',
'onAnimationStart',
'onAnimationComplete',
]);
const cache = new Map<string, React.ComponentType>();
function getMotionComponent(tag: string): React.ComponentType {
if (!cache.has(tag)) {
const Component = React.forwardRef<HTMLElement, Record<string, unknown>>(
(props, ref) => {
const domProps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(props)) {
if (!MOTION_PROPS_TO_STRIP.has(k)) {
domProps[k] = v;
}
}
return React.createElement(tag, { ...domProps, ref });
},
);
Component.displayName = `motion.${tag}`;
cache.set(tag, Component as unknown as React.ComponentType);
}
return cache.get(tag) as React.ComponentType;
}
const motionHandler: ProxyHandler<Record<string, React.ComponentType>> = {
get(_target, prop: string) {
return getMotionComponent(prop);
},
};
export const AnimatePresence: React.FC<{
children?: React.ReactNode;
mode?: string;
}> = ({ children }) => React.createElement(React.Fragment, null, children);
export const motion = new Proxy(
{} as Record<string, React.ComponentType>,
motionHandler,
);
export const useAnimation = (): Record<string, unknown> => ({
start: (): unknown => Promise.resolve(),
stop: (): unknown => undefined,
set: (): unknown => undefined,
});
export const useMotionValue = (
initial: unknown,
): { get: () => unknown; set: () => void } => ({
get: (): unknown => initial,
set: (): unknown => undefined,
});
export const useTransform = (): { get: () => number } => ({
get: (): number => 0,
});
export const useSpring = (v: unknown): unknown => v;
export const useScroll = (): { scrollY: { get: () => number } } => ({
scrollY: { get: (): number => 0 },
});
export default { motion, AnimatePresence };

View 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} />;
}

View File

@@ -20,7 +20,7 @@ const config: Config.InitialOptions = {
'\\.module\\.mjs$': '<rootDir>/__mocks__/cssMock.ts',
'\\.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,

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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
@@ -2813,7 +2813,7 @@ export enum CloudintegrationtypesServiceIDDTO {
cosmosdb = 'cosmosdb',
cassandradb = 'cassandradb',
redis = 'redis',
cloudsql_postgres = 'cloudsql_postgres',
cloudsql = 'cloudsql',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -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
*/
@@ -10678,7 +10614,7 @@ export type ListRoles200 = {
/**
* @type array
*/
data: AuthtypesGettableRoleDTO[];
data: AuthtypesRoleDTO[];
/**
* @type string
*/
@@ -10700,7 +10636,7 @@ export type GetRolePathParameters = {
id: string;
};
export type GetRole200 = {
data: AuthtypesRoleDTO;
data: AuthtypesRoleWithTransactionGroupsDTO;
/**
* @type string
*/

View File

@@ -12,4 +12,5 @@
import '@signozhq/design-tokens';
import '@signozhq/icons';
import '@signozhq/resizable';
import '@signozhq/ui';

View File

@@ -92,7 +92,6 @@ function CreateServiceAccountModal(): JSX.Element {
width="narrow"
className="create-sa-modal"
disableOutsideClick={isErrorModalVisible}
testId="create-service-account-modal"
>
<div className="create-sa-modal__content">
<form

View File

@@ -1,7 +1,13 @@
import { toast } from '@signozhq/ui/sonner';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import {
render,
screen,
userEvent,
waitFor,
waitForElementToBeRemoved,
} from 'tests/test-utils';
import CreateServiceAccountModal from '../CreateServiceAccountModal';
@@ -83,7 +89,7 @@ describe('CreateServiceAccountModal', () => {
await waitFor(() => {
expect(
screen.queryByTestId('create-service-account-modal'),
screen.queryByRole('dialog', { name: /New Service Account/i }),
).not.toBeInTheDocument();
});
});
@@ -123,7 +129,7 @@ describe('CreateServiceAccountModal', () => {
});
expect(
screen.getByTestId('create-service-account-modal'),
screen.getByRole('dialog', { name: /New Service Account/i }),
).toBeInTheDocument();
});
@@ -131,14 +137,15 @@ describe('CreateServiceAccountModal', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByTestId('create-service-account-modal');
const dialog = await screen.findByRole('dialog', {
name: /New Service Account/i,
});
await user.click(screen.getByRole('button', { name: /Cancel/i }));
await waitFor(() => {
expect(
screen.queryByTestId('create-service-account-modal'),
).not.toBeInTheDocument();
});
await waitForElementToBeRemoved(dialog);
expect(
screen.queryByRole('dialog', { name: /New Service Account/i }),
).not.toBeInTheDocument();
});
it('shows "Name is required" after clearing the name field', async () => {

View File

@@ -3,7 +3,7 @@ import { Button, Popover, Tooltip } from 'antd';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import { useExportRawData } from 'hooks/useExportData/useServerExport';
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
import { Download, LoaderCircle } from '@signozhq/icons';
import { DataSource } from 'types/common/queryBuilder';

View File

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

View File

@@ -151,7 +151,6 @@ function AddKeyModal(): JSX.Element {
className="add-key-modal"
showCloseButton
disableOutsideClick={isErrorModalVisible}
testId="add-key-modal"
>
{phase === Phase.FORM && (
<KeyFormPhase

View File

@@ -91,7 +91,6 @@ function DeleteAccountModal(): JSX.Element {
color="destructive"
loading={isDeleting}
onClick={handleConfirm}
data-testid="confirm-delete-btn"
>
<Trash2 size={12} />
Delete
@@ -112,7 +111,6 @@ function DeleteAccountModal(): JSX.Element {
className="alert-dialog sa-delete-dialog"
showCloseButton={false}
disableOutsideClick={isErrorModalVisible}
testId="delete-service-account-modal"
footer={footer}
>
{content}

View File

@@ -175,7 +175,6 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
}
showCloseButton={!isRevokeConfirmOpen}
disableOutsideClick={isErrorModalVisible}
testId="edit-key-modal"
footer={
isRevokeConfirmOpen ? (
<RevokeKeyFooter

View File

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

View File

@@ -2,7 +2,13 @@ import { toast } from '@signozhq/ui/sonner';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import {
render,
screen,
userEvent,
waitFor,
waitForElementToBeRemoved,
} from 'tests/test-utils';
import AddKeyModal from '../AddKeyModal';
@@ -91,7 +97,7 @@ describe('AddKeyModal', () => {
await screen.findByText('snz_abc123xyz456secret');
expect(screen.getByText(/Store the key securely/i)).toBeInTheDocument();
expect(screen.getByTestId('add-key-modal')).toBeInTheDocument();
await screen.findByRole('dialog', { name: /Key Created Successfully/i });
});
it('copy button writes key to clipboard and shows toast.success', async () => {
@@ -127,11 +133,9 @@ describe('AddKeyModal', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
await screen.findByTestId('add-key-modal');
const dialog = await screen.findByRole('dialog', { name: /Add a New Key/i });
await user.click(screen.getByRole('button', { name: /Cancel/i }));
await waitFor(() => {
expect(screen.queryByTestId('add-key-modal')).not.toBeInTheDocument();
});
await waitForElementToBeRemoved(dialog);
});
});

View File

@@ -73,7 +73,9 @@ describe('EditKeyModal (URL-controlled)', () => {
it('renders nothing when edit-key param is absent', () => {
renderModal(null, { account: 'sa-1' });
expect(screen.queryByTestId('edit-key-modal')).not.toBeInTheDocument();
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
});
it('renders key data from prop when edit-key param is set', async () => {
@@ -100,7 +102,9 @@ describe('EditKeyModal (URL-controlled)', () => {
});
await waitFor(() => {
expect(screen.queryByTestId('edit-key-modal')).not.toBeInTheDocument();
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
});
});
@@ -127,7 +131,9 @@ describe('EditKeyModal (URL-controlled)', () => {
expect(latestUrlUpdate.queryString).not.toContain('edit-key=');
await waitFor(() => {
expect(screen.queryByTestId('edit-key-modal')).not.toBeInTheDocument();
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
});
});
@@ -139,7 +145,9 @@ describe('EditKeyModal (URL-controlled)', () => {
await user.click(screen.getByRole('button', { name: /Revoke Key/i }));
// Same dialog, now showing revoke confirmation
expect(screen.getByTestId('edit-key-modal')).toBeInTheDocument();
await expect(
screen.findByRole('dialog', { name: /Revoke Original Key Name/i }),
).resolves.toBeInTheDocument();
expect(
screen.getByText(/Revoking this key will permanently invalidate it/i),
).toBeInTheDocument();
@@ -162,7 +170,9 @@ describe('EditKeyModal (URL-controlled)', () => {
});
await waitFor(() => {
expect(screen.queryByTestId('edit-key-modal')).not.toBeInTheDocument();
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
});
});
});

View File

@@ -222,20 +222,21 @@ describe('ServiceAccountDrawer', () => {
screen.getByRole('button', { name: /Delete Service Account/i }),
);
await screen.findByTestId('delete-service-account-modal');
expect(
screen.getByTestId('delete-service-account-modal'),
).toBeInTheDocument();
const dialog = await screen.findByRole('dialog', {
name: /Delete service account CI Bot/i,
});
expect(dialog).toBeInTheDocument();
await user.click(screen.getByTestId('confirm-delete-btn'));
const confirmBtns = screen.getAllByRole('button', { name: /^Delete$/i });
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(
() => {
expect(deleteSpy).toHaveBeenCalled();
expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument();
},
{ timeout: 3000 },
);
await waitFor(() => {
expect(deleteSpy).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument();
});
});
it('deleted account shows read-only name, no Save button, no Delete button', async () => {

View File

@@ -1,4 +1,3 @@
export enum SESSIONSTORAGE {
RETRY_LAZY_REFRESHED = 'retry-lazy-refreshed',
VIEW_PANEL_HANDOFF = 'view-panel-handoff',
}

View File

@@ -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>
);
},
},
];

View File

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

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
.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);
}

View File

@@ -1,21 +0,0 @@
import styles from './AttributeMappingsTab.module.scss';
import MappingsTable from './components/MappingsTable/MappingsTable';
import { useAttributeMappingStore } from './hooks/useAttributeMappingStore';
function AttributeMappingsTab(): JSX.Element {
const store = useAttributeMappingStore();
return (
<div data-testid="attribute-mappings-tab">
{store.isError ? (
<div className={styles.pageError} role="alert">
Failed to load mapping groups. Please try again.
</div>
) : (
<MappingsTable store={store} />
)}
</div>
);
}
export default AttributeMappingsTab;

View File

@@ -1,267 +0,0 @@
import {
SpantypesFieldContextDTO as FieldContext,
SpantypesSpanMapperOperationDTO as MapperOperation,
} from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import {
GROUPS_ENDPOINT,
makeGroupsResponse,
makeMapper,
makeMappersResponse,
mappersEndpoint,
mockGroups,
mockMappers,
} from 'container/LLMObservability/AttributeMapping/__tests__/fixtures';
import AttributeMappingsTab from '../AttributeMappingsTab';
function setupGroups(groups = mockGroups): void {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(groups))),
),
);
}
function setupMappers(mappers = mockMappers, groupId = 'group-1'): void {
server.use(
rest.get(mappersEndpoint(groupId), (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeMappersResponse(mappers))),
),
);
}
async function expandGroup(
user: ReturnType<typeof userEvent.setup>,
groupId = 'group-1',
): Promise<void> {
await user.click(screen.getByTestId(`group-expand-${groupId}`));
}
describe('AttributeMappingsTab (integration)', () => {
beforeEach(() => {
// Reset URL state between tests — jsdom shares window.location across a file.
window.history.pushState(null, '', '/');
});
afterEach(() => {
server.resetHandlers();
});
it('renders no error banner on a successful load', async () => {
setupGroups();
render(<AttributeMappingsTab />);
await waitFor(() =>
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('shows an error banner when the groups request fails', async () => {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
);
render(<AttributeMappingsTab />);
await expect(screen.findByRole('alert')).resolves.toHaveTextContent(
'Failed to load mapping groups. Please try again.',
);
});
it('shows the empty state when there are no groups', async () => {
setupGroups([]);
render(<AttributeMappingsTab />);
await expect(
screen.findByTestId('mapper-groups-empty'),
).resolves.toHaveTextContent('No mapping groups yet.');
});
it('renders each group header row with its name, condition count and status', async () => {
setupGroups();
render(<AttributeMappingsTab />);
// Condition filters are no longer shown inline as clauses — the header
// 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(
'.ant-collapse-item',
) as HTMLElement;
expect(
within(enabledRow).getByTestId('group-name-group-1'),
).toHaveTextContent('demo');
expect(
within(enabledRow).getByTestId('group-condition-count-group-1'),
).toHaveTextContent('2 conditions');
expect(within(enabledRow).getByTestId('group-enabled-group-1')).toBeChecked();
// group-2: disabled, with no condition keys.
const disabledRow = screen
.getByTestId('group-name-group-2')
.closest('.ant-collapse-item') as HTMLElement;
expect(within(disabledRow).getByText('Tool')).toBeInTheDocument();
expect(
within(disabledRow).getByTestId('group-condition-count-group-2'),
).toHaveTextContent('0 conditions');
expect(
within(disabledRow).getByTestId('group-enabled-group-2'),
).not.toBeChecked();
});
it('renders the group enable state as a read-only switch', async () => {
setupGroups();
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();
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(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
// The toggle is the antd Collapse header, which owns the expanded state.
const header = screen
.getByTestId('group-expand-group-1')
.closest('.ant-collapse-header') as HTMLElement;
expect(header).toHaveAttribute('aria-expanded', 'false');
await expandGroup(user);
expect(header).toHaveAttribute('aria-expanded', 'true');
await expect(
screen.findByTestId('mapper-target-mapper-1'),
).resolves.toBeInTheDocument();
await expandGroup(user);
await waitFor(() =>
expect(
screen.queryByTestId('mapper-target-mapper-1'),
).not.toBeInTheDocument(),
);
});
it("lazily fetches and renders a group's mappers on first expand", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([
makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model', enabled: true }),
]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
// Mappers are not fetched until the row is expanded.
expect(
screen.queryByTestId('mapper-target-mapper-1'),
).not.toBeInTheDocument();
await expandGroup(user);
const target = await screen.findByTestId('mapper-target-mapper-1');
expect(target).toHaveTextContent('gen_ai.request.model');
const mapperRow = target.closest('tr') as HTMLElement;
// Sources ordered by priority, highest first (see fixtures).
const sources = within(mapperRow).getByTestId('mapper-sources-mapper-1');
expect(sources).toHaveTextContent('genai.model');
expect(sources).toHaveTextContent('llm.model');
// 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();
server.use(
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
res(ctx.status(500)),
),
);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
await expect(
screen.findByTestId('mappers-error-group-1'),
).resolves.toHaveTextContent('Failed to load mappings. Please try again.');
});
it('shows the mappers empty state when a group has no mappers', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
await expect(
screen.findByTestId('mappers-empty-group-1'),
).resolves.toHaveTextContent('No mappings in this group yet.');
});
it('collapses extra mapper sources into a "+N more" label beyond the visible cap', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([
makeMapper({
id: 'mapper-1',
config: {
sources: [1, 2, 3, 4, 5].map((priority) => ({
key: `source-${priority}`,
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority,
})),
},
}),
]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
await expect(screen.findByText('+2 more')).resolves.toBeInTheDocument();
});
it('shows a muted placeholder when a mapper has no sources', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([makeMapper({ id: 'mapper-1', config: { sources: [] } })]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
await waitFor(() =>
expect(screen.getByTestId('mapper-sources-mapper-1')).toHaveTextContent('—'),
);
});
});

View File

@@ -1,19 +0,0 @@
.groupHeaderLabel {
display: flex;
align-items: center;
gap: var(--spacing-3);
min-width: 0;
}
.groupName {
color: var(--l1-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.groupCount {
color: var(--l3-foreground);
font-size: var(--font-size-xs);
white-space: nowrap;
}

View File

@@ -1,36 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
import styles from './GroupHeader.module.scss';
interface GroupHeaderProps {
group: MappingGroup;
}
function GroupHeader({ group }: GroupHeaderProps): JSX.Element {
const conditionCount = group.attributes.length + group.resource.length;
return (
<div
className={styles.groupHeaderLabel}
data-testid={`group-expand-${group.id}`}
>
<Typography.Text
as="span"
className={styles.groupName}
testId={`group-name-${group.id}`}
>
{group.name}
</Typography.Text>
<Typography.Text
as="span"
className={styles.groupCount}
testId={`group-condition-count-${group.id}`}
>
· {conditionCount} {conditionCount === 1 ? 'condition' : 'conditions'}
</Typography.Text>
</div>
);
}
export default GroupHeader;

View File

@@ -1,6 +0,0 @@
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-3);
}

View File

@@ -1,26 +0,0 @@
import { Switch } from '@signozhq/ui/switch';
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
import styles from './GroupHeaderActions.module.scss';
interface GroupHeaderActionsProps {
group: MappingGroup;
}
function GroupHeaderActions({ group }: GroupHeaderActionsProps): JSX.Element {
return (
<div
className={styles.actions}
onClick={(event): void => event.stopPropagation()}
>
<Switch
value={group.enabled}
// 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}`}
/>
</div>
);
}
export default GroupHeaderActions;

View File

@@ -1,14 +0,0 @@
.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.mapperStateRow .stateCell {
color: var(--l3-foreground);
}
.stateCell {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-4) var(--spacing-12);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,98 +0,0 @@
import { useListSpanMappers } from 'api/generated/services/spanmapper';
import { motion } from 'motion/react';
import {
MappingGroup,
Mapping,
} from 'container/LLMObservability/AttributeMapping/types';
import { buildMappingsFromListResponse } from 'container/LLMObservability/AttributeMapping/utils';
import { COLUMN_COUNT } from '../constants';
import MapperRow, { MapperRowSkeleton } from '../MapperRow';
import MappingsColgroup from '../MappingsColgroup';
import styles from './GroupMappers.module.scss';
const MAPPER_SKELETON_ROWS = 1;
const STATE_ROW_MOTION = {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: { duration: 0.18, ease: 'easeOut' },
} as const;
interface StateRowProps {
groupId: string;
}
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-${groupId}`}
>
Failed to load mappings. Please try again.
</td>
</motion.tr>
);
}
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-${groupId}`}
>
No mappings in this group yet.
</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,
},
},
);
let rows: JSX.Element[];
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 = mappers.map((mapper, index) => (
<MapperRow key={mapper.id} mapper={mapper} index={index} />
));
}
return (
<table className={styles.table}>
<MappingsColgroup />
<tbody>{rows}</tbody>
</table>
);
}
export default GroupMappers;

View File

@@ -1,65 +0,0 @@
.mapperRow {
&:hover {
background: var(--l2-background-hover);
}
}
.cell {
padding: var(--spacing-4) var(--spacing-6);
vertical-align: middle;
color: var(--l1-foreground);
font-size: var(--periscope-font-size-base);
}
// Indent the first cell so mapper rows read as nested under their group.
.targetCell {
padding-left: var(--spacing-12);
}
// Shorter vertical padding so the loading state reads as a compact placeholder.
.skeletonCell {
composes: cell;
:global(.ant-skeleton-input) {
min-height: 18px !important;
height: 18px !important;
}
:global(.ant-skeleton-button) {
min-height: 18px !important;
height: 18px !important;
}
}
.statusCell {
text-align: right;
}
.rowActions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-3);
}
.sources {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-3);
}
.sourceChipText {
display: block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sourceMore {
font-size: var(--font-size-xs);
white-space: nowrap;
}
.muted {
color: var(--l3-foreground);
}

View File

@@ -1,102 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { SpantypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { motion } from 'motion/react';
import { Mapping } from 'container/LLMObservability/AttributeMapping/types';
import styles from './MapperRow.module.scss';
const MAX_VISIBLE_SOURCES = 3;
const ROW_TRANSITION = { duration: 0.18, ease: 'easeOut' } as const;
const MAX_STAGGERED_ROWS = 6;
const STAGGER_STEP = 0.03;
interface MapperRowProps {
mapper: Mapping;
index: number;
}
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;
return (
<motion.tr
className={styles.mapperRow}
data-testid={`mapper-row-${mapper.id}`}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{
...ROW_TRANSITION,
delay: Math.min(index, MAX_STAGGERED_ROWS) * STAGGER_STEP,
}}
>
<td className={cx(styles.cell, styles.targetCell)}>
<Typography.Text
truncate={1}
title={mapper.name}
data-testid={`mapper-target-${mapper.id}`}
>
{mapper.name}
</Typography.Text>
</td>
<td className={styles.cell}>
{sources.length === 0 ? (
<span className={styles.muted} data-testid={`mapper-sources-${mapper.id}`}>
</span>
) : (
<div
className={styles.sources}
data-testid={`mapper-sources-${mapper.id}`}
>
{visibleSources.map((source) => (
<Badge
variant="outline"
color="vanilla"
className={styles.sourceChip}
key={`${source.context}:${source.key}`}
>
<span className={styles.sourceChipText} title={source.key}>
{source.key}
</span>
</Badge>
))}
{remainingSources > 0 && (
<span className={cx(styles.sourceMore, styles.muted)}>
+{remainingSources} more
</span>
)}
</div>
)}
</td>
<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}
disabled
testId={`mapper-enabled-${mapper.id}`}
/>
</div>
</td>
</motion.tr>
);
}
export default MapperRow;

View File

@@ -1,30 +0,0 @@
import { Skeleton } from 'antd';
import cx from 'classnames';
import styles from './MapperRow.module.scss';
function MapperRowSkeleton(): JSX.Element {
return (
<tr className={styles.mapperRow}>
<td className={cx(styles.skeletonCell, styles.targetCell)}>
<Skeleton.Input active size="small" style={{ width: '55%' }} />
</td>
<td className={styles.skeletonCell}>
<div className={styles.sources}>
<Skeleton.Button active size="small" style={{ width: 88 }} />
<Skeleton.Button active size="small" style={{ width: 56 }} />
</div>
</td>
<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>
</td>
</tr>
);
}
export default MapperRowSkeleton;

View File

@@ -1,2 +0,0 @@
export { default } from './MapperRow';
export { default as MapperRowSkeleton } from './MapperRowSkeleton';

View File

@@ -1,11 +0,0 @@
.colTarget {
width: 32%;
}
.colWritesTo {
width: 140px;
}
.colStatus {
width: 120px;
}

View File

@@ -1,14 +0,0 @@
import styles from './MappingsColgroup.module.scss';
function MappingsColgroup(): JSX.Element {
return (
<colgroup>
<col className={styles.colTarget} />
<col />
<col className={styles.colWritesTo} />
<col className={styles.colStatus} />
</colgroup>
);
}
export default MappingsColgroup;

View File

@@ -1,124 +0,0 @@
.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.headerRow {
border-bottom: 1px solid var(--l2-border);
}
.headerCell {
padding: var(--spacing-4) var(--spacing-6);
text-align: left;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-normal);
color: var(--l2-foreground);
&:last-child {
text-align: right;
}
}
.groupsCollapse:global(.ant-collapse) {
background: transparent;
border: none;
border-radius: 0;
> :global(.ant-collapse-item) {
border-bottom: none;
border-top: 1px solid var(--l2-border);
border-radius: 0;
&:last-child {
border-bottom: 1px solid var(--l2-border);
border-radius: 0;
}
> :global(.ant-collapse-header) {
align-items: center;
gap: var(--spacing-3);
background: var(--l2-background);
border-radius: 0;
padding: var(--spacing-3) var(--spacing-6);
color: var(--l3-foreground);
:global(.ant-collapse-expand-icon) {
display: flex;
align-items: center;
height: auto;
padding-inline-end: 0;
color: var(--l3-foreground);
}
:global(.ant-collapse-header-text) {
min-width: 0;
}
:global(.ant-collapse-extra) {
display: flex;
align-items: center;
}
}
}
:global(.ant-collapse-content) {
background: transparent;
border-top: none;
color: inherit;
> :global(.ant-collapse-content-box) {
padding: 0;
}
}
}
.tableEmpty {
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.skeletonList {
display: flex;
flex-direction: column;
}
// Mirrors the Collapse header banner while groups load.
.skeletonBanner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-6);
background: var(--l2-background);
border-top: 1px solid var(--l2-border);
&:last-child {
border-bottom: 1px solid var(--l2-border);
}
:global(.ant-skeleton-input) {
min-height: 18px !important;
height: 18px !important;
}
:global(.ant-skeleton-button) {
min-height: 18px !important;
height: 18px !important;
}
}
.skeletonGroupLeft {
display: flex;
align-items: center;
gap: var(--spacing-3);
flex: 1;
min-width: 0;
}
.skeletonGroupRight {
display: flex;
align-items: center;
gap: var(--spacing-3);
flex-shrink: 0;
}

View File

@@ -1,96 +0,0 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { Collapse, type CollapseProps, Skeleton } from 'antd';
import { AttributeMappingStore } from 'container/LLMObservability/AttributeMapping/AttributeMappingsTab/hooks/useAttributeMappingStore';
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;
}
function MappingsTable({ store }: MappingsTableProps): JSX.Element {
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
const isEmpty = !store.isLoading && store.groups.length === 0;
const items: CollapseProps['items'] = store.groups.map((group) => ({
key: group.id,
label: <GroupHeader group={group} />,
extra: <GroupHeaderActions group={group} />,
children: <GroupMappers group={group} />,
}));
const skeletonBanners = (
<div className={styles.skeletonList}>
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, index) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={`group-skeleton-${index}`}
className={styles.skeletonBanner}
>
<div className={styles.skeletonGroupLeft}>
<Skeleton.Input
active
size="small"
style={{ width: index % 2 === 0 ? 200 : 140 }}
/>
<Skeleton.Input active size="small" style={{ width: 64 }} />
</div>
<div className={styles.skeletonGroupRight}>
<Skeleton.Button active size="small" shape="round" />
</div>
</div>
))}
</div>
);
if (isEmpty) {
return (
<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
) : (
<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>
);
}
export default MappingsTable;

View File

@@ -1,33 +0,0 @@
import { useMemo } from 'react';
import { SpantypesSpanMapperGroupDTO } from 'api/generated/services/sigNoz.schemas';
import { useListSpanMapperGroups } from 'api/generated/services/spanmapper';
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
import { buildMappingGroup } from 'container/LLMObservability/AttributeMapping/utils';
export interface AttributeMappingStore {
groups: MappingGroup[];
isLoading: boolean;
isError: boolean;
}
// 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 groupsQuery = useListSpanMapperGroups();
const groups = useMemo<MappingGroup[]>(() => {
const serverGroups: SpantypesSpanMapperGroupDTO[] =
groupsQuery.data?.data?.items ?? [];
return serverGroups.map((group) => buildMappingGroup(group));
}, [groupsQuery.data]);
return {
groups,
isLoading: groupsQuery.isLoading,
isError: groupsQuery.isError,
};
}

View File

@@ -2,5 +2,12 @@
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-0);
padding: var(--spacing-12);
}
.tableEmpty {
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,27 +1,9 @@
import { Tabs } from '@signozhq/ui/tabs';
import AttributeMappingHeader from './components/AttributeMappingHeader';
import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
import styles from './LLMObservabilityAttributeMapping.module.scss';
const noop = (): void => undefined;
function LLMObservabilityAttributeMapping(): JSX.Element {
const tabItems = [
{
key: 'attribute-mappings',
label: 'Attribute mappings',
children: <AttributeMappingsTab />,
},
{
key: 'test',
label: 'Test',
disabled: true,
disabledReason: 'Coming soon',
children: null,
},
];
return (
<div
className={styles.llmObservabilityAttributeMapping}
@@ -34,11 +16,9 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
onSave={noop}
/>
<Tabs
testId="attribute-mapping-tabs"
defaultValue="attribute-mappings"
items={tabItems}
/>
<div className={styles.tableEmpty} data-testid="attribute-mapping-empty">
No mapping groups configured yet.
</div>
</div>
);
}

View File

@@ -1,67 +0,0 @@
import { rest, server } from 'mocks-server/server';
import { render, screen } from 'tests/test-utils';
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
function setupGroups(): void {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
);
}
describe('LLMObservabilityAttributeMapping', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
setupGroups();
});
afterEach(() => {
server.resetHandlers();
});
it('renders the page shell', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByTestId('llm-observability-attribute-mapping-page'),
).toBeInTheDocument();
});
it('shows the attribute-mappings and test sub-tab labels', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByRole('tab', { name: 'Attribute mappings' }),
).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Test' })).toBeInTheDocument();
});
it('activates the attribute-mappings tab by default and renders its content', async () => {
render(<LLMObservabilityAttributeMapping />);
const attributeMappingsTab = screen.getByRole('tab', {
name: 'Attribute mappings',
});
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
await expect(
screen.findByTestId('attribute-mappings-tab'),
).resolves.toBeInTheDocument();
});
it('renders the header with its description and no Save/Discard while pristine', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByText(
'Configure source-to-target attribute remapping for LLM traces',
),
).toBeInTheDocument();
// The actions only appear once there are staged changes.
expect(screen.queryByTestId('save-changes-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('unsaved-changes')).not.toBeInTheDocument();
});
});

View File

@@ -1,93 +0,0 @@
import {
SpantypesFieldContextDTO as FieldContext,
SpantypesSpanMapperDTO as Mapper,
SpantypesSpanMapperGroupDTO as MapperGroup,
SpantypesSpanMapperOperationDTO as MapperOperation,
} from 'api/generated/services/sigNoz.schemas';
// Endpoint globs used by MSW handlers. The generated client hits relative
// `/api/v1/span_mapper_groups[...]`, so the `*` prefix matches regardless of
// base URL.
export const GROUPS_ENDPOINT = '*/api/v1/span_mapper_groups';
export function mappersEndpoint(groupId: string): string {
return `*/api/v1/span_mapper_groups/${groupId}/span_mappers`;
}
export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
return {
id: 'group-1',
orgId: 'org-1',
name: 'demo',
enabled: true,
condition: {
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
},
...overrides,
};
}
export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
return {
id: 'mapper-1',
group_id: 'group-1',
name: 'gen_ai.request.model',
enabled: true,
fieldContext: FieldContext.attribute,
config: {
sources: [
{
key: 'genai.model',
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority: 2,
},
{
key: 'llm.model',
context: FieldContext.attribute,
operation: MapperOperation.move,
priority: 1,
},
],
},
...overrides,
};
}
// Both list endpoints share the same `{ status, data: { items } }` envelope —
// the generated schema mis-types the mappers response with the groups DTO
// (see GroupMappers), but the runtime envelope shape is identical.
export function makeGroupsResponse(groups: MapperGroup[]): {
status: string;
data: { items: MapperGroup[] };
} {
return { status: 'ok', data: { items: groups } };
}
export function makeMappersResponse(mappers: Mapper[]): {
status: string;
data: { items: Mapper[] };
} {
return { status: 'ok', data: { items: mappers } };
}
export const mockGroups: MapperGroup[] = [
makeGroup({
id: 'group-1',
name: 'demo',
condition: {
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
},
}),
makeGroup({
id: 'group-2',
name: 'Tool',
enabled: false,
condition: { attributes: null, resource: null },
}),
];
export const mockMappers: Mapper[] = [
makeMapper({ id: 'mapper-1', group_id: 'group-1' }),
];

View File

@@ -5,6 +5,23 @@
gap: var(--spacing-8);
}
.pageHeaderTitle {
display: flex;
flex-direction: column;
}
.title {
margin: 0;
font-size: var(--periscope-font-size-large);
font-weight: var(--font-weight-semibold);
}
.description {
margin: var(--spacing-2) 0 0;
font-size: var(--periscope-font-size-base);
color: var(--l3-foreground);
}
.pageHeaderActions {
display: flex;
align-items: center;

View File

@@ -1,5 +1,4 @@
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './AttributeMappingHeader.module.scss';
@@ -18,35 +17,38 @@ function AttributeMappingHeader({
}: AttributeMappingHeaderProps): JSX.Element {
return (
<header className={styles.pageHeader}>
<Typography.Text as="p" size="base" color="muted">
Configure source-to-target attribute remapping for LLM traces
</Typography.Text>
{isDirty && (
<div className={styles.pageHeaderActions}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>Attribute Mapping</h1>
<p className={styles.description}>
Configure source-to-target attribute remapping for LLM traces
</p>
</div>
<div className={styles.pageHeaderActions}>
{isDirty && (
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
)}
)}
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={!isDirty || isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={!isDirty || isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
</header>
);
}

View File

@@ -1,26 +0,0 @@
import {
SpantypesFieldContextDTO,
SpantypesSpanMapperOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
export interface SourceConfig {
key: string;
context: SpantypesFieldContextDTO;
operation: SpantypesSpanMapperOperationDTO;
}
export interface Mapping {
id: string;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
export interface MappingGroup {
id: string;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
}

View File

@@ -1,48 +0,0 @@
import {
ListSpanMappers200,
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { MappingGroup, Mapping, SourceConfig } from './types';
function getMapperSources(mapper: SpantypesSpanMapperDTO): SourceConfig[] {
const sources = mapper.config?.sources ?? [];
return [...sources]
.sort((a, b) => a.priority - b.priority)
.map((source) => ({
key: source.key,
context: source.context,
operation: source.operation,
}));
}
export function buildMapping(mapper: SpantypesSpanMapperDTO): Mapping {
return {
id: mapper.id,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources: getMapperSources(mapper),
enabled: mapper.enabled,
};
}
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 {
id: group.id,
name: group.name,
attributes: group.condition?.attributes ?? [],
resource: group.condition?.resource ?? [],
enabled: group.enabled,
};
}

View File

@@ -3,13 +3,3 @@
flex-direction: column;
gap: var(--spacing-8);
}
.tabLabel {
display: inline-flex;
align-items: center;
gap: 8px;
}
.tabBadge {
margin: 0;
}

View File

@@ -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,
},
]}
/>

View File

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

View File

@@ -25,6 +25,7 @@
.drawerSurface {
padding: var(--spacing-7);
border-radius: 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &quot;{modelName}&quot;
</ComboboxCreateItem>
</ComboboxCommand>
</ComboboxContent>
</Combobox>
</div>
);
}
export default MapToBillingModelSelect;

View File

@@ -1 +0,0 @@
export { default } from './MapToBillingModelSelect';

View File

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

View File

@@ -1,2 +0,0 @@
export { getUnpricedModelsColumns } from './table.config';
export type { UnpricedColumnsConfig } from './table.config';

View File

@@ -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)}
/>
),
},
];
}

View File

@@ -1,4 +0,0 @@
.spansBadge {
margin: 0;
font-family: var(--code-font-family, monospace);
}

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
export { default } from './UnpricedModelsTab';

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,10 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
type LlmpricingruletypesLLMPricingRuleDTO,
type LlmpricingruletypesUnmappedModelDTO,
} from 'api/generated/services/sigNoz.schemas';
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
// A model seen in trace data (gen_ai.request.model) that no pricing rule matches.
export type UnpricedModel = LlmpricingruletypesUnmappedModelDTO;
export interface ExtraBucket {
key: string;
pricePerMillion: number;
@@ -33,7 +29,6 @@ export interface DrawerDraft {
provider: string;
patterns: string[];
isOverride: boolean;
enabled: boolean;
pricing: {
input: number | null;
output: number | null;

View File

@@ -86,7 +86,6 @@ export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
provider: rule.provider,
patterns: rule.modelPattern || [],
isOverride: !!rule.isOverride,
enabled: rule.enabled,
pricing: {
input: rule.pricing?.input ?? 0,
output: rule.pricing?.output ?? 0,
@@ -130,7 +129,7 @@ export const buildRulePayload = (
provider: draft.provider.trim(),
modelPattern: draft.patterns,
isOverride: draft.isOverride,
enabled: draft.enabled,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: buildPricingPayload(draft),
});
@@ -162,38 +161,3 @@ export const validatePricing = (
}
return true;
};
const spanCountFormatter = new Intl.NumberFormat('en', {
notation: 'compact',
maximumFractionDigits: 1,
});
export const formatSpanCount = (count: number): string =>
spanCountFormatter.format(count);
// Label for the "Map to billing model" dropdown, e.g. "openai:gpt-4o ($15.00/$60.00)".
export const getRuleOptionLabel = (rule: PricingRule): string =>
`${getCanonicalId(rule)} (${formatPricePerMillion(
rule.pricing?.input,
)}/${formatPricePerMillion(rule.pricing?.output)})`;
export const buildPatternMappingPayload = (
rule: PricingRule,
modelName: string,
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => {
const existing = rule.modelPattern ?? [];
const modelPattern = existing.includes(modelName)
? existing
: [...existing, modelName];
return {
id: rule.id,
sourceId: rule.sourceId,
modelName: rule.modelName,
provider: rule.provider,
modelPattern,
isOverride: rule.isOverride,
enabled: rule.enabled,
unit: rule.unit,
pricing: rule.pricing,
};
};

View File

@@ -191,6 +191,14 @@
min-height: 0;
overflow-y: visible;
.time-series-view-container-header {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 12px;
flex-shrink: 0;
}
.time-series-view {
flex-shrink: 0;
height: 65vh;

View File

@@ -32,6 +32,7 @@ import {
getListQuery,
getQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
@@ -460,17 +461,18 @@ function LogsExplorerViewsContainer({
)}
{selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && (
<div className="time-series-view-container">
<div className="time-series-view-container-header">
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
</div>
<TimeSeriesView
isLoading={isLoading || isFetching}
data={data}
isError={isError}
error={error as APIError}
yAxisUnit={yAxisUnit}
onYAxisUnitChange={onUnitChange}
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
dataSource={DataSource.LOGS}
setWarning={setWarning}
allowExport
/>
</div>
)}

View File

@@ -311,7 +311,6 @@ function TimeSeries({
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
allowExport
/>
</div>
);

View File

@@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom';
import cx from 'classnames';
import { Pagination, Skeleton } from 'antd';
import { useListRoles } from 'api/generated/services/role';
import { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ROUTES from 'constants/routes';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
@@ -21,7 +21,7 @@ const PAGE_SIZE = 20;
type DisplayItem =
| { type: 'section'; label: string; count?: number }
| { type: 'role'; role: AuthtypesGettableRoleDTO };
| { type: 'role'; role: AuthtypesRoleDTO };
interface RolesListContentProps {
searchQuery: string;
@@ -176,7 +176,7 @@ function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
);
}
const renderRow = (role: AuthtypesGettableRoleDTO): JSX.Element => (
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
<div
key={role.id}
className={cx(styles.tableRow, {

View File

@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from 'react-query';
import { ErrorType } from 'api/generatedAPIInstance';
import type {
AuthtypesPostableRoleDTO,
AuthtypesRoleDTO,
AuthtypesRoleWithTransactionGroupsDTO,
AuthtypesTransactionGroupDTO,
AuthtypesUpdatableRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -133,7 +133,7 @@ export function transformTransactionGroupsToResourcePermissions(
}
export function transformApiToRolePermissions(
role: AuthtypesRoleDTO,
role: AuthtypesRoleWithTransactionGroupsDTO,
): RolePermissionsData {
return {
roleId: role.id,

View File

@@ -208,7 +208,7 @@ describe('ServiceAccountsSettings (integration)', () => {
fireEvent.click(screen.getByRole('button', { name: /New Service Account/i }));
await screen.findByTestId('create-service-account-modal');
await screen.findByRole('dialog', { name: /New Service Account/i });
expect(screen.getByPlaceholderText('Enter a name')).toBeInTheDocument();
});

View File

@@ -3,14 +3,6 @@
min-height: 350px;
padding: 0px 12px;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
flex-shrink: 0;
}
.ant-card-body {
height: 50vh;
min-height: 350px;

View File

@@ -16,7 +16,6 @@ import Uplot from 'components/Uplot';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
@@ -42,14 +41,11 @@ import { SuccessResponse, Warning } from 'types/api';
import { LegendPosition } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import TimeseriesExportMenu from './TimeseriesExportMenu';
import './TimeSeriesView.styles.scss';
function TimeSeriesView({
@@ -63,8 +59,6 @@ function TimeSeriesView({
setWarning,
panelType = PANEL_TYPES.TIME_SERIES,
stackBarChart = false,
allowExport = false,
onYAxisUnitChange,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -250,33 +244,10 @@ function TimeSeriesView({
[baseChartOptions, stackedBands],
);
const showExport = allowExport && !!data?.rawV5Response;
const showHeader = showExport || !!onYAxisUnitChange;
return (
<div className="time-series-view">
{isError && error && <ErrorInPlace error={error as APIError} />}
{showHeader && (
<div className="time-series-view__header">
<div>
{onYAxisUnitChange && (
<BuilderUnitsFilter onChange={onYAxisUnitChange} yAxisUnit={yAxisUnit} />
)}
</div>
{showExport && data?.rawV5Response && (
<TimeseriesExportMenu
dataSource={dataSource}
yAxisUnit={yAxisUnit}
queryResponse={data.rawV5Response}
query={currentQuery}
legendMap={data.legendMap}
fileName={`${dataSource}-timeseries`}
/>
)}
</div>
)}
<div
className="graph-container"
style={{ height: '100%', width: '100%' }}
@@ -324,11 +295,7 @@ function TimeSeriesView({
}
interface TimeSeriesViewProps {
data?: SuccessResponse<MetricRangePayloadProps> & {
warning?: Warning;
rawV5Response?: QueryRangeResponseV5;
legendMap?: Record<string, string>;
};
data?: SuccessResponse<MetricRangePayloadProps> & { warning?: Warning };
yAxisUnit?: string;
isLoading: boolean;
isError: boolean;
@@ -338,11 +305,6 @@ interface TimeSeriesViewProps {
setWarning?: Dispatch<SetStateAction<Warning | undefined>>;
panelType?: PANEL_TYPES;
stackBarChart?: boolean;
// Opt-in: render the client-side export menu (Logs explorer for now).
allowExport?: boolean;
// Opt-in: render the y-axis unit selector in the header (views without their
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
onYAxisUnitChange?: (value: string) => void;
}
TimeSeriesView.defaultProps = {

View File

@@ -1,33 +0,0 @@
.timeseries-export-popover {
width: 240px;
padding: 0 12px 12px 12px;
.title {
display: flex;
color: var(--l1-foreground);
font-family: Inter;
font-size: var(--periscope-font-size-small);
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
margin-bottom: 8px;
}
.export-format {
padding: 12px 4px;
display: flex;
flex-direction: column;
// radio option labels — radix popover content inherits the root font
// size; pin to the app's 13px base the antd popover used to impose
label {
font-size: var(--periscope-font-size-base);
}
}
.export-button {
width: 100%;
}
}

View File

@@ -1,91 +0,0 @@
import { Download } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { useClientExport } from 'hooks/useExportData/useClientExport';
import { ExportFormat } from 'lib/exportData/types';
import { useCallback, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import './TimeseriesExportMenu.styles.scss';
interface TimeseriesExportMenuProps {
dataSource: DataSource;
queryResponse: QueryRangeResponseV5;
query?: Query;
yAxisUnit?: string;
legendMap?: Record<string, string>;
fileName?: string;
}
// Download menu for in-memory timeseries data (client-side serialization).
// The raw/list backend export keeps its own menu in DownloadOptionsMenu.
export default function TimeseriesExportMenu({
dataSource,
queryResponse,
query,
yAxisUnit,
legendMap,
fileName,
}: TimeseriesExportMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const { isExporting, handleExport: handleClientExport } = useClientExport({
response: queryResponse,
query,
yAxisUnit,
legendMap,
fileName,
});
const handleExport = useCallback((): void => {
setIsPopoverOpen(false);
handleClientExport({ format: exportFormat as ExportFormat });
}, [exportFormat, handleClientExport]);
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<TooltipSimple title="Download">
<PopoverTrigger asChild>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Download"
data-testid={`timeseries-export-${dataSource}`}
disabled={isExporting}
loading={isExporting}
>
<Download size={14} />
</Button>
</PopoverTrigger>
</TooltipSimple>
<PopoverContent align="end" className="timeseries-export-popover">
<div className="export-format">
<Typography.Text className="title">FORMAT</Typography.Text>
<RadioGroup value={exportFormat} onChange={setExportFormat}>
<RadioGroupItem value={ExportFormat.Csv}>csv</RadioGroupItem>
<RadioGroupItem value={ExportFormat.Jsonl}>jsonl</RadioGroupItem>
</RadioGroup>
</div>
<Button
variant="solid"
color="primary"
className="export-button"
onClick={handleExport}
disabled={isExporting}
loading={isExporting}
prefix={<Download size={16} />}
>
Export
</Button>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,138 +0,0 @@
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import store from 'store';
import { DataSource } from 'types/common/queryBuilder';
import TimeSeriesView from '../TimeSeriesView';
jest.mock('components/Uplot', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="uplot-chart" />,
}));
jest.mock('../TimeseriesExportMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="timeseries-export-menu" />,
}));
jest.mock('container/QueryBuilder/filters/BuilderUnitsFilter', () => ({
BuilderUnitsFilter: (): JSX.Element => (
<div data-testid="builder-units-filter" />
),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({ currentQuery: null }),
}));
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
getUPlotChartOptions: (): unknown => ({}),
}));
jest.mock('lib/uPlotLib/utils/getUplotChartData', () => ({
getUPlotChartData: (): number[][] => [
[1, 2],
[3, 4],
],
}));
jest.mock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
() => ({ stackSeries: (): unknown => ({ data: [], bands: [] }) }),
);
jest.mock('container/GridCardLayout/GridCard/utils', () => ({
getLocalStorageGraphVisibilityState: (): unknown => ({
graphVisibilityStates: [],
}),
}));
jest.mock('providers/Timezone', () => ({
useTimezone: (): unknown => ({ timezone: { value: 'UTC' } }),
}));
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: (): unknown => ({ width: 800, height: 400 }),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockStore = configureStore([])({ ...store.getState() });
const rawV5Response = {
type: 'time_series',
data: { results: [] },
meta: {},
};
function makeData(withRawV5: boolean): any {
return {
statusCode: 200,
error: null,
message: '',
payload: { data: { result: [], resultType: '' } },
...(withRawV5 ? { rawV5Response, legendMap: {} } : {}),
};
}
function renderView(props: {
allowExport?: boolean;
withRawV5?: boolean;
onYAxisUnitChange?: (value: string) => void;
}): ReturnType<typeof render> {
const { allowExport, withRawV5 = true, onYAxisUnitChange } = props;
return render(
<Provider store={mockStore}>
<MemoryRouter>
<TimeSeriesView
isLoading={false}
isError={false}
isFilterApplied
dataSource={DataSource.LOGS}
data={makeData(withRawV5)}
allowExport={allowExport}
onYAxisUnitChange={onYAxisUnitChange}
/>
</MemoryRouter>
</Provider>,
);
}
describe('TimeSeriesView header gating', () => {
it('renders the export menu when allowExport is set and raw V5 data is present', () => {
const { queryByTestId } = renderView({ allowExport: true });
expect(queryByTestId('timeseries-export-menu')).toBeInTheDocument();
});
it('renders no export menu without allowExport', () => {
const { queryByTestId } = renderView({});
expect(queryByTestId('timeseries-export-menu')).not.toBeInTheDocument();
});
it('renders no export menu when the raw V5 response is missing', () => {
const { queryByTestId } = renderView({ allowExport: true, withRawV5: false });
expect(queryByTestId('timeseries-export-menu')).not.toBeInTheDocument();
});
it('renders the unit selector only when onYAxisUnitChange is passed', () => {
const withUnit = renderView({ onYAxisUnitChange: jest.fn() });
expect(withUnit.queryByTestId('builder-units-filter')).toBeInTheDocument();
withUnit.unmount();
const withoutUnit = renderView({ allowExport: true });
expect(
withoutUnit.queryByTestId('builder-units-filter'),
).not.toBeInTheDocument();
});
it('renders no header row when neither export nor unit selector is enabled', () => {
const { container } = renderView({ withRawV5: false });
expect(container.querySelector('.time-series-view__header')).toBeNull();
});
});

View File

@@ -1,84 +0,0 @@
import { fireEvent, render, screen } from 'tests/test-utils';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import TimeseriesExportMenu from '../TimeseriesExportMenu';
const mockHandleExport = jest.fn();
let mockIsExporting = false;
jest.mock('hooks/useExportData/useClientExport', () => ({
useClientExport: (): unknown => ({
isExporting: mockIsExporting,
handleExport: mockHandleExport,
}),
}));
const response = {
type: 'time_series',
data: { results: [] },
meta: {},
} as unknown as QueryRangeResponseV5;
const TEST_ID = `timeseries-export-${DataSource.LOGS}`;
function renderMenu(): void {
render(
<TimeseriesExportMenu
dataSource={DataSource.LOGS}
queryResponse={response}
fileName="logs-timeseries"
/>,
);
}
describe('TimeseriesExportMenu', () => {
beforeEach(() => {
mockHandleExport.mockReset();
mockIsExporting = false;
});
it('renders the download trigger button', () => {
renderMenu();
expect(screen.getByTestId(TEST_ID)).toBeInTheDocument();
});
it('shows only format options — no shape, row-count, or column controls', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'csv' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'jsonl' })).toBeInTheDocument();
expect(screen.queryByText('Number of Rows')).not.toBeInTheDocument();
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'long' })).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'wide' })).not.toBeInTheDocument();
});
it('exports as csv by default', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
fireEvent.click(screen.getByText('Export'));
expect(mockHandleExport).toHaveBeenCalledTimes(1);
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'csv' });
});
it('exports as jsonl when selected', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
fireEvent.click(screen.getByRole('radio', { name: 'jsonl' }));
fireEvent.click(screen.getByText('Export'));
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'jsonl' });
});
it('disables the trigger while an export is in progress', () => {
mockIsExporting = true;
renderMenu();
expect(screen.getByTestId(TEST_ID)).toBeDisabled();
});
});

View File

@@ -1,41 +1,10 @@
import { SuccessResponse } from 'types/api/index';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
type ConvertibleData = SuccessResponse<MetricRangePayloadProps> & {
rawV5Response?: QueryRangeResponseV5;
};
// Applies the same ns→ms conversion to the raw V5 tree, so client-side export
// serializes the values the chart displays (not the original nanoseconds).
function convertRawV5ValuesToMs(
response: QueryRangeResponseV5,
): QueryRangeResponseV5 {
if (response.type !== 'time_series') {
return response;
}
const results = (response.data.results as TimeSeriesData[]).map((result) => ({
...result,
aggregations: (result.aggregations ?? []).map((bucket) => ({
...bucket,
series: (bucket.series ?? []).map((series) => ({
...series,
values: (series.values ?? []).map((value) => ({
...value,
value: value.value / 1000000,
})),
})),
})),
}));
return { ...response, data: { ...response.data, results } };
}
export const convertDataValueToMs = (
data?: ConvertibleData,
): ConvertibleData | undefined => {
data?: SuccessResponse<MetricRangePayloadProps>,
): SuccessResponse<MetricRangePayloadProps> | undefined => {
const convertedData = data;
const convertedResult: QueryData[] = data?.payload?.data?.result
@@ -53,11 +22,5 @@ export const convertDataValueToMs = (
convertedData.payload.data.result = convertedResult;
}
if (convertedData?.rawV5Response) {
convertedData.rawV5Response = convertRawV5ValuesToMs(
convertedData.rawV5Response,
);
}
return convertedData;
};

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