Compare commits

..

2 Commits

139 changed files with 634 additions and 8303 deletions

View File

@@ -62,7 +62,6 @@
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
}
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
}

View File

@@ -87,7 +87,6 @@
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
}
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
}

View File

@@ -329,3 +329,10 @@ export const LLMObservabilityPage = Loadable(
/* webpackChunkName: "LLM Observability Page" */ 'pages/LLMObservability'
),
);
export const LLMObservabilityModelPricingPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
),
);

View File

@@ -24,6 +24,7 @@ import {
LicensePage,
ListAllALertsPage,
LLMObservabilityPage,
LLMObservabilityModelPricingPage,
LiveLogs,
Login,
Logs,
@@ -514,24 +515,17 @@ const routes: AppRoutes[] = [
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
path: ROUTES.LLM_OBSERVABILITY_BASE,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
key: 'LLM_OBSERVABILITY_BASE',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_CONFIGURATION',
component: LLMObservabilityModelPricingPage,
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
isPrivate: true,
},
];

View File

@@ -8869,44 +8869,6 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
items: SpantypesSpanMapperGroupDTO[];
}
export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOAttributes =
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOResource =
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
export interface SpantypesSpanMapperTestSpanDTO {
/**
* @type object,null
*/
attributes?: SpantypesSpanMapperTestSpanDTOAttributes;
/**
* @type object,null
*/
resource?: SpantypesSpanMapperTestSpanDTOResource;
}
export interface SpantypesGettableSpanMapperTestDTO {
/**
* @type array,null
*/
spans: SpantypesSpanMapperTestSpanDTO[] | null;
}
export enum SpantypesSpanAggregationTypeDTO {
span_count = 'span_count',
execution_time_percentage = 'execution_time_percentage',
@@ -9202,33 +9164,6 @@ export interface SpantypesPostableSpanMapperGroupDTO {
name: string;
}
export interface SpantypesPostableSpanMapperTestGroupDTO {
condition: SpantypesSpanMapperGroupConditionDTO | null;
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type array,null
*/
mappers?: SpantypesPostableSpanMapperDTO[] | null;
/**
* @type string
*/
name: string;
}
export interface SpantypesPostableSpanMapperTestDTO {
/**
* @type array,null
*/
groups: SpantypesPostableSpanMapperTestGroupDTO[] | null;
/**
* @type array,null
*/
spans: SpantypesSpanMapperTestSpanDTO[] | null;
}
export interface SpantypesSpanAggregationDTO {
aggregation: SpantypesSpanAggregationTypeDTO;
field: TelemetrytypesTelemetryFieldKeyDTO;
@@ -10618,14 +10553,6 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type TestSpanMappers200 = {
data: SpantypesGettableSpanMapperTestDTO;
/**
* @type string
*/
status: string;
};
export type GetStats200Data = { [key: string]: unknown };
export type GetStats200 = {

View File

@@ -30,10 +30,8 @@ import type {
RenderErrorResponseDTO,
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesPostableSpanMapperTestDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
TestSpanMappers200,
UpdateSpanMapperGroupPathParameters,
UpdateSpanMapperPathParameters,
} from '../sigNoz.schemas';
@@ -782,86 +780,3 @@ export const useUpdateSpanMapper = <
> => {
return useMutation(getUpdateSpanMapperMutationOptions(options));
};
/**
* Tests how span mappers would transform sample spans
* @summary Test span mappers against sample spans
*/
export const testSpanMappers = (
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<TestSpanMappers200>({
url: `/api/v1/span_mapper_groups/test`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableSpanMapperTestDTO,
signal,
});
};
export const getTestSpanMappersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
const mutationKey = ['testSpanMappers'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof testSpanMappers>>,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
> = (props) => {
const { data } = props ?? {};
return testSpanMappers(data);
};
return { mutationFn, ...mutationOptions };
};
export type TestSpanMappersMutationResult = NonNullable<
Awaited<ReturnType<typeof testSpanMappers>>
>;
export type TestSpanMappersMutationBody =
| BodyType<SpantypesPostableSpanMapperTestDTO>
| undefined;
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Test span mappers against sample spans
*/
export const useTestSpanMappers = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
return useMutation(getTestSpanMappersMutationOptions(options));
};

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#1287b1" viewBox="0 0 24 24"><title>Apache Cassandra</title><path d="M10.374 10.53a3 3 0 0 1-.428-.222l.555.143c0 .02-.01.036-.01.055l-.117.025zm-.283 1.506-.315.253.852-1.079-1.078.391c.002.017.009.033.009.05a.57.57 0 0 1-.184.42q.154.327.375.616a3.2 3.2 0 0 1 .34-.651zm.717-2.347-.652-.82a.43.43 0 0 1-.506.162c-.054.073-.083.162-.13.24l1.258.463zm-1.666.444c-.07.314-.087.637-.05.956a.57.57 0 0 1 .451.475l.946-.606c-.067-.022-.126-.06-.191-.088l-1.119-.08.64-.14a3.2 3.2 0 0 1-.668-.554zM20.1 11.648c-.164.202.833 1.022.833 1.022s-1.654-1.022-2.234-.72c-.278.144.574.811 1.175 1.242-.428-.274-.982-.571-1.175-.408-.328.277 1.565 2.549 1.565 2.549s-2.145-2.322-2.36-2.209c-.214.114.593 1.224.593 1.224s-1.06-1.16-1.35-.959c-.29.202 1.514 3.218 1.514 3.218s-1.956-3.091-2.763-2.574c1.268 2.782.795 3.18.795 3.18s-.162-2.839-1.742-2.764c-.795.038.379 2.12.379 2.12s-1.08-1.902-1.8-1.864c1.326 2.51.854 3.53.854 3.53s.219-2.143-1.58-3.336c.682.606-.427 3.336-.427 3.336s.976-4.023-.719-3.256c-.268.121-.019 2.007-.019 2.007s-.34-2.158-.851-2.045c-.298.066-1.893 2.99-1.893 2.99s1.306-3.16.908-3.027c-.29.096-.833 1.4-.833 1.4s.265-1.287 0-1.363c-.264-.075-1.74 1.363-1.74 1.363s1.097-1.287.908-1.552c-.287-.402-.623-.42-1.022-.265-.581.226-1.363 1.287-1.363 1.287s.78-1.074.643-1.476c-.219-.647-2.46 1.249-2.46 1.249s1.325-1.25 1.022-1.514c-.303-.265-1.947-.183-2.46-.185-1.515-.004-2.039-.36-2.498-.724 1.987.997 3.803-.151 6.094.494l.21.06c-1.3-.558-2.144-1.378-2.226-2.354-.036-.416.074-.827.297-1.222.619-.4 1.29-.773 2.06-1.095a4 4 0 0 0-.064.698c0 2.44 2.203 4.417 4.92 4.417s4.92-1.977 4.92-4.417c0-.45-.083-.881-.223-1.29 1.431.404 2.45.968 3.132 1.335.022.092.045.184.053.279.024.274-.018.547-.11.814.095-.147.198-.288.28-.445.367-.997 1.855.227 1.855.227s-1.085-.454-1.06-.24c.026.215 1.628.96 1.628.96s-1.45-.455-1.362-.114c.088.34 1.817 1.703 1.817 1.703s-1.956-1.489-2.12-1.287zm-7.268 2.65.042-.008-.06.01zM9.256 9.753c.12.13.26.234.396.343l.927-.029-1.064-.788c-.093.154-.195.303-.26.474Zm10.62 3.44c.3.215.54.373.54.373s-.24-.181-.54-.374zM7.507 8.617c-.14.229-.214.492-.215.76a3.99 3.99 0 0 0 2.358 3.64q0-.008.003-.014a3.2 3.2 0 0 1-.58-.788c-.648.099-.926-.794-.336-1.08a3.2 3.2 0 0 1 .138-1.388 3.16 3.16 0 0 1-.52-1.36q-.444.105-.848.23m1.488.82q.163-.36.402-.661a.435.435 0 0 1 .568-.557c.077-.059.166-.099.248-.15a16 16 0 0 0-1.727.284c.114.388.272.76.509 1.084m2.285 3.928c1.4 0 2.633-.723 3.344-1.816a3.4 3.4 0 0 0-1.265-.539l-.297-.023.916.9-1.197-.467.704 1.078-1.074-.832-.012.006.347 1.278-.596-1.134-.098 1.33-.401-1.326-.472 1.261.114-1.359-.015-.008-.814 1.154.286-1.067c-.34.322-.605.713-.781 1.146q.143.153.303.29.484.126 1.008.128m10.145-4.434c.971-.567 1.716-1.955 1.716-1.955s-1.893 1.955-3.205 1.665c1.186-.934 1.766-2.549 1.766-2.549s-1.506 2.325-2.448 2.423c1.086-.959 1.54-2.322 1.54-2.322s-1.237 1.817-2.196 1.944c1.287-1.161 1.338-1.893 1.338-1.893s-1.781 2.302-2.499 1.943c.858-.934 1.439-2.12 1.439-2.12s-1.489 2.019-1.893 1.69c-.277-.05.454-.958.454-.958s-.908.807-1.16.606c.454-.278 1.236-1.64 1.236-1.64S16 7.505 15.621 7.304l.731-1.483s-.73 1.483-1.715 1.23c.454-.58.63-1.112.63-1.112s-.756 1.213-1.69.885c-.22-.077.273-.635.273-.635s-.626.61-1.055.534c-.43-.076.025-.858.025-.858s-.757 1.186-.908 1.136.075-.833.075-.833-.555.908-.858.858 0-.934 0-.934-.328.984-.58.909c-.252-.076-.303-.656-.303-.656s-.068.788-.429.858c-2.725.53-5.728 1.69-9.489 5.45C3.887 10.738 5.3 7.91 11.962 7.659c5.044-.191 7.399 2.137 8.177 2.17C22.51 9.93 24 7.633 24 7.633s-1.489 1.716-2.574 1.3zm-7.74.872-.608.464v.001l.054.003a3.4 3.4 0 0 0 .554-.468m1.583-.426c0-.536-.237-.929-.594-1.217a3.2 3.2 0 0 1-.165.825.393.393 0 0 1-.328.681c-.154.233-.34.445-.549.63l.661.034-.995.237c-.025.018-.045.041-.07.058a3.2 3.2 0 0 1 1.536.691c.32-.574.504-1.235.504-1.94zM10.99 7.996a3.5 3.5 0 0 0-.785.46.43.43 0 0 1-.013.357l.885.643.023-.016-.36-1.262.627 1.12c.018-.006.04-.006.058-.011l-.02-1.251.398 1.163.477-1.15.016 1.268.012.007.713-1.005-.363 1.218.009.01 1.04-.69-.759 1.05.002.005.95-.34.041-.045a.395.395 0 0 1 .394-.632 3.4 3.4 0 0 0 .27-.784 14 14 0 0 0-2.798-.168c-.286.011-.55.033-.817.053"/></svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="48 -2.25 262.5 364"><defs><style>.cls-1{fill:#326ce5}.cls-3{fill:none}</style></defs><path d="M59.724 97.778a10.183 10.183 0 0 1 0-17.073l114.07-74.16a10.18 10.18 0 0 1 11.1 0l114.07 74.16a10.183 10.183 0 0 1 0 17.073l-114.07 74.16a10.18 10.18 0 0 1-11.1 0z" class="cls-1"/><path fill="#c1d2f7" d="M197.356 110.866h7.912a6.003 6.003 0 0 0 5.2-9.005l-25.924-44.902a6.004 6.004 0 0 0-10.399 0l-25.924 44.902a6.003 6.003 0 0 0 5.2 9.005h7.912a6.003 6.003 0 0 1 6.004 6.003v51.257l5.31 3.452a12.29 12.29 0 0 0 13.395 0l5.31-3.452v-51.257a6.003 6.003 0 0 1 6.004-6.003"/><path d="M173.793 353.271a10.1 10.1 0 0 0 3.457 1.402 119 119 0 0 0-6.623-3.46zm-6.456-161.195-11.316 7.356a111 111 0 0 0 11.316 6.6zm24.015 23.716c9.739 3.115 19.813 5.648 30.11 8.186 10.92 2.692 21.973 5.427 32.818 9.009l-35.416-23.025a216 216 0 0 1-27.512-9.41zm0 62.191v.586c0 2.57-2.688 4.652-6.003 4.652H173.34c-3.315 0-6.003-2.083-6.003-4.652v-6.605c-24.906-6.402-49.873-14.485-70.86-33.82l-14.529 9.445c22.468 22.05 50.28 28.933 79.715 36.189 27.178 6.698 55.18 13.651 78.685 33.442l14.68-9.544c-18.532-16.504-40.41-23.687-63.676-29.693M93.315 300.95l38.908 25.295c20.064 5.113 40.2 11.248 58.31 23.36l14.901-9.687c-19.752-13.672-42.55-19.313-66.419-25.197-15.287-3.767-30.835-7.616-45.7-13.771m74.022-82.254a124.3 124.3 0 0 1-21.534-12.62l-14.805 9.625a124.5 124.5 0 0 0 36.339 18.708zm51.41 16.294c-9.106-2.245-18.304-4.524-27.395-7.299v13.682q5.051 1.286 10.177 2.538c29.934 7.379 60.888 15.01 85.708 39.829.61.61 1.162 1.24 1.752 1.857l9.974-6.484a10.1 10.1 0 0 0 3.475-3.8 101 101 0 0 0-3.289-3.486c-22.585-22.585-50.669-29.508-80.402-36.837m-19.933 19.933q-3.722-.918-7.462-1.855v13.21c25.817 6.561 51.803 14.688 73.455 35.04l14.521-9.44-.11-.118c-22.586-22.585-50.67-29.509-80.404-36.837m-31.477-8.587c-16.28-5.278-32.118-12.397-46.344-24.131l-14.682 9.545c17.87 15.557 38.784 22.673 61.026 28.5z" class="cls-3"/><path d="M73.24 254.96c-.348-.348-.658-.71-1-1.06l-12.514 8.136a10 10 0 0 0-1.644 1.397c1.053 1.157 2.115 2.31 3.245 3.44 22.585 22.585 50.67 29.509 80.403 36.837 25.343 6.247 51.401 12.717 73.865 29.603l14.806-9.626c-20.865-16.42-45.53-22.509-71.453-28.898-29.934-7.379-60.888-15.01-85.708-39.83" class="cls-3"/><path d="m218.864 209.962-27.512-17.886v8.476a216 216 0 0 0 27.512 9.41m-27.512 5.83v11.9c9.091 2.774 18.289 5.053 27.395 7.298 29.733 7.329 57.817 14.252 80.402 36.837a101 101 0 0 1 3.29 3.486 10.193 10.193 0 0 0-3.476-13.277l-44.683-29.05c-10.845-3.58-21.898-6.316-32.818-9.008-10.297-2.538-20.371-5.071-30.11-8.186m-24.015-9.76a111 111 0 0 1-11.316-6.6l-10.218 6.644a124.3 124.3 0 0 0 21.534 12.62zm34.192 37.88q-5.125-1.264-10.177-2.539v11.695q3.737.938 7.462 1.855c29.733 7.328 57.818 14.252 80.403 36.837.039.039.073.079.111.118l9.66-6.28c-.589-.618-1.142-1.248-1.751-1.858-24.82-24.82-55.774-32.45-85.708-39.829m-34.192-9.503a124.5 124.5 0 0 1-36.34-18.708l-10.004 6.504c14.226 11.734 30.064 18.853 46.344 24.13zm0 25.842c-22.242-5.828-43.156-12.944-61.026-28.5l-9.834 6.392c20.987 19.336 45.954 27.42 70.86 33.821zm24.015 17.732c23.266 6.006 45.144 13.19 63.675 29.693l9.78-6.358c-21.652-20.352-47.638-28.479-73.455-35.04zM81.948 247.59l-9.707 6.311c.341.35.652.712 1 1.06 24.82 24.82 55.773 32.45 85.707 39.829 25.922 6.39 50.588 12.478 71.452 28.898l9.948-6.467c-23.505-19.791-51.507-26.744-78.685-33.442-29.436-7.256-57.247-14.138-79.715-36.189m-20.621 19.284c-1.13-1.13-2.192-2.283-3.245-3.44a10.154 10.154 0 0 0 1.644 15.68l33.589 21.837c14.865 6.155 30.413 10.004 45.7 13.771 23.869 5.884 46.667 11.525 66.419 25.197l10.16-6.605c-22.463-16.886-48.521-23.356-73.864-29.603-29.733-7.328-57.818-14.252-80.403-36.837m109.3 84.34a119 119 0 0 1 6.623 3.46 10.16 10.16 0 0 0 7.645-1.402l5.639-3.666c-18.11-12.112-38.247-18.247-58.311-23.36z" class="cls-1"/></svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><title>PlanetScale</title><path d="M0 12C0 5.373 5.373 0 12 0c4.873 0 9.067 2.904 10.947 7.077l-15.87 15.87a12 12 0 0 1-1.935-1.099L14.99 12H12l-8.485 8.485A11.96 11.96 0 0 1 0 12m12.004 12L24 12.004C23.998 18.628 18.628 23.998 12.004 24"/></svg>

Before

Width:  |  Height:  |  Size: 326 B

View File

@@ -20,9 +20,9 @@
padding: 0px 8px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
color: var(--l2-foreground);
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
color: var(--input-with-label-color, var(--l2-foreground));
display: flex;
justify-content: flex-start;
@@ -35,21 +35,54 @@
min-width: 150px;
font-family: 'Space Mono', monospace !important;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
--input-border-radius: 0px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
color: var(--input-with-label-color, var(--l2-foreground));
border-right: none;
border-left: none;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
font-size: 12px !important;
line-height: 27px;
line-height: 25px;
position: relative;
&:hover,
&:focus {
z-index: 1;
}
.ant-select-selector {
position: relative;
border-radius: inherit;
}
.ant-select:hover .ant-select-selector,
.ant-select-focused .ant-select-selector {
z-index: 1;
}
&.input__has-label-after {
margin-left: -1px;
.ant-select-selector {
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
&.input__has-close-button {
.ant-select-selector {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
~ .close-btn {
margin-left: -1px;
}
}
&::placeholder {
color: var(--l2-foreground) !important;
color: var(--input-with-label-color, var(--l3-foreground)) !important;
font-size: 12px !important;
}
&[type='number']::-webkit-inner-spin-button,
@@ -63,25 +96,35 @@
.close-btn {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
height: 38px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
height: 100%;
width: 38px;
position: relative;
&:hover,
&:focus {
z-index: 2;
}
}
&.labelAfter {
.input {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
border-radius: 2px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
.ant-select-selector {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
.label {
border-left: none;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
border-radius: 0px 2px 2px 0px;
}
}
}

View File

@@ -45,7 +45,10 @@ function InputWithLabel({
>
{!labelAfter && <Typography.Text className="label">{label}</Typography.Text>}
<Input
className="input"
className={cx('input', {
'input__has-label-after': !labelAfter,
'input__has-close-button': !!onClose,
})}
placeholder={placeholder}
type={type}
value={inputValue}

View File

@@ -80,8 +80,8 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -101,7 +101,8 @@
top: 12px;
width: 6px;
height: 6px;
border-left: 6px dotted var(--l1-border);
border-left: 6px dotted
var(--query-builder-v2-border-color, var(--l2-border));
}
/* Horizontal line pointing from vertical to the item */
@@ -114,8 +115,8 @@
height: 1px;
background: repeating-linear-gradient(
to right,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -241,7 +242,8 @@
top: 12px;
width: 6px;
height: 6px;
border-left: 6px dotted var(--l1-border);
border-left: 6px dotted
var(--query-builder-v2-border-color, var(--l2-border));
}
/* Horizontal line pointing from vertical to the item */
@@ -254,8 +256,8 @@
height: 1px;
background: repeating-linear-gradient(
to right,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -273,6 +275,16 @@
line-height: 16px; /* 128.571% */
resize: none;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
&:placeholder {
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
}
}
.formula-legend {
@@ -282,15 +294,42 @@
.ant-input-group-addon {
border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important;
background: var(--l2-background);
color: var(--l2-foreground);
background: var(
--query-builder-v2-background-color,
var(--l2-background)
);
color: var(--query-builder-v2-color, var(--l2-foreground));
font-size: 12px;
font-weight: 300;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
position: relative;
}
.ant-input {
border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important;
height: 36px;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
position: relative;
margin-left: -1px;
&:hover,
&:focus {
z-index: 1;
border-color: var(--internal-ant-border-color-hover);
}
&:placeholder {
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
}
}
}
}
@@ -323,8 +362,8 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -395,8 +434,8 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -412,7 +451,7 @@
min-width: 120px;
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--l1-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
@@ -457,13 +496,16 @@
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l1-background) !important;
height: 34px !important;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
background: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
height: 36px !important;
box-sizing: border-box !important;
.ant-select-selection-item {
color: var(--l1-foreground);
color: var(--query-builder-v2-color, var(--l1-foreground));
}
}

View File

@@ -92,6 +92,11 @@
.ant-select {
width: 100%;
.ant-select-selector {
min-height: 36px;
}
.ant-select-selection-search-input {
min-width: max-content !important;
max-width: 100% !important;
@@ -100,9 +105,12 @@
.ant-select-selector {
border-radius: 2px;
border: 1.005px solid var(--l1-border);
background: var(--l1-background);
color: var(--l1-foreground);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground));
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
@@ -123,6 +131,7 @@
.input {
flex: initial;
width: 100px !important;
min-height: 36px;
}
}
}

View File

@@ -8,9 +8,9 @@
.ant-select-selection-search-input {
font-size: 12px !important;
line-height: 27px;
line-height: 25px;
&::placeholder {
color: var(--l2-foreground) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
font-size: 12px !important;
}
}
@@ -22,9 +22,12 @@
.ant-select-selector {
width: 100%;
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l1-background);
color: var(--l1-foreground);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground));
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
@@ -33,36 +36,49 @@
min-height: 36px;
.ant-select-selection-placeholder {
color: var(--l2-foreground) !important;
color: var(
--query-builder-v2-placeholder-color,
var(--l3-foreground)
) !important;
font-size: 12px !important;
}
}
}
.ant-select-dropdown {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.qb-select-popover.ant-select-dropdown {
border-radius: 4px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.ant-select-item {
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
.ant-select-item {
color: var(--query-builder-v2-color, var(--l2-foreground));
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
&:hover,
&.ant-select-item-option-active {
background: var(--l3-background) !important;
}
&:not(:last-of-type) {
margin-bottom: 4px;
}
&.ant-select-item-option-selected {
background: var(--l3-background) !important;
border: 1px solid var(--l1-border);
font-weight: 600;
}
&:hover,
&.ant-select-item-option-active {
background: var(
--query-builder-v2-selected-background-color,
var(--l3-background)
) !important;
}
&.ant-select-item-option-selected {
background: var(
--query-builder-v2-selected-background-color,
var(--l3-background)
) !important;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
font-weight: 600;
}
}
}

View File

@@ -142,6 +142,7 @@ export const MetricsSelect = memo(function MetricsSelect({
{signalSourceChangeEnabled && (
<Select
className="source-selector"
popupClassName="qb-select-popover"
placeholder="Source"
options={SOURCE_OPTIONS}
value={source}

View File

@@ -1,8 +1,23 @@
// TODO: Improve the styling of the query aggregation container and its components. - @YounixM , @H4ad
.query-add-ons {
width: 100%;
--toggle-group-secondary-bg: var(
--query-builder-v2-toggle-group-background-color,
var(--l1-background-hover)
);
--toggle-group-secondary-border: var(
--query-builder-v2-toggle-group-border-color,
var(--l2-border)
);
--toggle-group-secondary-active-bg: var(
--query-builder-v2-toggle-group-active-background-color,
var(--l1-background)
);
--toggle-group-secondary-bg-hover: var(
--query-builder-v2-toggle-group-background-color-hover,
var(--l2-background)
);
.add-on-tab-title {
display: flex;
align-items: center;
@@ -29,32 +44,33 @@
font-style: normal;
font-weight: var(--font-weight-normal);
color: var(--l2-foreground);
color: var(--query-builder-v2-color, var(--l2-foreground));
}
> button {
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-left: none;
min-width: 120px;
height: 36px;
line-height: 36px;
&:first-child {
border-left: 1px solid var(--l1-border);
border-left: 1px solid
var(--query-builder-v2-border-color, var(--l2-border));
}
&::before {
background: var(--l1-border);
background: var(--query-builder-v2-border-color, var(--l2-border));
}
&[data-state='on'] {
color: var(--text-robin-500);
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
display: none;
&::before {
background: var(--l1-border);
background: var(--query-builder-v2-border-color, var(--l2-border));
}
}
}
@@ -65,7 +81,7 @@
height: 30px;
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
@@ -78,10 +94,13 @@
align-items: center;
.having-filter-select-container {
position: relative;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
align-items: flex-start;
background: var(--query-builder-v2-background-color, var(--l2-background));
padding-right: 38px;
.having-filter-select-editor {
border-radius: 2px;
@@ -106,15 +125,17 @@
}
.cm-content {
border-radius: 2px;
border: 1px solid var(--l1-border);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-left-width: 0px;
border-right-width: 0px;
padding: 0px !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&:focus-within {
border-color: var(--l1-border);
border-color: var(--query-builder-v2-border-color, var(--l2-border));
}
}
@@ -218,17 +239,32 @@
}
.cm-line {
line-height: 36px !important;
min-height: 34px;
line-height: 32px !important;
font-family: 'Space Mono', monospace !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&,
.ͼ1a {
color: var(--query-builder-v2-color, var(--l2-foreground));
}
::-moz-selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
::selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
@@ -237,8 +273,11 @@
}
.chip-decorator {
background: var(--l3-background) !important;
color: var(--l1-foreground) !important;
background: var(
--query-builder-v2-chip-decorator-background-color,
var(--l3-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
border-radius: 4px;
padding: 2px 4px;
margin-right: 4px;
@@ -246,34 +285,38 @@
}
.cm-selectionBackground {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
.cm-activeLine > span {
font-size: 12px !important;
}
.cm-placeholder {
color: var(
--query-builder-v2-placeholder-color,
var(--l3-foreground)
) !important;
}
}
}
.close-btn {
position: absolute;
top: 0;
right: 0;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
height: 38px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
height: 100%;
width: 38px;
border-left: transparent;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
&:focus:not(:focus-visible),
&.ant-btn:focus:not(:focus-visible) {
border-color: var(--l2-border);
border-left-color: transparent;
outline: none;
box-shadow: none;
}
}
}
}
@@ -300,20 +343,8 @@
font-size: 12px !important;
}
$add-on-row-height: 38px;
.periscope-input-with-label {
.input {
.ant-select {
height: $add-on-row-height;
}
}
}
.input-with-label {
.input {
height: $add-on-row-height;
}
input {
min-height: 36px;
}
}
}

View File

@@ -23,7 +23,7 @@
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--l2-foreground) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
&.error {
.cm-editor {
@@ -51,14 +51,15 @@
.cm-content {
border-radius: 2px;
border: 1px solid var(--l1-border);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
padding: 0px !important;
background-color: var(--l1-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&:focus-within {
border-color: var(--l1-border);
border-color: var(--query-builder-v2-border-color, var(--l2-border));
}
}
@@ -74,7 +75,7 @@
right: 0px !important;
border-radius: 4px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
@@ -118,7 +119,7 @@
box-sizing: border-box;
overflow: hidden;
color: var(--l2-foreground) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
font-family: 'Space Mono', monospace !important;
.cm-completionIcon {
@@ -127,7 +128,10 @@
&:hover,
&[aria-selected='true'] {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
color: var(--l1-foreground) !important;
font-weight: 600 !important;
}
@@ -142,15 +146,24 @@
.cm-line {
line-height: 36px !important;
font-family: 'Space Mono', monospace !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
::-moz-selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
::selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
@@ -159,8 +172,11 @@
}
.chip-decorator {
background: var(--l3-background) !important;
color: var(--l1-foreground) !important;
background: var(
--query-builder-v2-chip-decorator-background-color,
var(--l3-background)
) !important;
color: var(--query-builder-v2-color, var(--l1-foreground)) !important;
border-radius: 4px;
padding: 2px 4px;
margin-right: 4px;
@@ -168,7 +184,10 @@
}
.cm-selectionBackground {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
}
@@ -201,12 +220,11 @@
.close-btn {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
height: 38px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
height: 100%;
width: 38px;
border-left: transparent;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
@@ -217,13 +235,13 @@
height: 36px;
line-height: 36px;
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
font-family: 'Space Mono', monospace !important;
&::placeholder {
color: var(--l1-foreground);
color: var(--query-builder-v2-color, var(--l2-foreground));
opacity: 0.5;
}
}
@@ -238,9 +256,10 @@
.query-aggregation-interval-input-container {
.query-aggregation-interval-input {
input {
min-height: 36px;
max-width: 120px;
&::placeholder {
color: var(--l2-foreground);
color: var(--query-builder-v2-color, var(--l2-foreground));
}
}
}
@@ -251,8 +270,8 @@
.query-aggregation-error-popover {
.ant-popover-inner {
background-color: var(--l1-border);
border: 1px solid var(--l1-border);
background-color: var(--query-builder-v2-border-color, var(--l2-border));
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-radius: 4px;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
}

View File

@@ -1,7 +1,7 @@
.add-trace-operator-button,
.add-new-query-button,
.add-formula-button {
border: 1px solid var(--l1-border);
background: var(--l2-background);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}

View File

@@ -40,11 +40,14 @@ $max-recents-shown: 5;
.query-status-container {
width: 32px;
background-color: var(--l1-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-radius: 2px;
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;
@@ -83,16 +86,16 @@ $max-recents-shown: 5;
.cm-content {
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
padding: 0px !important;
&:focus-within {
border-color: var(--l1-border);
border-color: var(--query-builder-v2-border-color, var(--l2-border));
}
}
&.cm-focused {
outline: 1px solid var(--l1-border);
outline: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
}
.cm-tooltip-autocomplete {
@@ -183,11 +186,17 @@ $max-recents-shown: 5;
font-family: 'Space Mono', monospace !important;
background-color: var(--l1-background) !important;
color: var(--l2-foreground) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
&:hover {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
}
.cm-completionIcon {
@@ -205,7 +214,10 @@ $max-recents-shown: 5;
}
&[aria-selected='true'] {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
font-weight: 600 !important;
}
}
@@ -274,25 +286,49 @@ $max-recents-shown: 5;
}
.cm-line {
line-height: 34px !important;
line-height: 36px !important;
font-family: 'Space Mono', monospace !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&,
.ͼ1a {
color: var(--query-builder-v2-color, var(--l2-foreground));
}
::-moz-selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
::selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
}
.cm-selectionBackground {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
.cm-placeholder {
color: var(
--query-builder-v2-placeholder-color,
var(--l3-foreground)
) !important;
}
}
.cursor-position {

View File

@@ -65,6 +65,14 @@
display: flex;
flex-direction: column;
gap: 8px;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
}
&-aggregation-container {

View File

@@ -89,10 +89,8 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: '/llm-observability/attribute-mapping',
LLM_OBSERVABILITY_BASE: '/llm-observability',
LLM_OBSERVABILITY_OVERVIEW: '/llm-observability/overview',
LLM_OBSERVABILITY_CONFIGURATION: '/llm-observability/configuration',
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
} as const;
export default ROUTES;

View File

@@ -53,6 +53,26 @@
}
.alert-query-section-container {
--query-builder-v2-color: var(--l2-foreground);
--query-builder-v2-background-color: var(--l3-background);
--query-builder-v2-border-color: var(--l3-border);
--query-builder-v2-selection-background-color: var(--l2-background);
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
--query-builder-v2-placeholder-color: var(--l3-foreground);
--query-builder-v2-selected-background-color: var(--l3-foreground);
--query-search-background-color: var(--l3-background);
--query-search-background-color-selection: var(--l3-background);
--input-with-label-border-color: var(--l3-border);
--input-with-label-background-color: var(--l3-background);
--input-with-label-color: var(--l2-foreground);
--query-builder-v2-toggle-group-background-color: var(--l3-background);
--query-builder-v2-toggle-group-border-color: var(--l3-border);
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
--input-height: 36px;
--input-hover-background: var(--l3-background);
--input-hover-border-color: var(--internal-ant-border-color-hover);
.ant-card-body {
padding: 0px;

View File

@@ -3,6 +3,30 @@
overflow-x: auto;
overflow-y: hidden;
--query-builder-v2-color: var(--l2-foreground);
--query-builder-v2-background-color: var(--l3-background);
--query-builder-v2-border-color: var(--l3-border);
--query-builder-v2-selection-background-color: var(--l2-background);
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
--query-builder-v2-placeholder-color: var(--l3-foreground);
--query-builder-v2-selected-background-color: var(--l3-foreground);
--query-search-background-color: var(--l3-background);
--query-search-background-color-selection: var(--l3-background);
--input-with-label-border-color: var(--l3-border);
--input-with-label-background-color: var(--l3-background);
--input-with-label-color: var(--l2-foreground);
--query-builder-v2-toggle-group-background-color: var(--l3-background);
--query-builder-v2-toggle-group-border-color: var(--l3-border);
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
--input-height: 36px;
--input-hover-background: var(--l3-background);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
--input-background: var(--l3-background);
--input-focus-background: var(--l3-background);
--input-border-color: var(--l3-border);
.full-view-header-container {
display: flex;
flex-direction: column;

View File

@@ -1,13 +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);
}
.pageFooter {
margin-top: var(--spacing-8);
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}

View File

@@ -1,37 +0,0 @@
import { DraftGroup } from '../types';
import styles from './AttributeMappingsTab.module.scss';
import MapperGroupsTable from './components/MapperGroupsTable';
import { AttributeMappingStore } from './hooks/useAttributeMappingStore';
interface AttributeMappingsTabProps {
store: AttributeMappingStore;
onEditGroup: (group: DraftGroup) => void;
onAddGroup: () => void;
}
// "Attribute mappings" tab: the mapping-groups listing, its error state and
// footer summary. The store is owned by the container (the header's save/
// discard share it), so it's passed in rather than created here.
function AttributeMappingsTab({
store,
onEditGroup,
onAddGroup,
}: AttributeMappingsTabProps): JSX.Element {
return (
<div data-testid="attribute-mappings-tab">
{store.isError && (
<div className={styles.pageError} role="alert">
Failed to load mapping groups. Please try again.
</div>
)}
<MapperGroupsTable
store={store}
onEditGroup={onEditGroup}
onAddGroup={onAddGroup}
/>
</div>
);
}
export default AttributeMappingsTab;

View File

@@ -1,156 +0,0 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import {
GROUPS_ENDPOINT,
makeGroupsResponse,
makeMapper,
makeMappersResponse,
mappersEndpoint,
mockGroups,
mockMappers,
} from '../../__tests__/fixtures';
import AttributeMappingsTab from '../AttributeMappingsTab';
import { useAttributeMappingStore } from '../hooks/useAttributeMappingStore';
function setupGroups(): void {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
);
}
// The tab is a presentational view over a store owned by the container, so the
// harness creates the store (via the hook, backed by the mocked API) and wires
// the edit/add callbacks the tab needs. This suite only exercises the listing,
// so the callbacks are no-ops.
function AttributeMappingHarness(): JSX.Element {
const store = useAttributeMappingStore();
return (
<AttributeMappingsTab
store={store}
onEditGroup={jest.fn()}
onAddGroup={jest.fn()}
/>
);
}
// The real Save button lives in the page header; this harness exposes the same
// store.save() path the header wires up, so the tab suite can exercise it.
function SaveableHarness(): JSX.Element {
const store = useAttributeMappingStore();
return (
<>
<button
type="button"
data-testid="save-button"
onClick={(): void => {
void store.save();
}}
>
Save
</button>
<AttributeMappingsTab
store={store}
onEditGroup={jest.fn()}
onAddGroup={jest.fn()}
/>
</>
);
}
describe('AttributeMappingsTab (integration)', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
});
afterEach(() => {
server.resetHandlers();
});
it('renders no error banner on a successful load', async () => {
setupGroups();
render(<AttributeMappingHarness />);
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(<AttributeMappingHarness />);
await expect(screen.findByRole('alert')).resolves.toHaveTextContent(
'Failed to load mapping groups. Please try again.',
);
});
it("lazily fetches and renders a group's mappers on first expand", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
server.use(
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeMappersResponse(mockMappers))),
),
);
render(<AttributeMappingHarness />);
await waitFor(() =>
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
);
expect(
screen.queryByTestId('mapper-target-mapper-1'),
).not.toBeInTheDocument();
await user.click(screen.getByTestId('group-expand-group-1'));
await expect(
screen.findByTestId('mapper-target-mapper-1'),
).resolves.toHaveTextContent('gen_ai.request.model');
});
it("re-fetches an open group's mappers on save instead of showing stale rows", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
// The mapper list changes server-side between the first open and the save
// (e.g. another edit landed, or the save reconciled ids). The open table
// must reflect the latest list — not the list cached on first expand.
let mappers = mockMappers;
server.use(
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeMappersResponse(mappers))),
),
);
render(<SaveableHarness />);
await waitFor(() =>
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
);
await user.click(screen.getByTestId('group-expand-group-1'));
await expect(
screen.findByTestId('mapper-target-mapper-1'),
).resolves.toBeInTheDocument();
// Server now returns a different mapper for the group.
mappers = [makeMapper({ id: 'mapper-2', name: 'gen_ai.response.model' })];
await user.click(screen.getByTestId('save-button'));
// Fresh row appears; the stale one is gone. Without removeQueries the table
// would keep showing mapper-1 (the hydrate once-guard blocks the update).
await expect(
screen.findByTestId('mapper-target-mapper-2'),
).resolves.toHaveTextContent('gen_ai.response.model');
expect(
screen.queryByTestId('mapper-target-mapper-1'),
).not.toBeInTheDocument();
});
});

