mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-07 07:00:38 +01:00
Compare commits
2 Commits
feat/llm-a
...
fix/query-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69812ed33a | ||
|
|
9083f7f954 |
@@ -62,6 +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_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
|
||||
@@ -87,6 +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_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.pageError {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import MapperGroupsTable from './components/MapperGroupsTable';
|
||||
import { useAttributeMappingStore } from './hooks/useAttributeMappingStore';
|
||||
import styles from './AttributeMappingsTab.module.scss';
|
||||
|
||||
// "Attribute mappings" tab: the mapping-groups listing, its load/error states
|
||||
// and footer summary. Lives in its own tab so siblings (e.g. "Test") can be
|
||||
// added alongside without entangling this view's data fetching.
|
||||
function AttributeMappingsTab(): JSX.Element {
|
||||
const store = useAttributeMappingStore();
|
||||
|
||||
return (
|
||||
<div data-testid="attribute-mappings-tab">
|
||||
{store.isError && (
|
||||
<div className={styles.pageError} role="alert">
|
||||
Failed to load mapping groups. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MapperGroupsTable store={store} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeMappingsTab;
|
||||
@@ -1,75 +0,0 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
GROUPS_ENDPOINT,
|
||||
makeGroupsResponse,
|
||||
makeMappersResponse,
|
||||
mappersEndpoint,
|
||||
mockGroups,
|
||||
mockMappers,
|
||||
} from '../../__tests__/fixtures';
|
||||
import AttributeMappingsTab from '../AttributeMappingsTab';
|
||||
|
||||
function setupGroups(): void {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('AttributeMappingsTab (integration)', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders no error banner on a successful load', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error banner when the groups request fails', async () => {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await expect(screen.findByRole('alert')).resolves.toHaveTextContent(
|
||||
'Failed to load mapping groups. Please try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it("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(<AttributeMappingsTab />);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
.indexBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 var(--spacing-3);
|
||||
border-radius: 999px;
|
||||
background: var(--accent-primary);
|
||||
color: var(--accent-primary-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import styles from './IndexBadge.module.scss';
|
||||
|
||||
interface IndexBadgeProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
// Small positional badge mirroring the Pipelines list ordering chip.
|
||||
function IndexBadge({ index }: IndexBadgeProps): JSX.Element {
|
||||
return <span className={styles.indexBadge}>{index + 1}</span>;
|
||||
}
|
||||
|
||||
export default IndexBadge;
|
||||
@@ -1,17 +0,0 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import IndexBadge from '../IndexBadge';
|
||||
|
||||
describe('IndexBadge', () => {
|
||||
it('renders a 1-based label for a 0-based index', () => {
|
||||
render(<IndexBadge index={0} />);
|
||||
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the correct label for a later index', () => {
|
||||
render(<IndexBadge index={4} />);
|
||||
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './IndexBadge';
|
||||
@@ -1,16 +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);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
function MapperGroupsTable({ store }: MapperGroupsTableProps): JSX.Element {
|
||||
const columns = useMemo(() => getMapperGroupsColumns(), []);
|
||||
|
||||
if (!store.isLoading && store.groups.length === 0) {
|
||||
return (
|
||||
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
|
||||
No mapping groups yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TanStackTable<DraftGroup>
|
||||
className={styles.groupsTableWrapper}
|
||||
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} />}
|
||||
disableVirtualScroll
|
||||
testId="mapper-groups-table"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperGroupsTable;
|
||||
@@ -1 +0,0 @@
|
||||
export { getMapperGroupsColumns } from './mapperGroups.config';
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
|
||||
import type { DraftGroup } from '../../../../types';
|
||||
import { conditionFiltersFromGroup } from '../../../../utils';
|
||||
import styles from './tableConfig.module.scss';
|
||||
|
||||
// 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(): 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: 'status',
|
||||
header: 'Status',
|
||||
width: { min: 120, ignoreLastColumnFill: true },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Badge color={row.enabled ? 'forest' : 'vanilla'} variant="outline">
|
||||
{row.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,43 +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);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { render, screen, userEvent } 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: [], isLoading: false, isError: false, ...overrides };
|
||||
}
|
||||
|
||||
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', () => {
|
||||
render(<MapperGroupsTable store={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', () => {
|
||||
render(
|
||||
<MapperGroupsTable store={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'],
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
render(<MapperGroupsTable store={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.getByText('Enabled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a disabled badge for a disabled group', () => {
|
||||
const group = buildDraftGroup(
|
||||
makeGroup({ id: 'group-2', enabled: false, condition: null }),
|
||||
[],
|
||||
);
|
||||
render(<MapperGroupsTable store={storeWith({ groups: [group] })} />);
|
||||
|
||||
expect(screen.getByText('Disabled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the no-condition placeholder when a group has no attribute/resource keys', () => {
|
||||
const group = buildDraftGroup(
|
||||
makeGroup({ id: 'group-3', condition: null }),
|
||||
[],
|
||||
);
|
||||
render(<MapperGroupsTable store={storeWith({ groups: [group] })} />);
|
||||
|
||||
expect(screen.getByTestId('group-filters-group-3')).toHaveTextContent(
|
||||
'No condition · always runs',
|
||||
);
|
||||
});
|
||||
|
||||
it('toggles the expand button label when a row is expanded and collapsed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const group = buildDraftGroup(makeGroup({ id: 'group-1' }), []);
|
||||
render(<MapperGroupsTable store={storeWith({ groups: [group] })} />);
|
||||
|
||||
const expandButton = screen.getByTestId('group-expand-group-1');
|
||||
expect(expandButton).toHaveAccessibleName('Expand group');
|
||||
|
||||
await user.click(expandButton);
|
||||
expect(expandButton).toHaveAccessibleName('Collapse group');
|
||||
|
||||
await user.click(expandButton);
|
||||
expect(expandButton).toHaveAccessibleName('Expand group');
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './MapperGroupsTable';
|
||||
@@ -1,22 +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);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { SpantypesSpanMapperDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useListSpanMappers } from 'api/generated/services/spanmapper';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { motion, useReducedMotion } from 'motion/react';
|
||||
|
||||
import { DraftGroup, DraftMapper } from '../../../types';
|
||||
import { buildDraftMapper } from '../../../utils';
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 and are then
|
||||
// cached by react-query. New (unsaved) groups have no serverId, so skip.
|
||||
function MappersTable({ group }: MappersTableProps): JSX.Element {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const { data, isLoading, isError } = useListSpanMappers(
|
||||
{ groupId: group.serverId ?? '' },
|
||||
{ query: { enabled: group.serverId !== null } },
|
||||
);
|
||||
|
||||
const mappers = useMemo<DraftMapper[]>(() => {
|
||||
const items = (data?.data?.items ??
|
||||
[]) as unknown as SpantypesSpanMapperDTO[];
|
||||
return items.map(buildDraftMapper);
|
||||
}, [data]);
|
||||
|
||||
const columns = useMemo(() => getMappersColumns(), []);
|
||||
|
||||
let content: JSX.Element;
|
||||
if (!isLoading && isError) {
|
||||
content = (
|
||||
<div
|
||||
className={styles.tableEmpty}
|
||||
data-testid={`mappers-error-${group.localId}`}
|
||||
>
|
||||
Failed to load mappings. Please try again.
|
||||
</div>
|
||||
);
|
||||
} else if (!isLoading && 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={mappers}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
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}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MappersTable;
|
||||
@@ -1 +0,0 @@
|
||||
export { getMappersColumns } from './mappers.config';
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { SpantypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { DraftMapper } from '../../../../types';
|
||||
import styles from './tableConfig.module.scss';
|
||||
|
||||
const MAX_VISIBLE_SOURCES = 3;
|
||||
|
||||
// Column definitions for the per-group mappers TanStackTable (rendered inside an
|
||||
// expanded group row). Sorting is off — priority order is positional (top wins)
|
||||
// and surfaced by the leading index column.
|
||||
export function getMappersColumns(): 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: 'status',
|
||||
header: 'Status',
|
||||
// Opt the trailing column out of the "last column fills 100%" rule so the
|
||||
// spare width flows into Target / Sources instead of leaving a large empty
|
||||
// Status column on the right.
|
||||
width: { min: 120, ignoreLastColumnFill: true },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Badge color={row.enabled ? 'forest' : 'vanilla'} variant="outline">
|
||||
{row.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,22 +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);
|
||||
}
|
||||
@@ -1,119 +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 {
|
||||
makeGroup,
|
||||
makeMapper,
|
||||
makeMappersResponse,
|
||||
mappersEndpoint,
|
||||
} from '../../../../__tests__/fixtures';
|
||||
import { buildDraftGroup } from '../../../../utils';
|
||||
import MappersTable from '../MappersTable';
|
||||
|
||||
const GROUP = buildDraftGroup(makeGroup({ id: 'group-1' }), []);
|
||||
|
||||
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={GROUP} />);
|
||||
|
||||
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={GROUP} />);
|
||||
|
||||
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 = { ...GROUP, serverId: null };
|
||||
|
||||
render(<MappersTable group={draftGroup} />);
|
||||
|
||||
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={GROUP} />);
|
||||
|
||||
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.getByText('Enabled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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={GROUP} />);
|
||||
|
||||
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={GROUP} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('mapper-sources-mapper-1')).toHaveTextContent('—'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './MappersTable';
|
||||
@@ -1,89 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { 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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { SpantypesSpanMapperGroupDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useListSpanMapperGroups } from 'api/generated/services/spanmapper';
|
||||
|
||||
import { DraftGroup } from '../../types';
|
||||
import { buildDraftGroup } from '../../utils';
|
||||
|
||||
export interface AttributeMappingStore {
|
||||
groups: DraftGroup[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
// Read-only store for the listing view: loads the server groups only. Each
|
||||
// group's mappers are fetched lazily when its row is expanded (see
|
||||
// MappersTable), so page load is a single request instead of an N+1 fan-out
|
||||
// across every group. Editing (draft mutations, save/discard) is layered on in
|
||||
// a later PR.
|
||||
export function useAttributeMappingStore(): AttributeMappingStore {
|
||||
const groupsQuery = useListSpanMapperGroups();
|
||||
|
||||
const groups = useMemo<DraftGroup[]>(() => {
|
||||
const serverGroups: SpantypesSpanMapperGroupDTO[] =
|
||||
groupsQuery.data?.data?.items ?? [];
|
||||
// Mappers load lazily per group, so seed the tree with empty mappers.
|
||||
return serverGroups.map((group) => buildDraftGroup(group, []));
|
||||
}, [groupsQuery.data]);
|
||||
|
||||
return {
|
||||
groups,
|
||||
isLoading: groupsQuery.isLoading,
|
||||
isError: groupsQuery.isError,
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './AttributeMappingsTab';
|
||||
@@ -1,6 +0,0 @@
|
||||
.llmObservabilityAttributeMapping {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
|
||||
import AttributeMappingHeader from './components/AttributeMappingHeader';
|
||||
import AttributeMappingsTab from './AttributeMappingsTab';
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
|
||||
const noop = (): void => undefined;
|
||||
|
||||
function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'attribute-mappings',
|
||||
label: 'Attribute mappings',
|
||||
children: <AttributeMappingsTab />,
|
||||
},
|
||||
{
|
||||
key: 'test',
|
||||
label: 'Test',
|
||||
disabled: true,
|
||||
disabledReason: 'Coming soon',
|
||||
children: null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.llmObservabilityAttributeMapping}
|
||||
data-testid="llm-observability-attribute-mapping-page"
|
||||
>
|
||||
<AttributeMappingHeader
|
||||
isDirty={false}
|
||||
isSaving={false}
|
||||
onDiscard={noop}
|
||||
onSave={noop}
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
testId="attribute-mapping-tabs"
|
||||
defaultValue="attribute-mappings"
|
||||
items={tabItems}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LLMObservabilityAttributeMapping;
|
||||
@@ -1,67 +0,0 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
|
||||
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
|
||||
|
||||
function setupGroups(): void {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('LLMObservabilityAttributeMapping', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
setupGroups();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the page shell', () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('llm-observability-attribute-mapping-page'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the attribute-mappings and test sub-tab labels', () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Attribute mappings' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Test' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('activates the attribute-mappings tab by default and renders its content', async () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
const attributeMappingsTab = screen.getByRole('tab', {
|
||||
name: 'Attribute mappings',
|
||||
});
|
||||
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
|
||||
await expect(
|
||||
screen.findByTestId('attribute-mappings-tab'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the test tab', () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'Test' })).toBeDisabled();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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' }),
|
||||
];
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './AttributeMappingHeader';
|
||||
@@ -1,66 +0,0 @@
|
||||
import {
|
||||
SpantypesFieldContextDTO,
|
||||
SpantypesSpanMapperOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// 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[];
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import {
|
||||
SpantypesSpanMapperDTO,
|
||||
SpantypesSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
ConditionFilter,
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
SourceConfig,
|
||||
} from './types';
|
||||
|
||||
// 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: SpantypesSpanMapperDTO,
|
||||
): 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,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- working-copy (draft tree) helpers ----
|
||||
|
||||
export function buildDraftMapper(mapper: SpantypesSpanMapperDTO): DraftMapper {
|
||||
return {
|
||||
localId: mapper.id,
|
||||
serverId: mapper.id,
|
||||
name: mapper.name,
|
||||
fieldContext: mapper.fieldContext,
|
||||
sources: getMapperSources(mapper),
|
||||
enabled: mapper.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDraftGroup(
|
||||
group: SpantypesSpanMapperGroupDTO,
|
||||
mappers: SpantypesSpanMapperDTO[],
|
||||
): 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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -2,4 +2,29 @@
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import ModelCostTabPanel from './ModelCostTabPanel';
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
@@ -9,9 +10,20 @@ function LLMObservabilityModelPricing(): JSX.Element {
|
||||
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
|
||||
// Model costs is the only enabled tab for now, so default to it. When
|
||||
// the unpriced-models tab lands in a later PR.
|
||||
// the unpriced-models tab lands, this can become a URL-backed param.
|
||||
defaultValue="model-costs"
|
||||
items={[
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import LLMObservabilityModelPricing from '../LLMObservabilityModelPricing';
|
||||
import { LLM_PRICING_ENDPOINT, makeListResponse, mockRules } from './fixtures';
|
||||
|
||||
function setupList(items = mockRules): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeListResponse(items))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('LLMObservabilityModelPricing', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
setupList();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the model-pricing page', () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('llm-observability-model-pricing-page'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the model-costs and unpriced-models sub-tab labels', () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'Model costs' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: /Unpriced models/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('activates the model-costs tab by default and renders its content', async () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
const modelCostsTab = screen.getByRole('tab', { name: 'Model costs' });
|
||||
expect(modelCostsTab).toHaveAttribute('data-state', 'active');
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'Search by model or provider',
|
||||
);
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the unpriced-models tab', () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
const unpricedTab = screen.getByRole('tab', { name: /Unpriced models/ });
|
||||
expect(unpricedTab).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
type ListLLMPricingRules200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PricingRule } from '../types';
|
||||
|
||||
// Endpoint glob used by MSW handlers. The generated client hits a relative
|
||||
// `/api/v1/llm_pricing_rules`, so the `*` prefix matches regardless of base URL.
|
||||
export const LLM_PRICING_ENDPOINT = '*/api/v1/llm_pricing_rules';
|
||||
export const LLM_PRICING_RULE_ENDPOINT = '*/api/v1/llm_pricing_rules/:id';
|
||||
|
||||
// Builds a valid pricing rule, with overrides merged shallowly. Pricing is
|
||||
// replaced wholesale when provided so callers can shape cache buckets freely.
|
||||
export function makePricingRule(
|
||||
overrides: Partial<PricingRule> = {},
|
||||
): PricingRule {
|
||||
const { pricing, ...rest } = overrides;
|
||||
return {
|
||||
id: 'rule-1',
|
||||
enabled: true,
|
||||
isOverride: true,
|
||||
modelName: 'gpt-4o',
|
||||
modelPattern: ['gpt-4o'],
|
||||
orgId: 'org-1',
|
||||
provider: 'OpenAI',
|
||||
sourceId: 'source-1',
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
createdAt: '2023-10-01T00:00:00.000Z',
|
||||
updatedAt: '2023-10-10T00:00:00.000Z',
|
||||
syncedAt: '2023-10-10T00:00:00.000Z',
|
||||
pricing: {
|
||||
input: 3,
|
||||
output: 9,
|
||||
...pricing,
|
||||
},
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockRules: PricingRule[] = [
|
||||
makePricingRule({
|
||||
id: 'rule-openai',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
pricing: { input: 3, output: 9 },
|
||||
}),
|
||||
makePricingRule({
|
||||
id: 'rule-anthropic',
|
||||
modelName: 'claude-3-5-sonnet',
|
||||
provider: 'Anthropic',
|
||||
isOverride: false,
|
||||
pricing: {
|
||||
input: 2,
|
||||
output: 30,
|
||||
cache: { mode: CacheModeDTO.additive, read: 3, write: 6 },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// Wraps items in the list response envelope the list query reads
|
||||
// (`data.data.items` / `data.data.total`).
|
||||
export function makeListResponse(
|
||||
items: PricingRule[],
|
||||
total = items.length,
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
): ListLLMPricingRules200 {
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { EMPTY_DRAFT } from '../constants';
|
||||
import type { DrawerDraft } from '../types';
|
||||
import {
|
||||
buildPricingPayload,
|
||||
buildRulePayload,
|
||||
draftFromRule,
|
||||
formatPricePerMillion,
|
||||
getCanonicalId,
|
||||
getExtraBuckets,
|
||||
getRelativeLastSeen,
|
||||
getSourceLabel,
|
||||
parsePricingAmount,
|
||||
validateModelName,
|
||||
validatePricing,
|
||||
validateProvider,
|
||||
} from '../utils';
|
||||
import { makePricingRule } from './fixtures';
|
||||
|
||||
describe('parsePricingAmount', () => {
|
||||
it('returns null for empty / whitespace-only input', () => {
|
||||
expect(parsePricingAmount('')).toBeNull();
|
||||
expect(parsePricingAmount(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses numeric strings', () => {
|
||||
expect(parsePricingAmount('3.5')).toBe(3.5);
|
||||
expect(parsePricingAmount('0')).toBe(0);
|
||||
expect(parsePricingAmount('-2')).toBe(-2);
|
||||
});
|
||||
|
||||
it('returns 0 for non-numeric input', () => {
|
||||
expect(parsePricingAmount('abc')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPricePerMillion', () => {
|
||||
it('renders an em dash for missing values', () => {
|
||||
expect(formatPricePerMillion(undefined)).toBe('—');
|
||||
expect(formatPricePerMillion(null as unknown as undefined)).toBe('—');
|
||||
});
|
||||
|
||||
it('formats numbers to 2dp with a dollar sign', () => {
|
||||
expect(formatPricePerMillion(3)).toBe('$3.00');
|
||||
expect(formatPricePerMillion(0)).toBe('$0.00');
|
||||
expect(formatPricePerMillion(1.2345)).toBe('$1.23');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExtraBuckets', () => {
|
||||
it('returns no buckets when there is no cache pricing', () => {
|
||||
expect(
|
||||
getExtraBuckets(makePricingRule({ pricing: { input: 1, output: 2 } })),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('includes only buckets with a positive value', () => {
|
||||
const rule = makePricingRule({
|
||||
pricing: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cache: { mode: CacheModeDTO.subtract, read: 5, write: 0 },
|
||||
},
|
||||
});
|
||||
expect(getExtraBuckets(rule)).toStrictEqual([
|
||||
{ key: 'cache_read', pricePerMillion: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes both read and write when both are positive', () => {
|
||||
const rule = makePricingRule({
|
||||
pricing: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cache: { mode: CacheModeDTO.additive, read: 5, write: 7 },
|
||||
},
|
||||
});
|
||||
expect(getExtraBuckets(rule)).toStrictEqual([
|
||||
{ key: 'cache_read', pricePerMillion: 5 },
|
||||
{ key: 'cache_write', pricePerMillion: 7 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSourceLabel', () => {
|
||||
it('maps the override flag to a label', () => {
|
||||
expect(getSourceLabel(makePricingRule({ isOverride: true }))).toBe(
|
||||
'User override',
|
||||
);
|
||||
expect(getSourceLabel(makePricingRule({ isOverride: false }))).toBe('Auto');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCanonicalId', () => {
|
||||
it('lowercases and trims provider:model', () => {
|
||||
expect(
|
||||
getCanonicalId(
|
||||
makePricingRule({ provider: ' OpenAI ', modelName: ' GPT-4o ' }),
|
||||
),
|
||||
).toBe('openai:gpt-4o');
|
||||
});
|
||||
|
||||
it('falls back to "unknown" for missing segments', () => {
|
||||
expect(
|
||||
getCanonicalId(
|
||||
makePricingRule({
|
||||
provider: '' as unknown as string,
|
||||
modelName: '' as unknown as string,
|
||||
}),
|
||||
),
|
||||
).toBe('unknown:unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRelativeLastSeen', () => {
|
||||
it('returns an em dash when no timestamps are present', () => {
|
||||
const rule = makePricingRule({
|
||||
updatedAt: undefined,
|
||||
syncedAt: null,
|
||||
createdAt: undefined,
|
||||
});
|
||||
expect(getRelativeLastSeen(rule)).toBe('—');
|
||||
});
|
||||
|
||||
it('returns a relative string for a valid timestamp', () => {
|
||||
const rule = makePricingRule({ updatedAt: '2023-10-10T00:00:00.000Z' });
|
||||
expect(getRelativeLastSeen(rule)).not.toBe('—');
|
||||
expect(typeof getRelativeLastSeen(rule)).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('draftFromRule', () => {
|
||||
it('maps a rule to a drawer draft with cache defaults', () => {
|
||||
const rule = makePricingRule({
|
||||
id: 'r1',
|
||||
sourceId: 's1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
modelPattern: ['gpt-4o', 'gpt-4'],
|
||||
isOverride: true,
|
||||
pricing: {
|
||||
input: 3,
|
||||
output: 9,
|
||||
cache: { mode: CacheModeDTO.subtract, read: 1, write: 2 },
|
||||
},
|
||||
});
|
||||
expect(draftFromRule(rule)).toStrictEqual({
|
||||
id: 'r1',
|
||||
sourceId: 's1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
patterns: ['gpt-4o', 'gpt-4'],
|
||||
isOverride: true,
|
||||
pricing: {
|
||||
input: 3,
|
||||
output: 9,
|
||||
cacheMode: CacheModeDTO.subtract,
|
||||
cacheRead: 1,
|
||||
cacheWrite: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults cache mode/values when cache is absent', () => {
|
||||
const draft = draftFromRule(
|
||||
makePricingRule({ modelPattern: null, pricing: { input: 1, output: 2 } }),
|
||||
);
|
||||
expect(draft.patterns).toStrictEqual([]);
|
||||
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.unknown);
|
||||
expect(draft.pricing.cacheRead).toBeNull();
|
||||
expect(draft.pricing.cacheWrite).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPricingPayload', () => {
|
||||
it('omits cache when neither bucket has a value', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
pricing: { ...EMPTY_DRAFT.pricing, input: 3, output: 9 },
|
||||
};
|
||||
expect(buildPricingPayload(draft)).toStrictEqual({ input: 3, output: 9 });
|
||||
});
|
||||
|
||||
it('includes only the cache buckets that have a value', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
pricing: {
|
||||
input: 3,
|
||||
output: 9,
|
||||
cacheMode: CacheModeDTO.additive,
|
||||
cacheRead: 1,
|
||||
cacheWrite: null,
|
||||
},
|
||||
};
|
||||
expect(buildPricingPayload(draft)).toStrictEqual({
|
||||
input: 3,
|
||||
output: 9,
|
||||
cache: { mode: CacheModeDTO.additive, read: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRulePayload', () => {
|
||||
it('trims names, sets defaults and patterns', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
id: 'r1',
|
||||
sourceId: 's1',
|
||||
modelName: ' gpt-4o ',
|
||||
provider: ' OpenAI ',
|
||||
patterns: ['gpt-4o'],
|
||||
isOverride: true,
|
||||
pricing: { ...EMPTY_DRAFT.pricing, input: 3, output: 9 },
|
||||
};
|
||||
expect(buildRulePayload(draft)).toStrictEqual({
|
||||
id: 'r1',
|
||||
sourceId: 's1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
modelPattern: ['gpt-4o'],
|
||||
isOverride: true,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 3, output: 9 },
|
||||
});
|
||||
});
|
||||
|
||||
it('drops empty id/sourceId to undefined', () => {
|
||||
const payload = buildRulePayload({
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'm',
|
||||
provider: 'p',
|
||||
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 1 },
|
||||
});
|
||||
expect(payload.id).toBeUndefined();
|
||||
expect(payload.sourceId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateModelName', () => {
|
||||
it('requires a name only in add mode', () => {
|
||||
expect(validateModelName('', 'add')).toBe('Billing model ID is required.');
|
||||
expect(validateModelName(' ', 'add')).toBe('Billing model ID is required.');
|
||||
expect(validateModelName('gpt-4o', 'add')).toBe(true);
|
||||
expect(validateModelName('', 'edit')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateProvider', () => {
|
||||
it('requires a non-empty provider', () => {
|
||||
expect(validateProvider('')).toBe('Provider is required.');
|
||||
expect(validateProvider(' ')).toBe('Provider is required.');
|
||||
expect(validateProvider('OpenAI')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePricing', () => {
|
||||
const base = EMPTY_DRAFT.pricing;
|
||||
|
||||
it('skips validation when not an override', () => {
|
||||
expect(validatePricing({ ...base, input: null, output: null }, false)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('requires positive input and output when override', () => {
|
||||
expect(validatePricing({ ...base, input: 0, output: 9 }, true)).toBe(
|
||||
'Input cost must be greater than 0.',
|
||||
);
|
||||
expect(validatePricing({ ...base, input: 3, output: 0 }, true)).toBe(
|
||||
'Output cost must be greater than 0.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects negative cache values', () => {
|
||||
expect(
|
||||
validatePricing({ ...base, input: 3, output: 9, cacheRead: -1 }, true),
|
||||
).toBe('Cache costs must be non-negative.');
|
||||
});
|
||||
|
||||
it('passes for valid override pricing', () => {
|
||||
expect(
|
||||
validatePricing(
|
||||
{ ...base, input: 3, output: 9, cacheRead: 1, cacheWrite: 2 },
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
LLM_PRICING_ENDPOINT,
|
||||
makeListResponse,
|
||||
mockRules,
|
||||
} from '../Settings/ModelPricing/__tests__/fixtures';
|
||||
import LLMObservability from '../LLMObservability';
|
||||
|
||||
function setupList(items = mockRules): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeListResponse(items))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('LLMObservability (integration)', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the overview panel and the tab bar on the overview route', () => {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('llm-observability-tabs')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('llm-observability-overview')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM Observability')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Model pricing' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Attribute Mapping' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to the configuration route when the Model pricing tab is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Model pricing' }));
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to the attribute mapping route when that tab is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Attribute Mapping' }));
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the attribute mapping page on the attribute mapping route', () => {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId('llm-observability-attribute-mapping-page'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the model-pricing page on the configuration route', async () => {
|
||||
setupList();
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('llm-observability-model-pricing-page'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { useLLMObservabilityTabs } from '../useLLMObservabilityTabs';
|
||||
|
||||
function renderTabsAt(
|
||||
route: string,
|
||||
): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useLLMObservabilityTabs>, unknown>
|
||||
> {
|
||||
const wrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement => (
|
||||
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
|
||||
);
|
||||
return renderHook(() => useLLMObservabilityTabs(), { wrapper });
|
||||
}
|
||||
|
||||
describe('useLLMObservabilityTabs', () => {
|
||||
it('marks the overview tab active on the overview route', () => {
|
||||
const { result } = renderTabsAt(ROUTES.LLM_OBSERVABILITY_OVERVIEW);
|
||||
|
||||
expect(result.current.activeTab).toBe(ROUTES.LLM_OBSERVABILITY_OVERVIEW);
|
||||
});
|
||||
|
||||
it('marks the configuration tab active on the configuration route', () => {
|
||||
const { result } = renderTabsAt(ROUTES.LLM_OBSERVABILITY_CONFIGURATION);
|
||||
|
||||
expect(result.current.activeTab).toBe(ROUTES.LLM_OBSERVABILITY_CONFIGURATION);
|
||||
});
|
||||
|
||||
it('marks the attribute mapping tab active on the attribute mapping route', () => {
|
||||
const { result } = renderTabsAt(ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING);
|
||||
|
||||
expect(result.current.activeTab).toBe(
|
||||
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes all route-keyed tab items', () => {
|
||||
const { result } = renderTabsAt(ROUTES.LLM_OBSERVABILITY_OVERVIEW);
|
||||
|
||||
expect(result.current.items).toHaveLength(3);
|
||||
const keys = result.current.items.map((item) => item.key);
|
||||
expect(keys).toStrictEqual([
|
||||
ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
]);
|
||||
});
|
||||
|
||||
it('navigates to the selected tab key on change', () => {
|
||||
const { result } = renderTabsAt(ROUTES.LLM_OBSERVABILITY_OVERVIEW);
|
||||
|
||||
act(() => {
|
||||
result.current.onTabChange(ROUTES.LLM_OBSERVABILITY_CONFIGURATION);
|
||||
});
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { type TabItemProps } from '@signozhq/ui/tabs';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import LLMObservabilityAttributeMapping from '../AttributeMapping/LLMObservabilityAttributeMapping';
|
||||
import Overview from '../Overview/Overview';
|
||||
import LLMObservabilityModelPricing from '../Settings/ModelPricing/LLMObservabilityModelPricing';
|
||||
|
||||
const OVERVIEW_KEY = ROUTES.LLM_OBSERVABILITY_OVERVIEW;
|
||||
const CONFIGURATION_KEY = ROUTES.LLM_OBSERVABILITY_CONFIGURATION;
|
||||
const ATTRIBUTE_MAPPING_KEY = ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING;
|
||||
|
||||
interface UseLLMObservabilityTabsResult {
|
||||
items: TabItemProps[];
|
||||
activeTab: string;
|
||||
onTabChange: (key: string) => void;
|
||||
}
|
||||
|
||||
// Drives the top-level LLM Observability tabs. Route-driven: the active tab is
|
||||
// derived from the pathname (each tab owns a URL) and changing tabs navigates,
|
||||
// so tabs stay shareable/back-button friendly while rendering with the SigNoz
|
||||
// design-system Tabs.
|
||||
export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
|
||||
const { pathname } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
let activeTab: string = OVERVIEW_KEY;
|
||||
if (pathname.startsWith(CONFIGURATION_KEY)) {
|
||||
activeTab = CONFIGURATION_KEY;
|
||||
} else if (pathname.startsWith(ATTRIBUTE_MAPPING_KEY)) {
|
||||
activeTab = ATTRIBUTE_MAPPING_KEY;
|
||||
}
|
||||
|
||||
const onTabChange = useCallback(
|
||||
(key: string): void => {
|
||||
safeNavigate(key);
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
|
||||
const items: TabItemProps[] = [
|
||||
{
|
||||
key: OVERVIEW_KEY,
|
||||
label: 'Overview',
|
||||
children: <Overview />,
|
||||
},
|
||||
{
|
||||
key: CONFIGURATION_KEY,
|
||||
label: 'Model pricing',
|
||||
children: <LLMObservabilityModelPricing />,
|
||||
},
|
||||
{
|
||||
key: ATTRIBUTE_MAPPING_KEY,
|
||||
label: 'Attribute Mapping',
|
||||
children: <LLMObservabilityAttributeMapping />,
|
||||
},
|
||||
];
|
||||
|
||||
return { items, activeTab, onTabChange };
|
||||
}
|
||||
@@ -14,6 +14,14 @@
|
||||
.meter-explorer-content-section {
|
||||
width: 100%;
|
||||
|
||||
// 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);
|
||||
|
||||
.explore-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
.metrics-explorer-explore-container {
|
||||
padding-bottom: 80px;
|
||||
|
||||
// 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);
|
||||
|
||||
.explore-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
.dashboard-navigation {
|
||||
// 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);
|
||||
|
||||
.run-query-dashboard-btn {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
@@ -31,8 +31,14 @@
|
||||
min-width: 32px;
|
||||
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
--periscope-btn-border-color: var(
|
||||
--query-builder-v2-border-color,
|
||||
var(--l2-border)
|
||||
);
|
||||
--periscope-btn-background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,11 @@
|
||||
border-top-left-radius: 0px !important;
|
||||
border-bottom-left-radius: 0px !important;
|
||||
|
||||
background-color: var(--l1-border) !important;
|
||||
opacity: 0.8;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
margin-left: -1px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
@@ -86,8 +89,8 @@
|
||||
border-bottom-left-radius: 3px;
|
||||
|
||||
.ant-select-selector {
|
||||
border: none;
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--query-builder-v2-border-color, none);
|
||||
background: var(--query-builder-v2-background-color, var(--l3-background));
|
||||
}
|
||||
|
||||
&.showInput {
|
||||
@@ -101,12 +104,20 @@
|
||||
.query-function-value {
|
||||
width: 70px;
|
||||
border-left: 0;
|
||||
background: var(--l2-background);
|
||||
background: var(--query-builder-v2-background-color, var(--l2-background));
|
||||
border-radius: 0;
|
||||
border: 1px solid transparent;
|
||||
border-top: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border-bottom: 1px solid
|
||||
var(--query-builder-v2-border-color, var(--l2-border));
|
||||
height: 32px;
|
||||
|
||||
&:focus {
|
||||
border-color: transparent !important;
|
||||
border-color: var(--internal-ant-border-color-focus);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--internal-ant-border-color-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +125,7 @@
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
|
||||
border-left: 1px solid var(--l2-border);
|
||||
border-left: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
|
||||
border-top-left-radius: 0px !important;
|
||||
border-bottom-left-radius: 0px !important;
|
||||
|
||||
@@ -203,9 +203,8 @@ export const routesToSkip = [
|
||||
ROUTES.METER_EXPLORER_VIEWS,
|
||||
ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
|
||||
ROUTES.SOMETHING_WENT_WRONG,
|
||||
ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
ROUTES.LLM_OBSERVABILITY_BASE,
|
||||
ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
];
|
||||
|
||||
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import LLMObservabilityModelPricing from 'container/LLMObservability/Settings/ModelPricing/LLMObservabilityModelPricing';
|
||||
|
||||
function LLMObservabilityModelPricingPage(): JSX.Element {
|
||||
return <LLMObservabilityModelPricing />;
|
||||
}
|
||||
|
||||
export default LLMObservabilityModelPricingPage;
|
||||
@@ -16,6 +16,14 @@
|
||||
min-height: 0;
|
||||
|
||||
.log-explorer-query-container {
|
||||
// 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);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
@@ -67,6 +67,14 @@
|
||||
.trace-explorer-page {
|
||||
display: flex;
|
||||
|
||||
// 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);
|
||||
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
|
||||
padding: 6px;
|
||||
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--periscope-btn-border-color, var(--l1-border));
|
||||
border-radius: 3px;
|
||||
background: var(--l2-background);
|
||||
background: var(--periscope-btn-background-color, var(--l2-background));
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
color: var(--l2-foreground);
|
||||
|
||||
@@ -135,11 +135,15 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background-color: var(
|
||||
--input-with-label-background-color,
|
||||
var(--l2-background)
|
||||
);
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
|
||||
color: var(--l2-foreground);
|
||||
color: var(--input-with-label-color, var(--l2-foreground));
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
@@ -153,9 +157,8 @@
|
||||
text-overflow: ellipsis;
|
||||
padding: 0px 8px;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
@@ -165,7 +168,6 @@
|
||||
.input {
|
||||
flex: 1;
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
border-top-right-radius: 0px;
|
||||
@@ -173,7 +175,7 @@
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
|
||||
background: var(--l1-background);
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
|
||||
min-width: 0;
|
||||
|
||||
@@ -189,21 +191,44 @@
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
border: none;
|
||||
background: var(--l2-background);
|
||||
height: 36px;
|
||||
border-color: var(--input-with-label-border-color, var(--l2-border));
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
border-radius: 0;
|
||||
position: relative;
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.ant-select:hover .ant-select-selector,
|
||||
.ant-select-focused .ant-select-selector {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ant-select-disabled .ant-select-selector {
|
||||
background: var(
|
||||
--input-with-label-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--l2-foreground);
|
||||
color: var(--input-with-label-color, var(--l2-foreground));
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-left: -1px;
|
||||
position: relative;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&:focus:not(:focus-visible),
|
||||
&.ant-btn:focus:not(:focus-visible) {
|
||||
|
||||
@@ -839,6 +839,9 @@ body.ai-assistant-panel-open {
|
||||
// design libraries.
|
||||
--dropdown-menu-content-z-index: 1050;
|
||||
--dropdown-menu-sub-content-z-index: 1050;
|
||||
|
||||
--internal-ant-border-color-focus: #3a52a8;
|
||||
--internal-ant-border-color-hover: #6e8de8;
|
||||
}
|
||||
|
||||
div[data-slot='callout'] {
|
||||
|
||||
@@ -136,8 +136,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_CONFIGURATION: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_MODEL_PRICING: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user