Compare commits

..

2 Commits

60 changed files with 629 additions and 2396 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,26 +0,0 @@
import AttributeMappingHeader from './components/AttributeMappingHeader';
import styles from './LLMObservabilityAttributeMapping.module.scss';
const noop = (): void => undefined;
function LLMObservabilityAttributeMapping(): JSX.Element {
return (
<div
className={styles.llmObservabilityAttributeMapping}
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={false}
isSaving={false}
onDiscard={noop}
onSave={noop}
/>
<div className={styles.tableEmpty} data-testid="attribute-mapping-empty">
No mapping groups configured yet.
</div>
</div>
);
}
export default LLMObservabilityAttributeMapping;

View File

@@ -1,34 +0,0 @@
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
display: flex;
flex-direction: column;
}
.title {
margin: 0;
font-size: var(--periscope-font-size-large);
font-weight: var(--font-weight-semibold);
}
.description {
margin: var(--spacing-2) 0 0;
font-size: var(--periscope-font-size-base);
color: var(--l3-foreground);
}
.pageHeaderActions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedChanges {
font-size: var(--periscope-font-size-base);
color: var(--accent-amber);
}

View File

@@ -1,56 +0,0 @@
import { Button } from '@signozhq/ui/button';
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}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>Attribute Mapping</h1>
<p className={styles.description}>
Configure source-to-target attribute remapping for LLM traces
</p>
</div>
<div className={styles.pageHeaderActions}>
{isDirty && (
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
)}
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={!isDirty || isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={!isDirty || isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
</header>
);
}
export default AttributeMappingHeader;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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={[
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'] {

View File

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