View File

@@ -1,57 +0,0 @@
import { useMemo } from 'react';
import { EllipsisVertical, Pencil, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DraftGroup } from '../../../types';
interface MapperGroupActionsMenuProps {
group: DraftGroup;
onEdit: (group: DraftGroup) => void;
onRemove: (localId: string) => void;
}
// Per-row overflow menu for a mapping group. The enable/disable toggle stays
// inline in the row (it's the primary, high-frequency action); the lower-
// frequency Edit and Delete actions live behind the kebab to keep the row
// compact.
function MapperGroupActionsMenu({
group,
onEdit,
onRemove,
}: MapperGroupActionsMenuProps): JSX.Element {
const menuItems = useMemo<MenuItem[]>(
() => [
{
key: 'edit',
label: 'Edit',
icon: <Pencil size={14} />,
onClick: (): void => onEdit(group),
},
{
key: 'delete',
label: 'Delete',
danger: true,
icon: <Trash2 size={14} />,
onClick: (): void => onRemove(group.localId),
},
],
[onEdit, onRemove, group],
);
return (
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Group actions"
testId={`group-actions-${group.localId}`}
>
<EllipsisVertical size={16} />
</Button>
</DropdownMenuSimple>
);
}
export default MapperGroupActionsMenu;

View File

@@ -1,20 +0,0 @@
.groupsTableWrapper {
// Reserve a stable row height so short skeleton rows and taller loaded rows
// (multi-line filter cells) share a height and the table doesn't jump on load.
// Acts as a floor — richer rows still grow. 48px matches the model-costs table.
--tanstack-table-row-height: var(--spacing-24);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.tableEmpty {
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.addRow {
align-self: flex-start;
}

View File

@@ -1,80 +0,0 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Plus } from '@signozhq/icons';
import TanStackTable from 'components/TanStackTableView';
import { DraftGroup } from '../../../types';
import { AttributeMappingStore } from '../../hooks/useAttributeMappingStore';
import MappersTable from '../MappersTable';
import styles from './MapperGroupsTable.module.scss';
import { getMapperGroupsColumns } from './TableConfig';
const SKELETON_ROW_COUNT = 5;
interface MapperGroupsTableProps {
store: AttributeMappingStore;
onEditGroup: (group: DraftGroup) => void;
onAddGroup: () => void;
}
// Top-level listing of mapping groups. Each row expands to reveal its mappers,
// which MappersTable fetches lazily on first open. Built on the shared
// TanStackTable with virtual scroll disabled — this is a small, content-height
// list, and nested expanded tables need to grow with their content rather than
// live inside a fixed-height viewport. Row actions (edit/delete/toggle) live in
// the column config; the add-group affordance sits below the table.
function MapperGroupsTable({
store,
onEditGroup,
onAddGroup,
}: MapperGroupsTableProps): JSX.Element {
const columns = useMemo(
() =>
getMapperGroupsColumns({
onEditGroup,
onRemoveGroup: store.removeGroup,
onToggleGroup: store.toggleGroup,
}),
[onEditGroup, store.removeGroup, store.toggleGroup],
);
const isEmpty = !store.isLoading && store.groups.length === 0;
return (
<div className={styles.groupsTableWrapper}>
{isEmpty ? (
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
No mapping groups yet.
</div>
) : (
<TanStackTable<DraftGroup>
data={store.groups}
columns={columns}
isLoading={store.isLoading}
skeletonRowCount={SKELETON_ROW_COUNT}
getRowKey={(row): string => row.localId}
getRowCanExpand={(): boolean => true}
renderExpandedRow={(row): JSX.Element => (
<MappersTable group={row} store={store} />
)}
disableVirtualScroll
testId="mapper-groups-table"
/>
)}
<Button
variant="link"
color="primary"
prefix={<Plus size={14} />}
className={styles.addRow}
onClick={onAddGroup}
testId="add-group-row"
disabled={store.isLoading}
>
Add a new group
</Button>
</div>
);
}
export default MapperGroupsTable;

View File

@@ -1 +0,0 @@
export { getMapperGroupsColumns } from './mapperGroups.config';

View File

@@ -1,121 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import type { TableColumnDef } from 'components/TanStackTableView';
import type { DraftGroup } from '../../../../types';
import { conditionFiltersFromGroup } from '../../../../utils';
import MapperGroupActionsMenu from '../MapperGroupActionsMenu';
import styles from './tableConfig.module.scss';
interface ColumnsConfig {
onEditGroup: (group: DraftGroup) => void;
onRemoveGroup: (localId: string) => void;
onToggleGroup: (localId: string, enabled: boolean) => void;
}
// Column definitions for the mapping-groups TanStackTable. Sorting is off across
// the board — the groups list API returns the full set unordered, so there's no
// server-side ordering to back a sortable header yet.
export function getMapperGroupsColumns({
onEditGroup,
onRemoveGroup,
onToggleGroup,
}: ColumnsConfig): TableColumnDef<DraftGroup>[] {
return [
{
id: 'name',
header: 'Group name',
accessorFn: (row): string => row.name,
width: { min: 240, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row, isExpanded, toggleExpanded }): JSX.Element => (
<div className={styles.groupsTableNameCell}>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label={isExpanded ? 'Collapse group' : 'Expand group'}
onClick={(): void => toggleExpanded()}
testId={`group-expand-${row.localId}`}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</Button>
<span
className={styles.groupsTableName}
data-testid={`group-name-${row.localId}`}
>
{row.name}
</span>
</div>
),
},
{
id: 'filters',
header: 'Filters',
width: { min: 200, default: '100%' },
enableMove: false,
cell: ({ row }): JSX.Element => {
const filters = conditionFiltersFromGroup(row);
if (filters.length === 0) {
return (
<span
className={styles.muted}
data-testid={`group-filters-${row.localId}`}
>
No condition · always runs
</span>
);
}
return (
<div
className={styles.groupsTableFilters}
data-testid={`group-filters-${row.localId}`}
>
{filters.map((filter) => (
<div
className={styles.groupsTableFilter}
key={`${filter.context}:${filter.key}`}
>
<Badge
color={filter.context === 'resource' ? 'amber' : 'robin'}
variant="outline"
>
{filter.context}
</Badge>
<span className={styles.groupsTableFilterKey}>
contains {filter.key}
</span>
</div>
))}
</div>
);
},
},
{
id: 'actions',
header: 'Actions',
// Compact, right-aligned action cluster — opt out of the "last column
// fills 100%" rule so the spare width flows into Group name / Filters.
width: { fixed: '160px', ignoreLastColumnFill: true },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<div className={styles.rowActions}>
<Switch
value={row.enabled}
onChange={(checked): void => onToggleGroup(row.localId, checked)}
testId={`group-enabled-${row.localId}`}
/>
<MapperGroupActionsMenu
group={row}
onEdit={onEditGroup}
onRemove={onRemoveGroup}
/>
</div>
),
},
];
}

View File

@@ -1,49 +0,0 @@
.groupsTableNameCell {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.groupsTableName {
font-weight: var(--font-weight-semibold);
}
.groupsTableFilters {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
// Allow this column to shrink within the cell so long keys wrap
// instead of forcing the cell wider than its allotted width.
min-width: 0;
}
.groupsTableFilter {
display: flex;
align-items: flex-start;
gap: var(--spacing-4);
font-size: var(--periscope-font-size-base);
min-width: 0;
// Keep the context badge at full width; only the key text flexes.
> *:first-child {
flex-shrink: 0;
}
}
.groupsTableFilterKey {
min-width: 0;
font-family: 'Geist Mono', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.muted {
color: var(--l3-foreground);
}
.rowActions {
display: flex;
align-items: center;
gap: var(--spacing-3);
}

View File

@@ -1,117 +0,0 @@
import { render, screen } from 'tests/test-utils';
import { AttributeMappingStore } from '../../../hooks/useAttributeMappingStore';
import { buildDraftGroup } from '../../../../utils';
import { makeGroup } from '../../../../__tests__/fixtures';
import MapperGroupsTable from '../MapperGroupsTable';
function storeWith(
overrides: Partial<AttributeMappingStore>,
): AttributeMappingStore {
return {
groups: [],
snapshot: [],
isLoading: false,
isError: false,
isDirty: false,
isSaving: false,
saveError: null,
upsertGroup: jest.fn(),
removeGroup: jest.fn(),
toggleGroup: jest.fn(),
hydrateGroupMappers: jest.fn(),
upsertMapper: jest.fn(),
removeMapper: jest.fn(),
toggleMapper: jest.fn(),
save: jest.fn(),
discard: jest.fn(),
...overrides,
};
}
// The table is driven by the container's store plus edit/add callbacks. These
// tests focus on rendering, so the callbacks are no-ops.
function renderTable(store: AttributeMappingStore): void {
render(
<MapperGroupsTable
store={store}
onEditGroup={jest.fn()}
onAddGroup={jest.fn()}
/>,
);
}
describe('MapperGroupsTable', () => {
beforeEach(() => {
// The shared TanStackTable owns page/limit URL state via nuqs, which
// reads window.location — jsdom shares that across tests in a file.
window.history.pushState(null, '', '/');
});
it('renders the empty state when not loading and there are no groups', () => {
renderTable(storeWith({ groups: [] }));
expect(screen.getByTestId('mapper-groups-empty')).toHaveTextContent(
'No mapping groups yet.',
);
});
it('does not show the empty state while loading even with no groups', () => {
renderTable(storeWith({ groups: [], isLoading: true }));
expect(screen.queryByTestId('mapper-groups-empty')).not.toBeInTheDocument();
expect(screen.getByTestId('mapper-groups-table')).toBeInTheDocument();
});
it('renders a row with its name, condition filters and enabled status', () => {
const group = buildDraftGroup(
makeGroup({
id: 'group-1',
name: 'demo',
enabled: true,
condition: {
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
},
}),
[],
);
renderTable(storeWith({ groups: [group] }));
expect(screen.getByTestId('group-name-group-1')).toHaveTextContent('demo');
const filters = screen.getByTestId('group-filters-group-1');
expect(filters).toHaveTextContent('attribute');
expect(filters).toHaveTextContent('contains ai.embeddings');
expect(filters).toHaveTextContent('resource');
expect(filters).toHaveTextContent('contains cloud.account.id');
expect(screen.getByTestId('group-enabled-group-1')).toBeChecked();
});
it('shows a disabled badge for a disabled group', () => {
const group = buildDraftGroup(
makeGroup({ id: 'group-2', enabled: false, condition: null }),
[],
);
renderTable(storeWith({ groups: [group] }));
expect(screen.getByTestId('group-enabled-group-2')).not.toBeChecked();
});
it('shows the no-condition placeholder when a group has no attribute/resource keys', () => {
const group = buildDraftGroup(
makeGroup({ id: 'group-3', condition: null }),
[],
);
renderTable(storeWith({ groups: [group] }));
expect(screen.getByTestId('group-filters-group-3')).toHaveTextContent(
'No condition · always runs',
);
});
// NOTE: the expand/collapse affordance is verified end-to-end in
// AttributeMappingsTab.test.tsx ("lazily fetches and renders a group's
// mappers on first expand"). It isn't re-tested here because the shared
// TanStackTable keeps row-expanded state in URL params (nuqs), which does
// not toggle reliably when the table is mounted in isolation in jsdom.
});

View File

@@ -1,56 +0,0 @@
import { useMemo } from 'react';
import { EllipsisVertical, Pencil, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DraftMapper } from '../../../types';
interface MapperActionsMenuProps {
mapper: DraftMapper;
onEdit: (mapper: DraftMapper) => void;
onRemove: (localId: string) => void;
}
// Per-row overflow menu for a mapping. The enable/disable toggle stays inline in
// the row (it's the primary, high-frequency action); the lower-frequency Edit
// and Delete actions live behind the kebab to keep the row compact.
function MapperActionsMenu({
mapper,
onEdit,
onRemove,
}: MapperActionsMenuProps): JSX.Element {
const menuItems = useMemo<MenuItem[]>(
() => [
{
key: 'edit',
label: 'Edit',
icon: <Pencil size={14} />,
onClick: (): void => onEdit(mapper),
},
{
key: 'delete',
label: 'Delete',
danger: true,
icon: <Trash2 size={14} />,
onClick: (): void => onRemove(mapper.localId),
},
],
[onEdit, onRemove, mapper],
);
return (
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Mapping actions"
testId={`mapper-actions-${mapper.localId}`}
>
<EllipsisVertical size={16} />
</Button>
</DropdownMenuSimple>
);
}
export default MapperActionsMenu;

View File

@@ -1,26 +0,0 @@
.mappersTableWrapper {
--tanstack-expansion-first-col-padding-left: var(--spacing-6);
--tanstack-table-row-height: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: var(--spacing-2) var(--spacing-6) var(--spacing-6) var(--spacing-12);
padding: var(--spacing-4) 0;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
// Clip content while the panel grows during its expand (height) animation.
overflow: hidden;
}
.tableEmpty {
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.addRow {
align-self: flex-start;
}

View File

@@ -1,150 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Plus } from '@signozhq/icons';
import { useListSpanMappers } from 'api/generated/services/spanmapper';
import TanStackTable from 'components/TanStackTableView';
import { motion, useReducedMotion } from 'motion/react';
import MapperFormDrawer from '../../../components/MapperFormDrawer';
import { DraftGroup, DraftMapper, Mapper } from '../../../types';
import { useMapperFormDrawer } from '../../../useMapperFormDrawer';
import { AttributeMappingStore } from '../../hooks/useAttributeMappingStore';
import styles from './MappersTable.module.scss';
import { getMappersColumns } from './TableConfig';
const SKELETON_ROW_COUNT = 3;
// Expand reveal: the panel mounts already-open, so this mount transition IS the
// group's expand animation (height + fade).
const EXPAND_TRANSITION = { duration: 0.18, ease: 'easeOut' } as const;
interface MappersTableProps {
group: DraftGroup;
store: AttributeMappingStore;
}
// Nested table of a group's mappers, rendered inside the group's expanded row.
// This component only mounts when its group row is expanded, so the fetch is
// lazy by construction — a group's mappers load on first open, are folded into
// the store's draft so they're editable, and are then cached by react-query.
// New (unsaved) groups have no serverId, so skip the fetch.
function MappersTable({ group, store }: MappersTableProps): JSX.Element {
const prefersReducedMotion = useReducedMotion();
const drawer = useMapperFormDrawer();
const { hydrateGroupMappers, upsertMapper, removeMapper, toggleMapper } =
store;
const hasServerId = group.serverId !== null;
const { data, isLoading, isError } = useListSpanMappers(
{ groupId: group.serverId ?? '' },
{ query: { enabled: hasServerId } },
);
useEffect(() => {
const items = data?.data?.items;
if (group.serverId && items) {
// The generated schema mis-types this list response with the groups DTO;
// the runtime payload is mappers.
hydrateGroupMappers(group.serverId, items as unknown as Mapper[]);
}
}, [group.serverId, data, hydrateGroupMappers]);
const isLoadingMappers = hasServerId && isLoading;
const isErrorMappers = hasServerId && isError;
const handleSave = useCallback((): void => {
upsertMapper(group.localId, drawer.draft);
drawer.close();
}, [upsertMapper, group.localId, drawer]);
const handleDelete = useCallback((): void => {
if (drawer.draft.id) {
removeMapper(group.localId, drawer.draft.id);
}
drawer.close();
}, [removeMapper, group.localId, drawer]);
const columns = useMemo(
() =>
getMappersColumns({
onEdit: drawer.openForEdit,
onRemove: (localId): void => removeMapper(group.localId, localId),
onToggle: (localId, enabled): void =>
toggleMapper(group.localId, localId, enabled),
}),
[drawer.openForEdit, removeMapper, toggleMapper, group.localId],
);
let content: JSX.Element;
if (isErrorMappers) {
content = (
<div
className={styles.tableEmpty}
data-testid={`mappers-error-${group.localId}`}
>
Failed to load mappings. Please try again.
</div>
);
} else if (!isLoadingMappers && group.mappers.length === 0) {
content = (
<div
className={styles.tableEmpty}
data-testid={`mappers-empty-${group.localId}`}
>
No mappings in this group yet.
</div>
);
} else {
content = (
<TanStackTable<DraftMapper>
data={group.mappers}
columns={columns}
isLoading={isLoadingMappers}
skeletonRowCount={SKELETON_ROW_COUNT}
getRowKey={(row): string => row.localId}
disableVirtualScroll
testId={`mappers-table-${group.localId}`}
/>
);
}
return (
<motion.div
className={styles.mappersTableWrapper}
initial={prefersReducedMotion ? false : { height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
transition={EXPAND_TRANSITION}
>
{content}
<Button
variant="link"
color="primary"
size="sm"
prefix={<Plus size={14} />}
className={styles.addRow}
onClick={drawer.openForAdd}
testId={`add-mapper-${group.localId}`}
>
Add mapping
</Button>
{drawer.isOpen && (
<MapperFormDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
draft={drawer.draft}
setDraft={drawer.setDraft}
onClose={drawer.close}
onSave={handleSave}
onDelete={handleDelete}
isSaving={false}
isDeleting={false}
saveError={null}
/>
)}
</motion.div>
);
}
export default MappersTable;

View File

@@ -1 +0,0 @@
export { getMappersColumns } from './mappers.config';

View File

@@ -1,124 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { SpantypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import type { TableColumnDef } from 'components/TanStackTableView';
import cx from 'classnames';
import { DraftMapper } from '../../../../types';
import MapperActionsMenu from '../MapperActionsMenu';
import styles from './tableConfig.module.scss';
const MAX_VISIBLE_SOURCES = 3;
interface ColumnsConfig {
onEdit: (mapper: DraftMapper) => void;
onRemove: (localId: string) => void;
onToggle: (localId: string, enabled: boolean) => void;
}
// Column definitions for the per-group mappers TanStackTable (rendered inside an
// expanded group row). Sorting is off — priority order is positional (top wins).
export function getMappersColumns({
onEdit,
onRemove,
onToggle,
}: ColumnsConfig): TableColumnDef<DraftMapper>[] {
return [
{
id: 'target',
header: 'Target',
accessorFn: (row): string => row.name,
width: { min: 200, default: '100%' },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text
weight="semibold"
data-testid={`mapper-target-${row.localId}`}
>
{row.name}
</Typography.Text>
),
},
{
id: 'sources',
header: 'Sources',
width: { min: 220, default: '100%' },
enableMove: false,
cell: ({ row }): JSX.Element => {
// Skeleton placeholder rows reach the cell before real data, so
// `sources` can be undefined — default to empty.
const sources = row.sources ?? [];
if (sources.length === 0) {
return (
<span
className={styles.muted}
data-testid={`mapper-sources-${row.localId}`}
>
</span>
);
}
const visible = sources.slice(0, MAX_VISIBLE_SOURCES);
const remaining = sources.length - visible.length;
return (
<div
className={styles.mappersTableSources}
data-testid={`mapper-sources-${row.localId}`}
>
{visible.map((source) => (
<Badge
variant="outline"
color="vanilla"
className={styles.mappersTableSourceChip}
key={`${source.context}:${source.key}`}
>
{source.key}
</Badge>
))}
{remaining > 0 && (
<span className={cx(styles.mappersTableSourceMore, styles.muted)}>
+{remaining} more
</span>
)}
</div>
);
},
},
{
id: 'writesTo',
header: 'Writes to',
width: { min: 130 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Badge
color={
row.fieldContext === SpantypesFieldContextDTO.resource ? 'amber' : 'robin'
}
variant="outline"
>
{row.fieldContext}
</Badge>
),
},
{
id: 'actions',
header: 'Actions',
// Compact, right-aligned action cluster — opt out of the "last column
// fills 100%" rule so the spare width flows into Target / Sources.
width: { fixed: '160px', ignoreLastColumnFill: true },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<div className={styles.rowActions}>
<Switch
value={row.enabled}
onChange={(checked): void => onToggle(row.localId, checked)}
testId={`mapper-enabled-${row.localId}`}
/>
<MapperActionsMenu mapper={row} onEdit={onEdit} onRemove={onRemove} />
</div>
),
},
];
}

View File

@@ -1,28 +0,0 @@
.mappersTableSources {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-3);
}
.mappersTableSourceChip {
// Badge's outline/vanilla variant supplies the border, background, radius and
// padding; textEllipsis measures against this max-width to truncate long keys.
max-width: 220px;
font-family: 'Geist Mono', monospace;
}
.mappersTableSourceMore {
font-size: var(--font-size-xs);
white-space: nowrap;
}
.muted {
color: var(--l3-foreground);
}
.rowActions {
display: flex;
align-items: center;
gap: var(--spacing-3);
}

View File

@@ -1,173 +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, waitFor } from 'tests/test-utils';
import { AttributeMappingStore } from '../../../hooks/useAttributeMappingStore';
import {
makeGroup,
makeMapper,
makeMappersResponse,
mappersEndpoint,
} from '../../../../__tests__/fixtures';
import { buildDraftGroup } from '../../../../utils';
import MappersTable from '../MappersTable';
// MappersTable is store-driven: it renders the group's draft mappers and folds
// the lazy fetch into the store via hydrateGroupMappers. These rendering tests
// supply the draft mappers on the group directly and stub the store callbacks.
function storeWith(
overrides: Partial<AttributeMappingStore> = {},
): AttributeMappingStore {
return {
groups: [],
snapshot: [],
isLoading: false,
isError: false,
isDirty: false,
isSaving: false,
saveError: null,
upsertGroup: jest.fn(),
removeGroup: jest.fn(),
toggleGroup: jest.fn(),
hydrateGroupMappers: jest.fn(),
upsertMapper: jest.fn(),
removeMapper: jest.fn(),
toggleMapper: jest.fn(),
save: jest.fn(),
discard: jest.fn(),
...overrides,
};
}
function setupMappers(mappers = [makeMapper()]): void {
server.use(
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeMappersResponse(mappers))),
),
);
}
describe('MappersTable', () => {
afterEach(() => {
server.resetHandlers();
});
it('shows the error state when the mappers request fails', async () => {
server.use(
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
res(ctx.status(500)),
),
);
render(
<MappersTable
group={buildDraftGroup(makeGroup({ id: 'group-1' }), [])}
store={storeWith()}
/>,
);
await expect(
screen.findByTestId('mappers-error-group-1'),
).resolves.toHaveTextContent('Failed to load mappings. Please try again.');
});
it('shows the empty state when the group has no mappers', async () => {
setupMappers([]);
render(
<MappersTable
group={buildDraftGroup(makeGroup({ id: 'group-1' }), [])}
store={storeWith()}
/>,
);
await expect(
screen.findByTestId('mappers-empty-group-1'),
).resolves.toHaveTextContent('No mappings in this group yet.');
});
it('does not fetch and shows the empty state for a group with no server id', () => {
const fetchSpy = jest.fn();
server.use(
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) => {
fetchSpy();
return res(ctx.status(200), ctx.json(makeMappersResponse([])));
}),
);
const draftGroup = {
...buildDraftGroup(makeGroup({ id: 'group-1' }), []),
serverId: null,
};
render(<MappersTable group={draftGroup} store={storeWith()} />);
expect(screen.getByTestId('mappers-empty-group-1')).toBeInTheDocument();
expect(fetchSpy).not.toHaveBeenCalled();
});
it('renders a mapper row with target, sources, field context and status', async () => {
const mapper = makeMapper({
id: 'mapper-1',
name: 'gen_ai.request.model',
enabled: true,
});
setupMappers([mapper]);
render(
<MappersTable
group={buildDraftGroup(makeGroup({ id: 'group-1' }), [mapper])}
store={storeWith()}
/>,
);
await waitFor(() =>
expect(screen.getByTestId('mapper-target-mapper-1')).toBeInTheDocument(),
);
expect(screen.getByTestId('mapper-target-mapper-1')).toHaveTextContent(
'gen_ai.request.model',
);
const sources = screen.getByTestId('mapper-sources-mapper-1');
expect(sources).toHaveTextContent('genai.model');
expect(sources).toHaveTextContent('llm.model');
expect(screen.getByText('attribute')).toBeInTheDocument();
expect(screen.getByTestId('mapper-enabled-mapper-1')).toBeChecked();
});
it('collapses extra sources into a "+N more" label beyond the visible cap', async () => {
const mapper = makeMapper({
id: 'mapper-1',
config: {
sources: [1, 2, 3, 4, 5].map((priority) => ({
key: `source-${priority}`,
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority,
})),
},
});
setupMappers([mapper]);
render(
<MappersTable
group={buildDraftGroup(makeGroup({ id: 'group-1' }), [mapper])}
store={storeWith()}
/>,
);
await expect(screen.findByText('+2 more')).resolves.toBeInTheDocument();
});
it('shows a muted placeholder when a mapper has no sources', async () => {
const mapper = makeMapper({ id: 'mapper-1', config: { sources: [] } });
setupMappers([mapper]);
render(
<MappersTable
group={buildDraftGroup(makeGroup({ id: 'group-1' }), [mapper])}
store={storeWith()}
/>,
);
await waitFor(() =>
expect(screen.getByTestId('mapper-sources-mapper-1')).toHaveTextContent('—'),
);
});
});

View File

@@ -1,149 +0,0 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { rest, server } from 'mocks-server/server';
import {
GROUPS_ENDPOINT,
makeGroupsResponse,
mockGroups,
} from '../../../__tests__/fixtures';
import { useAttributeMappingStore } from '../useAttributeMappingStore';
function createWrapper(): ({
children,
}: {
children: React.ReactNode;
}) => React.ReactElement {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
function renderStore(): ReturnType<
typeof renderHook<ReturnType<typeof useAttributeMappingStore>, unknown>
> {
return renderHook(() => useAttributeMappingStore(), {
wrapper: createWrapper(),
});
}
describe('useAttributeMappingStore', () => {
afterEach(() => {
server.resetHandlers();
});
it('starts loading with no groups', () => {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
);
const { result } = renderStore();
expect(result.current.isLoading).toBe(true);
expect(result.current.groups).toStrictEqual([]);
});
it('builds a draft tree from the server groups once loaded', async () => {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
);
const { result } = renderStore();
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.groups).toHaveLength(2);
expect(result.current.groups[0]).toStrictEqual({
localId: 'group-1',
serverId: 'group-1',
name: 'demo',
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
enabled: true,
mappers: [],
});
expect(result.current.isError).toBe(false);
});
it('surfaces isError when the groups request fails', async () => {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
);
const { result } = renderStore();
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.groups).toStrictEqual([]);
});
// Regression guard for the "empty mappers after save" bug: on save the groups
// list must be *invalidated* in place (it stays mounted, so no flash), while
// each per-group mapper list must be *removed* from the cache — invalidate
// alone leaves an expanded group's table stale/empty because react-query keeps
// `data` referentially stable when the list is unchanged, so the hydrate effect
// never re-fires. This asserts the exact query-client calls and their key
// targeting so a revert to invalidate-everything fails here.
it('invalidates the groups list and removes cached mapper lists on save', async () => {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries');
const removeSpy = jest.spyOn(queryClient, 'removeQueries');
const { result } = renderHook(() => useAttributeMappingStore(), {
wrapper: function Wrapper({
children,
}: {
children: React.ReactNode;
}): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
},
});
// Wait until the draft is initialised, otherwise save() no-ops early.
await waitFor(() => expect(result.current.groups.length).toBeGreaterThan(0));
await act(async () => {
await result.current.save();
});
const groupsKey = ['/api/v1/span_mapper_groups'];
const mappersKey = ['/api/v1/span_mapper_groups/group-1/span_mappers'];
// Groups list: invalidated, and the predicate matches *only* the list key.
expect(invalidateSpy).toHaveBeenCalledTimes(1);
const invalidatePredicate = invalidateSpy.mock.calls[0][0] as unknown as {
predicate: (query: { queryKey: unknown[] }) => boolean;
};
expect(invalidatePredicate.predicate({ queryKey: groupsKey })).toBe(true);
expect(invalidatePredicate.predicate({ queryKey: mappersKey })).toBe(false);
// Per-group mapper lists: removed, and the predicate matches *only* the
// child mapper keys (never the groups list).
expect(removeSpy).toHaveBeenCalledTimes(1);
const removeFilters = removeSpy.mock.calls[0][0] as unknown as {
predicate: (query: { queryKey: unknown[] }) => boolean;
};
expect(removeFilters.predicate({ queryKey: mappersKey })).toBe(true);
expect(removeFilters.predicate({ queryKey: groupsKey })).toBe(false);
});
});

View File

@@ -1,331 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { cloneDeep, isEqual } from 'lodash-es';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
useCreateSpanMapper,
useCreateSpanMapperGroup,
useDeleteSpanMapper,
useDeleteSpanMapperGroup,
useListSpanMapperGroups,
useUpdateSpanMapper,
useUpdateSpanMapperGroup,
} from 'api/generated/services/spanmapper';
import { persistDraft, SaveMutations } from '../../saveDraft';
import {
DraftGroup,
GroupDraft,
Mapper,
MapperDraft,
MapperGroup,
} from '../../types';
import {
buildDraftGroup,
buildDraftMapper,
nodeFromGroupDraft,
nodeFromMapperDraft,
} from '../../utils';
const GROUPS_KEY_PREFIX = '/api/v1/span_mapper_groups';
function clone(groups: DraftGroup[]): DraftGroup[] {
return cloneDeep(groups);
}
export interface AttributeMappingStore {
groups: DraftGroup[];
// The last-saved server baseline the working `groups` are diffed against.
// Exposed so the Test tab can send only the groups whose mappers changed.
snapshot: DraftGroup[];
isLoading: boolean;
isError: boolean;
isDirty: boolean;
isSaving: boolean;
saveError: string | null;
upsertGroup: (draft: GroupDraft) => void;
removeGroup: (localId: string) => void;
toggleGroup: (localId: string, enabled: boolean) => void;
hydrateGroupMappers: (groupServerId: string, mappers: Mapper[]) => void;
upsertMapper: (groupLocalId: string, draft: MapperDraft) => void;
removeMapper: (groupLocalId: string, mapperLocalId: string) => void;
toggleMapper: (
groupLocalId: string,
mapperLocalId: string,
enabled: boolean,
) => void;
save: () => Promise<void>;
discard: () => void;
}
export function useAttributeMappingStore(): AttributeMappingStore {
const queryClient = useQueryClient();
const groupsQuery = useListSpanMapperGroups();
const serverGroups: MapperGroup[] = useMemo(
() => groupsQuery.data?.data?.items ?? [],
[groupsQuery.data],
);
const ready = !groupsQuery.isLoading;
// A group's mappers are fetched lazily when its row is first expanded (see
// MappersTable -> hydrateGroupMappers) and cached here, keyed by group server
// id. Page load stays a single groups request — never an N+1 fan-out across
// every group.
const [loadedMappers, setLoadedMappers] = useState<Record<string, Mapper[]>>(
{},
);
const loadedRef = useRef<Set<string>>(new Set());
const snapshot = useMemo<DraftGroup[]>(() => {
if (!ready) {
return [];
}
return serverGroups.map((group) =>
buildDraftGroup(group, loadedMappers[group.id] ?? []),
);
}, [ready, serverGroups, loadedMappers]);
const [draft, setDraft] = useState<DraftGroup[] | null>(null);
// Initialise the working copy once data is ready (and re-init after a save
// clears it). Never clobbers in-flight edits — only runs when draft is null.
useEffect(() => {
if (ready && draft === null) {
setDraft(clone(snapshot));
}
}, [ready, draft, snapshot]);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { mutateAsync: createGroup } = useCreateSpanMapperGroup();
const { mutateAsync: updateGroup } = useUpdateSpanMapperGroup();
const { mutateAsync: deleteGroup } = useDeleteSpanMapperGroup();
const { mutateAsync: createMapper } = useCreateSpanMapper();
const { mutateAsync: updateMapper } = useUpdateSpanMapper();
const { mutateAsync: deleteMapper } = useDeleteSpanMapper();
const mutations: SaveMutations = useMemo(
() => ({
createGroup: async (data): Promise<string> => {
const result = await createGroup({ data });
return result.data.id;
},
updateGroup: async (groupId, data): Promise<void> => {
await updateGroup({ pathParams: { groupId }, data });
},
deleteGroup: async (groupId): Promise<void> => {
await deleteGroup({ pathParams: { groupId } });
},
createMapper: async (groupId, data): Promise<void> => {
await createMapper({ pathParams: { groupId }, data });
},
updateMapper: async (groupId, mapperId, data): Promise<void> => {
await updateMapper({ pathParams: { groupId, mapperId }, data });
},
deleteMapper: async (groupId, mapperId): Promise<void> => {
await deleteMapper({ pathParams: { groupId, mapperId } });
},
}),
[
createGroup,
updateGroup,
deleteGroup,
createMapper,
updateMapper,
deleteMapper,
],
);
const upsertGroup = useCallback((groupDraft: GroupDraft): void => {
setDraft((prev) => {
const groups = prev ?? [];
if (groupDraft.id) {
return groups.map((group) =>
group.localId === groupDraft.id
? nodeFromGroupDraft(groupDraft, group)
: group,
);
}
return [...groups, nodeFromGroupDraft(groupDraft)];
});
}, []);
const removeGroup = useCallback((localId: string): void => {
setDraft((prev) => (prev ?? []).filter((group) => group.localId !== localId));
}, []);
const toggleGroup = useCallback((localId: string, enabled: boolean): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === localId ? { ...group, enabled } : group,
),
);
}, []);
// Fold a group's freshly-fetched server mappers into both the snapshot
// baseline and the working draft, exactly once per group (the guard skips
// re-expands). The "Add mapping" affordance renders while the fetch is still
// in flight, so a user can stage a new mapper (serverId === null) before the
// server list arrives — those unsaved rows are preserved, with the server
// mappers folded in ahead of them, so a racing add isn't clobbered.
const hydrateGroupMappers = useCallback(
(groupServerId: string, mappers: Mapper[]): void => {
if (loadedRef.current.has(groupServerId)) {
return;
}
loadedRef.current.add(groupServerId);
setLoadedMappers((prev) => ({ ...prev, [groupServerId]: mappers }));
setDraft((prev) =>
prev === null
? prev
: prev.map((group) =>
group.serverId === groupServerId
? {
...group,
mappers: [
...mappers.map(buildDraftMapper),
...group.mappers.filter((mapper) => mapper.serverId === null),
],
}
: group,
),
);
},
[],
);
const upsertMapper = useCallback(
(groupLocalId: string, mapperDraft: MapperDraft): void => {
setDraft((prev) =>
(prev ?? []).map((group) => {
if (group.localId !== groupLocalId) {
return group;
}
if (mapperDraft.id) {
return {
...group,
mappers: group.mappers.map((mapper) =>
mapper.localId === mapperDraft.id
? nodeFromMapperDraft(mapperDraft, mapper)
: mapper,
),
};
}
return {
...group,
mappers: [...group.mappers, nodeFromMapperDraft(mapperDraft)],
};
}),
);
},
[],
);
const removeMapper = useCallback(
(groupLocalId: string, mapperLocalId: string): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === groupLocalId
? {
...group,
mappers: group.mappers.filter(
(mapper) => mapper.localId !== mapperLocalId,
),
}
: group,
),
);
},
[],
);
const toggleMapper = useCallback(
(groupLocalId: string, mapperLocalId: string, enabled: boolean): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === groupLocalId
? {
...group,
mappers: group.mappers.map((mapper) =>
mapper.localId === mapperLocalId ? { ...mapper, enabled } : mapper,
),
}
: group,
),
);
},
[],
);
const discard = useCallback((): void => {
setSaveError(null);
setDraft(clone(snapshot));
}, [snapshot]);
const save = useCallback(async (): Promise<void> => {
if (!draft) {
return;
}
setIsSaving(true);
setSaveError(null);
try {
await persistDraft(snapshot, draft, mutations);
// Refetch the groups list in place — it stays mounted, so this just
// swaps in fresh data without a loading flash. Exact-match the key so
// this doesn't also touch the per-group mapper lists (handled below).
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey?.[0] === GROUPS_KEY_PREFIX,
});
// Reset the working copy and the lazily-loaded mapper mirror *before*
// the mapper queries re-emit, so the re-hydrate isn't clobbered.
loadedRef.current = new Set();
setLoadedMappers({});
setDraft(null);
// removeQueries (not invalidate) for the per-group mapper lists: an
// expanded group's table only re-hydrates when its query `data` changes
// reference, but react-query's structural sharing keeps `data` stable
// when the list is unchanged — so invalidate alone leaves the table
// empty. Removing the cache forces undefined -> refetch -> fresh, which
// always fires the hydrate effect. Also fixes stale mappers on re-expand.
queryClient.removeQueries({
predicate: (query) =>
typeof query.queryKey?.[0] === 'string' &&
(query.queryKey[0] as string).startsWith(`${GROUPS_KEY_PREFIX}/`),
});
toast.success('Attribute mapping changes saved');
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);
toast.error(`Failed to save changes: ${message}`);
} finally {
setIsSaving(false);
}
}, [draft, snapshot, mutations, queryClient]);
const isDirty = useMemo(
() => draft !== null && !isEqual(draft, snapshot),
[draft, snapshot],
);
return {
groups: draft ?? [],
snapshot,
isLoading: !ready || draft === null,
isError: groupsQuery.isError,
isDirty,
isSaving,
saveError,
upsertGroup,
removeGroup,
toggleGroup,
hydrateGroupMappers,
upsertMapper,
removeMapper,
toggleMapper,
save,
discard,
};
}

View File

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

View File

@@ -1,14 +0,0 @@
.llmObservabilityAttributeMapping {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
}
.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,88 +0,0 @@
import { useCallback } from 'react';
import { Tabs } from '@signozhq/ui/tabs';
import AttributeMappingHeader from './components/AttributeMappingHeader';
import AttributeMappingsTab from './AttributeMappingsTab';
import GroupFormDrawer from './components/GroupFormDrawer';
import styles from './LLMObservabilityAttributeMapping.module.scss';
import TestTab from './TestTab';
import { useAttributeMappingStore } from './AttributeMappingsTab/hooks/useAttributeMappingStore';
import { useGroupFormDrawer } from './useGroupFormDrawer';
function LLMObservabilityAttributeMapping(): JSX.Element {
const store = useAttributeMappingStore();
const groupDrawer = useGroupFormDrawer();
const handleGroupSave = useCallback((): void => {
store.upsertGroup(groupDrawer.draft);
groupDrawer.close();
}, [store, groupDrawer]);
const handleGroupDelete = useCallback((): void => {
if (groupDrawer.draft.id) {
store.removeGroup(groupDrawer.draft.id);
}
groupDrawer.close();
}, [store, groupDrawer]);
const tabItems = [
{
key: 'attribute-mappings',
label: 'Attribute mappings',
children: (
<AttributeMappingsTab
store={store}
onEditGroup={groupDrawer.openForEdit}
onAddGroup={groupDrawer.openForAdd}
/>
),
},
{
key: 'test',
label: 'Test',
children: <TestTab store={store} />,
},
];
return (
<div
className={styles.llmObservabilityAttributeMapping}
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={store.isDirty}
isSaving={store.isSaving}
onDiscard={store.discard}
onSave={store.save}
/>
{store.saveError && (
<div className={styles.pageError} role="alert">
{store.saveError}
</div>
)}
<Tabs
testId="attribute-mapping-tabs"
defaultValue="attribute-mappings"
items={tabItems}
/>
{groupDrawer.isOpen && (
<GroupFormDrawer
isOpen={groupDrawer.isOpen}
mode={groupDrawer.mode}
draft={groupDrawer.draft}
setDraft={groupDrawer.setDraft}
onClose={groupDrawer.close}
onSave={handleGroupSave}
onDelete={handleGroupDelete}
isSaving={false}
isDeleting={false}
saveError={null}
/>
)}
</div>
);
}
export default LLMObservabilityAttributeMapping;

View File

@@ -1,88 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { SpantypesSpanMapperTestSpanDTO } from 'api/generated/services/sigNoz.schemas';
import { useMemo } from 'react';
import { AttrChangeStatus, diffSpanAttributes } from './testPayload';
import styles from './TestTab.module.scss';
interface TestResultProps {
index: number;
span: SpantypesSpanMapperTestSpanDTO;
inputAttributes: Record<string, unknown>;
}
const STATUS_BADGE: Partial<
Record<
AttrChangeStatus,
{ color: 'success' | 'robin' | 'sienna'; label: string }
>
> = {
added: { color: 'success', label: 'populated' },
changed: { color: 'robin', label: 'remapped' },
removed: { color: 'sienna', label: 'moved out' },
};
// Only added/removed rows carry a background treatment; the rest render plain.
// Kept as an explicit map so we never do dynamic `styles[...]` access.
const ROW_CLASS: Partial<Record<AttrChangeStatus, string>> = {
added: styles.added,
removed: styles.removed,
};
function formatValue(value: unknown): string {
if (typeof value === 'string') {
return value;
}
return JSON.stringify(value);
}
// Renders one transformed span as a key/value list, highlighting which target
// attributes the mappers populated and which source keys a move consumed.
function TestResult({
index,
span,
inputAttributes,
}: TestResultProps): JSX.Element {
const entries = useMemo(
() => diffSpanAttributes(inputAttributes, span.attributes ?? {}),
[inputAttributes, span.attributes],
);
return (
<div className={styles.resultCard} data-testid={`test-result-${index}`}>
<div className={styles.resultTitle}>Resulting attributes</div>
{entries.length === 0 ? (
<div className={styles.resultEmpty}>This span has no attributes.</div>
) : (
<div className={styles.attrRows}>
{entries.map((entry) => {
const badge = STATUS_BADGE[entry.status];
return (
<div
key={entry.key}
className={`${styles.attrRow} ${ROW_CLASS[entry.status] ?? ''}`}
>
<span className={styles.attrKey} title={entry.key}>
{entry.key}
</span>
<span className={styles.attrValue} title={formatValue(entry.value)}>
{formatValue(entry.value)}
</span>
{badge ? (
<Badge color={badge.color} variant="outline">
{badge.label}
</Badge>
) : (
<span />
)}
</div>
);
})}
</div>
)}
</div>
);
}
export default TestResult;

View File

@@ -1,160 +0,0 @@
.testTab {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
// Heading + description on the left, Run button pinned to the top-right.
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-6);
}
.headerText {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
min-width: 0;
}
.heading {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin: 0;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
}
.description {
margin: 0;
font-size: var(--periscope-font-size-base);
color: var(--l3-foreground);
}
.body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-6);
height: calc(100vh - 320px);
min-height: 320px;
}
.editor {
width: 100%;
height: 100%;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
overflow: hidden;
}
.resultsPanel {
height: 100%;
overflow-y: auto;
padding: var(--padding-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
}
// Pre-run empty state that fills the blank right half.
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-2);
height: 100%;
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.placeholderTitle {
font-weight: var(--font-weight-semibold);
color: var(--l2-foreground);
}
.error {
padding: var(--padding-3) var(--padding-4);
border-radius: var(--radius-2);
background: var(--callout-error-background);
color: var(--callout-error-title);
font-size: var(--periscope-font-size-base);
}
.results {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.resultCard {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--padding-4);
border-radius: var(--radius-2);
background: var(--l1-background);
}
.resultTitle {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
}
.resultEmpty {
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.attrRows {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.attrRow {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-2) var(--padding-3);
border-radius: var(--radius-2);
font-family: 'Geist Mono', monospace;
font-size: var(--font-size-xs);
&.added {
background: var(--callout-success-background);
}
&.removed {
opacity: 0.65;
.attrKey,
.attrValue {
text-decoration: line-through;
}
}
}
.attrKey,
.attrValue {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attrKey {
font-weight: var(--font-weight-medium);
}
.attrValue {
color: var(--l3-foreground);
}

View File

@@ -1,118 +0,0 @@
import { Play } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import Editor from 'components/Editor';
import styles from './TestTab.module.scss';
import TestResult from './TestResult';
import { AttributeMappingStore } from './AttributeMappingsTab/hooks/useAttributeMappingStore';
import { useTestSpanMapper } from './useTestSpanMapper';
interface TestTabProps {
store: AttributeMappingStore;
}
// "Test" tab: paste a sample span, run it through the working draft's mappers,
// and see which target attributes get populated. Only groups whose mappers
// changed are sent in full — unchanged groups are backfilled server-side.
function TestTab({ store }: TestTabProps): JSX.Element {
const { input, setInput, run, isRunning, result, testedAttributes, error } =
useTestSpanMapper(store.snapshot, store.groups);
function renderResults(): JSX.Element {
if (error) {
return (
<div className={styles.error} role="alert" data-testid="test-error">
{error}
</div>
);
}
if (result) {
if (result.length === 0) {
return (
<div className={styles.resultEmpty} data-testid="test-results-empty">
No spans returned. The mappers produced no output for this input.
</div>
);
}
return (
<div className={styles.results} data-testid="test-results">
{result.map((span, index) => (
<TestResult
// eslint-disable-next-line react/no-array-index-key
key={index}
index={index}
span={span}
inputAttributes={testedAttributes ?? {}}
/>
))}
</div>
);
}
return (
<div className={styles.placeholder} data-testid="test-results-placeholder">
<span className={styles.placeholderTitle}>No results yet</span>
<span>
Run the test to see which target attributes your mappers populate.
</span>
</div>
);
}
return (
<div className={styles.testTab} data-testid="test-tab">
<div className={styles.header}>
<div className={styles.headerText}>
<h3 className={styles.heading}>Test with sample span</h3>
<p className={styles.description}>
Paste a JSON span object to see which target attributes get populated and
which source key matched.
</p>
</div>
<Button
testId="run-test-button"
variant="solid"
color="primary"
prefix={<Play size={14} />}
onClick={run}
loading={isRunning}
disabled={isRunning}
>
Run Test
</Button>
</div>
<div className={styles.body}>
<div
className={styles.editor}
data-testid="test-span-input"
aria-label="Sample span JSON"
>
<Editor
language="json"
value={input}
onChange={setInput}
height="100%"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
formatOnPaste: true,
tabSize: 2,
automaticLayout: true,
scrollbar: { alwaysConsumeMouseWheel: false },
}}
/>
</div>
<div className={styles.resultsPanel} data-testid="test-results-panel">
{renderResults()}
</div>
</div>
</div>
);
}
export default TestTab;

View File

@@ -1,72 +0,0 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent } 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('enables the test tab and renders its content when activated', async () => {
const user = userEvent.setup();
render(<LLMObservabilityAttributeMapping />);
const testTab = screen.getByRole('tab', { name: 'Test' });
expect(testTab).toBeEnabled();
await user.click(testTab);
await expect(screen.findByTestId('test-tab')).resolves.toBeInTheDocument();
});
it('renders the header with Save/Discard disabled by default', () => {
render(<LLMObservabilityAttributeMapping />);
expect(screen.getByTestId('save-changes-btn')).toBeDisabled();
expect(screen.getByTestId('discard-changes-btn')).toBeDisabled();
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 MappersTable), 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

@@ -1,114 +0,0 @@
import {
SpantypesFieldContextDTO as FieldContext,
SpantypesSpanMapperOperationDTO as MapperOperation,
} from 'api/generated/services/sigNoz.schemas';
import {
buildDraftGroup,
buildDraftMapper,
conditionFiltersFromGroup,
getMapperSources,
} from '../utils';
import { makeGroup, makeMapper } from './fixtures';
describe('conditionFiltersFromGroup', () => {
it('lists attribute keys before resource keys', () => {
const filters = conditionFiltersFromGroup({
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
});
expect(filters).toStrictEqual([
{ context: 'attribute', key: 'ai.embeddings' },
{ context: 'resource', key: 'cloud.account.id' },
]);
});
it('defaults missing attributes/resource to no filters', () => {
expect(conditionFiltersFromGroup({})).toStrictEqual([]);
});
});
describe('getMapperSources', () => {
it('orders sources by priority, highest first', () => {
const mapper = makeMapper({
config: {
sources: [
{
key: 'llm.model',
context: FieldContext.attribute,
operation: MapperOperation.move,
priority: 1,
},
{
key: 'genai.model',
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority: 2,
},
],
},
});
expect(getMapperSources(mapper)).toStrictEqual([
{
key: 'genai.model',
context: FieldContext.attribute,
operation: MapperOperation.copy,
},
{
key: 'llm.model',
context: FieldContext.attribute,
operation: MapperOperation.move,
},
]);
});
it('defaults a null sources config to an empty list', () => {
const mapper = makeMapper({ config: { sources: null } });
expect(getMapperSources(mapper)).toStrictEqual([]);
});
});
describe('buildDraftMapper', () => {
it('maps the server mapper into a draft node keyed by the server id', () => {
const mapper = makeMapper({ id: 'mapper-9', enabled: false });
const draft = buildDraftMapper(mapper);
expect(draft.localId).toBe('mapper-9');
expect(draft.serverId).toBe('mapper-9');
expect(draft.name).toBe(mapper.name);
expect(draft.enabled).toBe(false);
expect(draft.sources).toStrictEqual(getMapperSources(mapper));
});
});
describe('buildDraftGroup', () => {
it('maps the server group and its mappers into a draft tree', () => {
const group = makeGroup({
id: 'group-9',
condition: { attributes: ['a'], resource: ['b'] },
});
const mapper = makeMapper({ id: 'mapper-1' });
const draft = buildDraftGroup(group, [mapper]);
expect(draft.localId).toBe('group-9');
expect(draft.serverId).toBe('group-9');
expect(draft.attributes).toStrictEqual(['a']);
expect(draft.resource).toStrictEqual(['b']);
expect(draft.mappers).toHaveLength(1);
expect(draft.mappers[0].localId).toBe('mapper-1');
});
it('defaults a null condition to empty attributes/resource', () => {
const group = makeGroup({ condition: null });
const draft = buildDraftGroup(group, []);
expect(draft.attributes).toStrictEqual([]);
expect(draft.resource).toStrictEqual([]);
});
});

View File

@@ -1,17 +0,0 @@
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderActions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedChanges {
font-size: var(--periscope-font-size-base);
color: var(--accent-amber);
}

View File

@@ -1,54 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './AttributeMappingHeader.module.scss';
interface AttributeMappingHeaderProps {
isDirty: boolean;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function AttributeMappingHeader({
isDirty,
isSaving,
onDiscard,
onSave,
}: 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>
<div className={styles.pageHeaderActions}>
{isDirty && (
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
)}
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={!isDirty || isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={!isDirty || isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
</header>
);
}
export default AttributeMappingHeader;

View File

@@ -1,89 +0,0 @@
import { render, screen, userEvent } from 'tests/test-utils';
import AttributeMappingHeader from '../AttributeMappingHeader';
describe('AttributeMappingHeader', () => {
it('renders the description copy', () => {
render(
<AttributeMappingHeader
isDirty={false}
isSaving={false}
onDiscard={jest.fn()}
onSave={jest.fn()}
/>,
);
expect(
screen.getByText(
'Configure source-to-target attribute remapping for LLM traces',
),
).toBeInTheDocument();
});
it('hides the unsaved-changes indicator and disables Save/Discard when not dirty', () => {
render(
<AttributeMappingHeader
isDirty={false}
isSaving={false}
onDiscard={jest.fn()}
onSave={jest.fn()}
/>,
);
expect(screen.queryByTestId('unsaved-changes')).not.toBeInTheDocument();
expect(screen.getByTestId('discard-changes-btn')).toBeDisabled();
expect(screen.getByTestId('save-changes-btn')).toBeDisabled();
});
it('shows the unsaved-changes indicator and enables Save/Discard when dirty', () => {
render(
<AttributeMappingHeader
isDirty
isSaving={false}
onDiscard={jest.fn()}
onSave={jest.fn()}
/>,
);
expect(screen.getByTestId('unsaved-changes')).toHaveTextContent(
'Unsaved changes',
);
expect(screen.getByTestId('discard-changes-btn')).toBeEnabled();
expect(screen.getByTestId('save-changes-btn')).toBeEnabled();
});
it('disables Save/Discard and shows the saving label while saving', () => {
render(
<AttributeMappingHeader
isDirty
isSaving
onDiscard={jest.fn()}
onSave={jest.fn()}
/>,
);
expect(screen.getByTestId('discard-changes-btn')).toBeDisabled();
expect(screen.getByTestId('save-changes-btn')).toBeDisabled();
expect(screen.getByTestId('save-changes-btn')).toHaveTextContent('Saving…');
});
it('calls onSave and onDiscard when their buttons are clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSave = jest.fn();
const onDiscard = jest.fn();
render(
<AttributeMappingHeader
isDirty
isSaving={false}
onDiscard={onDiscard}
onSave={onSave}
/>,
);
await user.click(screen.getByTestId('save-changes-btn'));
await user.click(screen.getByTestId('discard-changes-btn'));
expect(onSave).toHaveBeenCalledTimes(1);
expect(onDiscard).toHaveBeenCalledTimes(1);
});
});

View File

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

View File

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

View File

@@ -1,76 +0,0 @@
.groupForm {
display: flex;
flex-direction: column;
gap: var(--spacing-10);
padding: var(--spacing-2) 0;
}
.groupFormField {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.groupFormFieldRow {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.groupFormLabel {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.groupFormLabelHint {
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
}
.groupFormHint {
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.groupFormKeys {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.groupFormKey {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.groupFormKeyInput {
flex: 1;
font-family: 'Geist Mono', monospace;
}
.groupFormError {
padding: var(--spacing-5) var(--spacing-6);
border-radius: var(--radius-2);
background: var(--callout-error-background);
color: var(--callout-error-title);
font-size: var(--periscope-font-size-base);
}
.groupFormFooter {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: var(--spacing-4);
}
.groupFormFooterActions {
display: flex;
gap: var(--spacing-4);
margin-left: auto;
}

View File

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

View File

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

View File

@@ -1,51 +0,0 @@
.keySearch {
position: relative;
flex: 1;
min-width: 0;
}
.keySearchDropdown {
position: absolute;
top: calc(100% + var(--spacing-2));
left: 0;
z-index: 20;
min-width: 100%;
width: max-content;
max-width: 420px;
max-height: 240px;
overflow-x: hidden;
overflow-y: auto;
padding: var(--spacing-2);
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l2-background);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.keySearchDropdownEmpty {
padding: var(--spacing-4) var(--spacing-5);
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.keySearchOption {
display: block;
width: 100%;
max-width: 100%;
padding: var(--spacing-3) var(--spacing-5);
border: none;
border-radius: 3px;
background: transparent;
color: var(--l1-foreground);
font-family: 'Geist Mono', monospace;
font-size: var(--font-size-xs);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
&:hover {
background: var(--l2-background-hover);
}
}

View File

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

View File

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

View File

@@ -1,63 +0,0 @@
.form {
display: flex;
flex-direction: column;
gap: var(--spacing-10);
padding: var(--spacing-2) 0;
}
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.labelHint {
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
}
.hint {
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.fieldContext {
width: 200px;
}
.sources {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.error {
padding: var(--spacing-5) var(--spacing-6);
border-radius: var(--radius-2);
background: var(--callout-error-background);
color: var(--callout-error-title);
font-size: var(--periscope-font-size-base);
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: var(--spacing-4);
}
.footerActions {
display: flex;
gap: var(--spacing-4);
margin-left: auto;
}

View File

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

View File

@@ -1,40 +0,0 @@
.source {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.sourceHandle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing-2);
border: none;
background: transparent;
color: var(--l3-foreground);
cursor: grab;
touch-action: none;
user-select: none;
&:active {
cursor: grabbing;
}
}
.sourceIndex {
width: 20px;
text-align: center;
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.sourceInput {
flex: 1;
min-width: 0;
font-family: 'Geist Mono', monospace;
}
.sourceSelect {
flex: 0 0 auto;
width: 120px;
}

View File

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

View File

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

View File

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

View File

@@ -1,135 +0,0 @@
import {
SpantypesPostableSpanMapperTestDTO,
SpantypesPostableSpanMapperTestGroupDTO,
SpantypesSpanMapperTestSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DraftGroup } from './types';
import {
buildPostableGroup,
buildPostableMapper,
groupDraftFromNode,
mapperDraftFromNode,
} from './utils';
// A group's mappers must be sent in full when the group is new (the backend has
// no saved group of that name to backfill from) or when its mapper set has
// diverged from the saved baseline. Otherwise we omit them and let the backend
// load the saved mappers by group name — which also covers groups whose rows
// were never expanded (their draft carries no mappers yet).
function shouldSendMappers(
snap: DraftGroup | undefined,
group: DraftGroup,
): boolean {
if (!snap) {
return true;
}
return JSON.stringify(snap.mappers) !== JSON.stringify(group.mappers);
}
// Builds the `groups` portion of the test request from the working draft,
// sending each group's name/enabled/condition from the current draft and its
// `mappers` only when they changed (null otherwise → backend backfills).
export function buildTestGroups(
snapshot: DraftGroup[],
draft: DraftGroup[],
): SpantypesPostableSpanMapperTestGroupDTO[] {
const snapById = new Map(
snapshot
.filter((group) => group.serverId)
.map((group) => [group.serverId as string, group]),
);
return draft.map((group) => {
const base = buildPostableGroup(groupDraftFromNode(group));
const snap = group.serverId ? snapById.get(group.serverId) : undefined;
return {
...base,
mappers: shouldSendMappers(snap, group)
? group.mappers.map((mapper) =>
buildPostableMapper(mapperDraftFromNode(mapper)),
)
: null,
};
});
}
// Parses the pasted JSON into a single test span. The pasted object is treated
// as the span's attribute map (matching the sample shown to the user); resource
// is left empty. Throws a friendly error on anything that isn't a JSON object.
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {
const trimmed = input.trim();
if (!trimmed) {
throw new Error('Paste a JSON span object to run the test.');
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
throw new Error(
'Invalid JSON — check for trailing commas or missing quotes.',
);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Span must be a JSON object of attribute key-value pairs.');
}
return { attributes: parsed as Record<string, unknown>, resource: {} };
}
export function buildTestRequest(
snapshot: DraftGroup[],
draft: DraftGroup[],
input: string,
): SpantypesPostableSpanMapperTestDTO {
return {
groups: buildTestGroups(snapshot, draft),
spans: [parseSpanInput(input)],
};
}
export type AttrChangeStatus = 'added' | 'changed' | 'unchanged' | 'removed';
export interface AttrDiffEntry {
key: string;
value: unknown;
status: AttrChangeStatus;
}
function valuesEqual(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
// Diffs the span attributes a user pasted against the attributes the mappers
// produced, so the UI can highlight which target keys got populated ('added'),
// which source keys were consumed by a move ('removed'), and what stayed.
// Added keys (the populated targets) sort first as the primary signal.
export function diffSpanAttributes(
inputAttributes: Record<string, unknown>,
resultAttributes: Record<string, unknown>,
): AttrDiffEntry[] {
const added: AttrDiffEntry[] = [];
const changed: AttrDiffEntry[] = [];
const unchanged: AttrDiffEntry[] = [];
const removed: AttrDiffEntry[] = [];
Object.entries(resultAttributes).forEach(([key, value]) => {
if (!(key in inputAttributes)) {
added.push({ key, value, status: 'added' });
} else if (!valuesEqual(inputAttributes[key], value)) {
changed.push({ key, value, status: 'changed' });
} else {
unchanged.push({ key, value, status: 'unchanged' });
}
});
Object.entries(inputAttributes).forEach(([key, value]) => {
if (!(key in resultAttributes)) {
removed.push({ key, value, status: 'removed' });
}
});
return [...added, ...changed, ...unchanged, ...removed];
}

View File

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

View File

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

View File

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

View File

@@ -1,109 +0,0 @@
import { useCallback, useState } from 'react';
import {
RenderErrorResponseDTO,
SpantypesSpanMapperTestSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useTestSpanMappers } from 'api/generated/services/spanmapper';
import { AxiosError } from 'axios';
import { buildTestRequest } from './testPayload';
import { DraftGroup } from './types';
// Pre-filled sample so the tab is runnable on first open (mirrors the design).
export const SAMPLE_SPAN_JSON = `{
"my_company.llm.input": "What is quantum computing?",
"llm.input_messages": "What is quantum computing?",
"gen_ai.request.model": "gpt-4",
"gen_ai.usage.total_tokens": 1250,
"gen_ai.content.completion": "Quantum computing leverages..."
}`;
function apiErrorMessage(error: unknown): string {
const axiosError = error as AxiosError<RenderErrorResponseDTO>;
return (
axiosError?.response?.data?.error?.message ??
(error instanceof Error ? error.message : 'Test failed. Please try again.')
);
}
export interface UseTestSpanMapper {
input: string;
setInput: (value: string) => void;
run: () => void;
reset: () => void;
isRunning: boolean;
result: SpantypesSpanMapperTestSpanDTO[] | null;
// The attributes that were actually submitted with the last successful run,
// so the result diff stays stable even if the textarea is edited afterwards.
testedAttributes: Record<string, unknown> | null;
error: string | null;
}
// Owns the Test tab's local state: the pasted span JSON, the parsed/built
// request, and the result/error of the test mutation. Builds the request from
// the working draft (sending only changed groups' mappers — see testPayload).
export function useTestSpanMapper(
snapshot: DraftGroup[],
draft: DraftGroup[],
): UseTestSpanMapper {
const [input, setInput] = useState<string>(SAMPLE_SPAN_JSON);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<SpantypesSpanMapperTestSpanDTO[] | null>(
null,
);
const [testedAttributes, setTestedAttributes] = useState<Record<
string,
unknown
> | null>(null);
const { mutate, isLoading } = useTestSpanMappers();
const run = useCallback((): void => {
setError(null);
let body;
try {
body = buildTestRequest(snapshot, draft, input);
} catch (parseError) {
setResult(null);
setError(apiErrorMessage(parseError));
return;
}
const submittedAttributes = (body.spans?.[0]?.attributes ?? {}) as Record<
string,
unknown
>;
mutate(
{ data: body },
{
onSuccess: (response) => {
setTestedAttributes(submittedAttributes);
setResult(response.data?.spans ?? []);
},
onError: (mutationError) => {
setResult(null);
setError(apiErrorMessage(mutationError));
},
},
);
}, [snapshot, draft, input, mutate]);
const reset = useCallback((): void => {
setError(null);
setResult(null);
setTestedAttributes(null);
}, []);
return {
input,
setInput,
run,
reset,
isRunning: isLoading,
result,
testedAttributes,
error,
};
}

View File

@@ -1,264 +0,0 @@
import {
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { v4 as uuid } from 'uuid';
import {
ConditionFilter,
DraftGroup,
DraftMapper,
FieldContext,
GroupDraft,
Mapper,
MapperDraft,
MapperGroup,
MapperOperation,
SourceConfig,
} from './types';
// Client-side id for not-yet-persisted rows. Prefixed so it never collides
// with a server UUID and is easy to spot in logs.
export function genLocalId(prefix: 'group' | 'mapper'): string {
return `local-${prefix}-${uuid()}`;
}
// Trimmed, de-duplicated, non-empty keys preserving input order.
export function cleanKeys(keys: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
keys.forEach((raw) => {
const key = raw.trim();
if (key && !seen.has(key)) {
seen.add(key);
result.push(key);
}
});
return result;
}
// Display clauses for a group's condition keys (span attribute keys first,
// then resource keys).
export function conditionFiltersFromGroup(group: {
attributes?: string[];
resource?: string[];
}): ConditionFilter[] {
// TanStackTable renders skeleton placeholder rows through the cells on first
// render, so these arrays can be undefined before real data lands — default
// to empty rather than crashing the cell.
return [
...(group.attributes ?? []).map((key) => ({
context: 'attribute' as const,
key,
})),
...(group.resource ?? []).map((key) => ({
context: 'resource' as const,
key,
})),
];
}
// Source configs for a mapper, highest priority first (first match wins at
// evaluation time).
export function getMapperSources(mapper: Mapper): SourceConfig[] {
const sources = mapper.config?.sources ?? [];
return [...sources]
.sort((a, b) => b.priority - a.priority)
.map((source) => ({
key: source.key,
context: source.context,
operation: source.operation,
}));
}
export function createEmptySource(): SourceConfig {
return {
key: '',
context: FieldContext.attribute,
operation: MapperOperation.copy,
};
}
export const EMPTY_MAPPER_DRAFT: MapperDraft = {
id: null,
name: '',
fieldContext: FieldContext.attribute,
sources: [createEmptySource()],
enabled: true,
};
// Trimmed, de-duplicated (by context+key), non-empty sources in priority order,
// preserving each source's context and operation. A key identifies a different
// source per context (span attribute vs resource), so both can coexist; only an
// exact (context, key) repeat is collapsed, keeping the higher-priority row.
export function getCleanSources(draft: MapperDraft): SourceConfig[] {
const seen = new Set<string>();
const result: SourceConfig[] = [];
draft.sources.forEach((source) => {
const key = source.key.trim();
const dedupeKey = `${source.context}:${key}`;
if (key && !seen.has(dedupeKey)) {
seen.add(dedupeKey);
result.push({ ...source, key });
}
});
return result;
}
export function isMapperDraftValid(draft: MapperDraft): boolean {
return draft.name.trim().length > 0 && getCleanSources(draft).length > 0;
}
// Priority is derived from list order so the first row wins.
function buildSources(
draft: MapperDraft,
): SpantypesPostableSpanMapperDTO['config']['sources'] {
const sources = getCleanSources(draft);
return sources.map((source, index) => ({
key: source.key,
context: source.context,
operation: source.operation,
priority: sources.length - index,
}));
}
export function buildPostableMapper(
draft: MapperDraft,
): SpantypesPostableSpanMapperDTO {
return {
name: draft.name.trim(),
fieldContext: draft.fieldContext,
enabled: draft.enabled,
config: { sources: buildSources(draft) },
};
}
// The target name is immutable on update (UpdatableSpanMapper has no name).
export function buildUpdatableMapper(
draft: MapperDraft,
): SpantypesUpdatableSpanMapperDTO {
return {
fieldContext: draft.fieldContext,
enabled: draft.enabled,
config: { sources: buildSources(draft) },
};
}
export const EMPTY_GROUP_DRAFT: GroupDraft = {
id: null,
name: '',
attributes: [''],
resource: [],
enabled: true,
};
export function isGroupDraftValid(draft: GroupDraft): boolean {
return draft.name.trim().length > 0;
}
export function buildPostableGroup(
draft: GroupDraft,
): SpantypesPostableSpanMapperGroupDTO {
return {
name: draft.name.trim(),
enabled: draft.enabled,
condition: {
attributes: cleanKeys(draft.attributes),
resource: cleanKeys(draft.resource),
},
};
}
// A full group payload is also a valid partial-update payload (all updatable
// fields are present), so we reuse the postable builder.
export function buildUpdatableGroup(
draft: GroupDraft,
): SpantypesUpdatableSpanMapperGroupDTO {
return buildPostableGroup(draft);
}
// ---- working-copy (draft tree) helpers ----
export function buildDraftMapper(mapper: Mapper): DraftMapper {
return {
localId: mapper.id,
serverId: mapper.id,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources: getMapperSources(mapper),
enabled: mapper.enabled,
};
}
export function buildDraftGroup(
group: MapperGroup,
mappers: Mapper[],
): DraftGroup {
return {
localId: group.id,
serverId: group.id,
name: group.name,
attributes: group.condition?.attributes ?? [],
resource: group.condition?.resource ?? [],
enabled: group.enabled,
mappers: mappers.map(buildDraftMapper),
};
}
// DraftGroup -> editable form state (id carries the localId).
export function groupDraftFromNode(group: DraftGroup): GroupDraft {
return {
id: group.localId,
name: group.name,
attributes: group.attributes.length > 0 ? group.attributes : [''],
resource: group.resource,
enabled: group.enabled,
};
}
// DraftMapper -> editable form state (id carries the localId).
export function mapperDraftFromNode(mapper: DraftMapper): MapperDraft {
return {
id: mapper.localId,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources:
mapper.sources.length > 0
? mapper.sources.map((source) => ({ ...source }))
: [createEmptySource()],
enabled: mapper.enabled,
};
}
// Form state -> working-copy node. Reuses cleanKeys/getCleanSourceKeys so the
// staged tree already holds normalized values.
export function nodeFromGroupDraft(
draft: GroupDraft,
existing?: DraftGroup,
): DraftGroup {
return {
localId: existing?.localId ?? genLocalId('group'),
serverId: existing?.serverId ?? null,
name: draft.name.trim(),
attributes: cleanKeys(draft.attributes),
resource: cleanKeys(draft.resource),
enabled: draft.enabled,
mappers: existing?.mappers ?? [],
};
}
export function nodeFromMapperDraft(
draft: MapperDraft,
existing?: DraftMapper,
): DraftMapper {
return {
localId: existing?.localId ?? genLocalId('mapper'),
serverId: existing?.serverId ?? null,
name: draft.name.trim(),
fieldContext: draft.fieldContext,
sources: getCleanSources(draft),
enabled: draft.enabled,
};
}

View File

@@ -1,7 +1,27 @@
.llmObservability {
display: flex;
flex-direction: column;
height: 100%;
margin-top: var(--spacing-2);
margin-left: var(--spacing-2);
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
}
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
.title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.subtitle {
margin: var(--spacing-2) 0 0;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}
}

View File

@@ -1,22 +1,16 @@
import { Tabs } from '@signozhq/ui/tabs';
import { useLLMObservabilityTabs } from './hooks/useLLMObservabilityTabs';
import styles from './LLMObservability.module.scss';
// Shell for the LLM Observability page: renders the top-level tab bar
// (Overview / Configuration) using the SigNoz design-system Tabs, with
// route-driven active state from useLLMObservabilityTabs.
function LLMObservability(): JSX.Element {
const { items, activeTab, onTabChange } = useLLMObservabilityTabs();
return (
<div className={styles.llmObservability} data-testid="llm-observability-page">
<Tabs
items={items}
value={activeTab}
onChange={onTabChange}
testId="llm-observability-tabs"
/>
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>LLM Observability</h1>
<p className={styles.subtitle}>
Monitor and analyze your LLM usage, costs, and performance
</p>
</div>
</header>
</div>
);
}

View File

@@ -1,27 +0,0 @@
.overview {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
}
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
.title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.subtitle {
margin: var(--spacing-2) 0 0;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}
}

View File

@@ -1,20 +0,0 @@
import styles from './Overview.module.scss';
// Overview tab content for LLM Observability. Currently the feature's landing
// surface; usage/cost/performance widgets land in later PRs.
function Overview(): JSX.Element {
return (
<div className={styles.overview} data-testid="llm-observability-overview">
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>LLM Observability</h1>
<p className={styles.subtitle}>
Monitor and analyze your LLM usage, costs, and performance
</p>
</div>
</header>
</div>
);
}
export default Overview;

View File

@@ -2,14 +2,29 @@
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
}
.tabLabel {
display: inline-flex;
align-items: center;
gap: 8px;
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.tabBadge {
margin: 0;
.pageHeaderTitle {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
.title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.subtitle {
margin: var(--spacing-2) 0 0;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}
}

View File

@@ -1,58 +1,42 @@
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 { Typography } from '@signozhq/ui/typography';
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"
>
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<Typography.Text as="h1" size="large" weight="semibold">
Configuration
</Typography.Text>
<Typography.Text color="muted">
Model pricing and cost estimation settings
</Typography.Text>
</div>
</header>
<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, this can become a URL-backed param.
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

@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { Plus, Search, X } from '@signozhq/icons';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
@@ -160,6 +161,12 @@ function ModelCostTabPanel(): JSX.Element {
onDelete={deletion.requestDelete}
/>
<footer>
<Typography.Text color="muted" size="small">
All prices per 1M tokens (USD)
</Typography.Text>
</footer>
{drawer.isOpen && (
<ModelCostDrawer
isOpen={drawer.isOpen}

View File

@@ -1,229 +0,0 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import {
LLM_PRICING_ENDPOINT,
LLM_PRICING_RULE_ENDPOINT,
makeListResponse,
mockRules,
} from '../../__tests__/fixtures';
import ModelCostTabPanel from '../ModelCostTabPanel';
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),
},
}));
function setupList(items = mockRules, total = items.length): void {
server.use(
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeListResponse(items, total))),
),
);
}
// The list panel keeps page/search/source in the URL via nuqs, which reads
// window.location. jsdom shares that across tests in a file, so reset it.
function resetUrl(): void {
window.history.pushState(null, '', '/');
}
// The row kebab is a DropdownMenuSimple trigger; its testId isn't forwarded, so
// select it as the row's only button and open the Edit/Delete menu.
async function openRowMenu(
user: ReturnType<typeof userEvent.setup>,
ruleId: string,
): Promise<void> {
const row = screen.getByTestId(`model-cell-name-${ruleId}`).closest('tr');
await user.click(within(row as HTMLElement).getByRole('button'));
}
describe('ModelCostTabPanel (integration)', () => {
beforeEach(() => {
resetUrl();
});
afterEach(() => {
server.resetHandlers();
});
it('renders pricing rules returned by the list API', async () => {
setupList();
render(<ModelCostTabPanel />);
const openaiCell = await screen.findByTestId('model-cell-name-rule-openai');
expect(openaiCell).toHaveTextContent('gpt-4o');
expect(
screen.getByTestId('model-cell-name-rule-anthropic'),
).toHaveTextContent('claude-3-5-sonnet');
// Canonical id under the model name + provider column.
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0);
// Source badges reflect the override flag.
expect(screen.getByTestId('source-badge-rule-openai')).toHaveTextContent(
'User override',
);
expect(screen.getByTestId('source-badge-rule-anthropic')).toHaveTextContent(
'Auto',
);
});
it('shows the empty state when there are no rules', async () => {
setupList([], 0);
render(<ModelCostTabPanel />);
const empty = await screen.findByTestId('model-costs-empty');
expect(empty).toHaveTextContent('No model costs yet.');
});
it('shows an error message when the list request fails', async () => {
server.use(
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
);
render(<ModelCostTabPanel />);
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent(
'Failed to load pricing rules. Please try again.',
);
});
it('sends the debounced search term as the q param', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let lastParams: URLSearchParams | null = null;
server.use(
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
lastParams = req.url.searchParams;
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
}),
);
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await user.type(
screen.getByPlaceholderText('Search by model or provider'),
'claude',
);
await waitFor(() => expect(lastParams?.get('q')).toBe('claude'));
});
it('clears the search via the clear button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupList();
render(<ModelCostTabPanel />);
const input = screen.getByPlaceholderText(
'Search by model or provider',
) as HTMLInputElement;
await user.type(input, 'gpt');
expect(input.value).toBe('gpt');
await user.click(screen.getByTestId('model-cost-search-clear'));
await waitFor(() => expect(input.value).toBe(''));
});
it('sends isOverride=true when the source filter is set to User override', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let lastParams: URLSearchParams | null = null;
server.use(
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
lastParams = req.url.searchParams;
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
}),
);
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await user.click(screen.getByTestId('source-filter'));
// Scope to the listbox option — "User override" also appears as a row badge.
await user.click(
await screen.findByRole('option', { name: 'User override' }),
);
await waitFor(() => expect(lastParams?.get('isOverride')).toBe('true'));
});
it('opens the add drawer for a manager (ADMIN)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupList();
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await user.click(screen.getByTestId('add-model-cost-btn'));
const modelInput = await screen.findByTestId('drawer-model-id-input');
expect(modelInput).toBeInTheDocument();
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
});
it('hides the add button and row actions for a viewer', async () => {
setupList();
render(<ModelCostTabPanel />, undefined, { role: 'VIEWER' });
const row = (
await screen.findByTestId('model-cell-name-rule-openai')
).closest('tr') as HTMLElement;
expect(screen.queryByTestId('add-model-cost-btn')).not.toBeInTheDocument();
// View-only rows render no action menu (no buttons in the row).
expect(within(row).queryByRole('button')).not.toBeInTheDocument();
});
it('opens the edit drawer prefilled from the row action menu', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupList();
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await openRowMenu(user, 'rule-openai');
await user.click(await screen.findByText('Edit'));
const drawerTitle = await screen.findByText('Edit model cost');
expect(drawerTitle).toBeInTheDocument();
const modelInput = screen.getByTestId(
'drawer-model-id-input',
) as HTMLInputElement;
expect(modelInput.value).toBe('gpt-4o');
expect(modelInput).toBeDisabled();
});
it('deletes a rule through the confirm dialog', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let deletedId: string | null = null;
setupList();
server.use(
rest.delete(LLM_PRICING_RULE_ENDPOINT, (req, res, ctx) => {
deletedId = req.params.id as string;
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await openRowMenu(user, 'rule-openai');
await user.click(await screen.findByText('Delete'));
await user.click(await screen.findByTestId('drawer-delete-confirm-btn'));
await waitFor(() => expect(deletedId).toBe('rule-openai'));
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith('Model cost deleted'),
);
});
it('renders cache buckets for rules that have cache pricing', async () => {
setupList();
render(<ModelCostTabPanel />);
const anthropicRow = (
await screen.findByTestId('model-cell-name-rule-anthropic')
).closest('tr') as HTMLElement;
expect(within(anthropicRow).getByText(/Cache Read/i)).toBeInTheDocument();
expect(within(anthropicRow).getByText(/Cache Write/i)).toBeInTheDocument();
});
});

View File

@@ -1,87 +0,0 @@
import { render, screen, userEvent } from 'tests/test-utils';
import DeleteConfirmDialog from '../DeleteConfirmDialog';
describe('DeleteConfirmDialog', () => {
it('renders the model name in the confirmation copy', () => {
render(
<DeleteConfirmDialog
open
modelName="gpt-4o"
isDeleting={false}
onConfirm={jest.fn()}
onCancel={jest.fn()}
/>,
);
expect(screen.getByText('gpt-4o')).toBeInTheDocument();
});
it('calls onConfirm when the confirm button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onConfirm = jest.fn();
render(
<DeleteConfirmDialog
open
modelName="gpt-4o"
isDeleting={false}
onConfirm={onConfirm}
onCancel={jest.fn()}
/>,
);
await user.click(screen.getByTestId('drawer-delete-confirm-btn'));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('calls onCancel when the cancel button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onCancel = jest.fn();
render(
<DeleteConfirmDialog
open
modelName="gpt-4o"
isDeleting={false}
onConfirm={jest.fn()}
onCancel={onCancel}
/>,
);
await user.click(screen.getByTestId('drawer-delete-cancel-btn'));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('disables the confirm button while deleting', () => {
render(
<DeleteConfirmDialog
open
modelName="gpt-4o"
isDeleting
onConfirm={jest.fn()}
onCancel={jest.fn()}
/>,
);
expect(screen.getByTestId('drawer-delete-confirm-btn')).toBeDisabled();
});
it('calls onCancel when the dialog is dismissed via Escape', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onCancel = jest.fn();
render(
<DeleteConfirmDialog
open
modelName="gpt-4o"
isDeleting={false}
onConfirm={jest.fn()}
onCancel={onCancel}
/>,
);
await user.keyboard('{Escape}');
expect(onCancel).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,173 +0,0 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { makePricingRule } from '../../../../__tests__/fixtures';
import { EMPTY_DRAFT } from '../../../../constants';
import { draftFromRule } from '../../../../utils';
import ModelCostDrawer from '../ModelCostDrawer';
const editDraft = draftFromRule(
makePricingRule({
id: 'rule-openai',
modelName: 'gpt-4o',
provider: 'OpenAI',
}),
);
describe('ModelCostDrawer (integration)', () => {
it('renders the add title and a save button for a manager', () => {
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError={null}
canManage
/>,
);
expect(screen.getByText('Add model cost')).toBeInTheDocument();
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
});
it('disables save until the form is dirty', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError={null}
canManage
/>,
);
expect(screen.getByTestId('drawer-save-btn')).toBeDisabled();
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
await waitFor(() =>
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
);
});
it('shows the model id required error and does not call onSave when the name is empty', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSave = jest.fn();
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={onSave}
isSaving={false}
saveError={null}
canManage
/>,
);
// Make the form dirty without touching the model id: add a pattern, which
// mutates the `patterns` form field while leaving the name empty.
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt');
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
await waitFor(() =>
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
);
await user.click(screen.getByTestId('drawer-save-btn'));
const error = await screen.findByText('Billing model ID is required.');
expect(error).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it('calls onSave once on the happy path with valid model id and pricing', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSave = jest.fn();
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={onSave}
isSaving={false}
saveError={null}
canManage
/>,
);
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
await user.type(screen.getByTestId('drawer-input-cost'), '3');
await user.type(screen.getByTestId('drawer-output-cost'), '9');
await user.click(screen.getByTestId('drawer-save-btn'));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
});
it('renders the edit title with disabled, prefilled model id and disabled provider', () => {
render(
<ModelCostDrawer
isOpen
mode="edit"
initialDraft={editDraft}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError={null}
canManage
/>,
);
expect(screen.getByText('Edit model cost')).toBeInTheDocument();
const modelInput = screen.getByTestId(
'drawer-model-id-input',
) as HTMLInputElement;
expect(modelInput.value).toBe('gpt-4o');
expect(modelInput).toBeDisabled();
expect(screen.getByTestId('drawer-provider-select')).toBeDisabled();
});
it('renders a read-only view with a Close button and no save for a viewer', () => {
render(
<ModelCostDrawer
isOpen
mode="edit"
initialDraft={editDraft}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError={null}
canManage={false}
/>,
);
expect(screen.getByText('View model cost')).toBeInTheDocument();
expect(screen.queryByTestId('drawer-save-btn')).not.toBeInTheDocument();
expect(screen.getByTestId('drawer-cancel-btn')).toHaveTextContent('Close');
});
it('renders the save error text', () => {
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError="boom"
canManage
/>,
);
expect(screen.getByText('boom')).toBeInTheDocument();
});
});

View File

@@ -1,120 +0,0 @@
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
import type { DrawerDraft } from '../../../../../../types';
import ExtraPricingBuckets from '../ExtraPricingBuckets';
type Pricing = DrawerDraft['pricing'];
function makePricing(overrides: Partial<Pricing> = {}): Pricing {
return {
input: 3,
output: 9,
cacheMode: CacheModeDTO.unknown,
cacheRead: null,
cacheWrite: null,
...overrides,
};
}
describe('ExtraPricingBuckets', () => {
it('shows only the add button when no bucket has a value', () => {
render(
<ExtraPricingBuckets
pricing={makePricing()}
isReadOnly={false}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('drawer-add-bucket-btn')).toBeInTheDocument();
expect(
screen.queryByTestId('drawer-cache-read-cost'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('drawer-cache-mode')).not.toBeInTheDocument();
});
it('opens the picker and adds a cache_read row with the cache mode select', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ExtraPricingBuckets
pricing={makePricing()}
isReadOnly={false}
onChange={jest.fn()}
/>,
);
await user.click(screen.getByTestId('drawer-add-bucket-btn'));
expect(screen.getByTestId('drawer-bucket-picker')).toBeInTheDocument();
await user.click(screen.getByTestId('drawer-add-bucket-cache-read'));
expect(screen.getByTestId('drawer-cache-read-cost')).toBeInTheDocument();
expect(screen.getByTestId('drawer-cache-mode')).toBeInTheDocument();
});
it('calls onChange with the cache_read value typed into the row', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<ExtraPricingBuckets
pricing={makePricing()}
isReadOnly={false}
onChange={onChange}
/>,
);
await user.click(screen.getByTestId('drawer-add-bucket-btn'));
await user.click(screen.getByTestId('drawer-add-bucket-cache-read'));
fireEvent.change(screen.getByTestId('drawer-cache-read-cost'), {
target: { value: '2' },
});
expect(onChange).toHaveBeenCalledWith({ cacheRead: 2 });
});
it('calls onChange with cacheRead null when the row is removed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<ExtraPricingBuckets
pricing={makePricing({ cacheRead: 2 })}
isReadOnly={false}
onChange={onChange}
/>,
);
await user.click(screen.getByTestId('drawer-remove-cache-read'));
expect(onChange).toHaveBeenCalledWith({ cacheRead: null });
});
it('renders the cache_read row on mount when pricing already has a value', () => {
render(
<ExtraPricingBuckets
pricing={makePricing({ cacheRead: 2 })}
isReadOnly={false}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('drawer-cache-read-cost')).toBeInTheDocument();
expect(screen.getByTestId('drawer-cache-mode')).toBeInTheDocument();
});
it('hides the add and remove buttons when read-only', () => {
render(
<ExtraPricingBuckets
pricing={makePricing({ cacheRead: 2 })}
isReadOnly
onChange={jest.fn()}
/>,
);
expect(screen.queryByTestId('drawer-add-bucket-btn')).not.toBeInTheDocument();
expect(
screen.queryByTestId('drawer-remove-cache-read'),
).not.toBeInTheDocument();
});
});

View File

@@ -1,99 +0,0 @@
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
import PatternEditor from '../PatternEditor';
describe('PatternEditor', () => {
it('adds a typed pattern via the Add button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<PatternEditor
patterns={['gpt-4o']}
isReadOnly={false}
onChange={onChange}
/>,
);
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt-5');
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
expect(onChange).toHaveBeenCalledWith(['gpt-4o', 'gpt-5']);
});
it('adds a pattern when Enter is pressed', () => {
const onChange = jest.fn();
render(
<PatternEditor patterns={[]} isReadOnly={false} onChange={onChange} />,
);
const input = screen.getByTestId('drawer-pattern-input');
fireEvent.change(input, { target: { value: 'claude' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith(['claude']);
});
it('does not call onChange for a duplicate and clears the input', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<PatternEditor
patterns={['gpt-4o']}
isReadOnly={false}
onChange={onChange}
/>,
);
const input = screen.getByTestId('drawer-pattern-input') as HTMLInputElement;
await user.type(input, 'gpt-4o');
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
expect(onChange).not.toHaveBeenCalled();
expect(input.value).toBe('');
});
it('trims surrounding whitespace before adding', () => {
const onChange = jest.fn();
render(
<PatternEditor patterns={[]} isReadOnly={false} onChange={onChange} />,
);
const input = screen.getByTestId('drawer-pattern-input');
fireEvent.change(input, { target: { value: ' gemini ' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith(['gemini']);
});
it('removes a pattern when its chip remove button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<PatternEditor
patterns={['gpt-4o', 'gpt-5']}
isReadOnly={false}
onChange={onChange}
/>,
);
await user.click(
screen.getByRole('button', { name: 'Remove pattern gpt-4o' }),
);
expect(onChange).toHaveBeenCalledWith(['gpt-5']);
});
it('renders chips without remove buttons and no input when read-only', () => {
render(
<PatternEditor patterns={['gpt-4o']} isReadOnly onChange={jest.fn()} />,
);
expect(screen.queryByTestId('drawer-pattern-input')).not.toBeInTheDocument();
expect(
screen.queryByTestId('drawer-pattern-add-btn'),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Remove pattern gpt-4o' }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,85 +0,0 @@
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import { fireEvent, render, screen } from 'tests/test-utils';
import type { DrawerDraft } from '../../../../../../types';
import PricingFields from '../PricingFields';
type Pricing = DrawerDraft['pricing'];
function makePricing(overrides: Partial<Pricing> = {}): Pricing {
return {
input: null,
output: null,
cacheMode: CacheModeDTO.unknown,
cacheRead: null,
cacheWrite: null,
...overrides,
};
}
describe('PricingFields', () => {
it('calls onChange with the parsed input cost', () => {
const onChange = jest.fn();
render(
<PricingFields
pricing={makePricing()}
isReadOnly={false}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('drawer-input-cost'), {
target: { value: '5' },
});
expect(onChange).toHaveBeenCalledWith({ input: 5 });
});
it('calls onChange with the parsed output cost', () => {
const onChange = jest.fn();
render(
<PricingFields
pricing={makePricing()}
isReadOnly={false}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('drawer-output-cost'), {
target: { value: '12' },
});
expect(onChange).toHaveBeenCalledWith({ output: 12 });
});
it('calls onChange with null when the input is cleared', () => {
const onChange = jest.fn();
render(
<PricingFields
pricing={makePricing({ input: 5 })}
isReadOnly={false}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('drawer-input-cost'), {
target: { value: '' },
});
expect(onChange).toHaveBeenCalledWith({ input: null });
});
it('disables the inputs and shows the read-only label when read-only', () => {
render(
<PricingFields
pricing={makePricing({ input: 3, output: 9 })}
isReadOnly
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled();
expect(screen.getByTestId('drawer-output-cost')).toBeDisabled();
expect(screen.getByTestId('drawer-readonly-label')).toBeInTheDocument();
});
});

View File

@@ -1,76 +0,0 @@
import { render, screen, userEvent } from 'tests/test-utils';
import SourceSelector from '../SourceSelector';
describe('SourceSelector', () => {
it('calls onChange(true) when picking override while currently auto', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<SourceSelector isOverride={false} isReadOnly={false} onChange={onChange} />,
);
await user.click(screen.getByTestId('drawer-source-override'));
expect(onChange).toHaveBeenCalledWith(true);
});
it('shows the reset confirm UI without calling onChange when switching to auto from override', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(<SourceSelector isOverride isReadOnly={false} onChange={onChange} />);
await user.click(screen.getByTestId('drawer-source-auto'));
expect(screen.getByTestId('drawer-reset-keep-btn')).toBeInTheDocument();
expect(screen.getByTestId('drawer-reset-confirm-btn')).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it('hides the confirm UI and does not call onChange when Keep is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(<SourceSelector isOverride isReadOnly={false} onChange={onChange} />);
await user.click(screen.getByTestId('drawer-source-auto'));
await user.click(screen.getByTestId('drawer-reset-keep-btn'));
expect(
screen.queryByTestId('drawer-reset-confirm-btn'),
).not.toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it('calls onChange(false) when Reset is confirmed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(<SourceSelector isOverride isReadOnly={false} onChange={onChange} />);
await user.click(screen.getByTestId('drawer-source-auto'));
await user.click(screen.getByTestId('drawer-reset-confirm-btn'));
expect(onChange).toHaveBeenCalledWith(false);
expect(
screen.queryByTestId('drawer-reset-confirm-btn'),
).not.toBeInTheDocument();
});
it('shows the managed label when read-only', () => {
render(<SourceSelector isOverride isReadOnly onChange={jest.fn()} />);
expect(screen.getByTestId('drawer-managed-label')).toBeInTheDocument();
});
it('disables the auto radio when disableAuto is set', () => {
render(
<SourceSelector
isOverride
isReadOnly={false}
disableAuto
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('drawer-source-auto')).toBeDisabled();
});
});

View File

@@ -1,152 +0,0 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { rest, server } from 'mocks-server/server';
import { EMPTY_DRAFT } from '../../../../../constants';
import {
LLM_PRICING_ENDPOINT,
makePricingRule,
} from '../../../../../__tests__/fixtures';
import { draftFromRule } from '../../../../../utils';
import { useModelCostDrawer } from '../useModelCostDrawer';
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),
},
}));
function createWrapper(): ({
children,
}: {
children: React.ReactNode;
}) => React.ReactElement {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
function renderUseModelCostDrawer(): ReturnType<
typeof renderHook<ReturnType<typeof useModelCostDrawer>, unknown>
> {
return renderHook(() => useModelCostDrawer(), { wrapper: createWrapper() });
}
describe('useModelCostDrawer', () => {
afterEach(() => {
server.resetHandlers();
});
it('starts closed in add mode with no selected rule', () => {
const { result } = renderUseModelCostDrawer();
expect(result.current.isOpen).toBe(false);
expect(result.current.mode).toBe('add');
expect(result.current.selectedRuleId).toBeNull();
});
it('openForAdd opens the drawer in add mode with the empty draft', () => {
const { result } = renderUseModelCostDrawer();
act(() => {
result.current.openForAdd();
});
expect(result.current.isOpen).toBe(true);
expect(result.current.mode).toBe('add');
expect(result.current.selectedRuleId).toBeNull();
expect(result.current.initialDraft).toStrictEqual({
...EMPTY_DRAFT,
modelName: '',
patterns: [],
});
});
it('openForEdit opens the drawer in edit mode prefilled from the rule', () => {
const rule = makePricingRule({ id: 'rule-edit', modelName: 'gpt-4o' });
const { result } = renderUseModelCostDrawer();
act(() => {
result.current.openForEdit(rule);
});
expect(result.current.isOpen).toBe(true);
expect(result.current.mode).toBe('edit');
expect(result.current.selectedRuleId).toBe('rule-edit');
expect(result.current.initialDraft).toStrictEqual(draftFromRule(rule));
});
it('close resets the open state and selection', () => {
const rule = makePricingRule({ id: 'rule-edit' });
const { result } = renderUseModelCostDrawer();
act(() => {
result.current.openForEdit(rule);
});
act(() => {
result.current.close();
});
expect(result.current.isOpen).toBe(false);
expect(result.current.selectedRuleId).toBeNull();
expect(result.current.saveError).toBeNull();
});
it('save success closes the drawer and shows a success toast', async () => {
server.use(
rest.put(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
);
const { result } = renderUseModelCostDrawer();
act(() => {
result.current.openForAdd();
});
const draft = { ...EMPTY_DRAFT, modelName: 'gpt-4o' };
await act(async () => {
await result.current.save(draft);
});
await waitFor(() => expect(result.current.isOpen).toBe(false));
expect(toastSuccess).toHaveBeenCalledWith('Model cost added');
expect(result.current.saveError).toBeNull();
});
it('save failure sets saveError and keeps the drawer open', async () => {
server.use(
rest.put(LLM_PRICING_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
);
const { result } = renderUseModelCostDrawer();
act(() => {
result.current.openForAdd();
});
const draft = { ...EMPTY_DRAFT, modelName: 'gpt-4o' };
await act(async () => {
await result.current.save(draft);
});
await waitFor(() => expect(result.current.saveError).not.toBeNull());
expect(result.current.isOpen).toBe(true);
expect(toastSuccess).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,7 @@
.modelCostsTable {
margin-top: var(--spacing-8);
--tanstack-table-row-height: 48px;
height: calc(100vh - 170px);
height: calc(100vh - 250px);
overflow-y: auto;
:global(table) tbody tr {

View File

@@ -1,73 +0,0 @@
import { render, screen, userEvent } from 'tests/test-utils';
import { makePricingRule } from '../../../../__tests__/fixtures';
import ModelCostActionsMenu from '../ModelCostActionsMenu';
const rule = makePricingRule({ id: 'rule-openai', modelName: 'gpt-4o' });
describe('ModelCostActionsMenu', () => {
it('renders nothing when the user cannot manage', () => {
const { container } = render(
<ModelCostActionsMenu
rule={rule}
canManage={false}
onEdit={jest.fn()}
onDelete={jest.fn()}
/>,
);
expect(container.firstChild).toBeNull();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('renders a trigger button when the user can manage', () => {
render(
<ModelCostActionsMenu
rule={rule}
canManage
onEdit={jest.fn()}
onDelete={jest.fn()}
/>,
);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('calls onEdit with the rule when clicking Edit', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onEdit = jest.fn();
render(
<ModelCostActionsMenu
rule={rule}
canManage
onEdit={onEdit}
onDelete={jest.fn()}
/>,
);
await user.click(screen.getByRole('button'));
await user.click(await screen.findByText('Edit'));
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onEdit).toHaveBeenCalledWith(rule);
});
it('calls onDelete with the rule when clicking Delete', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onDelete = jest.fn();
render(
<ModelCostActionsMenu
rule={rule}
canManage
onEdit={jest.fn()}
onDelete={onDelete}
/>,
);
await user.click(screen.getByRole('button'));
await user.click(await screen.findByText('Delete'));
expect(onDelete).toHaveBeenCalledTimes(1);
expect(onDelete).toHaveBeenCalledWith(rule);
});
});

View File

@@ -1,115 +0,0 @@
import { render, screen, within } from 'tests/test-utils';
import { mockRules } from '../../../../__tests__/fixtures';
import ModelCostsTable from '../ModelCostsTable';
const noop = (): void => {};
// The table owns page/limit URL state via nuqs, which reads window.location.
// jsdom shares that across tests in a file, so reset it before each.
function resetUrl(): void {
window.history.pushState(null, '', '/');
}
function getRow(ruleId: string): HTMLElement {
return screen
.getByTestId(`model-cell-name-${ruleId}`)
.closest('tr') as HTMLElement;
}
describe('ModelCostsTable', () => {
beforeEach(() => {
resetUrl();
});
it('renders the empty state when not loading and there are no rules', () => {
render(
<ModelCostsTable
rules={[]}
isLoading={false}
total={0}
selectedRuleId={null}
canManage
onEdit={noop}
onDelete={noop}
/>,
);
const empty = screen.getByTestId('model-costs-empty');
expect(empty).toHaveTextContent('No model costs yet.');
});
it('does not show the empty state while loading even with no rules', () => {
render(
<ModelCostsTable
rules={[]}
isLoading
total={0}
selectedRuleId={null}
canManage
onEdit={noop}
onDelete={noop}
/>,
);
expect(screen.queryByTestId('model-costs-empty')).not.toBeInTheDocument();
expect(screen.getByTestId('model-costs-table')).toBeInTheDocument();
});
it('renders rows from the rules with formatted prices, provider, canonical id and source badges', () => {
render(
<ModelCostsTable
rules={mockRules}
isLoading={false}
total={mockRules.length}
selectedRuleId={null}
canManage
onEdit={noop}
onDelete={noop}
/>,
);
// Model names.
expect(screen.getByTestId('model-cell-name-rule-openai')).toHaveTextContent(
'gpt-4o',
);
expect(
screen.getByTestId('model-cell-name-rule-anthropic'),
).toHaveTextContent('claude-3-5-sonnet');
// Canonical id under the model name.
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
// Provider column.
expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0);
// Formatted input price ($3.00) for the openai row.
const openaiRow = getRow('rule-openai');
expect(within(openaiRow).getByText('$3.00')).toBeInTheDocument();
// Source label badges reflect the override flag.
expect(screen.getByTestId('source-badge-rule-openai')).toHaveTextContent(
'User override',
);
expect(screen.getByTestId('source-badge-rule-anthropic')).toHaveTextContent(
'Auto',
);
});
it('renders no row action button when the user cannot manage', () => {
render(
<ModelCostsTable
rules={mockRules}
isLoading={false}
total={mockRules.length}
selectedRuleId={null}
canManage={false}
onEdit={noop}
onDelete={noop}
/>,
);
const openaiRow = getRow('rule-openai');
expect(within(openaiRow).queryByRole('button')).not.toBeInTheDocument();
});
});

View File

@@ -1,123 +0,0 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { rest, server } from 'mocks-server/server';
import { LLM_PRICING_RULE_ENDPOINT } from '../../../__tests__/fixtures';
import { useModelCostDelete } from '../useModelCostDelete';
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),
},
}));
function createWrapper(): ({
children,
}: {
children: React.ReactNode;
}) => React.ReactElement {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
function renderUseModelCostDelete(): ReturnType<
typeof renderHook<ReturnType<typeof useModelCostDelete>, unknown>
> {
return renderHook(() => useModelCostDelete(), { wrapper: createWrapper() });
}
const PENDING = { id: 'rule-openai', modelName: 'gpt-4o' };
describe('useModelCostDelete', () => {
afterEach(() => {
server.resetHandlers();
});
it('starts with no pending delete', () => {
const { result } = renderUseModelCostDelete();
expect(result.current.pendingDelete).toBeNull();
});
it('requestDelete queues the rule for deletion', () => {
const { result } = renderUseModelCostDelete();
act(() => {
result.current.requestDelete(PENDING);
});
expect(result.current.pendingDelete).toStrictEqual(PENDING);
});
it('cancelDelete clears the pending delete', () => {
const { result } = renderUseModelCostDelete();
act(() => {
result.current.requestDelete(PENDING);
});
act(() => {
result.current.cancelDelete();
});
expect(result.current.pendingDelete).toBeNull();
});
it('confirmDelete success fires the DELETE, clears state and toasts success', async () => {
let deletedId: string | null = null;
server.use(
rest.delete(LLM_PRICING_RULE_ENDPOINT, (req, res, ctx) => {
deletedId = req.params.id as string;
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const { result } = renderUseModelCostDelete();
act(() => {
result.current.requestDelete(PENDING);
});
await act(async () => {
await result.current.confirmDelete();
});
await waitFor(() => expect(deletedId).toBe('rule-openai'));
await waitFor(() => expect(result.current.pendingDelete).toBeNull());
expect(toastSuccess).toHaveBeenCalledWith('Model cost deleted');
});
it('confirmDelete failure keeps the pending delete and toasts an error', async () => {
server.use(
rest.delete(LLM_PRICING_RULE_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(500)),
),
);
const { result } = renderUseModelCostDelete();
act(() => {
result.current.requestDelete(PENDING);
});
await act(async () => {
await result.current.confirmDelete();
});
await waitFor(() => expect(toastError).toHaveBeenCalled());
expect(result.current.pendingDelete).toStrictEqual(PENDING);
expect(toastSuccess).not.toHaveBeenCalled();
});
});

View File

@@ -1,34 +0,0 @@
.unpricedModelsTab {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.toolbar {
display: flex;
align-items: flex-start;
gap: var(--spacing-6);
}
.banner {
display: flex;
flex: 1;
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,130 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { ArrowRight, 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 type { PricingRule, UnpricedModel } from '../types';
import type { UnpricedColumnsConfig } from './components/UnpricedModelsTable/TableConfig';
import UnpricedMappingConfirmDrawer from './components/UnpricedMappingConfirmDrawer';
import UnpricedModelsTable from './components/UnpricedModelsTable';
import {
useUnpricedModelMapping,
type UnpricedModelMapping,
} from './hooks/useUnpricedModelMapping';
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]);
const [selections, setSelections] = useState<Record<string, PricingRule>>({});
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const { mapModels, isSaving } = useUnpricedModelMapping();
const onSelectRule = useCallback(
(modelName: string, rule: PricingRule): void => {
setSelections((prev) => ({ ...prev, [modelName]: rule }));
},
[],
);
// Drop a row's pick so it's no longer committed (and the Save count updates).
const onClearRule = useCallback((modelName: string): void => {
setSelections((prev) => {
if (!(modelName in prev)) {
return prev;
}
const next = { ...prev };
delete next[modelName];
return next;
});
}, []);
const mappings: UnpricedModelMapping[] = useMemo(
() =>
models
.filter((model) => selections[model.modelName])
.map((model) => ({ model, rule: selections[model.modelName] })),
[models, selections],
);
const onConfirm = useCallback(async (): Promise<void> => {
const didSave = await mapModels(mappings);
if (didSave) {
setSelections({});
setIsConfirmOpen(false);
}
}, [mapModels, mappings]);
const columnsConfig: UnpricedColumnsConfig = {
canManage: canManagePricing,
selections,
onSelectRule,
onClearRule,
};
const selectedCount = mappings.length;
return (
<div className={styles.unpricedModelsTab}>
<div className={styles.toolbar}>
<div className={styles.banner}>
<TriangleAlert size="sm" className={styles.bannerIcon} />
<Typography.Text as="span" size="small" color="warning">
Models detected in traces without pricing. Add costs so estimated cost can
be computed.
</Typography.Text>
</div>
{canManagePricing && (
<Button
variant="solid"
color="primary"
suffix={<ArrowRight size={14} />}
disabled={selectedCount === 0}
onClick={(): void => setIsConfirmOpen(true)}
testId="unpriced-save-btn"
>
{selectedCount > 0
? `Save ${selectedCount} model${selectedCount === 1 ? '' : 's'}`
: 'Save models'}
</Button>
)}
</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}
/>
<UnpricedMappingConfirmDrawer
open={isConfirmOpen}
mappings={mappings}
isSaving={isSaving}
onConfirm={onConfirm}
onCancel={(): void => setIsConfirmOpen(false)}
/>
</div>
);
}
export default UnpricedModelsTab;

View File

@@ -1,127 +0,0 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import type { LlmpricingruletypesUpdatableLLMPricingRulesDTO } from 'api/generated/services/sigNoz.schemas';
import UnpricedModelsTab from '../UnpricedModelsTab';
import {
LLM_PRICING_ENDPOINT,
LLM_UNMAPPED_ENDPOINT,
makeListResponse,
makeUnmappedResponse,
mockRules,
} from '../../__tests__/fixtures';
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('keeps the Save button disabled until a billing model is selected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
const saveBtn = screen.getByTestId('unpriced-save-btn');
expect(saveBtn).toBeDisabled();
await selectRule(user, MODEL, 'rule-openai');
await waitFor(() => expect(saveBtn).not.toBeDisabled());
expect(saveBtn).toHaveTextContent('Save 1 model');
});
it('clears a selection via the row clear button, re-disabling Save', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
const saveBtn = screen.getByTestId('unpriced-save-btn');
// The clear button only shows once a rule is picked.
expect(screen.queryByTestId(`map-to-clear-${MODEL}`)).not.toBeInTheDocument();
await selectRule(user, MODEL, 'rule-openai');
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(await screen.findByTestId(`map-to-clear-${MODEL}`));
await waitFor(() => expect(saveBtn).toBeDisabled());
expect(saveBtn).toHaveTextContent('Save models');
expect(screen.queryByTestId(`map-to-clear-${MODEL}`)).not.toBeInTheDocument();
});
it('opens a confirm dialog and commits the mapping in one request', 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(screen.getByTestId('unpriced-save-btn'));
// Confirm dialog lists the pending mapping.
const confirmItem = await screen.findByTestId(
`unpriced-map-confirm-item-${MODEL}`,
);
expect(confirmItem).toBeInTheDocument();
await user.click(screen.getByTestId('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 1 model'),
);
});
});

View File

@@ -1,15 +0,0 @@
.mapToCell {
display: flex;
align-items: center;
gap: var(--spacing-1);
min-width: 0;
}
.mapToSelect {
width: 100%;
max-width: 280px;
}
.mapToDropdown {
width: 280px;
}

View File

@@ -1,110 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import {
Combobox,
ComboboxCommand,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxLoading,
ComboboxTrigger,
} from '@signozhq/ui/combobox';
import { X } from '@signozhq/icons';
import styles from './MapToBillingModelSelect.module.scss';
import type { PricingRule } from '../../../types';
import { useMapToBillingModelSearch } from './useMapToBillingModelSearch';
import { getRuleOptionLabel } from '../../../utils';
interface MapToBillingModelSelectProps {
modelName: string;
// The rule currently chosen for this row, used to render the trigger label.
selectedRule: PricingRule | undefined;
disabled: boolean;
onSelect: (rule: PricingRule) => void;
// Drops this row's pick so it's no longer mapped/committed.
onClear: () => 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}). Once a rule is picked, a clear
// button next to the trigger lets the row drop its selection.
function MapToBillingModelSelect({
modelName,
selectedRule,
disabled,
onSelect,
onClear,
}: MapToBillingModelSelectProps): JSX.Element {
const [open, setOpen] = useState(false);
const { searchText, setSearchText, rules, rulesById, isFetching } =
useMapToBillingModelSearch(open);
const handleSelect = (ruleId: string): void => {
const rule = rulesById.get(ruleId);
if (rule) {
onSelect(rule);
}
setOpen(false);
};
return (
<div className={styles.mapToCell}>
<Combobox open={open} onOpenChange={setOpen}>
<ComboboxTrigger
className={styles.mapToSelect}
disabled={disabled}
placeholder="Select a billing model"
value={selectedRule ? getRuleOptionLabel(selectedRule) : undefined}
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}
isSelected={selectedRule?.id === rule.id}
onSelect={(): void => handleSelect(rule.id)}
data-testid={`map-to-option-${rule.id}`}
>
{getRuleOptionLabel(rule)}
</ComboboxItem>
))}
{isFetching && <ComboboxLoading>Loading</ComboboxLoading>}
{!isFetching && rules.length === 0 && (
<ComboboxEmpty>No billing models found</ComboboxEmpty>
)}
</ComboboxList>
</ComboboxCommand>
</ComboboxContent>
</Combobox>
{selectedRule && !disabled && (
<Button
variant="ghost"
color="secondary"
size="icon"
prefix={<X size={14} />}
onClick={onClear}
title="Clear selection"
aria-label="Clear selection"
testId={`map-to-clear-${modelName}`}
/>
)}
</div>
);
}
export default MapToBillingModelSelect;

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