mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-16 13:30:36 +01:00
Compare commits
16 Commits
issue_5365
...
feat/llm-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4292678a8 | ||
|
|
0b57a1940f | ||
|
|
f109d40ef6 | ||
|
|
e2fa3387f4 | ||
|
|
667cd5ae3b | ||
|
|
1dfff8f43e | ||
|
|
aaac971606 | ||
|
|
a5a510fb89 | ||
|
|
7a7678f4ba | ||
|
|
2c42c13a5c | ||
|
|
9af7fd6170 | ||
|
|
e49d17861c | ||
|
|
990a4e63af | ||
|
|
67f56e0be1 | ||
|
|
c9a6b26be0 | ||
|
|
1dd887f7fd |
@@ -323,3 +323,17 @@ export const AIAssistantPage = Loadable(
|
||||
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityModelPricingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityAttributeMappingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Attribute Mapping Page" */ 'pages/LLMObservabilityAttributeMapping'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
IntegrationsDetailsPage,
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityAttributeMappingPage,
|
||||
LLMObservabilityModelPricingPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
@@ -507,6 +509,20 @@ const routes: AppRoutes[] = [
|
||||
key: 'AI_ASSISTANT',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
exact: true,
|
||||
component: LLMObservabilityModelPricingPage,
|
||||
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
exact: true,
|
||||
component: LLMObservabilityAttributeMappingPage,
|
||||
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORT_ROUTE: AppRoutes = {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export const ENVIRONMENT = {
|
||||
baseURL:
|
||||
process?.env?.FRONTEND_API_ENDPOINT ||
|
||||
process?.env?.GITPOD_WORKSPACE_URL?.replace('://', '://8080-') ||
|
||||
'',
|
||||
wsURL: process?.env?.WEBSOCKET_API_ENDPOINT || '',
|
||||
// baseURL:
|
||||
// process?.env?.FRONTEND_API_ENDPOINT ||
|
||||
// process?.env?.GITPOD_WORKSPACE_URL?.replace('://', '://8080-') ||
|
||||
// '',
|
||||
// wsURL: process?.env?.WEBSOCKET_API_ENDPOINT || '',
|
||||
baseURL: 'https://app.us.staging.signoz.cloud',
|
||||
wsURL: 'ws://app.us.staging.signoz.cloud',
|
||||
};
|
||||
|
||||
@@ -91,6 +91,9 @@ const ROUTES = {
|
||||
AI_ASSISTANT_BASE: '/ai-assistant',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
|
||||
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING:
|
||||
'/llm-observability/settings/attribute-mapping',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
interface AttributeMappingHeaderProps {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
onDiscard: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
function AttributeMappingHeader({
|
||||
isDirty,
|
||||
isSaving,
|
||||
onDiscard,
|
||||
onSave,
|
||||
}: AttributeMappingHeaderProps): JSX.Element {
|
||||
return (
|
||||
<header className="page-header">
|
||||
<div className="page-header__title">
|
||||
<h1>Attribute Mapping</h1>
|
||||
<p>Configure source-to-target attribute remapping for LLM traces</p>
|
||||
</div>
|
||||
<div className="page-header__actions">
|
||||
{isDirty && (
|
||||
<span className="page-header__unsaved" 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;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus, X } from '@signozhq/icons';
|
||||
|
||||
import KeySearchInput from './KeySearchInput';
|
||||
import { FieldContextValue } from './types';
|
||||
|
||||
interface ConditionKeyListProps {
|
||||
label: string;
|
||||
labelHint?: string;
|
||||
keys: string[];
|
||||
placeholder: string;
|
||||
addLabel: string;
|
||||
testIdPrefix: string;
|
||||
fieldContext: FieldContextValue;
|
||||
onChange: (keys: string[]) => void;
|
||||
}
|
||||
|
||||
// Editor for one list of condition keys (the group's span-attribute or
|
||||
// resource gating keys). Substring "contains" match, order irrelevant.
|
||||
function ConditionKeyList({
|
||||
label,
|
||||
labelHint,
|
||||
keys,
|
||||
placeholder,
|
||||
addLabel,
|
||||
testIdPrefix,
|
||||
fieldContext,
|
||||
onChange,
|
||||
}: ConditionKeyListProps): JSX.Element {
|
||||
const updateKey = (index: number, value: string): void => {
|
||||
onChange(keys.map((key, i) => (i === index ? value : key)));
|
||||
};
|
||||
|
||||
const addKey = (): void => {
|
||||
onChange([...keys, '']);
|
||||
};
|
||||
|
||||
const removeKey = (index: number): void => {
|
||||
onChange(keys.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group-form__field">
|
||||
<span className="group-form__label">
|
||||
{label}
|
||||
{labelHint && <span className="group-form__label-hint"> {labelHint}</span>}
|
||||
</span>
|
||||
|
||||
{keys.length > 0 && (
|
||||
<div className="group-form__keys">
|
||||
{keys.map((key, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div className="group-form__key" key={index}>
|
||||
<KeySearchInput
|
||||
className="group-form__key-input"
|
||||
placeholder={placeholder}
|
||||
value={key}
|
||||
fieldContext={fieldContext}
|
||||
onChange={(next): void => updateKey(index, next)}
|
||||
testId={`${testIdPrefix}-${index}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Remove key"
|
||||
onClick={(): void => removeKey(index)}
|
||||
testId={`${testIdPrefix}-remove-${index}`}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={addKey}
|
||||
testId={`${testIdPrefix}-add`}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConditionKeyList;
|
||||
@@ -0,0 +1,76 @@
|
||||
.group-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 4px 0;
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&--row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
&__label-hint {
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
&__keys {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__key {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__key-input {
|
||||
flex: 1;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
&__error {
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(229, 72, 77, 0.1);
|
||||
color: var(--bg-cherry-400);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__footer-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Trash2 } from '@signozhq/icons';
|
||||
|
||||
import ConditionKeyList from './ConditionKeyList';
|
||||
import { FieldContext, GroupDraft, MapperDraftMode } from './types';
|
||||
import { isGroupDraftValid } from './utils';
|
||||
|
||||
import './GroupFormDrawer.styles.scss';
|
||||
|
||||
interface GroupFormDrawerProps {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: GroupDraft;
|
||||
setDraft: (next: GroupDraft) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
saveError: string | null;
|
||||
}
|
||||
|
||||
function GroupFormDrawer({
|
||||
isOpen,
|
||||
mode,
|
||||
draft,
|
||||
setDraft,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
}: GroupFormDrawerProps): JSX.Element {
|
||||
const isEdit = mode === 'edit';
|
||||
const isValid = isGroupDraftValid(draft);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={isEdit ? 'Edit group' : 'New group'}
|
||||
subTitle="A group gates which spans its mappings run on"
|
||||
width="wide"
|
||||
testId="group-form-drawer"
|
||||
footer={
|
||||
<div className="group-form__footer">
|
||||
{isEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
prefix={<Trash2 size={14} />}
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
testId="group-form-delete"
|
||||
>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
<div className="group-form__footer-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
testId="group-form-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
disabled={!isValid || isSaving}
|
||||
testId="group-form-save"
|
||||
>
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{isSaving ? 'Saving…' : isEdit ? 'Save group' : 'Create group'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="group-form">
|
||||
<div className="group-form__field">
|
||||
<span className="group-form__label">Group name</span>
|
||||
<Input
|
||||
placeholder="e.g. OpenAI gateway"
|
||||
value={draft.name}
|
||||
onChange={(event): void =>
|
||||
setDraft({ ...draft, name: event.target.value })
|
||||
}
|
||||
testId="group-form-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="group-form__field group-form__field--row">
|
||||
<span className="group-form__label">Enabled</span>
|
||||
<Switch
|
||||
value={draft.enabled}
|
||||
onChange={(checked): void => setDraft({ ...draft, enabled: checked })}
|
||||
testId="group-form-enabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConditionKeyList
|
||||
label="Condition · span attribute keys"
|
||||
labelHint="· runs when a span attribute key contains any of these"
|
||||
keys={draft.attributes}
|
||||
placeholder="e.g. gen_ai."
|
||||
addLabel="Add attribute key"
|
||||
testIdPrefix="group-form-attribute"
|
||||
fieldContext={FieldContext.attribute}
|
||||
onChange={(attributes): void => setDraft({ ...draft, attributes })}
|
||||
/>
|
||||
|
||||
<ConditionKeyList
|
||||
label="Condition · resource keys"
|
||||
labelHint="· or when a resource key contains any of these"
|
||||
keys={draft.resource}
|
||||
placeholder="e.g. service.name"
|
||||
addLabel="Add resource key"
|
||||
testIdPrefix="group-form-resource"
|
||||
fieldContext={FieldContext.resource}
|
||||
onChange={(resource): void => setDraft({ ...draft, resource })}
|
||||
/>
|
||||
|
||||
<span className="group-form__hint">
|
||||
Leave both empty to run this group on every span.
|
||||
</span>
|
||||
|
||||
{saveError && (
|
||||
<div className="group-form__error" role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupFormDrawer;
|
||||
@@ -0,0 +1,10 @@
|
||||
interface IndexBadgeProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
// Small positional badge mirroring the Pipelines list ordering chip.
|
||||
function IndexBadge({ index }: IndexBadgeProps): JSX.Element {
|
||||
return <span className="am-index-badge">{index + 1}</span>;
|
||||
}
|
||||
|
||||
export default IndexBadge;
|
||||
@@ -0,0 +1,51 @@
|
||||
.key-search {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&__dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
max-width: 420px;
|
||||
max-height: 240px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--bg-slate-400, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 6px;
|
||||
background: var(--bg-ink-400, #121317);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
|
||||
&--empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
&__option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-slate-400, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useGetFieldsKeys } from 'api/generated/services/fields';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
import { FieldContext, FieldContextValue } from './types';
|
||||
|
||||
import './KeySearchInput.styles.scss';
|
||||
|
||||
const SUGGESTION_LIMIT = 50;
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
interface KeySearchInputProps {
|
||||
value: string;
|
||||
fieldContext: FieldContextValue;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
// Maps the mapper's attribute/resource context to the fields-endpoint context.
|
||||
function toFieldsContext(
|
||||
context: FieldContextValue,
|
||||
): TelemetrytypesFieldContextDTO {
|
||||
return context === FieldContext.resource
|
||||
? TelemetrytypesFieldContextDTO.resource
|
||||
: TelemetrytypesFieldContextDTO.attribute;
|
||||
}
|
||||
|
||||
// Free-text input with span/resource key suggestions from /api/v1/fields/keys
|
||||
// (signal=traces). Typing keeps the custom value; suggestions are assistive.
|
||||
function KeySearchInput({
|
||||
value,
|
||||
fieldContext,
|
||||
placeholder,
|
||||
className,
|
||||
disabled,
|
||||
testId,
|
||||
onChange,
|
||||
}: KeySearchInputProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const debouncedSearch = useDebounce(value, DEBOUNCE_MS);
|
||||
|
||||
const { data, isFetching } = useGetFieldsKeys(
|
||||
{
|
||||
signal: TelemetrytypesSignalDTO.traces,
|
||||
fieldContext: toFieldsContext(fieldContext),
|
||||
searchText: debouncedSearch,
|
||||
limit: SUGGESTION_LIMIT,
|
||||
},
|
||||
{ query: { enabled: isOpen && !disabled, keepPreviousData: true } },
|
||||
);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const keys = data?.data?.keys ?? {};
|
||||
return Object.keys(keys)
|
||||
.filter((key) => key !== value)
|
||||
.slice(0, SUGGESTION_LIMIT);
|
||||
}, [data, value]);
|
||||
|
||||
return (
|
||||
<div className={cx('key-search', className)}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
onChange={(event): void => {
|
||||
onChange(event.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={(): void => setIsOpen(true)}
|
||||
onBlur={(): void => setIsOpen(false)}
|
||||
testId={testId}
|
||||
/>
|
||||
{isOpen && suggestions.length > 0 && (
|
||||
<div className="key-search__dropdown" data-testid={`${testId}-dropdown`}>
|
||||
{suggestions.map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
className="key-search__option"
|
||||
// onMouseDown (not onClick) so selection runs before the input blur.
|
||||
onMouseDown={(event): void => {
|
||||
event.preventDefault();
|
||||
onChange(name);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
data-testid={`${testId}-option-${name}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isOpen && isFetching && suggestions.length === 0 && (
|
||||
<div className="key-search__dropdown key-search__dropdown--empty">
|
||||
Searching…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeySearchInput;
|
||||
@@ -0,0 +1,183 @@
|
||||
.llm-observability-attribute-mapping {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
&__title {
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__unsaved {
|
||||
font-size: 13px;
|
||||
color: var(--bg-amber-400);
|
||||
}
|
||||
}
|
||||
|
||||
.page-error {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-cherry-500-opacity-10, rgba(229, 72, 77, 0.1));
|
||||
color: var(--bg-cherry-400);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.am-index-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-robin-500);
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.am-row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.am-add-row {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.groups-table__wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.am-table {
|
||||
&__empty {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.groups-table {
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__sub-row > td {
|
||||
padding: 0 16px 12px;
|
||||
background: var(--bg-ink-400, rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
|
||||
&__filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__filter-key {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
&__edited {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.mappers-table__wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.mappers-table {
|
||||
margin: 4px 0;
|
||||
|
||||
&__target {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__source-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 220px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--bg-slate-400, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 6px;
|
||||
background: var(--bg-ink-300, rgba(255, 255, 255, 0.04));
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__source-more {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__error {
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import AttributeMappingHeader from './AttributeMappingHeader';
|
||||
import GroupFormDrawer from './GroupFormDrawer';
|
||||
import MapperGroupsTable from './MapperGroupsTable';
|
||||
import { useAttributeMappingStore } from './useAttributeMappingStore';
|
||||
import { useGroupFormDrawer } from './useGroupFormDrawer';
|
||||
|
||||
import './LLMObservabilityAttributeMapping.styles.scss';
|
||||
|
||||
function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
const store = useAttributeMappingStore();
|
||||
const groupDrawer = useGroupFormDrawer();
|
||||
|
||||
const handleGroupSave = useCallback((): void => {
|
||||
store.upsertGroup(groupDrawer.draft);
|
||||
groupDrawer.close();
|
||||
}, [store, groupDrawer]);
|
||||
|
||||
const handleGroupDelete = useCallback((): void => {
|
||||
if (groupDrawer.draft.id) {
|
||||
store.removeGroup(groupDrawer.draft.id);
|
||||
}
|
||||
groupDrawer.close();
|
||||
}, [store, groupDrawer]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="llm-observability-attribute-mapping"
|
||||
data-testid="llm-observability-attribute-mapping-page"
|
||||
>
|
||||
<AttributeMappingHeader
|
||||
isDirty={store.isDirty}
|
||||
isSaving={store.isSaving}
|
||||
onDiscard={store.discard}
|
||||
onSave={store.save}
|
||||
/>
|
||||
|
||||
{store.saveError && (
|
||||
<div className="page-error" role="alert">
|
||||
{store.saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{store.isError && (
|
||||
<div className="page-error" role="alert">
|
||||
Failed to load mapping groups. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MapperGroupsTable
|
||||
store={store}
|
||||
onEditGroup={groupDrawer.openForEdit}
|
||||
onAddGroup={groupDrawer.openForAdd}
|
||||
/>
|
||||
|
||||
<footer className="page-footer">
|
||||
Showing {store.groups.length} group{store.groups.length === 1 ? '' : 's'}
|
||||
</footer>
|
||||
|
||||
<GroupFormDrawer
|
||||
isOpen={groupDrawer.isOpen}
|
||||
mode={groupDrawer.mode}
|
||||
draft={groupDrawer.draft}
|
||||
setDraft={groupDrawer.setDraft}
|
||||
onClose={groupDrawer.close}
|
||||
onSave={handleGroupSave}
|
||||
onDelete={handleGroupDelete}
|
||||
isSaving={false}
|
||||
isDeleting={false}
|
||||
saveError={null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LLMObservabilityAttributeMapping;
|
||||
@@ -0,0 +1,104 @@
|
||||
.mapper-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 4px 0;
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
&__label-hint {
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
&__sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__source-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--bg-vanilla-400);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
&__source-index {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
&__source-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
&__source-select {
|
||||
flex: 0 0 auto;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
&__field-context {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
&__error {
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(229, 72, 77, 0.1);
|
||||
color: var(--bg-cherry-400);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__footer-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import KeySearchInput from './KeySearchInput';
|
||||
import SourceAttributeRow from './SourceAttributeRow';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldContextValue,
|
||||
MapperDraft,
|
||||
MapperDraftMode,
|
||||
SourceConfig,
|
||||
} from './types';
|
||||
import { createEmptySource, isMapperDraftValid } from './utils';
|
||||
|
||||
import './MapperFormDrawer.styles.scss';
|
||||
|
||||
const FIELD_CONTEXT_OPTIONS = [
|
||||
{ value: FieldContext.attribute, label: 'Span attribute' },
|
||||
{ value: FieldContext.resource, label: 'Resource' },
|
||||
];
|
||||
|
||||
interface MapperFormDrawerProps {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: MapperDraft;
|
||||
setDraft: (next: MapperDraft) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
saveError: string | null;
|
||||
}
|
||||
|
||||
function MapperFormDrawer({
|
||||
isOpen,
|
||||
mode,
|
||||
draft,
|
||||
setDraft,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
}: MapperFormDrawerProps): JSX.Element {
|
||||
const isEdit = mode === 'edit';
|
||||
const isValid = isMapperDraftValid(draft);
|
||||
|
||||
// Stable per-row ids for the sortable list. These are UI-only (never sent to
|
||||
// the API and excluded from the draft), so dnd-kit can track rows reliably
|
||||
// even though sources are stored as a plain array. Re-seeded each time the
|
||||
// drawer opens; kept in lockstep with the sources array on add/remove/drag.
|
||||
const [rowIds, setRowIds] = useState<string[]>([]);
|
||||
const wasOpen = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !wasOpen.current) {
|
||||
setRowIds(draft.sources.map(() => uuid()));
|
||||
}
|
||||
wasOpen.current = isOpen;
|
||||
// Only re-seed on the closed→open transition.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
const sourceIds = draft.sources.map(
|
||||
(_, index) => rowIds[index] ?? `pending-${index}`,
|
||||
);
|
||||
|
||||
// 5px activation distance so clicking into the input never starts a drag.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
const updateSource = (index: number, patch: Partial<SourceConfig>): void => {
|
||||
const sources = draft.sources.map((source, i) =>
|
||||
i === index ? { ...source, ...patch } : source,
|
||||
);
|
||||
setDraft({ ...draft, sources });
|
||||
};
|
||||
|
||||
const addSource = (): void => {
|
||||
setDraft({ ...draft, sources: [...draft.sources, createEmptySource()] });
|
||||
setRowIds((prev) => [...prev, uuid()]);
|
||||
};
|
||||
|
||||
const removeSource = (index: number): void => {
|
||||
const sources = draft.sources.filter((_, i) => i !== index);
|
||||
if (sources.length === 0) {
|
||||
setDraft({ ...draft, sources: [createEmptySource()] });
|
||||
setRowIds([uuid()]);
|
||||
return;
|
||||
}
|
||||
setDraft({ ...draft, sources });
|
||||
setRowIds((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent): void => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
const from = sourceIds.indexOf(String(active.id));
|
||||
const to = sourceIds.indexOf(String(over.id));
|
||||
if (from === -1 || to === -1) {
|
||||
return;
|
||||
}
|
||||
setDraft({ ...draft, sources: arrayMove(draft.sources, from, to) });
|
||||
setRowIds((prev) => arrayMove(prev, from, to));
|
||||
};
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={isEdit ? 'Edit mapping' : 'New custom mapping'}
|
||||
subTitle="Map source attributes onto a canonical target attribute"
|
||||
width="wide"
|
||||
testId="mapper-form-drawer"
|
||||
footer={
|
||||
<div className="mapper-form__footer">
|
||||
{isEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
prefix={<Trash2 size={14} />}
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
testId="mapper-form-delete"
|
||||
>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
<div className="mapper-form__footer-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
testId="mapper-form-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
disabled={!isValid || isSaving}
|
||||
testId="mapper-form-save"
|
||||
>
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{isSaving ? 'Saving…' : isEdit ? 'Save mapping' : 'Create mapping'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mapper-form">
|
||||
<div className="mapper-form__field">
|
||||
<span className="mapper-form__label">Target attribute</span>
|
||||
<KeySearchInput
|
||||
placeholder="e.g. gen_ai.content.prompt"
|
||||
value={draft.name}
|
||||
fieldContext={draft.fieldContext}
|
||||
disabled={isEdit}
|
||||
onChange={(name): void => setDraft({ ...draft, name })}
|
||||
testId="mapper-form-target"
|
||||
/>
|
||||
{isEdit && (
|
||||
<span className="mapper-form__hint">
|
||||
The target attribute can't be changed after creation.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mapper-form__field">
|
||||
<span className="mapper-form__label">Write target to</span>
|
||||
<SelectSimple
|
||||
className="mapper-form__field-context"
|
||||
items={FIELD_CONTEXT_OPTIONS}
|
||||
value={draft.fieldContext}
|
||||
withPortal={false}
|
||||
onChange={(next): void =>
|
||||
setDraft({ ...draft, fieldContext: next as FieldContextValue })
|
||||
}
|
||||
testId="mapper-form-field-context"
|
||||
/>
|
||||
<span className="mapper-form__hint">
|
||||
Where the standardized attribute is written.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mapper-form__field">
|
||||
<span className="mapper-form__label">
|
||||
Source attributes
|
||||
<span className="mapper-form__label-hint">
|
||||
{' '}
|
||||
· priority: top → bottom · drag to reorder
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={sourceIds} strategy={verticalListSortingStrategy}>
|
||||
<div className="mapper-form__sources">
|
||||
{draft.sources.map((source, index) => (
|
||||
<SourceAttributeRow
|
||||
key={sourceIds[index]}
|
||||
id={sourceIds[index]}
|
||||
index={index}
|
||||
value={source}
|
||||
canRemove={draft.sources.length > 1}
|
||||
onChange={updateSource}
|
||||
onRemove={removeSource}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={addSource}
|
||||
testId="mapper-form-add-source"
|
||||
>
|
||||
Add another source
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="mapper-form__error" role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperFormDrawer;
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@signozhq/ui/table';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import MappersTable from './MappersTable';
|
||||
import { DraftGroup } from './types';
|
||||
import { AttributeMappingStore } from './useAttributeMappingStore';
|
||||
import { conditionFiltersFromGroup } from './utils';
|
||||
|
||||
const COLUMN_COUNT = 4;
|
||||
|
||||
interface MapperGroupsTableProps {
|
||||
store: AttributeMappingStore;
|
||||
onEditGroup: (group: DraftGroup) => void;
|
||||
onAddGroup: () => void;
|
||||
}
|
||||
|
||||
function FiltersCell({ group }: { group: DraftGroup }): JSX.Element {
|
||||
const filters = conditionFiltersFromGroup(group);
|
||||
if (filters.length === 0) {
|
||||
return <span className="muted">No condition · always runs</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="groups-table__filters"
|
||||
data-testid={`group-filters-${group.localId}`}
|
||||
>
|
||||
{filters.map((filter) => (
|
||||
<div
|
||||
className="groups-table__filter"
|
||||
key={`${filter.context}:${filter.key}`}
|
||||
>
|
||||
<Badge
|
||||
color={filter.context === 'resource' ? 'amber' : 'robin'}
|
||||
variant="outline"
|
||||
>
|
||||
{filter.context}
|
||||
</Badge>
|
||||
<span className="groups-table__filter-key">contains {filter.key}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupRowProps {
|
||||
group: DraftGroup;
|
||||
store: AttributeMappingStore;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: (localId: string) => void;
|
||||
onEditGroup: (group: DraftGroup) => void;
|
||||
}
|
||||
|
||||
function GroupRow({
|
||||
group,
|
||||
store,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onEditGroup,
|
||||
}: GroupRowProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<TableRow data-testid={`group-row-${group.localId}`}>
|
||||
<TableCell>
|
||||
<div className="groups-table__name-cell">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label={isExpanded ? 'Collapse group' : 'Expand group'}
|
||||
onClick={(): void => onToggleExpand(group.localId)}
|
||||
testId={`group-expand-${group.localId}`}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</Button>
|
||||
<span
|
||||
className="groups-table__name"
|
||||
data-testid={`group-name-${group.localId}`}
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FiltersCell group={group} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="muted">{group.mappers.length} mappings</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="am-row-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Edit group"
|
||||
onClick={(): void => onEditGroup(group)}
|
||||
testId={`group-edit-${group.localId}`}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
aria-label="Delete group"
|
||||
onClick={(): void => store.removeGroup(group.localId)}
|
||||
testId={`group-delete-${group.localId}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
<Switch
|
||||
value={group.enabled}
|
||||
onChange={(checked): void => store.toggleGroup(group.localId, checked)}
|
||||
testId={`group-enabled-${group.localId}`}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && (
|
||||
<TableRow className="groups-table__sub-row">
|
||||
<TableCell colSpan={COLUMN_COUNT}>
|
||||
<MappersTable group={group} store={store} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MapperGroupsTable({
|
||||
store,
|
||||
onEditGroup,
|
||||
onAddGroup,
|
||||
}: MapperGroupsTableProps): JSX.Element {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const toggleExpand = (localId: string): void => {
|
||||
setExpandedId((current) => (current === localId ? null : localId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="groups-table__wrapper">
|
||||
<Table testId="mapper-groups-table" className="am-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Group name</TableHead>
|
||||
<TableHead>Filters</TableHead>
|
||||
<TableHead>Mappings</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{store.isLoading && store.groups.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={COLUMN_COUNT} className="am-table__empty">
|
||||
Loading groups…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!store.isLoading && store.groups.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={COLUMN_COUNT} className="am-table__empty">
|
||||
No mapping groups yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{store.groups.map((group) => (
|
||||
<GroupRow
|
||||
key={group.localId}
|
||||
group={group}
|
||||
store={store}
|
||||
isExpanded={expandedId === group.localId}
|
||||
onToggleExpand={toggleExpand}
|
||||
onEditGroup={onEditGroup}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
className="am-add-row"
|
||||
onClick={onAddGroup}
|
||||
testId="add-group-row"
|
||||
>
|
||||
Add a new group
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperGroupsTable;
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@signozhq/ui/table';
|
||||
import { Pencil, Plus, Trash2 } from '@signozhq/icons';
|
||||
|
||||
import IndexBadge from './IndexBadge';
|
||||
import MapperFormDrawer from './MapperFormDrawer';
|
||||
import { DraftGroup, DraftMapper, FieldContext } from './types';
|
||||
import { AttributeMappingStore } from './useAttributeMappingStore';
|
||||
import { useMapperFormDrawer } from './useMapperFormDrawer';
|
||||
|
||||
const MAX_VISIBLE_SOURCES = 3;
|
||||
const COLUMN_COUNT = 5;
|
||||
|
||||
interface MappersTableProps {
|
||||
group: DraftGroup;
|
||||
store: AttributeMappingStore;
|
||||
}
|
||||
|
||||
function SourcesCell({ mapper }: { mapper: DraftMapper }): JSX.Element {
|
||||
if (mapper.sources.length === 0) {
|
||||
return <span className="muted">—</span>;
|
||||
}
|
||||
|
||||
const visible = mapper.sources.slice(0, MAX_VISIBLE_SOURCES);
|
||||
const remaining = mapper.sources.length - visible.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mappers-table__sources"
|
||||
data-testid={`mapper-sources-${mapper.localId}`}
|
||||
>
|
||||
{visible.map((source) => (
|
||||
<span
|
||||
className="mappers-table__source-chip"
|
||||
key={`${source.context}:${source.key}`}
|
||||
title={source.key}
|
||||
>
|
||||
{source.key}
|
||||
</span>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<span className="mappers-table__source-more muted">+{remaining} more</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MapperRowProps {
|
||||
mapper: DraftMapper;
|
||||
index: number;
|
||||
onEdit: (mapper: DraftMapper) => void;
|
||||
onDelete: (mapperLocalId: string) => void;
|
||||
onToggle: (mapperLocalId: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function MapperRow({
|
||||
mapper,
|
||||
index,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
}: MapperRowProps): JSX.Element {
|
||||
return (
|
||||
<TableRow data-testid={`mapper-row-${mapper.localId}`}>
|
||||
<TableCell>
|
||||
<IndexBadge index={index} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className="mappers-table__target"
|
||||
data-testid={`mapper-target-${mapper.localId}`}
|
||||
>
|
||||
{mapper.name}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SourcesCell mapper={mapper} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
color={mapper.fieldContext === FieldContext.resource ? 'amber' : 'robin'}
|
||||
variant="outline"
|
||||
>
|
||||
{mapper.fieldContext}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="am-row-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Edit mapping"
|
||||
onClick={(): void => onEdit(mapper)}
|
||||
testId={`mapper-edit-${mapper.localId}`}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
aria-label="Delete mapping"
|
||||
onClick={(): void => onDelete(mapper.localId)}
|
||||
testId={`mapper-delete-${mapper.localId}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
<Switch
|
||||
value={mapper.enabled}
|
||||
onChange={(checked): void => onToggle(mapper.localId, checked)}
|
||||
testId={`mapper-enabled-${mapper.localId}`}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function MappersTable({ group, store }: MappersTableProps): JSX.Element {
|
||||
const drawer = useMapperFormDrawer();
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
store.upsertMapper(group.localId, drawer.draft);
|
||||
drawer.close();
|
||||
}, [store, group.localId, drawer]);
|
||||
|
||||
const handleDelete = useCallback((): void => {
|
||||
if (drawer.draft.id) {
|
||||
store.removeMapper(group.localId, drawer.draft.id);
|
||||
}
|
||||
drawer.close();
|
||||
}, [store, group.localId, drawer]);
|
||||
|
||||
return (
|
||||
<div className="mappers-table__wrapper">
|
||||
<Table testId={`mappers-table-${group.localId}`} className="am-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Target</TableHead>
|
||||
<TableHead>Sources</TableHead>
|
||||
<TableHead>Writes to</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.mappers.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={COLUMN_COUNT} className="am-table__empty">
|
||||
No mappings in this group yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{group.mappers.map((mapper, index) => (
|
||||
<MapperRow
|
||||
key={mapper.localId}
|
||||
mapper={mapper}
|
||||
index={index}
|
||||
onEdit={drawer.openForEdit}
|
||||
onDelete={(localId): void => store.removeMapper(group.localId, localId)}
|
||||
onToggle={(localId, enabled): void =>
|
||||
store.toggleMapper(group.localId, localId, enabled)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Plus size={14} />}
|
||||
className="am-add-row"
|
||||
onClick={drawer.openForAdd}
|
||||
testId={`add-mapper-${group.localId}`}
|
||||
>
|
||||
Add mapping
|
||||
</Button>
|
||||
|
||||
<MapperFormDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
mode={drawer.mode}
|
||||
draft={drawer.draft}
|
||||
setDraft={drawer.setDraft}
|
||||
onClose={drawer.close}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
isSaving={false}
|
||||
isDeleting={false}
|
||||
saveError={null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MappersTable;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, X } from '@signozhq/icons';
|
||||
|
||||
import KeySearchInput from './KeySearchInput';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldContextValue,
|
||||
MapperOperation,
|
||||
MapperOperationValue,
|
||||
SourceConfig,
|
||||
} from './types';
|
||||
|
||||
const CONTEXT_OPTIONS = [
|
||||
{ value: FieldContext.attribute, label: 'Attribute' },
|
||||
{ value: FieldContext.resource, label: 'Resource' },
|
||||
];
|
||||
|
||||
const OPERATION_OPTIONS = [
|
||||
{ value: MapperOperation.move, label: 'Move' },
|
||||
{ value: MapperOperation.copy, label: 'Copy' },
|
||||
];
|
||||
|
||||
interface SourceAttributeRowProps {
|
||||
id: string;
|
||||
index: number;
|
||||
value: SourceConfig;
|
||||
canRemove: boolean;
|
||||
onChange: (index: number, patch: Partial<SourceConfig>) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
// A single draggable source row. Order = priority (top wins). Each source can
|
||||
// be read from a span attribute or the resource, and moved (delete source) or
|
||||
// copied (keep source). Only the grip is a drag handle.
|
||||
function SourceAttributeRow({
|
||||
id,
|
||||
index,
|
||||
value,
|
||||
canRemove,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: SourceAttributeRowProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mapper-form__source" ref={setNodeRef} style={style}>
|
||||
<div
|
||||
className="mapper-form__source-handle"
|
||||
data-testid={`mapper-form-source-handle-${index}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
<span className="mapper-form__source-index">{index + 1}</span>
|
||||
<KeySearchInput
|
||||
className="mapper-form__source-input"
|
||||
placeholder="Source attribute key"
|
||||
value={value.key}
|
||||
fieldContext={value.context}
|
||||
onChange={(key): void => onChange(index, { key })}
|
||||
testId={`mapper-form-source-${index}`}
|
||||
/>
|
||||
<SelectSimple
|
||||
className="mapper-form__source-select"
|
||||
items={CONTEXT_OPTIONS}
|
||||
value={value.context}
|
||||
withPortal={false}
|
||||
onChange={(next): void =>
|
||||
onChange(index, { context: next as FieldContextValue })
|
||||
}
|
||||
testId={`mapper-form-source-context-${index}`}
|
||||
/>
|
||||
<SelectSimple
|
||||
className="mapper-form__source-select"
|
||||
items={OPERATION_OPTIONS}
|
||||
value={value.operation}
|
||||
withPortal={false}
|
||||
onChange={(next): void =>
|
||||
onChange(index, { operation: next as MapperOperationValue })
|
||||
}
|
||||
testId={`mapper-form-source-operation-${index}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Remove source"
|
||||
disabled={!canRemove}
|
||||
onClick={(): void => onRemove(index)}
|
||||
testId={`mapper-form-source-remove-${index}`}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SourceAttributeRow;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import ConditionKeyList from '../ConditionKeyList';
|
||||
import { FieldContext } from '../types';
|
||||
|
||||
const FIELDS_ENDPOINT = '*/api/v1/fields/keys';
|
||||
|
||||
function Harness({ initial = [] as string[] }): JSX.Element {
|
||||
const [keys, setKeys] = useState<string[]>(initial);
|
||||
return (
|
||||
<ConditionKeyList
|
||||
label="Attributes"
|
||||
keys={keys}
|
||||
placeholder="key"
|
||||
addLabel="Add attribute key"
|
||||
testIdPrefix="cond"
|
||||
fieldContext={FieldContext.attribute}
|
||||
onChange={setKeys}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ConditionKeyList', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(FIELDS_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { complete: true, keys: {} } }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders no key rows and only the add button when empty', () => {
|
||||
render(<Harness />);
|
||||
expect(screen.queryByTestId('cond-0')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('cond-add')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a key row when the add button is clicked', () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByTestId('cond-add'));
|
||||
expect(screen.getByTestId('cond-0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes a key row when its remove button is clicked', () => {
|
||||
render(<Harness initial={['gen_ai.', 'llm.']} />);
|
||||
|
||||
expect(screen.getByTestId('cond-0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('cond-1')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('cond-remove-1'));
|
||||
|
||||
expect(screen.queryByTestId('cond-1')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('cond-0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import KeySearchInput from '../KeySearchInput';
|
||||
import { FieldContext } from '../types';
|
||||
|
||||
const FIELDS_ENDPOINT = '*/api/v1/fields/keys';
|
||||
|
||||
const mockKeysResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: {
|
||||
'gen_ai.request.model': [
|
||||
{ name: 'gen_ai.request.model', fieldContext: 'attribute' },
|
||||
],
|
||||
'llm.model': [{ name: 'llm.model', fieldContext: 'attribute' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('KeySearchInput', () => {
|
||||
let lastRequestParams: Record<string, string | null> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
lastRequestParams = {};
|
||||
server.use(
|
||||
rest.get(FIELDS_ENDPOINT, (req, res, ctx) => {
|
||||
lastRequestParams = {
|
||||
signal: req.url.searchParams.get('signal'),
|
||||
fieldContext: req.url.searchParams.get('fieldContext'),
|
||||
searchText: req.url.searchParams.get('searchText'),
|
||||
};
|
||||
return res(ctx.status(200), ctx.json(mockKeysResponse));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('does not show suggestions until the input is focused', () => {
|
||||
render(
|
||||
<KeySearchInput
|
||||
value=""
|
||||
fieldContext={FieldContext.attribute}
|
||||
onChange={jest.fn()}
|
||||
testId="key"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('key-dropdown')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows suggestions on focus and selecting one calls onChange with the key', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<KeySearchInput
|
||||
value=""
|
||||
fieldContext={FieldContext.attribute}
|
||||
onChange={onChange}
|
||||
testId="key"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.focus(screen.getByTestId('key'));
|
||||
|
||||
const option = await screen.findByTestId('key-option-gen_ai.request.model');
|
||||
fireEvent.mouseDown(option);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('gen_ai.request.model');
|
||||
});
|
||||
|
||||
it('queries traces with the resource context when fieldContext is resource', async () => {
|
||||
render(
|
||||
<KeySearchInput
|
||||
value=""
|
||||
fieldContext={FieldContext.resource}
|
||||
onChange={jest.fn()}
|
||||
testId="key"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.focus(screen.getByTestId('key'));
|
||||
|
||||
await waitFor(() => expect(lastRequestParams.fieldContext).toBe('resource'));
|
||||
expect(lastRequestParams.signal).toBe('traces');
|
||||
});
|
||||
|
||||
it('accepts free text typed by the user (custom key)', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<KeySearchInput
|
||||
value=""
|
||||
fieldContext={FieldContext.attribute}
|
||||
onChange={onChange}
|
||||
testId="key"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('key'), {
|
||||
target: { value: 'my.custom.key' },
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('my.custom.key');
|
||||
});
|
||||
|
||||
it('does not query while disabled', () => {
|
||||
render(
|
||||
<KeySearchInput
|
||||
value=""
|
||||
fieldContext={FieldContext.attribute}
|
||||
disabled
|
||||
onChange={jest.fn()}
|
||||
testId="key"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.focus(screen.getByTestId('key'));
|
||||
expect(screen.queryByTestId('key-dropdown')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { SpantypesSpanMapperGroupDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
|
||||
|
||||
const GROUPS_ENDPOINT = '*/api/v1/span_mapper_groups';
|
||||
const MAPPERS_ENDPOINT = '*/api/v1/span_mapper_groups/:groupId/span_mappers';
|
||||
|
||||
const mockGroups: SpantypesSpanMapperGroupDTO[] = [
|
||||
{
|
||||
id: 'group-openai',
|
||||
orgId: 'org-1',
|
||||
name: 'OpenAI gateway',
|
||||
enabled: true,
|
||||
condition: { attributes: ['gen_ai.'], resource: [] },
|
||||
updatedBy: 'gaurav.tewari@signoz.io',
|
||||
updatedAt: '2026-06-10T09:30:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
describe('LLMObservabilityAttributeMapping', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(MAPPERS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: { items: [] } })),
|
||||
),
|
||||
rest.get(GROUPS_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { items: mockGroups } }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the page header and the group row', async () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
await screen.findByTestId('group-name-group-openai');
|
||||
expect(screen.getByText('Attribute Mapping')).toBeInTheDocument();
|
||||
expect(screen.getByText('OpenAI gateway')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the group drawer in Add mode with empty name', async () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
await screen.findByTestId('group-name-group-openai');
|
||||
fireEvent.click(screen.getByTestId('add-group-row'));
|
||||
|
||||
const input = (await screen.findByTestId(
|
||||
'group-form-name',
|
||||
)) as HTMLInputElement;
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('opens the group drawer in Edit mode prefilled with the group name', async () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
await screen.findByTestId('group-name-group-openai');
|
||||
fireEvent.click(screen.getByTestId('group-edit-group-openai'));
|
||||
|
||||
const input = (await screen.findByTestId(
|
||||
'group-form-name',
|
||||
)) as HTMLInputElement;
|
||||
expect(input.value).toBe('OpenAI gateway');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import MapperFormDrawer from '../MapperFormDrawer';
|
||||
import {
|
||||
FieldContext,
|
||||
MapperDraft,
|
||||
MapperDraftMode,
|
||||
MapperOperation,
|
||||
} from '../types';
|
||||
import { EMPTY_MAPPER_DRAFT } from '../utils';
|
||||
|
||||
const FIELDS_ENDPOINT = '*/api/v1/fields/keys';
|
||||
|
||||
const filledDraft: MapperDraft = {
|
||||
id: null,
|
||||
name: 'gen_ai.request.model',
|
||||
fieldContext: FieldContext.attribute,
|
||||
sources: [
|
||||
{
|
||||
key: 'llm.model',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.move,
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
interface HarnessProps {
|
||||
initialDraft?: MapperDraft;
|
||||
mode?: MapperDraftMode;
|
||||
onSave?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function Harness({
|
||||
initialDraft = EMPTY_MAPPER_DRAFT,
|
||||
mode = 'add',
|
||||
onSave = jest.fn(),
|
||||
onDelete = jest.fn(),
|
||||
}: HarnessProps): JSX.Element {
|
||||
const [draft, setDraft] = useState<MapperDraft>(initialDraft);
|
||||
return (
|
||||
<MapperFormDrawer
|
||||
isOpen
|
||||
mode={mode}
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
isSaving={false}
|
||||
isDeleting={false}
|
||||
saveError={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('MapperFormDrawer', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(FIELDS_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { complete: true, keys: {} } }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('disables Create when the target/sources are empty', () => {
|
||||
render(<Harness />);
|
||||
expect(screen.getByTestId('mapper-form-save')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables Create with a target and a source key, and calls onSave', () => {
|
||||
const onSave = jest.fn();
|
||||
render(<Harness initialDraft={filledDraft} onSave={onSave} />);
|
||||
|
||||
const save = screen.getByTestId('mapper-form-save');
|
||||
expect(save).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(save);
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('adds a source row when "Add another source" is clicked', () => {
|
||||
render(<Harness initialDraft={filledDraft} />);
|
||||
|
||||
expect(screen.queryByTestId('mapper-form-source-1')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('mapper-form-add-source'));
|
||||
expect(screen.getByTestId('mapper-form-source-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('makes the target read-only in edit mode and shows delete', () => {
|
||||
const onDelete = jest.fn();
|
||||
render(
|
||||
<Harness
|
||||
initialDraft={{ ...filledDraft, id: 'local-mapper-1' }}
|
||||
mode="edit"
|
||||
onDelete={onDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('mapper-form-target')).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('mapper-form-delete'));
|
||||
expect(onDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { persistDraft, SaveMutations } from '../saveDraft';
|
||||
import {
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
FieldContext,
|
||||
MapperOperation,
|
||||
} from '../types';
|
||||
|
||||
function mapper(over: Partial<DraftMapper>): DraftMapper {
|
||||
return {
|
||||
localId: 'm',
|
||||
serverId: 'm',
|
||||
name: 'm',
|
||||
fieldContext: FieldContext.attribute,
|
||||
sources: [
|
||||
{
|
||||
key: 'x',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.move,
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function group(over: Partial<DraftGroup>): DraftGroup {
|
||||
return {
|
||||
localId: 'g',
|
||||
serverId: 'g',
|
||||
name: 'g',
|
||||
attributes: [],
|
||||
resource: [],
|
||||
enabled: true,
|
||||
mappers: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
interface RecordedCalls {
|
||||
createGroup: unknown[];
|
||||
updateGroup: [string, unknown][];
|
||||
deleteGroup: string[];
|
||||
createMapper: [string, unknown][];
|
||||
updateMapper: [string, string, unknown][];
|
||||
deleteMapper: [string, string][];
|
||||
}
|
||||
|
||||
function makeMutations(): { calls: RecordedCalls; mutations: SaveMutations } {
|
||||
const calls: RecordedCalls = {
|
||||
createGroup: [],
|
||||
updateGroup: [],
|
||||
deleteGroup: [],
|
||||
createMapper: [],
|
||||
updateMapper: [],
|
||||
deleteMapper: [],
|
||||
};
|
||||
const mutations: SaveMutations = {
|
||||
createGroup: async (data): Promise<string> => {
|
||||
calls.createGroup.push(data);
|
||||
return 'new-group-id';
|
||||
},
|
||||
updateGroup: async (id, data): Promise<void> => {
|
||||
calls.updateGroup.push([id, data]);
|
||||
},
|
||||
deleteGroup: async (id): Promise<void> => {
|
||||
calls.deleteGroup.push(id);
|
||||
},
|
||||
createMapper: async (gid, data): Promise<void> => {
|
||||
calls.createMapper.push([gid, data]);
|
||||
},
|
||||
updateMapper: async (gid, mid, data): Promise<void> => {
|
||||
calls.updateMapper.push([gid, mid, data]);
|
||||
},
|
||||
deleteMapper: async (gid, mid): Promise<void> => {
|
||||
calls.deleteMapper.push([gid, mid]);
|
||||
},
|
||||
};
|
||||
return { calls, mutations };
|
||||
}
|
||||
|
||||
describe('persistDraft', () => {
|
||||
it('issues the minimal set of create/update/delete calls', async () => {
|
||||
const snapshot: DraftGroup[] = [
|
||||
group({
|
||||
localId: 'g1',
|
||||
serverId: 'g1',
|
||||
name: 'G1',
|
||||
mappers: [
|
||||
mapper({ localId: 'm1', serverId: 'm1', name: 'keep' }),
|
||||
mapper({ localId: 'mdel', serverId: 'mdel', name: 'del' }),
|
||||
],
|
||||
}),
|
||||
group({ localId: 'g2', serverId: 'g2', name: 'G2' }),
|
||||
];
|
||||
|
||||
const draft: DraftGroup[] = [
|
||||
group({
|
||||
localId: 'g1',
|
||||
serverId: 'g1',
|
||||
name: 'G1-renamed', // changed -> update
|
||||
mappers: [
|
||||
mapper({ localId: 'm1', serverId: 'm1', name: 'keep' }), // unchanged
|
||||
mapper({ localId: 'local-mapper-x', serverId: null, name: 'fresh' }), // new
|
||||
],
|
||||
}),
|
||||
// g2 removed -> delete
|
||||
group({
|
||||
localId: 'local-group-y',
|
||||
serverId: null,
|
||||
name: 'G3',
|
||||
mappers: [
|
||||
mapper({ localId: 'local-mapper-z', serverId: null, name: 'g3map' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const { calls, mutations } = makeMutations();
|
||||
await persistDraft(snapshot, draft, mutations);
|
||||
|
||||
expect(calls.deleteGroup).toStrictEqual(['g2']);
|
||||
expect(calls.updateGroup.map(([id]) => id)).toStrictEqual(['g1']);
|
||||
expect(calls.deleteMapper).toStrictEqual([['g1', 'mdel']]);
|
||||
// no-op mapper is not updated
|
||||
expect(calls.updateMapper).toStrictEqual([]);
|
||||
// new group created once, then its mapper created under the returned id
|
||||
expect(calls.createGroup).toHaveLength(1);
|
||||
const createMapperGroupIds = calls.createMapper.map(([gid]) => gid).sort();
|
||||
expect(createMapperGroupIds).toStrictEqual(['g1', 'new-group-id']);
|
||||
});
|
||||
|
||||
it('does nothing when the draft equals the snapshot', async () => {
|
||||
const snapshot: DraftGroup[] = [group({ localId: 'g1', serverId: 'g1' })];
|
||||
const draft: DraftGroup[] = [group({ localId: 'g1', serverId: 'g1' })];
|
||||
|
||||
const { calls, mutations } = makeMutations();
|
||||
await persistDraft(snapshot, draft, mutations);
|
||||
|
||||
expect(calls.createGroup).toHaveLength(0);
|
||||
expect(calls.updateGroup).toHaveLength(0);
|
||||
expect(calls.deleteGroup).toHaveLength(0);
|
||||
expect(calls.createMapper).toHaveLength(0);
|
||||
expect(calls.updateMapper).toHaveLength(0);
|
||||
expect(calls.deleteMapper).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
import {
|
||||
DraftGroup,
|
||||
FieldContext,
|
||||
Mapper,
|
||||
MapperDraft,
|
||||
MapperGroup,
|
||||
MapperOperation,
|
||||
SourceConfig,
|
||||
} from '../types';
|
||||
import {
|
||||
buildDraftGroup,
|
||||
buildDraftMapper,
|
||||
buildPostableGroup,
|
||||
buildPostableMapper,
|
||||
buildUpdatableMapper,
|
||||
cleanKeys,
|
||||
conditionFiltersFromGroup,
|
||||
EMPTY_GROUP_DRAFT,
|
||||
EMPTY_MAPPER_DRAFT,
|
||||
formatTimestamp,
|
||||
getMapperSources,
|
||||
groupDraftFromNode,
|
||||
isGroupDraftValid,
|
||||
isMapperDraftValid,
|
||||
mapperDraftFromNode,
|
||||
nodeFromGroupDraft,
|
||||
nodeFromMapperDraft,
|
||||
} from '../utils';
|
||||
|
||||
function src(
|
||||
key: string,
|
||||
operation = MapperOperation.copy,
|
||||
context = FieldContext.attribute,
|
||||
): SourceConfig {
|
||||
return { key, context, operation };
|
||||
}
|
||||
|
||||
function mapperDraft(over: Partial<MapperDraft>): MapperDraft {
|
||||
return {
|
||||
id: null,
|
||||
name: 'target',
|
||||
fieldContext: FieldContext.attribute,
|
||||
sources: [src('a')],
|
||||
enabled: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const mapper: Mapper = {
|
||||
id: 'm1',
|
||||
group_id: 'g1',
|
||||
name: 'gen_ai.request.model',
|
||||
enabled: true,
|
||||
fieldContext: FieldContext.attribute,
|
||||
config: {
|
||||
sources: [
|
||||
{
|
||||
key: 'low',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
key: 'high',
|
||||
context: FieldContext.resource,
|
||||
operation: MapperOperation.move,
|
||||
priority: 3,
|
||||
},
|
||||
{
|
||||
key: 'mid',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const group: MapperGroup = {
|
||||
id: 'g1',
|
||||
orgId: 'org1',
|
||||
name: 'OpenAI',
|
||||
enabled: true,
|
||||
condition: { attributes: ['gen_ai.', 'llm.'], resource: [] },
|
||||
};
|
||||
|
||||
describe('attribute-mapping utils', () => {
|
||||
describe('cleanKeys', () => {
|
||||
it('trims, drops empties, and de-duplicates preserving order', () => {
|
||||
expect(cleanKeys([' a ', '', 'b', 'a', ' '])).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMapperSources', () => {
|
||||
it('returns sources sorted by priority descending, preserving context/operation', () => {
|
||||
expect(getMapperSources(mapper)).toStrictEqual([
|
||||
{
|
||||
key: 'high',
|
||||
context: FieldContext.resource,
|
||||
operation: MapperOperation.move,
|
||||
},
|
||||
{
|
||||
key: 'mid',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
},
|
||||
{
|
||||
key: 'low',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when there are no sources', () => {
|
||||
expect(
|
||||
getMapperSources({ ...mapper, config: { sources: null } }),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conditionFiltersFromGroup', () => {
|
||||
it('lists attribute clauses first, then resource clauses', () => {
|
||||
expect(
|
||||
conditionFiltersFromGroup({
|
||||
attributes: ['gen_ai.', 'llm.'],
|
||||
resource: ['service.name'],
|
||||
}),
|
||||
).toStrictEqual([
|
||||
{ context: 'attribute', key: 'gen_ai.' },
|
||||
{ context: 'attribute', key: 'llm.' },
|
||||
{ context: 'resource', key: 'service.name' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTimestamp', () => {
|
||||
it('renders a dash for missing timestamps', () => {
|
||||
expect(formatTimestamp(undefined)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('draft-tree builders', () => {
|
||||
it('builds a draft mapper node carrying server id, fieldContext and sources', () => {
|
||||
expect(buildDraftMapper(mapper)).toStrictEqual({
|
||||
localId: 'm1',
|
||||
serverId: 'm1',
|
||||
name: 'gen_ai.request.model',
|
||||
fieldContext: FieldContext.attribute,
|
||||
sources: [
|
||||
{
|
||||
key: 'high',
|
||||
context: FieldContext.resource,
|
||||
operation: MapperOperation.move,
|
||||
},
|
||||
{
|
||||
key: 'mid',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
},
|
||||
{
|
||||
key: 'low',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a draft group node with nested mappers', () => {
|
||||
const node = buildDraftGroup(group, [mapper]);
|
||||
expect(node.localId).toBe('g1');
|
||||
expect(node.attributes).toStrictEqual(['gen_ai.', 'llm.']);
|
||||
expect(node.mappers).toHaveLength(1);
|
||||
expect(node.mappers[0].localId).toBe('m1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('node <-> form conversions', () => {
|
||||
const node: DraftGroup = {
|
||||
localId: 'g1',
|
||||
serverId: 'g1',
|
||||
name: 'OpenAI',
|
||||
attributes: ['gen_ai.'],
|
||||
resource: ['service.name'],
|
||||
enabled: true,
|
||||
mappers: [],
|
||||
};
|
||||
|
||||
it('builds a form draft from a group node', () => {
|
||||
expect(groupDraftFromNode(node)).toStrictEqual({
|
||||
id: 'g1',
|
||||
name: 'OpenAI',
|
||||
attributes: ['gen_ai.'],
|
||||
resource: ['service.name'],
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates an existing group node, preserving its ids and mappers', () => {
|
||||
const existing = { ...node, mappers: [buildDraftMapper(mapper)] };
|
||||
const updated = nodeFromGroupDraft(
|
||||
{
|
||||
id: 'g1',
|
||||
name: ' Renamed ',
|
||||
attributes: ['a', '', 'a'],
|
||||
resource: ['service.name', ''],
|
||||
enabled: false,
|
||||
},
|
||||
existing,
|
||||
);
|
||||
expect(updated.serverId).toBe('g1');
|
||||
expect(updated.name).toBe('Renamed');
|
||||
expect(updated.attributes).toStrictEqual(['a']);
|
||||
expect(updated.resource).toStrictEqual(['service.name']);
|
||||
expect(updated.mappers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('round-trips a mapper node through the form and de-dups sources by key', () => {
|
||||
const draft = mapperDraftFromNode(buildDraftMapper(mapper));
|
||||
expect(draft.id).toBe('m1');
|
||||
expect(draft.fieldContext).toBe(FieldContext.attribute);
|
||||
|
||||
const created = nodeFromMapperDraft(
|
||||
mapperDraft({
|
||||
id: null,
|
||||
sources: [src('a', MapperOperation.move), src(' '), src('a'), src('b')],
|
||||
}),
|
||||
);
|
||||
expect(created.serverId).toBeNull();
|
||||
expect(created.localId).toMatch(/^local-mapper-/);
|
||||
expect(created.sources).toStrictEqual([
|
||||
{
|
||||
key: 'a',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.move,
|
||||
},
|
||||
{
|
||||
key: 'b',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('validates a mapper draft (name + at least one keyed source)', () => {
|
||||
expect(isMapperDraftValid(EMPTY_MAPPER_DRAFT)).toBe(false);
|
||||
expect(
|
||||
isMapperDraftValid(mapperDraft({ name: 'x', sources: [src(' ')] })),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isMapperDraftValid(mapperDraft({ name: 'x', sources: [src('a')] })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('validates a group draft (name only)', () => {
|
||||
expect(isGroupDraftValid(EMPTY_GROUP_DRAFT)).toBe(false);
|
||||
expect(
|
||||
isGroupDraftValid({
|
||||
id: null,
|
||||
name: 'g',
|
||||
attributes: [],
|
||||
resource: [],
|
||||
enabled: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API payload builders', () => {
|
||||
it('derives descending priorities and carries per-source context/operation', () => {
|
||||
const payload = buildPostableMapper(
|
||||
mapperDraft({
|
||||
name: ' target ',
|
||||
fieldContext: FieldContext.resource,
|
||||
sources: [
|
||||
src('a', MapperOperation.move),
|
||||
src('b', MapperOperation.copy, FieldContext.resource),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(payload.name).toBe('target');
|
||||
expect(payload.fieldContext).toBe(FieldContext.resource);
|
||||
expect(payload.config.sources).toStrictEqual([
|
||||
{
|
||||
key: 'a',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.move,
|
||||
priority: 2,
|
||||
},
|
||||
{
|
||||
key: 'b',
|
||||
context: FieldContext.resource,
|
||||
operation: MapperOperation.copy,
|
||||
priority: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits name on the mapper update payload (immutable target)', () => {
|
||||
const payload = buildUpdatableMapper(
|
||||
mapperDraft({
|
||||
id: 'm1',
|
||||
enabled: false,
|
||||
fieldContext: FieldContext.resource,
|
||||
}),
|
||||
);
|
||||
expect(payload).not.toHaveProperty('name');
|
||||
expect(payload.enabled).toBe(false);
|
||||
expect(payload.fieldContext).toBe(FieldContext.resource);
|
||||
});
|
||||
|
||||
it('cleans both attribute and resource condition keys', () => {
|
||||
const payload = buildPostableGroup({
|
||||
id: null,
|
||||
name: 'g',
|
||||
attributes: ['gen_ai.', '', 'gen_ai.'],
|
||||
resource: ['service.name', ' ', 'service.name'],
|
||||
enabled: true,
|
||||
});
|
||||
expect(payload.condition).toStrictEqual({
|
||||
attributes: ['gen_ai.'],
|
||||
resource: ['service.name'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { DraftGroup, DraftMapper, SourceConfig } from './types';
|
||||
import {
|
||||
buildPostableGroup,
|
||||
buildPostableMapper,
|
||||
buildUpdatableGroup,
|
||||
buildUpdatableMapper,
|
||||
} from './utils';
|
||||
|
||||
// Thin persistence surface the store wires to the generated mutations.
|
||||
// createGroup returns the new server id so its mappers can be created under it.
|
||||
export interface SaveMutations {
|
||||
createGroup: (data: SpantypesPostableSpanMapperGroupDTO) => Promise<string>;
|
||||
updateGroup: (
|
||||
groupId: string,
|
||||
data: SpantypesUpdatableSpanMapperGroupDTO,
|
||||
) => Promise<void>;
|
||||
deleteGroup: (groupId: string) => Promise<void>;
|
||||
createMapper: (
|
||||
groupId: string,
|
||||
data: SpantypesPostableSpanMapperDTO,
|
||||
) => Promise<void>;
|
||||
updateMapper: (
|
||||
groupId: string,
|
||||
mapperId: string,
|
||||
data: SpantypesUpdatableSpanMapperDTO,
|
||||
) => Promise<void>;
|
||||
deleteMapper: (groupId: string, mapperId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function arraysEqual(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((value, index) => value === b[index]);
|
||||
}
|
||||
|
||||
function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
|
||||
return (
|
||||
a.length === b.length &&
|
||||
a.every(
|
||||
(source, index) =>
|
||||
source.key === b[index].key &&
|
||||
source.context === b[index].context &&
|
||||
source.operation === b[index].operation,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function groupChanged(snapshot: DraftGroup, draft: DraftGroup): boolean {
|
||||
return (
|
||||
snapshot.name !== draft.name ||
|
||||
snapshot.enabled !== draft.enabled ||
|
||||
!arraysEqual(snapshot.attributes, draft.attributes) ||
|
||||
!arraysEqual(snapshot.resource, draft.resource)
|
||||
);
|
||||
}
|
||||
|
||||
function mapperChanged(snapshot: DraftMapper, draft: DraftMapper): boolean {
|
||||
return (
|
||||
snapshot.enabled !== draft.enabled ||
|
||||
snapshot.fieldContext !== draft.fieldContext ||
|
||||
!sourcesEqual(snapshot.sources, draft.sources)
|
||||
);
|
||||
}
|
||||
|
||||
function groupDraftOf(
|
||||
node: DraftGroup,
|
||||
): Parameters<typeof buildPostableGroup>[0] {
|
||||
return {
|
||||
id: node.serverId,
|
||||
name: node.name,
|
||||
attributes: node.attributes,
|
||||
resource: node.resource,
|
||||
enabled: node.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function mapperDraftOf(
|
||||
node: DraftMapper,
|
||||
): Parameters<typeof buildPostableMapper>[0] {
|
||||
return {
|
||||
id: node.serverId,
|
||||
name: node.name,
|
||||
fieldContext: node.fieldContext,
|
||||
sources: node.sources,
|
||||
enabled: node.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistMappers(
|
||||
groupServerId: string,
|
||||
snapshotMappers: DraftMapper[],
|
||||
draftMappers: DraftMapper[],
|
||||
m: SaveMutations,
|
||||
): Promise<void> {
|
||||
const snapById = new Map(
|
||||
snapshotMappers
|
||||
.filter((mapper) => mapper.serverId)
|
||||
.map((mapper) => [mapper.serverId as string, mapper]),
|
||||
);
|
||||
const draftServerIds = new Set(
|
||||
draftMappers
|
||||
.filter((mapper) => mapper.serverId)
|
||||
.map((mapper) => mapper.serverId as string),
|
||||
);
|
||||
|
||||
// Deleted mappers.
|
||||
await Promise.all(
|
||||
snapshotMappers
|
||||
.filter((mapper) => mapper.serverId && !draftServerIds.has(mapper.serverId))
|
||||
.map((mapper) => m.deleteMapper(groupServerId, mapper.serverId as string)),
|
||||
);
|
||||
|
||||
// Created + updated mappers (sequential to keep ordering deterministic).
|
||||
for (const mapper of draftMappers) {
|
||||
if (!mapper.serverId) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await m.createMapper(
|
||||
groupServerId,
|
||||
buildPostableMapper(mapperDraftOf(mapper)),
|
||||
);
|
||||
} else {
|
||||
const snap = snapById.get(mapper.serverId);
|
||||
if (!snap || mapperChanged(snap, mapper)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await m.updateMapper(
|
||||
groupServerId,
|
||||
mapper.serverId,
|
||||
buildUpdatableMapper(mapperDraftOf(mapper)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Diffs the staged tree against the server snapshot and issues the minimal set
|
||||
// of create/update/delete calls to reconcile them.
|
||||
export async function persistDraft(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
m: SaveMutations,
|
||||
): Promise<void> {
|
||||
const snapById = new Map(
|
||||
snapshot
|
||||
.filter((group) => group.serverId)
|
||||
.map((group) => [group.serverId as string, group]),
|
||||
);
|
||||
const draftServerIds = new Set(
|
||||
draft
|
||||
.filter((group) => group.serverId)
|
||||
.map((group) => group.serverId as string),
|
||||
);
|
||||
|
||||
// Deleted groups (cascades mappers server-side).
|
||||
await Promise.all(
|
||||
snapshot
|
||||
.filter((group) => group.serverId && !draftServerIds.has(group.serverId))
|
||||
.map((group) => m.deleteGroup(group.serverId as string)),
|
||||
);
|
||||
|
||||
for (const group of draft) {
|
||||
if (!group.serverId) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const newId = await m.createGroup(buildPostableGroup(groupDraftOf(group)));
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await persistMappers(newId, [], group.mappers, m);
|
||||
continue;
|
||||
}
|
||||
|
||||
const snap = snapById.get(group.serverId);
|
||||
if (!snap || groupChanged(snap, group)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await m.updateGroup(
|
||||
group.serverId,
|
||||
buildUpdatableGroup(groupDraftOf(group)),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await persistMappers(group.serverId, snap?.mappers ?? [], group.mappers, m);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
SpantypesFieldContextDTO,
|
||||
SpantypesSpanMapperConfigDTO,
|
||||
SpantypesSpanMapperDTO,
|
||||
SpantypesSpanMapperGroupConditionDTO,
|
||||
SpantypesSpanMapperGroupDTO,
|
||||
SpantypesSpanMapperOperationDTO,
|
||||
SpantypesSpanMapperSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type MapperGroup = SpantypesSpanMapperGroupDTO;
|
||||
export type MapperGroupCondition =
|
||||
NonNullable<SpantypesSpanMapperGroupConditionDTO>;
|
||||
export type Mapper = SpantypesSpanMapperDTO;
|
||||
export type MapperConfig = SpantypesSpanMapperConfigDTO;
|
||||
export type MapperSource = SpantypesSpanMapperSourceDTO;
|
||||
|
||||
export const FieldContext = SpantypesFieldContextDTO;
|
||||
export const MapperOperation = SpantypesSpanMapperOperationDTO;
|
||||
|
||||
export type FieldContextValue = SpantypesFieldContextDTO;
|
||||
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
|
||||
|
||||
// A single human-readable condition clause shown in the group's Filters column.
|
||||
export interface ConditionFilter {
|
||||
context: 'attribute' | 'resource';
|
||||
key: string;
|
||||
}
|
||||
|
||||
export type MapperDraftMode = 'add' | 'edit';
|
||||
|
||||
// One source candidate. `context` is where the key is read from (span
|
||||
// attribute or resource); `operation` is move (delete source) or copy (keep).
|
||||
// Priority is implicit in list order (top wins), derived on save.
|
||||
export interface SourceConfig {
|
||||
key: string;
|
||||
context: SpantypesFieldContextDTO;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
}
|
||||
|
||||
// Editable form state for a mapper. `sources` is ordered highest priority
|
||||
// first; `fieldContext` is where the standardized target is written.
|
||||
export interface MapperDraft {
|
||||
id: string | null;
|
||||
name: string;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
sources: SourceConfig[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Editable form state for a group. The group runs when a span carries a
|
||||
// span-attribute key matching `attributes` OR a resource key matching
|
||||
// `resource` (plain substring match).
|
||||
export interface GroupDraft {
|
||||
id: string | null;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Working-copy node for a mapper. `localId` is a stable client key (the server
|
||||
// id once persisted, or a temporary id for not-yet-saved rows). `serverId` is
|
||||
// null until the row has been persisted.
|
||||
export interface DraftMapper {
|
||||
localId: string;
|
||||
serverId: string | null;
|
||||
name: string;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
sources: SourceConfig[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Working-copy node for a group, holding its mappers inline so the whole tree
|
||||
// can be staged locally and diffed against the server snapshot on save.
|
||||
export interface DraftGroup {
|
||||
localId: string;
|
||||
serverId: string | null;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
enabled: boolean;
|
||||
mappers: DraftMapper[];
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useQueries, useQueryClient } from 'react-query';
|
||||
import {
|
||||
getListSpanMappersQueryOptions,
|
||||
useCreateSpanMapper,
|
||||
useCreateSpanMapperGroup,
|
||||
useDeleteSpanMapper,
|
||||
useDeleteSpanMapperGroup,
|
||||
useListSpanMapperGroups,
|
||||
useUpdateSpanMapper,
|
||||
useUpdateSpanMapperGroup,
|
||||
} from 'api/generated/services/spanmapper';
|
||||
|
||||
import { persistDraft, SaveMutations } from './saveDraft';
|
||||
import {
|
||||
DraftGroup,
|
||||
GroupDraft,
|
||||
Mapper,
|
||||
MapperDraft,
|
||||
MapperGroup,
|
||||
} from './types';
|
||||
import {
|
||||
buildDraftGroup,
|
||||
nodeFromGroupDraft,
|
||||
nodeFromMapperDraft,
|
||||
} from './utils';
|
||||
|
||||
const GROUPS_KEY_PREFIX = '/api/v1/span_mapper_groups';
|
||||
|
||||
function clone(groups: DraftGroup[]): DraftGroup[] {
|
||||
return JSON.parse(JSON.stringify(groups)) as DraftGroup[];
|
||||
}
|
||||
|
||||
export interface AttributeMappingStore {
|
||||
groups: DraftGroup[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
saveError: string | null;
|
||||
upsertGroup: (draft: GroupDraft) => void;
|
||||
removeGroup: (localId: string) => void;
|
||||
toggleGroup: (localId: string, enabled: boolean) => void;
|
||||
upsertMapper: (groupLocalId: string, draft: MapperDraft) => void;
|
||||
removeMapper: (groupLocalId: string, mapperLocalId: string) => void;
|
||||
toggleMapper: (
|
||||
groupLocalId: string,
|
||||
mapperLocalId: string,
|
||||
enabled: boolean,
|
||||
) => void;
|
||||
save: () => Promise<void>;
|
||||
discard: () => void;
|
||||
}
|
||||
|
||||
export function useAttributeMappingStore(): AttributeMappingStore {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const groupsQuery = useListSpanMapperGroups();
|
||||
const serverGroups: MapperGroup[] = useMemo(
|
||||
() => groupsQuery.data?.data?.items ?? [],
|
||||
[groupsQuery.data],
|
||||
);
|
||||
|
||||
const mapperQueries = useQueries(
|
||||
serverGroups.map((group) =>
|
||||
getListSpanMappersQueryOptions({ groupId: group.id }),
|
||||
),
|
||||
);
|
||||
|
||||
const mappersReady = mapperQueries.every((query) => !query.isLoading);
|
||||
const ready = !groupsQuery.isLoading && mappersReady;
|
||||
|
||||
// Stable signature so the snapshot only rebuilds when server data changes.
|
||||
const dataSignature = useMemo(
|
||||
() =>
|
||||
JSON.stringify(serverGroups) +
|
||||
JSON.stringify(mapperQueries.map((query) => query.data?.data?.items ?? [])),
|
||||
[serverGroups, mapperQueries],
|
||||
);
|
||||
|
||||
const snapshot = useMemo<DraftGroup[]>(() => {
|
||||
if (!ready) {
|
||||
return [];
|
||||
}
|
||||
return serverGroups.map((group, index) =>
|
||||
buildDraftGroup(
|
||||
group,
|
||||
(mapperQueries[index]?.data?.data?.items ?? []) as unknown as Mapper[],
|
||||
),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, dataSignature]);
|
||||
|
||||
const [draft, setDraft] = useState<DraftGroup[] | null>(null);
|
||||
|
||||
// Initialise the working copy once data is ready (and re-init after a save
|
||||
// clears it). Never clobbers in-flight edits — only runs when draft is null.
|
||||
useEffect(() => {
|
||||
if (ready && draft === null) {
|
||||
setDraft(clone(snapshot));
|
||||
}
|
||||
}, [ready, draft, snapshot]);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const { mutateAsync: createGroup } = useCreateSpanMapperGroup();
|
||||
const { mutateAsync: updateGroup } = useUpdateSpanMapperGroup();
|
||||
const { mutateAsync: deleteGroup } = useDeleteSpanMapperGroup();
|
||||
const { mutateAsync: createMapper } = useCreateSpanMapper();
|
||||
const { mutateAsync: updateMapper } = useUpdateSpanMapper();
|
||||
const { mutateAsync: deleteMapper } = useDeleteSpanMapper();
|
||||
|
||||
const mutations: SaveMutations = useMemo(
|
||||
() => ({
|
||||
createGroup: async (data): Promise<string> => {
|
||||
const result = await createGroup({ data });
|
||||
return result.data.id;
|
||||
},
|
||||
updateGroup: async (groupId, data): Promise<void> => {
|
||||
await updateGroup({ pathParams: { groupId }, data });
|
||||
},
|
||||
deleteGroup: async (groupId): Promise<void> => {
|
||||
await deleteGroup({ pathParams: { groupId } });
|
||||
},
|
||||
createMapper: async (groupId, data): Promise<void> => {
|
||||
await createMapper({ pathParams: { groupId }, data });
|
||||
},
|
||||
updateMapper: async (groupId, mapperId, data): Promise<void> => {
|
||||
await updateMapper({ pathParams: { groupId, mapperId }, data });
|
||||
},
|
||||
deleteMapper: async (groupId, mapperId): Promise<void> => {
|
||||
await deleteMapper({ pathParams: { groupId, mapperId } });
|
||||
},
|
||||
}),
|
||||
[
|
||||
createGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
createMapper,
|
||||
updateMapper,
|
||||
deleteMapper,
|
||||
],
|
||||
);
|
||||
|
||||
const upsertGroup = useCallback((groupDraft: GroupDraft): void => {
|
||||
setDraft((prev) => {
|
||||
const groups = prev ?? [];
|
||||
if (groupDraft.id) {
|
||||
return groups.map((group) =>
|
||||
group.localId === groupDraft.id
|
||||
? nodeFromGroupDraft(groupDraft, group)
|
||||
: group,
|
||||
);
|
||||
}
|
||||
return [...groups, nodeFromGroupDraft(groupDraft)];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeGroup = useCallback((localId: string): void => {
|
||||
setDraft((prev) => (prev ?? []).filter((group) => group.localId !== localId));
|
||||
}, []);
|
||||
|
||||
const toggleGroup = useCallback((localId: string, enabled: boolean): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) =>
|
||||
group.localId === localId ? { ...group, enabled } : group,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const upsertMapper = useCallback(
|
||||
(groupLocalId: string, mapperDraft: MapperDraft): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) => {
|
||||
if (group.localId !== groupLocalId) {
|
||||
return group;
|
||||
}
|
||||
if (mapperDraft.id) {
|
||||
return {
|
||||
...group,
|
||||
mappers: group.mappers.map((mapper) =>
|
||||
mapper.localId === mapperDraft.id
|
||||
? nodeFromMapperDraft(mapperDraft, mapper)
|
||||
: mapper,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...group,
|
||||
mappers: [...group.mappers, nodeFromMapperDraft(mapperDraft)],
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeMapper = useCallback(
|
||||
(groupLocalId: string, mapperLocalId: string): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) =>
|
||||
group.localId === groupLocalId
|
||||
? {
|
||||
...group,
|
||||
mappers: group.mappers.filter(
|
||||
(mapper) => mapper.localId !== mapperLocalId,
|
||||
),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleMapper = useCallback(
|
||||
(groupLocalId: string, mapperLocalId: string, enabled: boolean): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) =>
|
||||
group.localId === groupLocalId
|
||||
? {
|
||||
...group,
|
||||
mappers: group.mappers.map((mapper) =>
|
||||
mapper.localId === mapperLocalId ? { ...mapper, enabled } : mapper,
|
||||
),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const discard = useCallback((): void => {
|
||||
setSaveError(null);
|
||||
setDraft(clone(snapshot));
|
||||
}, [snapshot]);
|
||||
|
||||
const save = useCallback(async (): Promise<void> => {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await persistDraft(snapshot, draft, mutations);
|
||||
await queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
typeof query.queryKey?.[0] === 'string' &&
|
||||
(query.queryKey[0] as string).startsWith(GROUPS_KEY_PREFIX),
|
||||
});
|
||||
// Re-initialise the working copy from the freshly-fetched server data.
|
||||
setDraft(null);
|
||||
toast.success('Attribute mapping changes saved');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Save failed';
|
||||
setSaveError(message);
|
||||
toast.error(`Failed to save changes: ${message}`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [draft, snapshot, mutations, queryClient]);
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => draft !== null && JSON.stringify(draft) !== JSON.stringify(snapshot),
|
||||
[draft, snapshot],
|
||||
);
|
||||
|
||||
return {
|
||||
groups: draft ?? [],
|
||||
isLoading: !ready || draft === null,
|
||||
isError: groupsQuery.isError,
|
||||
isDirty,
|
||||
isSaving,
|
||||
saveError,
|
||||
upsertGroup,
|
||||
removeGroup,
|
||||
toggleGroup,
|
||||
upsertMapper,
|
||||
removeMapper,
|
||||
toggleMapper,
|
||||
save,
|
||||
discard,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { DraftGroup, GroupDraft, MapperDraftMode } from './types';
|
||||
import { EMPTY_GROUP_DRAFT, groupDraftFromNode } from './utils';
|
||||
|
||||
interface UseGroupFormDrawerResult {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: GroupDraft;
|
||||
setDraft: (next: GroupDraft) => void;
|
||||
openForAdd: () => void;
|
||||
openForEdit: (group: DraftGroup) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
// Form state for the group drawer. Persistence is staged through the store,
|
||||
// so this hook only owns open/draft/mode.
|
||||
export function useGroupFormDrawer(): UseGroupFormDrawerResult {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [mode, setMode] = useState<MapperDraftMode>('add');
|
||||
const [draft, setDraft] = useState<GroupDraft>(EMPTY_GROUP_DRAFT);
|
||||
|
||||
const openForAdd = useCallback((): void => {
|
||||
setMode('add');
|
||||
setDraft(EMPTY_GROUP_DRAFT);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const openForEdit = useCallback((group: DraftGroup): void => {
|
||||
setMode('edit');
|
||||
setDraft(groupDraftFromNode(group));
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { DraftMapper, MapperDraft, MapperDraftMode } from './types';
|
||||
import { EMPTY_MAPPER_DRAFT, mapperDraftFromNode } from './utils';
|
||||
|
||||
interface UseMapperFormDrawerResult {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: MapperDraft;
|
||||
setDraft: (next: MapperDraft) => void;
|
||||
openForAdd: () => void;
|
||||
openForEdit: (mapper: DraftMapper) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
// Form state for the mapper drawer. Persistence is staged through the store,
|
||||
// so this hook only owns open/draft/mode.
|
||||
export function useMapperFormDrawer(): UseMapperFormDrawerResult {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [mode, setMode] = useState<MapperDraftMode>('add');
|
||||
const [draft, setDraft] = useState<MapperDraft>(EMPTY_MAPPER_DRAFT);
|
||||
|
||||
const openForAdd = useCallback((): void => {
|
||||
setMode('add');
|
||||
setDraft(EMPTY_MAPPER_DRAFT);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const openForEdit = useCallback((mapper: DraftMapper): void => {
|
||||
setMode('edit');
|
||||
setDraft(mapperDraftFromNode(mapper));
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
|
||||
}
|
||||
262
frontend/src/container/LLMObservabilityAttributeMapping/utils.ts
Normal file
262
frontend/src/container/LLMObservabilityAttributeMapping/utils.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
ConditionFilter,
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
FieldContext,
|
||||
GroupDraft,
|
||||
Mapper,
|
||||
MapperDraft,
|
||||
MapperGroup,
|
||||
MapperOperation,
|
||||
SourceConfig,
|
||||
} from './types';
|
||||
|
||||
// Client-side id for not-yet-persisted rows. Prefixed so it never collides
|
||||
// with a server UUID and is easy to spot in logs.
|
||||
export function genLocalId(prefix: 'group' | 'mapper'): string {
|
||||
return `local-${prefix}-${uuid()}`;
|
||||
}
|
||||
|
||||
// Trimmed, de-duplicated, non-empty keys preserving input order.
|
||||
export function cleanKeys(keys: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
keys.forEach((raw) => {
|
||||
const key = raw.trim();
|
||||
if (key && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(key);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Display clauses for a group's staged condition keys (span attribute keys
|
||||
// first, then resource keys).
|
||||
export function conditionFiltersFromGroup(group: {
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
}): ConditionFilter[] {
|
||||
return [
|
||||
...group.attributes.map((key) => ({ context: 'attribute' as const, key })),
|
||||
...group.resource.map((key) => ({ context: 'resource' as const, key })),
|
||||
];
|
||||
}
|
||||
|
||||
// Source configs for a mapper, highest priority first (first match wins at
|
||||
// evaluation time).
|
||||
export function getMapperSources(mapper: Mapper): SourceConfig[] {
|
||||
const sources = mapper.config?.sources ?? [];
|
||||
return [...sources]
|
||||
.sort((a, b) => b.priority - a.priority)
|
||||
.map((source) => ({
|
||||
key: source.key,
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
}));
|
||||
}
|
||||
|
||||
// A blank source row. New sources default to `move` so the original key is
|
||||
// removed once standardized (the PRD default — minimizes duplication).
|
||||
export function createEmptySource(): SourceConfig {
|
||||
return {
|
||||
key: '',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.move,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTimestamp(iso?: string): string {
|
||||
if (!iso) {
|
||||
return '—';
|
||||
}
|
||||
return dayjs(iso).format('MMM D, YYYY HH:mm');
|
||||
}
|
||||
|
||||
export const EMPTY_MAPPER_DRAFT: MapperDraft = {
|
||||
id: null,
|
||||
name: '',
|
||||
fieldContext: FieldContext.attribute,
|
||||
sources: [createEmptySource()],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// Trimmed, de-duplicated (by key), non-empty sources in priority order,
|
||||
// preserving each source's context and operation.
|
||||
export function getCleanSources(draft: MapperDraft): SourceConfig[] {
|
||||
const seen = new Set<string>();
|
||||
const result: SourceConfig[] = [];
|
||||
draft.sources.forEach((source) => {
|
||||
const key = source.key.trim();
|
||||
if (key && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push({ ...source, key });
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isMapperDraftValid(draft: MapperDraft): boolean {
|
||||
return draft.name.trim().length > 0 && getCleanSources(draft).length > 0;
|
||||
}
|
||||
|
||||
// Priority is derived from list order so the first row wins.
|
||||
function buildSources(
|
||||
draft: MapperDraft,
|
||||
): SpantypesPostableSpanMapperDTO['config']['sources'] {
|
||||
const sources = getCleanSources(draft);
|
||||
return sources.map((source, index) => ({
|
||||
key: source.key,
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
priority: sources.length - index,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildPostableMapper(
|
||||
draft: MapperDraft,
|
||||
): SpantypesPostableSpanMapperDTO {
|
||||
return {
|
||||
name: draft.name.trim(),
|
||||
fieldContext: draft.fieldContext,
|
||||
enabled: draft.enabled,
|
||||
config: { sources: buildSources(draft) },
|
||||
};
|
||||
}
|
||||
|
||||
// The target name is immutable on update (UpdatableSpanMapper has no name).
|
||||
export function buildUpdatableMapper(
|
||||
draft: MapperDraft,
|
||||
): SpantypesUpdatableSpanMapperDTO {
|
||||
return {
|
||||
fieldContext: draft.fieldContext,
|
||||
enabled: draft.enabled,
|
||||
config: { sources: buildSources(draft) },
|
||||
};
|
||||
}
|
||||
|
||||
export const EMPTY_GROUP_DRAFT: GroupDraft = {
|
||||
id: null,
|
||||
name: '',
|
||||
attributes: [''],
|
||||
resource: [],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export function isGroupDraftValid(draft: GroupDraft): boolean {
|
||||
return draft.name.trim().length > 0;
|
||||
}
|
||||
|
||||
export function buildPostableGroup(
|
||||
draft: GroupDraft,
|
||||
): SpantypesPostableSpanMapperGroupDTO {
|
||||
return {
|
||||
name: draft.name.trim(),
|
||||
enabled: draft.enabled,
|
||||
condition: {
|
||||
attributes: cleanKeys(draft.attributes),
|
||||
resource: cleanKeys(draft.resource),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A full group payload is also a valid partial-update payload (all updatable
|
||||
// fields are present), so we reuse the postable builder.
|
||||
export function buildUpdatableGroup(
|
||||
draft: GroupDraft,
|
||||
): SpantypesUpdatableSpanMapperGroupDTO {
|
||||
return buildPostableGroup(draft);
|
||||
}
|
||||
|
||||
// ---- working-copy (draft tree) helpers ----
|
||||
|
||||
export function buildDraftMapper(mapper: Mapper): DraftMapper {
|
||||
return {
|
||||
localId: mapper.id,
|
||||
serverId: mapper.id,
|
||||
name: mapper.name,
|
||||
fieldContext: mapper.fieldContext,
|
||||
sources: getMapperSources(mapper),
|
||||
enabled: mapper.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDraftGroup(
|
||||
group: MapperGroup,
|
||||
mappers: Mapper[],
|
||||
): DraftGroup {
|
||||
return {
|
||||
localId: group.id,
|
||||
serverId: group.id,
|
||||
name: group.name,
|
||||
attributes: group.condition?.attributes ?? [],
|
||||
resource: group.condition?.resource ?? [],
|
||||
enabled: group.enabled,
|
||||
mappers: mappers.map(buildDraftMapper),
|
||||
};
|
||||
}
|
||||
|
||||
// DraftGroup -> editable form state (id carries the localId).
|
||||
export function groupDraftFromNode(group: DraftGroup): GroupDraft {
|
||||
return {
|
||||
id: group.localId,
|
||||
name: group.name,
|
||||
attributes: group.attributes.length > 0 ? group.attributes : [''],
|
||||
resource: group.resource,
|
||||
enabled: group.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
// DraftMapper -> editable form state (id carries the localId).
|
||||
export function mapperDraftFromNode(mapper: DraftMapper): MapperDraft {
|
||||
return {
|
||||
id: mapper.localId,
|
||||
name: mapper.name,
|
||||
fieldContext: mapper.fieldContext,
|
||||
sources:
|
||||
mapper.sources.length > 0
|
||||
? mapper.sources.map((source) => ({ ...source }))
|
||||
: [createEmptySource()],
|
||||
enabled: mapper.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
// Form state -> working-copy node. Reuses cleanKeys/getCleanSourceKeys so the
|
||||
// staged tree already holds normalized values.
|
||||
export function nodeFromGroupDraft(
|
||||
draft: GroupDraft,
|
||||
existing?: DraftGroup,
|
||||
): DraftGroup {
|
||||
return {
|
||||
localId: existing?.localId ?? genLocalId('group'),
|
||||
serverId: existing?.serverId ?? null,
|
||||
name: draft.name.trim(),
|
||||
attributes: cleanKeys(draft.attributes),
|
||||
resource: cleanKeys(draft.resource),
|
||||
enabled: draft.enabled,
|
||||
mappers: existing?.mappers ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeFromMapperDraft(
|
||||
draft: MapperDraft,
|
||||
existing?: DraftMapper,
|
||||
): DraftMapper {
|
||||
return {
|
||||
localId: existing?.localId ?? genLocalId('mapper'),
|
||||
serverId: existing?.serverId ?? null,
|
||||
name: draft.name.trim(),
|
||||
fieldContext: draft.fieldContext,
|
||||
sources: getCleanSources(draft),
|
||||
enabled: draft.enabled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
.llm-observability-model-pricing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px 32px;
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
&__title {
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-tabs {
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filters-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
|
||||
&__search {
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
&__source,
|
||||
&__currency {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
&__add {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.page-error {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 90, 90, 0.08);
|
||||
color: var(--bg-cherry-400);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.page-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.model-costs-table {
|
||||
.ant-table-thead > tr > th {
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.model-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
// Allow the flex children to shrink below their content width so the
|
||||
// table's fixed-layout / nowrap cells truncate instead of overflowing
|
||||
// into the Provider column.
|
||||
min-width: 0;
|
||||
|
||||
&__name {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__name,
|
||||
&__canonical-id {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__canonical-id {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.price-cell {
|
||||
font-family: var(--code-font-family, monospace);
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.extra-buckets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
&__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__key {
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__price {
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
margin: 0;
|
||||
|
||||
&--auto {
|
||||
background: rgba(78, 116, 248, 0.12);
|
||||
color: var(--bg-robin-400);
|
||||
border-color: rgba(78, 116, 248, 0.24);
|
||||
}
|
||||
|
||||
&--override {
|
||||
background: rgba(245, 175, 25, 0.12);
|
||||
color: var(--bg-amber-400);
|
||||
border-color: rgba(245, 175, 25, 0.24);
|
||||
}
|
||||
}
|
||||
|
||||
&__row--selected {
|
||||
background: rgba(78, 116, 248, 0.06);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Pagination } from '@signozhq/ui/pagination';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Plus, Search } from '@signozhq/icons';
|
||||
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import ModelCostDrawer from './ModelCostDrawer';
|
||||
import ModelCostsTable from './ModelCostsTable';
|
||||
import { useModelCostDrawer } from './useModelCostDrawer';
|
||||
import type { PricingRule, SourceFilter } from './types';
|
||||
import { filterRules } from './utils';
|
||||
|
||||
import './LLMObservabilityModelPricing.styles.scss';
|
||||
|
||||
const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [
|
||||
{ value: 'all', label: 'Source: All' },
|
||||
{ value: 'auto', label: 'Auto-populated' },
|
||||
{ value: 'override', label: 'User override' },
|
||||
];
|
||||
|
||||
const CURRENCY_OPTIONS = [
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'EUR', label: 'EUR', disabled: true },
|
||||
{ value: 'INR', label: 'INR', disabled: true },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function LLMObservabilityModelPricing(): JSX.Element {
|
||||
const [search, setSearch] = useState<string>('');
|
||||
const [source, setSource] = useState<SourceFilter>('all');
|
||||
const [currency, setCurrency] = useState<string>('USD');
|
||||
const [page, setPage] = useState<number>(1);
|
||||
|
||||
const { data, isLoading, isError } = useListLLMPricingRules({
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [canManagePricing] = useComponentPermission(
|
||||
['manage_llm_pricing'],
|
||||
user.role,
|
||||
);
|
||||
|
||||
const rules: PricingRule[] = useMemo(() => data?.data?.items || [], [data]);
|
||||
const total = data?.data?.total ?? 0;
|
||||
|
||||
const filteredRules = useMemo(
|
||||
() => filterRules(rules, search, source),
|
||||
[rules, search, source],
|
||||
);
|
||||
|
||||
const drawer = useModelCostDrawer();
|
||||
|
||||
// Search/source filter the current page client-side (the list endpoint only
|
||||
// supports offset/limit), so reset to the first page when they change.
|
||||
const resetToFirstPage = (): void => setPage(1);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="llm-observability-model-pricing"
|
||||
data-testid="llm-observability-model-pricing-page"
|
||||
>
|
||||
<header className="page-header">
|
||||
<div className="page-header__title">
|
||||
<h1>Configuration</h1>
|
||||
<p>Model pricing and cost estimation settings</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
className="page-tabs"
|
||||
defaultValue="model-costs"
|
||||
items={[
|
||||
{ key: 'model-costs', label: 'Model costs', children: null },
|
||||
{
|
||||
key: 'unpriced-models',
|
||||
label: 'Unpriced models',
|
||||
disabled: true,
|
||||
children: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="filters-bar">
|
||||
<Input
|
||||
className="filters-bar__search"
|
||||
placeholder="Search by model or provider…"
|
||||
prefix={<Search size={14} />}
|
||||
value={search}
|
||||
onChange={(event): void => {
|
||||
setSearch(event.target.value);
|
||||
resetToFirstPage();
|
||||
}}
|
||||
testId="search-input"
|
||||
/>
|
||||
<SelectSimple
|
||||
className="filters-bar__source"
|
||||
value={source}
|
||||
onChange={(value): void => {
|
||||
setSource(value as SourceFilter);
|
||||
resetToFirstPage();
|
||||
}}
|
||||
items={SOURCE_OPTIONS}
|
||||
testId="source-select"
|
||||
/>
|
||||
<SelectSimple
|
||||
className="filters-bar__currency"
|
||||
value={currency}
|
||||
onChange={(value): void => setCurrency(value as string)}
|
||||
items={CURRENCY_OPTIONS}
|
||||
testId="currency-select"
|
||||
/>
|
||||
{canManagePricing && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className="filters-bar__add"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => drawer.openForAdd()}
|
||||
testId="add-model-cost-btn"
|
||||
>
|
||||
Add model cost
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className="page-error" role="alert">
|
||||
Failed to load pricing rules. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelCostsTable
|
||||
rules={filteredRules}
|
||||
isLoading={isLoading}
|
||||
selectedRuleId={drawer.selectedRuleId}
|
||||
canManage={canManagePricing}
|
||||
onEdit={drawer.openForEdit}
|
||||
/>
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
<Pagination
|
||||
className="page-pagination"
|
||||
total={total}
|
||||
pageSize={PAGE_SIZE}
|
||||
current={page}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<footer className="page-footer">
|
||||
Showing {filteredRules.length} of {total} model{total === 1 ? '' : 's'}
|
||||
{' · '}All prices per 1M tokens (USD)
|
||||
</footer>
|
||||
|
||||
<ModelCostDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
mode={drawer.mode}
|
||||
draft={drawer.draft}
|
||||
setDraft={drawer.setDraft}
|
||||
onClose={drawer.close}
|
||||
onSave={drawer.save}
|
||||
onDelete={drawer.deleteRule}
|
||||
isSaving={drawer.isSaving}
|
||||
isDeleting={drawer.isDeleting}
|
||||
saveError={drawer.saveError}
|
||||
canManage={canManagePricing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LLMObservabilityModelPricing;
|
||||
@@ -0,0 +1,313 @@
|
||||
.model-cost-drawer {
|
||||
// Uniform horizontal padding across header / body / footer. The header and
|
||||
// footer read these dialog vars; the body (rendered in drawer-description)
|
||||
// is set directly below.
|
||||
--dialog-header-padding: 20px 24px;
|
||||
--dialog-footer-padding: 16px 24px;
|
||||
|
||||
// The drawer body — children render inside [data-slot='drawer-description']
|
||||
// (this is the @signozhq drawer, not antd, so .ant-drawer-body was a no-op).
|
||||
[data-slot='drawer-description'] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
[data-slot='select-content'] {
|
||||
width: var(--radix-select-trigger-width);
|
||||
}
|
||||
display: flex;
|
||||
overflow-y: auto;
|
||||
|
||||
&__title {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
// Horizontal padding is provided by the drawer-footer slot var above.
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
|
||||
&-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
label,
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--bg-vanilla-200);
|
||||
}
|
||||
|
||||
.help {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.drawer-surface {
|
||||
padding: 14px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.managed-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.pattern-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.pattern-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.pattern-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&__remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-left: 2px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pattern-add {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.help {
|
||||
code {
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.source-radio-group {
|
||||
// @signozhq/ui's RadioGroupItem defaults its unchecked border to
|
||||
// --l3-background, which matches the drawer surface and makes the dot
|
||||
// invisible. Override with a contrasting border so users can see the
|
||||
// unchecked state.
|
||||
--radio-group-item-border-color: var(--bg-slate-200);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
// Layout overrides for @signozhq/ui's RadioGroupItem wrapper. The
|
||||
// library injects single-class CSS at runtime (after our bundled
|
||||
// stylesheet loads), so we use a two-class selector to win the
|
||||
// cascade and force the wrapper to lay the dot on the left with the
|
||||
// label text flush beside it.
|
||||
.source-radio {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
// Include padding + border in the 100% width so the card fits inside
|
||||
// the SOURCE surface instead of overflowing its right edge.
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.12s ease,
|
||||
border-color 0.12s ease;
|
||||
|
||||
// The radio button itself: keep it fixed-size and aligned with
|
||||
// the title baseline (margin-top compensates for align-items:
|
||||
// flex-start vs the title's line-box).
|
||||
> button[role='radio'] {
|
||||
flex: 0 0 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
// The library wraps children in a <label>. Make it grow into the
|
||||
// remaining width and reset the .drawer-section label typography
|
||||
// leak (set earlier in this file) so the title/desc divs use
|
||||
// their own styles.
|
||||
> label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
|
||||
// Use :has() to highlight the wrapper card when its inner button is checked.
|
||||
&.source-radio--auto:has(button[data-state='checked']) {
|
||||
background: rgba(78, 116, 248, 0.1);
|
||||
border-color: rgba(78, 116, 248, 0.3);
|
||||
}
|
||||
|
||||
&.source-radio--override:has(button[data-state='checked']) {
|
||||
background: rgba(245, 175, 25, 0.1);
|
||||
border-color: rgba(245, 175, 25, 0.3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reset-confirm {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(78, 116, 248, 0.06);
|
||||
border: 1px solid rgba(78, 116, 248, 0.2);
|
||||
|
||||
p {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.pricing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pricing-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.cache-mode-field {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.extras-divider {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.drawer-error {
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 90, 90, 0.08);
|
||||
color: var(--bg-cherry-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Trash2 } from '@signozhq/icons';
|
||||
|
||||
import PatternEditor from './PatternEditor';
|
||||
import PricingFields from './PricingFields';
|
||||
import SourceSelector from './SourceSelector';
|
||||
import { PROVIDER_OPTIONS } from './constants';
|
||||
import { validateDraft } from './utils';
|
||||
import type { DrawerDraft, DrawerMode } from './types';
|
||||
import './ModelCostDrawer.styles.scss';
|
||||
|
||||
interface ModelCostDrawerProps {
|
||||
isOpen: boolean;
|
||||
mode: DrawerMode;
|
||||
draft: DrawerDraft;
|
||||
setDraft: (next: DrawerDraft) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
saveError: string | null;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
function ModelCostDrawer({
|
||||
isOpen,
|
||||
mode,
|
||||
draft,
|
||||
setDraft,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
canManage,
|
||||
}: ModelCostDrawerProps): JSX.Element {
|
||||
// Metadata (model id / provider / patterns / source) is editable by any
|
||||
// manager. Pricing fields are editable only once the user picks "User
|
||||
// override" — auto-populated pricing is managed by SigNoz. Write APIs are
|
||||
// Admin-only, so non-managers can't edit anything.
|
||||
const metadataReadOnly = !canManage;
|
||||
const pricingReadOnly = !canManage || !draft.isOverride;
|
||||
|
||||
const validation = validateDraft(draft, mode);
|
||||
const showValidationTooltip =
|
||||
canManage && !validation.ok && !!validation.message;
|
||||
|
||||
const update = (patch: Partial<DrawerDraft>): void => {
|
||||
setDraft({ ...draft, ...patch });
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<div className="model-cost-drawer__footer">
|
||||
{mode === 'edit' && canManage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
prefix={<Trash2 size={14} />}
|
||||
onClick={onDelete}
|
||||
loading={isDeleting}
|
||||
testId="drawer-delete-btn"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<div className="model-cost-drawer__footer-right">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
testId="drawer-cancel-btn"
|
||||
>
|
||||
{canManage ? 'Cancel' : 'Close'}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<TooltipSimple
|
||||
title={showValidationTooltip ? validation.message : ''}
|
||||
withPortal={false}
|
||||
>
|
||||
{/* span wrapper so the tooltip fires even when the button is disabled */}
|
||||
<span className="model-cost-drawer__save-wrap">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
loading={isSaving}
|
||||
disabled={!validation.ok}
|
||||
testId="drawer-save-btn"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
width="base"
|
||||
className="model-cost-drawer"
|
||||
footer={footer}
|
||||
title={mode === 'edit' ? 'Edit model cost' : 'Add model cost'}
|
||||
subTitle="Pricing computes gen_ai.estimated_total_cost at ingest."
|
||||
drawerHeaderProps={{ className: 'model-cost-drawer__title' }}
|
||||
>
|
||||
<div className="drawer-section">
|
||||
<label htmlFor="billing-model-id">Billing model ID</label>
|
||||
<Input
|
||||
id="billing-model-id"
|
||||
placeholder="e.g. openai:gpt-4o"
|
||||
value={draft.modelName}
|
||||
disabled={mode === 'edit' || metadataReadOnly}
|
||||
onChange={(e): void => update({ modelName: e.target.value })}
|
||||
testId="drawer-model-id-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="drawer-section">
|
||||
<label htmlFor="provider-select">Provider</label>
|
||||
<SelectSimple
|
||||
id="provider-select"
|
||||
value={draft.provider}
|
||||
onChange={(value): void => update({ provider: value as string })}
|
||||
items={PROVIDER_OPTIONS}
|
||||
disabled={mode === 'edit' || metadataReadOnly}
|
||||
className="full-width"
|
||||
withPortal={false}
|
||||
testId="drawer-provider-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PatternEditor
|
||||
patterns={draft.patterns}
|
||||
isReadOnly={metadataReadOnly}
|
||||
onChange={(patterns): void => update({ patterns })}
|
||||
/>
|
||||
|
||||
<SourceSelector
|
||||
isOverride={draft.isOverride}
|
||||
isReadOnly={metadataReadOnly}
|
||||
onChange={(isOverride): void => update({ isOverride })}
|
||||
/>
|
||||
|
||||
<PricingFields
|
||||
pricing={draft.pricing}
|
||||
isReadOnly={pricingReadOnly}
|
||||
onChange={(patch): void =>
|
||||
setDraft({ ...draft, pricing: { ...draft.pricing, ...patch } })
|
||||
}
|
||||
/>
|
||||
|
||||
{saveError && (
|
||||
<div className="drawer-error" role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostDrawer;
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@signozhq/ui/table';
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import { startCase } from 'lodash-es';
|
||||
|
||||
import type { PricingRule } from './types';
|
||||
import {
|
||||
formatPricePerMillion,
|
||||
getCanonicalId,
|
||||
getExtraBuckets,
|
||||
getRelativeLastSeen,
|
||||
getSourceLabel,
|
||||
} from './utils';
|
||||
|
||||
const COLUMN_COUNT = 8;
|
||||
|
||||
interface ModelCostsTableProps {
|
||||
rules: PricingRule[];
|
||||
isLoading: boolean;
|
||||
selectedRuleId: string | null;
|
||||
canManage: boolean;
|
||||
onEdit: (rule: PricingRule) => void;
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
rule: PricingRule;
|
||||
isSelected: boolean;
|
||||
canManage: boolean;
|
||||
onEdit: (rule: PricingRule) => void;
|
||||
}
|
||||
|
||||
function ModelCostRow({
|
||||
rule,
|
||||
isSelected,
|
||||
canManage,
|
||||
onEdit,
|
||||
}: RowProps): JSX.Element {
|
||||
const buckets = getExtraBuckets(rule);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
className={cx({ 'model-costs-table__row--selected': isSelected })}
|
||||
data-testid={`model-cost-row-${rule.id}`}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="model-cell">
|
||||
<div
|
||||
className="model-cell__name"
|
||||
data-testid={`model-cell-name-${rule.id}`}
|
||||
>
|
||||
{rule.modelName}
|
||||
</div>
|
||||
<div
|
||||
className="model-cell__canonical-id"
|
||||
data-testid={`model-cell-canonical-id-${rule.id}`}
|
||||
>
|
||||
{getCanonicalId(rule)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{rule.provider}</TableCell>
|
||||
<TableCell>
|
||||
<span className="price-cell" data-testid={`price-cell-input-${rule.id}`}>
|
||||
{formatPricePerMillion(rule.pricing?.input)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="price-cell" data-testid={`price-cell-output-${rule.id}`}>
|
||||
{formatPricePerMillion(rule.pricing?.output)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{buckets.length === 0 ? (
|
||||
<span className="muted">—</span>
|
||||
) : (
|
||||
<div className="extra-buckets">
|
||||
{buckets.map((bucket) => (
|
||||
<Badge
|
||||
key={bucket.key}
|
||||
color="vanilla"
|
||||
variant="outline"
|
||||
className="extra-buckets__chip"
|
||||
>
|
||||
<span className="extra-buckets__key">{startCase(bucket.key)}</span>
|
||||
<span className="extra-buckets__price">
|
||||
{formatPricePerMillion(bucket.pricePerMillion)}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
color={rule.isOverride ? 'amber' : 'robin'}
|
||||
variant="outline"
|
||||
className="source-badge"
|
||||
data-testid={`source-badge-${rule.id}`}
|
||||
>
|
||||
{getSourceLabel(rule)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{getRelativeLastSeen(rule)}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
suffix={<ChevronDown size={14} />}
|
||||
testId={`edit-rule-${rule.id}`}
|
||||
onClick={(): void => onEdit(rule)}
|
||||
>
|
||||
{canManage ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelCostsTable({
|
||||
rules,
|
||||
isLoading,
|
||||
selectedRuleId,
|
||||
canManage,
|
||||
onEdit,
|
||||
}: ModelCostsTableProps): JSX.Element {
|
||||
return (
|
||||
<Table className="model-costs-table" testId="model-costs-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>Input / 1M</TableHead>
|
||||
<TableHead>Output / 1M</TableHead>
|
||||
<TableHead>Extra buckets</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead>Last seen</TableHead>
|
||||
<TableHead aria-label="Actions" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && rules.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={COLUMN_COUNT} className="model-costs-table__empty">
|
||||
Loading pricing rules…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!isLoading && rules.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={COLUMN_COUNT} className="model-costs-table__empty">
|
||||
No model costs yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{rules.map((rule) => (
|
||||
<ModelCostRow
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
isSelected={rule.id === selectedRuleId}
|
||||
canManage={canManage}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostsTable;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
interface PatternEditorProps {
|
||||
patterns: string[];
|
||||
isReadOnly: boolean;
|
||||
onChange: (patterns: string[]) => void;
|
||||
}
|
||||
|
||||
// Model-name prefix patterns as removable chips + an add input.
|
||||
function PatternEditor({
|
||||
patterns,
|
||||
isReadOnly,
|
||||
onChange,
|
||||
}: PatternEditorProps): JSX.Element {
|
||||
const [patternInput, setPatternInput] = useState<string>('');
|
||||
|
||||
const addPattern = (): void => {
|
||||
const next = patternInput.trim();
|
||||
if (!next || patterns.includes(next)) {
|
||||
setPatternInput('');
|
||||
return;
|
||||
}
|
||||
onChange([...patterns, next]);
|
||||
setPatternInput('');
|
||||
};
|
||||
|
||||
const removePattern = (pattern: string): void => {
|
||||
onChange(patterns.filter((p) => p !== pattern));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="drawer-section">
|
||||
<span className="field-label">
|
||||
Model name patterns <span className="muted">(prefix match)</span>
|
||||
</span>
|
||||
<div className="pattern-box">
|
||||
<div className="pattern-chips">
|
||||
{patterns.map((pattern) => (
|
||||
<Badge
|
||||
key={pattern}
|
||||
color="vanilla"
|
||||
variant="outline"
|
||||
className="pattern-chip"
|
||||
>
|
||||
{pattern}*
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove pattern ${pattern}`}
|
||||
className="pattern-chip__remove"
|
||||
onClick={(): void => removePattern(pattern)}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<div className="pattern-add">
|
||||
<Input
|
||||
placeholder="Add pattern…"
|
||||
value={patternInput}
|
||||
onChange={(e): void => setPatternInput(e.target.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addPattern();
|
||||
}
|
||||
}}
|
||||
testId="drawer-pattern-input"
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={addPattern}
|
||||
testId="drawer-pattern-add-btn"
|
||||
>
|
||||
+ Add
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted help">
|
||||
Each pattern uses <strong>prefix matching</strong> against{' '}
|
||||
<code>gen_ai.request.model</code>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PatternEditor;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Lock } from '@signozhq/icons';
|
||||
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { CACHE_MODE_OPTIONS } from './constants';
|
||||
import type { DrawerDraft } from './types';
|
||||
|
||||
type Pricing = DrawerDraft['pricing'];
|
||||
|
||||
interface PricingFieldsProps {
|
||||
pricing: Pricing;
|
||||
isReadOnly: boolean;
|
||||
onChange: (patch: Partial<Pricing>) => void;
|
||||
}
|
||||
|
||||
// Parses a number input's raw string. Empty → null (used by optional buckets),
|
||||
// otherwise a finite number (NaN coerced to 0).
|
||||
function parseAmount(raw: string): number | null {
|
||||
if (raw.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function PricingFields({
|
||||
pricing,
|
||||
isReadOnly,
|
||||
onChange,
|
||||
}: PricingFieldsProps): JSX.Element {
|
||||
const hasCacheBucket =
|
||||
pricing.cacheRead !== null || pricing.cacheWrite !== null;
|
||||
|
||||
return (
|
||||
<div className="drawer-section drawer-surface">
|
||||
<div className="drawer-surface__head">
|
||||
<h4>Pricing (per 1M tokens, USD)</h4>
|
||||
{isReadOnly && (
|
||||
<span className="managed-label" data-testid="drawer-readonly-label">
|
||||
<Lock size={12} />
|
||||
Read-only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pricing-grid">
|
||||
<div className="pricing-field">
|
||||
<label htmlFor="input-cost">
|
||||
Input cost <span className="required">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="input-cost"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={pricing.input}
|
||||
disabled={isReadOnly}
|
||||
onChange={(e): void =>
|
||||
onChange({ input: parseAmount(e.target.value) ?? 0 })
|
||||
}
|
||||
testId="drawer-input-cost"
|
||||
/>
|
||||
</div>
|
||||
<div className="pricing-field">
|
||||
<label htmlFor="output-cost">
|
||||
Output cost <span className="required">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="output-cost"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={pricing.output}
|
||||
disabled={isReadOnly}
|
||||
onChange={(e): void =>
|
||||
onChange({ output: parseAmount(e.target.value) ?? 0 })
|
||||
}
|
||||
testId="drawer-output-cost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="extras-divider">Extra pricing buckets</div>
|
||||
<div className="pricing-grid">
|
||||
<div className="pricing-field">
|
||||
<label htmlFor="cache-read">cache_read</label>
|
||||
<Input
|
||||
id="cache-read"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={pricing.cacheRead ?? ''}
|
||||
placeholder="—"
|
||||
disabled={isReadOnly}
|
||||
onChange={(e): void =>
|
||||
onChange({ cacheRead: parseAmount(e.target.value) })
|
||||
}
|
||||
testId="drawer-cache-read-cost"
|
||||
/>
|
||||
</div>
|
||||
<div className="pricing-field">
|
||||
<label htmlFor="cache-write">cache_write</label>
|
||||
<Input
|
||||
id="cache-write"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={pricing.cacheWrite ?? ''}
|
||||
placeholder="—"
|
||||
disabled={isReadOnly}
|
||||
onChange={(e): void =>
|
||||
onChange({ cacheWrite: parseAmount(e.target.value) })
|
||||
}
|
||||
testId="drawer-cache-write-cost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasCacheBucket && (
|
||||
<div className="pricing-field cache-mode-field">
|
||||
<label htmlFor="cache-mode">Cache mode</label>
|
||||
<SelectSimple
|
||||
id="cache-mode"
|
||||
value={pricing.cacheMode}
|
||||
items={CACHE_MODE_OPTIONS}
|
||||
onChange={(v): void => onChange({ cacheMode: v as CacheModeDTO })}
|
||||
disabled={isReadOnly}
|
||||
className="full-width"
|
||||
withPortal={false}
|
||||
testId="drawer-cache-mode"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingFields;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Lock } from '@signozhq/icons';
|
||||
|
||||
interface SourceSelectorProps {
|
||||
isOverride: boolean;
|
||||
isReadOnly: boolean;
|
||||
onChange: (isOverride: boolean) => void;
|
||||
}
|
||||
|
||||
// Auto-populated vs user-override selector, with a confirm step before
|
||||
// discarding custom values back to defaults.
|
||||
function SourceSelector({
|
||||
isOverride,
|
||||
isReadOnly,
|
||||
onChange,
|
||||
}: SourceSelectorProps): JSX.Element {
|
||||
const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
|
||||
|
||||
const handleSourceChange = (value: 'auto' | 'override'): void => {
|
||||
if (value === 'auto' && isOverride) {
|
||||
setShowResetConfirm(true);
|
||||
return;
|
||||
}
|
||||
if (value === 'override' && !isOverride) {
|
||||
onChange(true);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReset = (): void => {
|
||||
onChange(false);
|
||||
setShowResetConfirm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="drawer-section drawer-surface">
|
||||
<div className="drawer-surface__head">
|
||||
<h4>Source</h4>
|
||||
{isReadOnly && (
|
||||
<span className="managed-label" data-testid="drawer-managed-label">
|
||||
<Lock size={12} />
|
||||
Managed by SigNoz
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={isOverride ? 'override' : 'auto'}
|
||||
onChange={(value): void => handleSourceChange(value as 'auto' | 'override')}
|
||||
className="source-radio-group"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="auto"
|
||||
containerClassName="source-radio source-radio--auto"
|
||||
testId="drawer-source-auto"
|
||||
>
|
||||
<div className="source-radio__title">Auto-populated</div>
|
||||
<div className="source-radio__desc">Default pricing from SigNoz.</div>
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
value="override"
|
||||
containerClassName="source-radio source-radio--override"
|
||||
testId="drawer-source-override"
|
||||
>
|
||||
<div className="source-radio__title">User override</div>
|
||||
<div className="source-radio__desc">Custom pricing. Takes precedence.</div>
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
{showResetConfirm && (
|
||||
<div
|
||||
className="reset-confirm"
|
||||
role="dialog"
|
||||
aria-label="Reset to default pricing"
|
||||
>
|
||||
<p>
|
||||
Reset to default pricing? Custom values will be discarded. it might take
|
||||
24 hours for changes to take effect.
|
||||
</p>
|
||||
<div className="reset-confirm__actions">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={(): void => setShowResetConfirm(false)}
|
||||
testId="drawer-reset-keep-btn"
|
||||
>
|
||||
Keep
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={confirmReset}
|
||||
testId="drawer-reset-confirm-btn"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SourceSelector;
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
type LlmpricingruletypesLLMPricingRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import LLMObservabilityModelPricing from '../LLMObservabilityModelPricing';
|
||||
|
||||
const ENDPOINT = '*/api/v1/llm_pricing_rules';
|
||||
|
||||
const mockRules: LlmpricingruletypesLLMPricingRuleDTO[] = [
|
||||
{
|
||||
id: 'rule-gpt4o',
|
||||
orgId: 'org-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
modelPattern: ['gpt-4o'],
|
||||
isOverride: false,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 15, output: 60 },
|
||||
},
|
||||
{
|
||||
id: 'rule-llama',
|
||||
orgId: 'org-1',
|
||||
modelName: 'llama-3.1-70b',
|
||||
provider: 'Self-hosted',
|
||||
modelPattern: ['llama-3.1'],
|
||||
isOverride: true,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 0, output: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
describe('LLMObservabilityModelPricing', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
items: mockRules,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
total: mockRules.length,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the page header and both rules', async () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
await screen.findByText('gpt-4o');
|
||||
expect(screen.getByText('Configuration')).toBeInTheDocument();
|
||||
expect(screen.getByText('llama-3.1-70b')).toBeInTheDocument();
|
||||
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters rules by the search input', async () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
await screen.findByText('gpt-4o');
|
||||
|
||||
fireEvent.change(screen.getByTestId('search-input'), {
|
||||
target: { value: 'llama' },
|
||||
});
|
||||
|
||||
expect(screen.queryByText('gpt-4o')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('llama-3.1-70b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the drawer in Add mode when the Add button is clicked', async () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
await screen.findByText('gpt-4o');
|
||||
fireEvent.click(screen.getByTestId('add-model-cost-btn'));
|
||||
|
||||
const input = (await screen.findByTestId(
|
||||
'drawer-model-id-input',
|
||||
)) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('opens the drawer in Edit mode with prefilled values when a row Edit is clicked', async () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
await screen.findByText('gpt-4o');
|
||||
fireEvent.click(screen.getByTestId('edit-rule-rule-gpt4o'));
|
||||
|
||||
const input = (await screen.findByTestId(
|
||||
'drawer-model-id-input',
|
||||
)) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.value).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
it('hides the Add button for non-admin users (write APIs are Admin-only)', async () => {
|
||||
render(<LLMObservabilityModelPricing />, {}, { role: 'VIEWER' });
|
||||
|
||||
await screen.findByText('gpt-4o');
|
||||
expect(screen.queryByTestId('add-model-cost-btn')).not.toBeInTheDocument();
|
||||
// rows still open in read-only "View" mode
|
||||
expect(screen.getByTestId('edit-rule-rule-gpt4o')).toHaveTextContent('View');
|
||||
});
|
||||
|
||||
it('paginates server-side: selecting page 2 requests the next offset', async () => {
|
||||
const requestedOffsets: number[] = [];
|
||||
server.use(
|
||||
rest.get(ENDPOINT, (req, res, ctx) => {
|
||||
const offset = Number(req.url.searchParams.get('offset') ?? '0');
|
||||
requestedOffsets.push(offset);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{ ...mockRules[0], id: `rule-${offset}`, modelName: `model-${offset}` },
|
||||
],
|
||||
limit: 20,
|
||||
offset,
|
||||
total: 25,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
await screen.findByText('model-0');
|
||||
fireEvent.click(screen.getByRole('button', { name: '2' }));
|
||||
|
||||
await screen.findByText('model-20');
|
||||
expect(requestedOffsets).toContain(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useState } from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import { EMPTY_DRAFT } from '../constants';
|
||||
import type { DrawerDraft } from '../types';
|
||||
import ModelCostDrawer from '../ModelCostDrawer';
|
||||
|
||||
interface HarnessProps {
|
||||
initialDraft?: DrawerDraft;
|
||||
mode?: 'add' | 'edit';
|
||||
canManage?: boolean;
|
||||
onSave?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function Harness({
|
||||
initialDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 1 },
|
||||
},
|
||||
mode = 'add',
|
||||
canManage = true,
|
||||
onSave = jest.fn(),
|
||||
onDelete = jest.fn(),
|
||||
}: HarnessProps): JSX.Element {
|
||||
const [draft, setDraft] = useState<DrawerDraft>(initialDraft);
|
||||
return (
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode={mode}
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
isSaving={false}
|
||||
isDeleting={false}
|
||||
saveError={null}
|
||||
canManage={canManage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ModelCostDrawer', () => {
|
||||
it('adds a pattern chip when the user types and presses Enter', () => {
|
||||
render(<Harness />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('drawer-pattern-input'), {
|
||||
target: { value: 'gpt-4o-mini' },
|
||||
});
|
||||
fireEvent.keyDown(screen.getByTestId('drawer-pattern-input'), {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
});
|
||||
|
||||
expect(screen.getByText('gpt-4o-mini*')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables pricing fields when isOverride is false', () => {
|
||||
render(
|
||||
<Harness
|
||||
mode="edit"
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
id: 'rule-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled();
|
||||
expect(screen.getByTestId('drawer-output-cost')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables pricing fields when isOverride is true', () => {
|
||||
render(
|
||||
<Harness
|
||||
mode="edit"
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
id: 'rule-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('drawer-input-cost')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('drawer-output-cost')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables the Provider select in Edit mode but allows it in Add mode', () => {
|
||||
const { unmount } = render(<Harness mode="add" />);
|
||||
|
||||
expect(screen.getByTestId('drawer-provider-select')).not.toHaveAttribute(
|
||||
'data-disabled',
|
||||
);
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<Harness
|
||||
mode="edit"
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
id: 'rule-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('drawer-provider-select')).toHaveAttribute(
|
||||
'data-disabled',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps metadata editable but locks pricing when source is auto-populated', () => {
|
||||
render(
|
||||
<Harness
|
||||
mode="add"
|
||||
initialDraft={{ ...EMPTY_DRAFT, modelName: 'gpt-4o', isOverride: false }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Metadata stays editable while the rule is auto-populated…
|
||||
expect(screen.getByTestId('drawer-model-id-input')).not.toBeDisabled();
|
||||
// …but pricing is read-only until "User override" is chosen.
|
||||
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows a reset confirmation when switching from Override to Auto', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<Harness
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
isOverride: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('drawer-source-auto'));
|
||||
|
||||
expect(screen.getByTestId('drawer-reset-confirm-btn')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-reset-keep-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the Delete action in Add mode', () => {
|
||||
render(<Harness mode="add" />);
|
||||
expect(screen.queryByTestId('drawer-delete-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the Delete action in Edit mode', () => {
|
||||
render(
|
||||
<Harness
|
||||
mode="edit"
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
id: 'rule-1',
|
||||
modelName: 'gpt-4o',
|
||||
isOverride: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('drawer-delete-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the provider select in Edit mode', () => {
|
||||
render(
|
||||
<Harness
|
||||
mode="edit"
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
id: 'rule-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const providerNode = screen.getByTestId('drawer-provider-select');
|
||||
expect(providerNode.className).toMatch(/ant-select-disabled/);
|
||||
});
|
||||
|
||||
it('calls onSave when the Save button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(<Harness onSave={onSave} />);
|
||||
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('is read-only when the user cannot manage pricing (hides Save/Delete)', () => {
|
||||
render(
|
||||
<Harness
|
||||
mode="edit"
|
||||
canManage={false}
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
id: 'rule-1',
|
||||
modelName: 'gpt-4o',
|
||||
isOverride: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('drawer-save-btn')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('drawer-delete-btn')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-model-id-input')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { EMPTY_DRAFT } from '../constants';
|
||||
import type { DrawerDraft, PricingRule } from '../types';
|
||||
import {
|
||||
buildPricingPayload,
|
||||
buildRulePayload,
|
||||
draftFromRule,
|
||||
validateDraft,
|
||||
} from '../utils';
|
||||
|
||||
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
|
||||
id: 'rule-1',
|
||||
orgId: 'org-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
modelPattern: ['gpt-4o'],
|
||||
isOverride: false,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 15, output: 60 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('drawer draft utils', () => {
|
||||
describe('draftFromRule', () => {
|
||||
it('maps a rule to a draft with cache values when present', () => {
|
||||
const rule = makeRule({
|
||||
pricing: {
|
||||
input: 3,
|
||||
output: 15,
|
||||
cache: {
|
||||
mode: CacheModeDTO.additive,
|
||||
read: 0.3,
|
||||
write: 3.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
const draft = draftFromRule(rule);
|
||||
expect(draft.modelName).toBe('gpt-4o');
|
||||
expect(draft.pricing.input).toBe(3);
|
||||
expect(draft.pricing.cacheRead).toBe(0.3);
|
||||
expect(draft.pricing.cacheWrite).toBe(3.75);
|
||||
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.additive);
|
||||
});
|
||||
|
||||
it('falls back to defaults when cache is missing', () => {
|
||||
const draft = draftFromRule(makeRule());
|
||||
expect(draft.pricing.cacheRead).toBeNull();
|
||||
expect(draft.pricing.cacheWrite).toBeNull();
|
||||
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.unknown);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPricingPayload', () => {
|
||||
it('omits the cache block when no cache values are set', () => {
|
||||
const payload = buildPricingPayload(EMPTY_DRAFT);
|
||||
expect(payload.cache).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes only the cache values that are > 0', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
pricing: {
|
||||
...EMPTY_DRAFT.pricing,
|
||||
cacheRead: 1.5,
|
||||
cacheWrite: 0,
|
||||
cacheMode: CacheModeDTO.subtract,
|
||||
},
|
||||
};
|
||||
const payload = buildPricingPayload(draft);
|
||||
expect(payload.cache?.read).toBe(1.5);
|
||||
expect(payload.cache?.write).toBeUndefined();
|
||||
expect(payload.cache?.mode).toBe(CacheModeDTO.subtract);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRulePayload', () => {
|
||||
it('uses the modelName as a default pattern when no patterns are supplied', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
patterns: [],
|
||||
provider: 'OpenAI',
|
||||
};
|
||||
const payload = buildRulePayload(draft);
|
||||
expect(payload.modelPattern).toStrictEqual(['gpt-4o']);
|
||||
expect(payload.unit).toBe(UnitDTO.per_million_tokens);
|
||||
expect(payload.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('omits id and sourceId for an Add draft', () => {
|
||||
const payload = buildRulePayload(EMPTY_DRAFT);
|
||||
expect(payload.id).toBeUndefined();
|
||||
expect(payload.sourceId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateDraft', () => {
|
||||
it('requires a model name in Add mode', () => {
|
||||
const result = validateDraft(EMPTY_DRAFT, 'add');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toMatch(/billing model id/i);
|
||||
});
|
||||
|
||||
it('rejects negative pricing', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
pricing: { ...EMPTY_DRAFT.pricing, input: -1 },
|
||||
};
|
||||
expect(validateDraft(draft, 'add').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a valid Add draft', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 2 },
|
||||
};
|
||||
expect(validateDraft(draft, 'add').ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects zero input/output cost for overrides', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
isOverride: true,
|
||||
pricing: { ...EMPTY_DRAFT.pricing, input: 0, output: 5 },
|
||||
};
|
||||
const result = validateDraft(draft, 'add');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toMatch(/input cost must be greater than 0/i);
|
||||
});
|
||||
|
||||
it('skips pricing validation for auto-populated (non-override) rules', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
isOverride: false,
|
||||
pricing: { ...EMPTY_DRAFT.pricing, input: 0, output: 0 },
|
||||
};
|
||||
expect(validateDraft(draft, 'edit').ok).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PricingRule } from '../types';
|
||||
import {
|
||||
filterRules,
|
||||
formatPricePerMillion,
|
||||
getCanonicalId,
|
||||
getExtraBuckets,
|
||||
getRelativeLastSeen,
|
||||
getSourceLabel,
|
||||
} from '../utils';
|
||||
|
||||
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
|
||||
id: 'rule-1',
|
||||
orgId: 'org-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
modelPattern: ['gpt-4o'],
|
||||
isOverride: false,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 15, output: 60 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('utils', () => {
|
||||
describe('formatPricePerMillion', () => {
|
||||
it('formats numbers with 2 decimals and dollar prefix', () => {
|
||||
expect(formatPricePerMillion(15)).toBe('$15.00');
|
||||
expect(formatPricePerMillion(0.15)).toBe('$0.15');
|
||||
});
|
||||
|
||||
it('returns em-dash for nullish or NaN', () => {
|
||||
expect(formatPricePerMillion(undefined)).toBe('—');
|
||||
expect(formatPricePerMillion(Number.NaN)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExtraBuckets', () => {
|
||||
it('returns an empty array when there is no cache pricing', () => {
|
||||
expect(getExtraBuckets(makeRule())).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns only buckets with values > 0', () => {
|
||||
const rule = makeRule({
|
||||
pricing: {
|
||||
input: 3,
|
||||
output: 15,
|
||||
cache: {
|
||||
mode: CacheModeDTO.additive,
|
||||
read: 0.3,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
const buckets = getExtraBuckets(rule);
|
||||
expect(buckets).toStrictEqual([{ key: 'cache_read', pricePerMillion: 0.3 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSourceLabel', () => {
|
||||
it('returns "Auto" for non-override and "User override" otherwise', () => {
|
||||
expect(getSourceLabel(makeRule({ isOverride: false }))).toBe('Auto');
|
||||
expect(getSourceLabel(makeRule({ isOverride: true }))).toBe('User override');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCanonicalId', () => {
|
||||
it('lowercases the provider and joins with the model name', () => {
|
||||
expect(getCanonicalId(makeRule({ provider: 'OpenAI' }))).toBe(
|
||||
'openai:gpt-4o',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRelativeLastSeen', () => {
|
||||
it('returns em-dash when no timestamp is present', () => {
|
||||
expect(getRelativeLastSeen(makeRule())).toBe('—');
|
||||
});
|
||||
|
||||
it('formats minutes-old timestamps via dayjs fromNow', () => {
|
||||
const recent = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||
expect(getRelativeLastSeen(makeRule({ updatedAt: recent }))).toMatch(
|
||||
/minutes? ago/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterRules', () => {
|
||||
const auto = makeRule({ id: 'r1', modelName: 'gpt-4o', isOverride: false });
|
||||
const override = makeRule({
|
||||
id: 'r2',
|
||||
modelName: 'llama-3',
|
||||
provider: 'Self-hosted',
|
||||
modelPattern: ['llama-3'],
|
||||
isOverride: true,
|
||||
});
|
||||
|
||||
it('returns everything when no filters are applied', () => {
|
||||
expect(filterRules([auto, override], '', 'all')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('narrows by source = override', () => {
|
||||
expect(filterRules([auto, override], '', 'override')).toStrictEqual([
|
||||
override,
|
||||
]);
|
||||
});
|
||||
|
||||
it('narrows by free-text search across model and provider', () => {
|
||||
expect(filterRules([auto, override], 'self', 'all')).toStrictEqual([
|
||||
override,
|
||||
]);
|
||||
expect(filterRules([auto, override], 'gpt-4', 'all')).toStrictEqual([auto]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DrawerDraft } from './types';
|
||||
|
||||
export const PROVIDER_OPTIONS = [
|
||||
{ value: 'OpenAI', label: 'OpenAI' },
|
||||
{ value: 'Anthropic', label: 'Anthropic' },
|
||||
{ value: 'Azure OpenAI', label: 'Azure OpenAI' },
|
||||
{ value: 'Google', label: 'Google' },
|
||||
{ value: 'Self-hosted', label: 'Self-hosted' },
|
||||
{ value: 'Other', label: 'Other' },
|
||||
];
|
||||
|
||||
export const CACHE_MODE_OPTIONS = [
|
||||
{ value: CacheModeDTO.subtract, label: 'Subtract (OpenAI style)' },
|
||||
{ value: CacheModeDTO.additive, label: 'Additive (Anthropic style)' },
|
||||
{ value: CacheModeDTO.unknown, label: 'Unknown' },
|
||||
];
|
||||
|
||||
export const EMPTY_DRAFT: DrawerDraft = {
|
||||
id: null,
|
||||
sourceId: null,
|
||||
modelName: '',
|
||||
provider: 'OpenAI',
|
||||
patterns: [],
|
||||
isOverride: true,
|
||||
pricing: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheMode: CacheModeDTO.unknown,
|
||||
cacheRead: null,
|
||||
cacheWrite: null,
|
||||
},
|
||||
};
|
||||
36
frontend/src/container/LLMObservabilityModelPricing/types.ts
Normal file
36
frontend/src/container/LLMObservabilityModelPricing/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
type LlmpricingruletypesLLMPricingRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
|
||||
|
||||
export type SourceFilter = 'all' | 'auto' | 'override';
|
||||
|
||||
export interface ExtraBucket {
|
||||
key: string;
|
||||
pricePerMillion: number;
|
||||
}
|
||||
|
||||
export type DrawerMode = 'add' | 'edit';
|
||||
|
||||
export interface DrawerDraft {
|
||||
id: string | null;
|
||||
sourceId: string | null;
|
||||
modelName: string;
|
||||
provider: string;
|
||||
patterns: string[];
|
||||
isOverride: boolean;
|
||||
pricing: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheMode: CacheModeDTO;
|
||||
cacheRead: number | null;
|
||||
cacheWrite: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getListLLMPricingRulesQueryKey,
|
||||
useCreateOrUpdateLLMPricingRules,
|
||||
useDeleteLLMPricingRule,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import { EMPTY_DRAFT } from './constants';
|
||||
import type { DrawerDraft, DrawerMode, PricingRule } from './types';
|
||||
import { buildRulePayload, draftFromRule } from './utils';
|
||||
|
||||
interface UseModelCostDrawerResult {
|
||||
isOpen: boolean;
|
||||
mode: DrawerMode;
|
||||
draft: DrawerDraft;
|
||||
setDraft: (next: DrawerDraft) => void;
|
||||
openForAdd: (prefillModelName?: string) => void;
|
||||
openForEdit: (rule: PricingRule) => void;
|
||||
close: () => void;
|
||||
save: () => Promise<void>;
|
||||
deleteRule: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
saveError: string | null;
|
||||
selectedRuleId: string | null;
|
||||
}
|
||||
|
||||
export function useModelCostDrawer(): UseModelCostDrawerResult {
|
||||
const queryClient = useQueryClient();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [mode, setMode] = useState<DrawerMode>('add');
|
||||
const [draft, setDraft] = useState<DrawerDraft>(EMPTY_DRAFT);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
|
||||
useCreateOrUpdateLLMPricingRules();
|
||||
const { mutateAsync: deleteRuleApi, isLoading: isDeleting } =
|
||||
useDeleteLLMPricingRule();
|
||||
|
||||
const invalidateList = useCallback(async (): Promise<void> => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
const openForAdd = useCallback((prefillModelName?: string): void => {
|
||||
setMode('add');
|
||||
setDraft({
|
||||
...EMPTY_DRAFT,
|
||||
modelName: prefillModelName || '',
|
||||
patterns: prefillModelName ? [prefillModelName] : [],
|
||||
});
|
||||
setSelectedRuleId(null);
|
||||
setSaveError(null);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const openForEdit = useCallback((rule: PricingRule): void => {
|
||||
setMode('edit');
|
||||
setDraft(draftFromRule(rule));
|
||||
setSelectedRuleId(rule.id);
|
||||
setSaveError(null);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
setSaveError(null);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async (): Promise<void> => {
|
||||
setSaveError(null);
|
||||
try {
|
||||
await createOrUpdate({
|
||||
data: { rules: [buildRulePayload(draft)] },
|
||||
});
|
||||
await invalidateList();
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
toast.success(mode === 'edit' ? 'Model cost updated' : 'Model cost added');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Save failed';
|
||||
setSaveError(message);
|
||||
}
|
||||
}, [createOrUpdate, draft, invalidateList, mode]);
|
||||
|
||||
const deleteRule = useCallback(async (): Promise<void> => {
|
||||
if (!draft.id) {
|
||||
return;
|
||||
}
|
||||
setSaveError(null);
|
||||
try {
|
||||
await deleteRuleApi({ pathParams: { id: draft.id } });
|
||||
await invalidateList();
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
toast.success('Model cost deleted');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Delete failed';
|
||||
setSaveError(message);
|
||||
}
|
||||
}, [deleteRuleApi, draft.id, invalidateList]);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
mode,
|
||||
draft,
|
||||
setDraft,
|
||||
openForAdd,
|
||||
openForEdit,
|
||||
close,
|
||||
save,
|
||||
deleteRule,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
selectedRuleId,
|
||||
};
|
||||
}
|
||||
175
frontend/src/container/LLMObservabilityModelPricing/utils.ts
Normal file
175
frontend/src/container/LLMObservabilityModelPricing/utils.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
type LlmpricingruletypesLLMPricingCacheCostsDTO,
|
||||
type LlmpricingruletypesLLMRulePricingDTO,
|
||||
type LlmpricingruletypesUpdatableLLMPricingRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
import type {
|
||||
DrawerDraft,
|
||||
DrawerMode,
|
||||
ExtraBucket,
|
||||
PricingRule,
|
||||
SourceFilter,
|
||||
ValidationResult,
|
||||
} from './types';
|
||||
|
||||
// Idempotent — relativeTime is also extended globally in utils/timeUtils.
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const lc = (value: string): string => value.toLowerCase();
|
||||
|
||||
const hasCacheValue = (value: number | null): boolean =>
|
||||
typeof value === 'number' && value > 0;
|
||||
|
||||
// ─── Display helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
export const formatPricePerMillion = (value: number | undefined): string => {
|
||||
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||
return '—';
|
||||
}
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
|
||||
const cache = rule.pricing?.cache;
|
||||
if (!cache) {
|
||||
return [];
|
||||
}
|
||||
const buckets: ExtraBucket[] = [];
|
||||
if (typeof cache.read === 'number' && cache.read > 0) {
|
||||
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
|
||||
}
|
||||
if (typeof cache.write === 'number' && cache.write > 0) {
|
||||
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
|
||||
}
|
||||
return buckets;
|
||||
};
|
||||
|
||||
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
|
||||
rule.isOverride ? 'User override' : 'Auto';
|
||||
|
||||
export const getRelativeLastSeen = (rule: PricingRule): string => {
|
||||
const ts = rule.updatedAt || rule.syncedAt || rule.createdAt;
|
||||
const parsed = ts ? dayjs(ts) : null;
|
||||
return parsed?.isValid() ? parsed.fromNow() : '—';
|
||||
};
|
||||
|
||||
export const filterRules = (
|
||||
rules: PricingRule[],
|
||||
search: string,
|
||||
source: SourceFilter,
|
||||
): PricingRule[] => {
|
||||
const normalized = lc(search.trim());
|
||||
return rules.filter((rule) => {
|
||||
if (source === 'auto' && rule.isOverride) {
|
||||
return false;
|
||||
}
|
||||
if (source === 'override' && !rule.isOverride) {
|
||||
return false;
|
||||
}
|
||||
if (!normalized) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
lc(rule.modelName).includes(normalized) ||
|
||||
lc(rule.provider).includes(normalized) ||
|
||||
(rule.modelPattern || []).some((pattern) => lc(pattern).includes(normalized))
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const getCanonicalId = (rule: PricingRule): string => {
|
||||
const provider = rule.provider?.trim() || 'unknown';
|
||||
return `${lc(provider)}:${rule.modelName}`;
|
||||
};
|
||||
|
||||
// ─── Drawer draft <-> API helpers ────────────────────────────────────────────
|
||||
|
||||
export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
|
||||
id: rule.id,
|
||||
sourceId: rule.sourceId ?? null,
|
||||
modelName: rule.modelName,
|
||||
provider: rule.provider || 'OpenAI',
|
||||
patterns: rule.modelPattern || [],
|
||||
isOverride: !!rule.isOverride,
|
||||
pricing: {
|
||||
input: rule.pricing?.input ?? 0,
|
||||
output: rule.pricing?.output ?? 0,
|
||||
cacheMode: rule.pricing?.cache?.mode ?? CacheModeDTO.unknown,
|
||||
cacheRead: rule.pricing?.cache?.read ?? null,
|
||||
cacheWrite: rule.pricing?.cache?.write ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
export const buildPricingPayload = (
|
||||
draft: DrawerDraft,
|
||||
): LlmpricingruletypesLLMRulePricingDTO => {
|
||||
const pricing: LlmpricingruletypesLLMRulePricingDTO = {
|
||||
input: draft.pricing.input,
|
||||
output: draft.pricing.output,
|
||||
};
|
||||
if (
|
||||
hasCacheValue(draft.pricing.cacheRead) ||
|
||||
hasCacheValue(draft.pricing.cacheWrite)
|
||||
) {
|
||||
const cache: LlmpricingruletypesLLMPricingCacheCostsDTO = {
|
||||
mode: draft.pricing.cacheMode,
|
||||
};
|
||||
if (hasCacheValue(draft.pricing.cacheRead)) {
|
||||
cache.read = draft.pricing.cacheRead as number;
|
||||
}
|
||||
if (hasCacheValue(draft.pricing.cacheWrite)) {
|
||||
cache.write = draft.pricing.cacheWrite as number;
|
||||
}
|
||||
pricing.cache = cache;
|
||||
}
|
||||
return pricing;
|
||||
};
|
||||
|
||||
export const buildRulePayload = (
|
||||
draft: DrawerDraft,
|
||||
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => ({
|
||||
id: draft.id || undefined,
|
||||
sourceId: draft.sourceId || undefined,
|
||||
modelName: draft.modelName.trim(),
|
||||
provider: draft.provider.trim(),
|
||||
modelPattern:
|
||||
draft.patterns.length > 0 ? draft.patterns : [draft.modelName.trim()],
|
||||
isOverride: draft.isOverride,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: buildPricingPayload(draft),
|
||||
});
|
||||
|
||||
export const validateDraft = (
|
||||
draft: DrawerDraft,
|
||||
mode: DrawerMode,
|
||||
): ValidationResult => {
|
||||
if (mode === 'add' && !draft.modelName.trim()) {
|
||||
return { ok: false, message: 'Billing model ID is required.' };
|
||||
}
|
||||
if (!draft.provider.trim()) {
|
||||
return { ok: false, message: 'Provider is required.' };
|
||||
}
|
||||
// Pricing is only user-entered for overrides; auto-populated rules are
|
||||
// managed by SigNoz (and may legitimately be 0 for self-hosted models).
|
||||
if (draft.isOverride) {
|
||||
if (!(draft.pricing.input > 0)) {
|
||||
return { ok: false, message: 'Input cost must be greater than 0.' };
|
||||
}
|
||||
if (!(draft.pricing.output > 0)) {
|
||||
return { ok: false, message: 'Output cost must be greater than 0.' };
|
||||
}
|
||||
if (
|
||||
(draft.pricing.cacheRead !== null && draft.pricing.cacheRead < 0) ||
|
||||
(draft.pricing.cacheWrite !== null && draft.pricing.cacheWrite < 0)
|
||||
) {
|
||||
return { ok: false, message: 'Cache costs must be non-negative.' };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
@@ -206,6 +206,8 @@ export const routesToSkip = [
|
||||
ROUTES.METER,
|
||||
ROUTES.METER_EXPLORER_VIEWS,
|
||||
ROUTES.SOMETHING_WENT_WRONG,
|
||||
ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
];
|
||||
|
||||
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import LLMObservabilityAttributeMapping from 'container/LLMObservabilityAttributeMapping/LLMObservabilityAttributeMapping';
|
||||
|
||||
function LLMObservabilityAttributeMappingPage(): JSX.Element {
|
||||
return <LLMObservabilityAttributeMapping />;
|
||||
}
|
||||
|
||||
export default LLMObservabilityAttributeMappingPage;
|
||||
@@ -0,0 +1,7 @@
|
||||
import LLMObservabilityModelPricing from 'container/LLMObservabilityModelPricing/LLMObservabilityModelPricing';
|
||||
|
||||
function LLMObservabilityModelPricingPage(): JSX.Element {
|
||||
return <LLMObservabilityModelPricing />;
|
||||
}
|
||||
|
||||
export default LLMObservabilityModelPricingPage;
|
||||
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, RenderOptions, RenderResult } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
||||
@@ -301,7 +302,7 @@ export function AllTheProviders({
|
||||
<ErrorModalProvider>
|
||||
<TimezoneProvider>
|
||||
<PreferenceContextProvider>
|
||||
{queryBuilderContent}
|
||||
<TooltipProvider>{queryBuilderContent}</TooltipProvider>
|
||||
</PreferenceContextProvider>
|
||||
</TimezoneProvider>
|
||||
</ErrorModalProvider>
|
||||
|
||||
@@ -20,7 +20,8 @@ export type ComponentTypes =
|
||||
| 'add_panel'
|
||||
| 'page_pipelines'
|
||||
| 'edit_locked_dashboard'
|
||||
| 'add_panel_locked_dashboard';
|
||||
| 'add_panel_locked_dashboard'
|
||||
| 'manage_llm_pricing';
|
||||
|
||||
export const componentPermission: Record<ComponentTypes, ROLES[]> = {
|
||||
current_org_settings: ['ADMIN'],
|
||||
@@ -42,6 +43,7 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
|
||||
page_pipelines: ['ADMIN', 'EDITOR'],
|
||||
edit_locked_dashboard: ['ADMIN', 'AUTHOR'],
|
||||
add_panel_locked_dashboard: ['ADMIN', 'AUTHOR'],
|
||||
manage_llm_pricing: ['ADMIN', 'EDITOR', 'AUTHOR'],
|
||||
};
|
||||
|
||||
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
@@ -136,4 +138,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_MODEL_PRICING: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
};
|
||||
|
||||
122
tests/e2e/helpers/llm-pricing.ts
Normal file
122
tests/e2e/helpers/llm-pricing.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
|
||||
export const LLM_PRICING_PATH = '/llm-observability/settings/model-pricing';
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the JWT the auth fixture stored in `localStorage.AUTH_TOKEN`. The
|
||||
* page must be on the SigNoz origin first; if not, this navigates to the
|
||||
* pricing page to populate localStorage from the context's storageState.
|
||||
*/
|
||||
export async function authToken(page: Page): Promise<string> {
|
||||
if (!page.url().startsWith('http')) {
|
||||
await page.goto(LLM_PRICING_PATH);
|
||||
}
|
||||
return page.evaluate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Navigation ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Navigate to the LLM pricing page and wait for the root container.
|
||||
*/
|
||||
export async function gotoLlmPricingPage(page: Page): Promise<void> {
|
||||
await page.goto(LLM_PRICING_PATH);
|
||||
await page
|
||||
.getByTestId('llm-observability-model-pricing-page')
|
||||
.waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
// ─── API helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface SeedOptions {
|
||||
modelName?: string;
|
||||
provider?: string;
|
||||
isOverride?: boolean;
|
||||
inputCost?: number;
|
||||
outputCost?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed one pricing rule via `PUT /api/v1/llm_pricing_rules` and return its id.
|
||||
* The PUT response is void (HTTP 200, no body), so a follow-up GET by modelName
|
||||
* is required to obtain the id.
|
||||
*/
|
||||
export async function createPricingRuleViaApi(
|
||||
page: Page,
|
||||
overrides: SeedOptions = {},
|
||||
): Promise<string> {
|
||||
const {
|
||||
modelName = 'e2e-test-model',
|
||||
provider = 'OpenAI',
|
||||
isOverride = true,
|
||||
inputCost = 10.0,
|
||||
outputCost = 30.0,
|
||||
} = overrides;
|
||||
|
||||
const token = await authToken(page);
|
||||
|
||||
const putRes = await page.request.put('/api/v1/llm_pricing_rules', {
|
||||
data: {
|
||||
rules: [
|
||||
{
|
||||
modelName,
|
||||
provider,
|
||||
modelPattern: [modelName],
|
||||
isOverride,
|
||||
enabled: true,
|
||||
unit: 'per_million_tokens',
|
||||
pricing: { input: inputCost, output: outputCost },
|
||||
},
|
||||
],
|
||||
},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!putRes.ok()) {
|
||||
throw new Error(
|
||||
`PUT /api/v1/llm_pricing_rules ${putRes.status()}: ${await putRes.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// PUT returns void — fetch the list to find the newly-created rule's id.
|
||||
const getRes = await page.request.get(
|
||||
'/api/v1/llm_pricing_rules?offset=0&limit=200',
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
if (!getRes.ok()) {
|
||||
throw new Error(
|
||||
`GET /api/v1/llm_pricing_rules ${getRes.status()}: ${await getRes.text()}`,
|
||||
);
|
||||
}
|
||||
const body = (await getRes.json()) as {
|
||||
data: { items: Array<{ id: string; modelName: string }> };
|
||||
};
|
||||
const rule = body.data.items.find((r) => r.modelName === modelName);
|
||||
if (!rule) {
|
||||
throw new Error(`Could not find seeded rule "${modelName}" in GET response`);
|
||||
}
|
||||
return rule.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort delete via API. Errors are swallowed so suite-level cleanup
|
||||
* stays resilient when a UI flow already deleted the resource (404) or the
|
||||
* stack is mid-shutdown.
|
||||
*/
|
||||
export async function deletePricingRuleViaApi(
|
||||
request: APIRequestContext,
|
||||
id: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
await request
|
||||
.delete(`/api/v1/llm_pricing_rules/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
123
tests/e2e/tests/llm-pricing/delete.spec.ts
Normal file
123
tests/e2e/tests/llm-pricing/delete.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
authToken,
|
||||
createPricingRuleViaApi,
|
||||
deletePricingRuleViaApi,
|
||||
gotoLlmPricingPage,
|
||||
} from '../../helpers/llm-pricing';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('LLM Pricing — Delete rule', () => {
|
||||
test('TC-20 Delete button removes the rule from the list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ruleId = await createPricingRuleViaApi(page, {
|
||||
modelName: 'e2e-delete-target',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
inputCost: 5.0,
|
||||
outputCost: 15.0,
|
||||
});
|
||||
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId(`edit-rule-${ruleId}`).click();
|
||||
await expect(page.getByText('Edit model cost').first()).toBeVisible();
|
||||
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes('llm_pricing_rules') && r.request().method() === 'DELETE',
|
||||
);
|
||||
const listRefresh = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes('llm_pricing_rules') && r.request().method() === 'GET',
|
||||
);
|
||||
await page.getByTestId('drawer-delete-btn').click();
|
||||
const del = await deleteResponse;
|
||||
// DELETE returns 200 or 204
|
||||
expect([200, 204]).toContain(del.status());
|
||||
await listRefresh;
|
||||
|
||||
await expect(page.getByText('Edit model cost').first()).not.toBeVisible();
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleId}`)).not.toBeVisible();
|
||||
|
||||
// Rule was deleted by the UI; best-effort API call swallows any 404
|
||||
const token = await authToken(page);
|
||||
await deletePricingRuleViaApi(page.request, ruleId, token);
|
||||
});
|
||||
|
||||
test('TC-21 delete error is surfaced in the drawer', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ruleId = await createPricingRuleViaApi(page, {
|
||||
modelName: 'e2e-delete-error',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
inputCost: 3.0,
|
||||
outputCost: 9.0,
|
||||
});
|
||||
try {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId(`edit-rule-${ruleId}`).click();
|
||||
await expect(page.getByText('Edit model cost').first()).toBeVisible();
|
||||
|
||||
// Intercept the DELETE and return 500
|
||||
await page.route(`**/api/v1/llm_pricing_rules/${ruleId}`, async (route) => {
|
||||
if (route.request().method() === 'DELETE') {
|
||||
await route.fulfill({ status: 500, body: '{"message":"internal error"}' });
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByTestId('drawer-delete-btn').click();
|
||||
|
||||
// Error alert must appear inside the open drawer
|
||||
await expect(page.locator('[role="alert"].drawer-error')).toBeVisible();
|
||||
|
||||
// Drawer stays open
|
||||
await expect(page.getByText('Edit model cost').first()).toBeVisible();
|
||||
|
||||
// Remove the route intercept so cleanup can proceed
|
||||
await page.unrouteAll();
|
||||
} finally {
|
||||
await page.getByTestId('drawer-cancel-btn').click().catch(() => undefined);
|
||||
const token = await authToken(page);
|
||||
await deletePricingRuleViaApi(page.request, ruleId, token);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-22 save error is surfaced in the drawer', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId('add-model-cost-btn').click();
|
||||
await expect(page.getByText('Add model cost').first()).toBeVisible();
|
||||
|
||||
await page.getByTestId('drawer-model-id-input').fill('e2e-err-test');
|
||||
|
||||
// Intercept PUT and return 422
|
||||
await page.route('**/api/v1/llm_pricing_rules', async (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
await route.fulfill({
|
||||
status: 422,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'duplicate key' }),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByTestId('drawer-save-btn').click();
|
||||
|
||||
// Error alert appears inside the drawer
|
||||
await expect(page.locator('[role="alert"].drawer-error')).toBeVisible();
|
||||
|
||||
// Drawer remains open
|
||||
await expect(page.getByText('Add model cost').first()).toBeVisible();
|
||||
|
||||
await page.unrouteAll();
|
||||
await page.getByTestId('drawer-cancel-btn').click();
|
||||
});
|
||||
});
|
||||
232
tests/e2e/tests/llm-pricing/drawer-add.spec.ts
Normal file
232
tests/e2e/tests/llm-pricing/drawer-add.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
authToken,
|
||||
deletePricingRuleViaApi,
|
||||
gotoLlmPricingPage,
|
||||
} from '../../helpers/llm-pricing';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('LLM Pricing — Add drawer', () => {
|
||||
test('TC-06 opening the drawer shows correct default state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId('add-model-cost-btn').click();
|
||||
|
||||
// Drawer must be visible before asserting field values
|
||||
await expect(page.getByText('Add model cost').first()).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('drawer-model-id-input')).toBeVisible();
|
||||
// Model ID field is empty by default
|
||||
await expect(page.getByTestId('drawer-model-id-input')).toHaveValue('');
|
||||
|
||||
// Provider defaults to OpenAI
|
||||
await expect(page.getByTestId('drawer-provider-select')).toContainText('OpenAI');
|
||||
|
||||
// Source radio: Override is checked, Auto is unchecked
|
||||
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
|
||||
'data-state',
|
||||
'checked',
|
||||
);
|
||||
await expect(page.getByTestId('drawer-source-auto')).toHaveAttribute(
|
||||
'data-state',
|
||||
'unchecked',
|
||||
);
|
||||
|
||||
// Pricing defaults to 0 (Ant InputNumber wraps a real <input>)
|
||||
await expect(
|
||||
page.locator('[data-testid="drawer-input-cost"] input'),
|
||||
).toHaveValue('0');
|
||||
await expect(
|
||||
page.locator('[data-testid="drawer-output-cost"] input'),
|
||||
).toHaveValue('0');
|
||||
|
||||
// Cache mode is hidden when no cache bucket value
|
||||
await expect(page.getByTestId('drawer-cache-mode')).not.toBeVisible();
|
||||
|
||||
// Save is disabled (empty model name); Delete is absent in add mode
|
||||
await expect(page.getByTestId('drawer-save-btn')).toBeDisabled();
|
||||
await expect(page.getByTestId('drawer-cancel-btn')).toBeVisible();
|
||||
await expect(page.getByTestId('drawer-delete-btn')).not.toBeVisible();
|
||||
|
||||
await page.getByTestId('drawer-cancel-btn').click();
|
||||
await expect(page.getByText('Add model cost').first()).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-07 Save button tooltip shows validation message when model name is empty', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId('add-model-cost-btn').click();
|
||||
await expect(page.getByText('Add model cost').first()).toBeVisible();
|
||||
|
||||
// Save is disabled; hover triggers tooltip
|
||||
const saveBtn = page.getByTestId('drawer-save-btn');
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
await saveBtn.hover();
|
||||
await expect(page.getByText('Billing model ID is required.')).toBeVisible();
|
||||
|
||||
await page.getByTestId('drawer-cancel-btn').click();
|
||||
});
|
||||
|
||||
test('TC-08 adding a pattern chip via Add button and Enter key', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId('add-model-cost-btn').click();
|
||||
await expect(page.getByText('Add model cost').first()).toBeVisible();
|
||||
|
||||
await page.getByTestId('drawer-model-id-input').fill('e2e-add-test');
|
||||
|
||||
// Add via button
|
||||
await page.getByTestId('drawer-pattern-input').fill('e2e-add');
|
||||
await page.getByTestId('drawer-pattern-add-btn').click();
|
||||
await expect(page.getByText('e2e-add*')).toBeVisible();
|
||||
await expect(page.getByTestId('drawer-pattern-input')).toHaveValue('');
|
||||
|
||||
// Add via Enter key
|
||||
await page.getByTestId('drawer-pattern-input').fill('e2e-add-v2');
|
||||
await page.getByTestId('drawer-pattern-input').press('Enter');
|
||||
await expect(page.getByText('e2e-add-v2*')).toBeVisible();
|
||||
|
||||
// Deduplication: adding e2e-add again produces no second chip
|
||||
await page.getByTestId('drawer-pattern-input').fill('e2e-add');
|
||||
await page.getByTestId('drawer-pattern-add-btn').click();
|
||||
const chips = page.locator('.pattern-chip');
|
||||
await expect(chips).toHaveCount(2);
|
||||
|
||||
// Remove first chip
|
||||
await page.getByRole('button', { name: 'Remove pattern e2e-add' }).click();
|
||||
await expect(page.getByText('e2e-add*')).not.toBeVisible();
|
||||
await expect(page.getByText('e2e-add-v2*')).toBeVisible();
|
||||
|
||||
await page.getByTestId('drawer-cancel-btn').click();
|
||||
});
|
||||
|
||||
test('TC-09 save a new user-override rule end-to-end', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId('add-model-cost-btn').click();
|
||||
await expect(page.getByText('Add model cost').first()).toBeVisible();
|
||||
|
||||
await page.getByTestId('drawer-model-id-input').fill('e2e-save-model');
|
||||
|
||||
// Select Anthropic provider
|
||||
await page.getByTestId('drawer-provider-select').click();
|
||||
await page.getByText('Anthropic').click();
|
||||
|
||||
// Add pattern
|
||||
await page.getByTestId('drawer-pattern-input').fill('e2e-save');
|
||||
await page.getByTestId('drawer-pattern-add-btn').click();
|
||||
|
||||
// Override radio is the default
|
||||
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
|
||||
'data-state',
|
||||
'checked',
|
||||
);
|
||||
|
||||
// Set pricing
|
||||
await page.locator('[data-testid="drawer-input-cost"] input').fill('5.50');
|
||||
await page.locator('[data-testid="drawer-output-cost"] input').fill('17.00');
|
||||
|
||||
// Wait for PUT before clicking Save to avoid a race
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes('llm_pricing_rules') && r.request().method() === 'PUT',
|
||||
);
|
||||
const listRefresh = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes('llm_pricing_rules') && r.request().method() === 'GET',
|
||||
);
|
||||
await page.getByTestId('drawer-save-btn').click();
|
||||
const put = await putResponse;
|
||||
expect(put.status()).toBe(200);
|
||||
await listRefresh;
|
||||
|
||||
// Drawer closes
|
||||
await expect(page.getByText('Add model cost').first()).not.toBeVisible();
|
||||
|
||||
// Row appears in table — locate by model name text since we don't know the id yet
|
||||
await expect(page.getByText('e2e-save-model')).toBeVisible();
|
||||
// Source badge says "User override"
|
||||
await expect(page.getByText('User override').first()).toBeVisible();
|
||||
|
||||
// Cleanup: resolve the rule id and delete via API (deletePricingRuleViaApi swallows errors)
|
||||
const token = await authToken(page);
|
||||
const getRes = await page.request.get(
|
||||
'/api/v1/llm_pricing_rules?offset=0&limit=200',
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
const body = (await getRes.json()) as {
|
||||
data: { items: Array<{ id: string; modelName: string }> };
|
||||
};
|
||||
const createdId = body.data.items.find((r) => r.modelName === 'e2e-save-model')?.id ?? '';
|
||||
await deletePricingRuleViaApi(page.request, createdId, token);
|
||||
});
|
||||
|
||||
test('TC-10 cache-mode select appears only when cache bucket has a value', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId('add-model-cost-btn').click();
|
||||
await expect(page.getByText('Add model cost').first()).toBeVisible();
|
||||
|
||||
await page.getByTestId('drawer-model-id-input').fill('e2e-cache-test');
|
||||
|
||||
// Cache mode is hidden before any cache bucket value is set
|
||||
await expect(page.getByTestId('drawer-cache-mode')).not.toBeVisible();
|
||||
|
||||
// Set cache_read → cache-mode select must appear
|
||||
await page.locator('[data-testid="drawer-cache-read-cost"] input').fill('0.30');
|
||||
await page.locator('[data-testid="drawer-cache-read-cost"] input').press('Tab');
|
||||
await expect(page.getByTestId('drawer-cache-mode')).toBeVisible();
|
||||
|
||||
// Cache mode defaults to "Unknown"
|
||||
await expect(page.getByTestId('drawer-cache-mode')).toContainText('Unknown');
|
||||
|
||||
// Change to Subtract
|
||||
await page.getByTestId('drawer-cache-mode').click();
|
||||
await page.getByText('Subtract (OpenAI style)').click();
|
||||
await expect(page.getByTestId('drawer-cache-mode')).toContainText('Subtract');
|
||||
|
||||
// Clearing cache_read hides the select again (Ant InputNumber: Ctrl+A then Delete)
|
||||
const cacheInput = page.locator('[data-testid="drawer-cache-read-cost"] input');
|
||||
await cacheInput.click();
|
||||
await cacheInput.press('Control+a');
|
||||
await cacheInput.press('Delete');
|
||||
// Ant InputNumber fires onChange with null on clear — click elsewhere to commit
|
||||
await page.locator('[data-testid="drawer-output-cost"] input').click();
|
||||
await expect(page.getByTestId('drawer-cache-mode')).not.toBeVisible();
|
||||
|
||||
await page.getByTestId('drawer-cancel-btn').click();
|
||||
});
|
||||
|
||||
test('TC-11 cancel closes the drawer without persisting data', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId('add-model-cost-btn').click();
|
||||
await expect(page.getByText('Add model cost').first()).toBeVisible();
|
||||
|
||||
await page.getByTestId('drawer-model-id-input').fill('e2e-cancel-test');
|
||||
await page.locator('[data-testid="drawer-input-cost"] input').fill('99');
|
||||
|
||||
await page.getByTestId('drawer-cancel-btn').click();
|
||||
await expect(page.getByText('Add model cost').first()).not.toBeVisible();
|
||||
|
||||
// No row created
|
||||
await expect(page.getByText('e2e-cancel-test')).not.toBeVisible();
|
||||
|
||||
// Re-opening resets all fields to defaults
|
||||
await page.getByTestId('add-model-cost-btn').click();
|
||||
await expect(page.getByText('Add model cost').first()).toBeVisible();
|
||||
await expect(page.getByTestId('drawer-model-id-input')).toHaveValue('');
|
||||
await expect(
|
||||
page.locator('[data-testid="drawer-input-cost"] input'),
|
||||
).toHaveValue('0');
|
||||
|
||||
await page.getByTestId('drawer-cancel-btn').click();
|
||||
});
|
||||
});
|
||||
165
tests/e2e/tests/llm-pricing/drawer-edit.spec.ts
Normal file
165
tests/e2e/tests/llm-pricing/drawer-edit.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
authToken,
|
||||
createPricingRuleViaApi,
|
||||
deletePricingRuleViaApi,
|
||||
gotoLlmPricingPage,
|
||||
} from '../../helpers/llm-pricing';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('LLM Pricing — Edit drawer', () => {
|
||||
test('TC-12 Edit button opens drawer pre-filled with rule data', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ruleId = await createPricingRuleViaApi(page, {
|
||||
modelName: 'e2e-edit-subject',
|
||||
provider: 'Google',
|
||||
isOverride: true,
|
||||
inputCost: 3.0,
|
||||
outputCost: 12.0,
|
||||
});
|
||||
try {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId(`edit-rule-${ruleId}`).click();
|
||||
|
||||
await expect(page.getByText('Edit model cost').first()).toBeVisible();
|
||||
|
||||
// Model ID is locked in edit mode
|
||||
const modelInput = page.getByTestId('drawer-model-id-input');
|
||||
await expect(modelInput).toBeDisabled();
|
||||
await expect(modelInput).toHaveValue('e2e-edit-subject');
|
||||
|
||||
// Provider is locked and pre-filled
|
||||
await expect(page.getByTestId('drawer-provider-select')).toBeDisabled();
|
||||
await expect(page.getByTestId('drawer-provider-select')).toContainText('Google');
|
||||
|
||||
// Source radio
|
||||
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
|
||||
'data-state',
|
||||
'checked',
|
||||
);
|
||||
|
||||
// Pricing values
|
||||
await expect(
|
||||
page.locator('[data-testid="drawer-input-cost"] input'),
|
||||
).toHaveValue('3');
|
||||
await expect(
|
||||
page.locator('[data-testid="drawer-output-cost"] input'),
|
||||
).toHaveValue('12');
|
||||
|
||||
// Delete button present in edit mode
|
||||
await expect(page.getByTestId('drawer-delete-btn')).toBeVisible();
|
||||
} finally {
|
||||
await page.getByTestId('drawer-cancel-btn').click().catch(() => undefined);
|
||||
const token = await authToken(page);
|
||||
await deletePricingRuleViaApi(page.request, ruleId, token);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-13 editing pricing values and saving updates the row', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ruleId = await createPricingRuleViaApi(page, {
|
||||
modelName: 'e2e-edit-subject',
|
||||
provider: 'Google',
|
||||
isOverride: true,
|
||||
inputCost: 3.0,
|
||||
outputCost: 12.0,
|
||||
});
|
||||
try {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId(`edit-rule-${ruleId}`).click();
|
||||
await expect(page.getByText('Edit model cost').first()).toBeVisible();
|
||||
|
||||
// Update pricing
|
||||
const inputField = page.locator('[data-testid="drawer-input-cost"] input');
|
||||
const outputField = page.locator('[data-testid="drawer-output-cost"] input');
|
||||
|
||||
await inputField.click();
|
||||
await inputField.press('Control+a');
|
||||
await inputField.fill('7.00');
|
||||
|
||||
await outputField.click();
|
||||
await outputField.press('Control+a');
|
||||
await outputField.fill('21.00');
|
||||
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes('llm_pricing_rules') && r.request().method() === 'PUT',
|
||||
);
|
||||
const listRefresh = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes('llm_pricing_rules') && r.request().method() === 'GET',
|
||||
);
|
||||
await page.getByTestId('drawer-save-btn').click();
|
||||
const put = await putResponse;
|
||||
expect(put.status()).toBe(200);
|
||||
await listRefresh;
|
||||
|
||||
await expect(page.getByText('Edit model cost').first()).not.toBeVisible();
|
||||
|
||||
// Updated values visible in the row
|
||||
await expect(page.getByTestId(`price-cell-input-${ruleId}`)).toHaveText('$7.00');
|
||||
await expect(page.getByTestId(`price-cell-output-${ruleId}`)).toHaveText('$21.00');
|
||||
} finally {
|
||||
const token = await authToken(page);
|
||||
await deletePricingRuleViaApi(page.request, ruleId, token);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-14 read-only mode — auto rule opens with Managed-by-SigNoz and Read-only badges', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ruleId = await createPricingRuleViaApi(page, {
|
||||
modelName: 'e2e-auto-rule',
|
||||
provider: 'OpenAI',
|
||||
isOverride: false,
|
||||
inputCost: 2.0,
|
||||
outputCost: 8.0,
|
||||
});
|
||||
try {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId(`edit-rule-${ruleId}`).click();
|
||||
await expect(page.getByText('Edit model cost').first()).toBeVisible();
|
||||
|
||||
// Source section lock badge
|
||||
await expect(page.getByTestId('drawer-managed-label')).toBeVisible();
|
||||
await expect(page.getByTestId('drawer-managed-label')).toContainText(
|
||||
'Managed by SigNoz',
|
||||
);
|
||||
|
||||
// Pricing section lock badge
|
||||
await expect(page.getByTestId('drawer-readonly-label')).toBeVisible();
|
||||
await expect(page.getByTestId('drawer-readonly-label')).toContainText('Read-only');
|
||||
|
||||
// Fields are locked
|
||||
await expect(page.getByTestId('drawer-model-id-input')).toBeDisabled();
|
||||
|
||||
// Radio: auto is checked
|
||||
await expect(page.getByTestId('drawer-source-auto')).toHaveAttribute(
|
||||
'data-state',
|
||||
'checked',
|
||||
);
|
||||
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
|
||||
'data-state',
|
||||
'unchecked',
|
||||
);
|
||||
|
||||
// Pricing inputs are disabled
|
||||
await expect(
|
||||
page.locator('[data-testid="drawer-input-cost"] input'),
|
||||
).toBeDisabled();
|
||||
await expect(
|
||||
page.locator('[data-testid="drawer-output-cost"] input'),
|
||||
).toBeDisabled();
|
||||
|
||||
// Pattern input is hidden when read-only
|
||||
await expect(page.getByTestId('drawer-pattern-input')).not.toBeVisible();
|
||||
} finally {
|
||||
await page.getByTestId('drawer-cancel-btn').click().catch(() => undefined);
|
||||
const token = await authToken(page);
|
||||
await deletePricingRuleViaApi(page.request, ruleId, token);
|
||||
}
|
||||
});
|
||||
});
|
||||
192
tests/e2e/tests/llm-pricing/listing.spec.ts
Normal file
192
tests/e2e/tests/llm-pricing/listing.spec.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createPricingRuleViaApi,
|
||||
deletePricingRuleViaApi,
|
||||
gotoLlmPricingPage,
|
||||
LLM_PRICING_PATH,
|
||||
} from '../../helpers/llm-pricing';
|
||||
|
||||
// Tests mutate the pricing rule list — run serially within the worker.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// ─── Suite-level seed registry ────────────────────────────────────────────────
|
||||
//
|
||||
// TC-01 through TC-04 share two seeded rules (ruleA and ruleB) seeded in
|
||||
// beforeAll. TC-05 runs against an empty workspace and must not seed.
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let ruleAId = '';
|
||||
let ruleBId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
ruleAId = await createPricingRuleViaApi(page, {
|
||||
modelName: 'e2e-gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: false,
|
||||
inputCost: 5.0,
|
||||
outputCost: 15.0,
|
||||
});
|
||||
seedIds.add(ruleAId);
|
||||
|
||||
ruleBId = await createPricingRuleViaApi(page, {
|
||||
modelName: 'e2e-claude-3',
|
||||
provider: 'Anthropic',
|
||||
isOverride: true,
|
||||
inputCost: 8.0,
|
||||
outputCost: 24.0,
|
||||
});
|
||||
seedIds.add(ruleBId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deletePricingRuleViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('LLM Pricing — Listing', () => {
|
||||
test('TC-01 listing page renders with data', async ({ authedPage: page }) => {
|
||||
await gotoLlmPricingPage(page);
|
||||
|
||||
await expect(page.getByTestId('llm-observability-model-pricing-page')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Configuration', level: 1 })).toBeVisible();
|
||||
|
||||
// Tabs
|
||||
const modelCostsTab = page.getByRole('tab', { name: 'Model costs' });
|
||||
await expect(modelCostsTab).toBeVisible();
|
||||
await expect(modelCostsTab).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
const unpricedTab = page.getByRole('tab', { name: 'Unpriced models' });
|
||||
await expect(unpricedTab).toBeVisible();
|
||||
await expect(unpricedTab).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
// Filter bar
|
||||
await expect(page.getByTestId('search-input')).toBeVisible();
|
||||
await expect(page.getByTestId('source-select')).toBeVisible();
|
||||
await expect(page.getByTestId('add-model-cost-btn')).toBeVisible();
|
||||
|
||||
// Table and rows
|
||||
await expect(page.getByTestId('model-costs-table')).toBeVisible();
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).toBeVisible();
|
||||
|
||||
// Footer
|
||||
await expect(page.getByText(/Showing 2 models/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 table columns and source badge render correctly', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoLlmPricingPage(page);
|
||||
|
||||
// Rule A — auto (isOverride: false)
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).toHaveText('e2e-gpt-4o');
|
||||
await expect(page.getByTestId(`model-cell-canonical-id-${ruleAId}`)).toHaveText(
|
||||
'openai:e2e-gpt-4o',
|
||||
);
|
||||
const badgeA = page.getByTestId(`source-badge-${ruleAId}`);
|
||||
await expect(badgeA).toHaveText('Auto');
|
||||
|
||||
// Rule B — user override (isOverride: true)
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).toHaveText('e2e-claude-3');
|
||||
const badgeB = page.getByTestId(`source-badge-${ruleBId}`);
|
||||
await expect(badgeB).toHaveText('User override');
|
||||
|
||||
// Price cells show $ prefix
|
||||
await expect(page.getByTestId(`price-cell-input-${ruleAId}`)).toHaveText('$5.00');
|
||||
await expect(page.getByTestId(`price-cell-output-${ruleAId}`)).toHaveText('$15.00');
|
||||
|
||||
// Edit button present for each row
|
||||
await expect(page.getByTestId(`edit-rule-${ruleAId}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`edit-rule-${ruleBId}`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 search filters rows by model name and provider', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoLlmPricingPage(page);
|
||||
|
||||
// Search by model name prefix
|
||||
await page.getByTestId('search-input').fill('e2e-gpt');
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).not.toBeVisible();
|
||||
await expect(page.getByText(/Showing 1 model[^s]/)).toBeVisible();
|
||||
|
||||
// Search by provider (case-insensitive)
|
||||
await page.getByTestId('search-input').fill('anthropic');
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).not.toBeVisible();
|
||||
await expect(page.getByText(/Showing 1 model[^s]/)).toBeVisible();
|
||||
|
||||
// Clear search restores both rows
|
||||
await page.getByTestId('search-input').fill('');
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).toBeVisible();
|
||||
await expect(page.getByText(/Showing 2 models/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 source filter narrows the table to auto-only or override-only', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoLlmPricingPage(page);
|
||||
|
||||
// Filter to auto-populated only
|
||||
await page.getByTestId('source-select').click();
|
||||
await page.getByText('Auto-populated').click();
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).not.toBeVisible();
|
||||
await expect(page.getByText(/Showing 1 model[^s]/)).toBeVisible();
|
||||
|
||||
// Filter to user override only
|
||||
await page.getByTestId('source-select').click();
|
||||
await page.getByText('User override').click();
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).not.toBeVisible();
|
||||
await expect(page.getByText(/Showing 1 model[^s]/)).toBeVisible();
|
||||
|
||||
// Reset to all
|
||||
await page.getByTestId('source-select').click();
|
||||
await page.getByText('Source: All').click();
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleAId}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`model-cell-name-${ruleBId}`)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// TC-05 is isolated — no seeded data; runs in a standalone describe so it
|
||||
// does not inherit the shared beforeAll seed.
|
||||
test.describe('LLM Pricing — Empty state', () => {
|
||||
test('TC-05 empty state — zero rules returns empty table', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Wait for the list request to complete before asserting the empty state
|
||||
// so the test does not race against the initial fetch.
|
||||
const listResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes('llm_pricing_rules') && r.request().method() === 'GET',
|
||||
);
|
||||
await page.goto(LLM_PRICING_PATH);
|
||||
await page.getByTestId('llm-observability-model-pricing-page').waitFor({ state: 'visible' });
|
||||
await listResponse;
|
||||
|
||||
await expect(page.getByTestId('model-costs-table')).toBeVisible();
|
||||
// No data rows — Ant Table renders its empty placeholder
|
||||
await expect(page.getByText(/Showing 0 models/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
246
tests/e2e/tests/llm-pricing/source-radio.spec.ts
Normal file
246
tests/e2e/tests/llm-pricing/source-radio.spec.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
authToken,
|
||||
createPricingRuleViaApi,
|
||||
deletePricingRuleViaApi,
|
||||
gotoLlmPricingPage,
|
||||
} from '../../helpers/llm-pricing';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// Helper — executes in the browser context (Playwright evaluate). TypeScript
|
||||
// cannot see browser globals from Node, so we use `any` to avoid DOM-lib deps.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getBorderColor = (el: any): string =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).getComputedStyle(el).borderColor;
|
||||
|
||||
test.describe('LLM Pricing — Source radio behaviour', () => {
|
||||
test('TC-15 Override → Auto triggers reset-confirm; Keep cancels it', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId('add-model-cost-btn').click();
|
||||
await expect(page.getByText('Add model cost').first()).toBeVisible();
|
||||
|
||||
await page.getByTestId('drawer-model-id-input').fill('e2e-src-switch');
|
||||
await page.locator('[data-testid="drawer-input-cost"] input').fill('25');
|
||||
await page.locator('[data-testid="drawer-output-cost"] input').fill('75');
|
||||
|
||||
// Confirm starting state: Override checked
|
||||
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
|
||||
'data-state',
|
||||
'checked',
|
||||
);
|
||||
|
||||
// Click Auto — should trigger the reset-confirm dialog
|
||||
await page.getByTestId('drawer-source-auto').click();
|
||||
|
||||
const resetDialog = page.locator(
|
||||
'[role="dialog"][aria-label="Reset to default pricing"]',
|
||||
);
|
||||
await expect(resetDialog).toBeVisible();
|
||||
(await expect(
|
||||
resetDialog
|
||||
.getByText(
|
||||
'Reset to default pricing? Custom values will be discarded. it might take 24 hours for changes to take effect..',
|
||||
)
|
||||
.toBeVisible(),
|
||||
),
|
||||
await expect(page.getByTestId('drawer-reset-keep-btn')).toBeVisible());
|
||||
await expect(page.getByTestId('drawer-reset-confirm-btn')).toBeVisible();
|
||||
|
||||
// While confirm is showing, the switch has NOT been applied yet
|
||||
await expect(page.getByTestId('drawer-source-auto')).toHaveAttribute(
|
||||
'data-state',
|
||||
'unchecked',
|
||||
);
|
||||
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
|
||||
'data-state',
|
||||
'checked',
|
||||
);
|
||||
|
||||
// Click Keep — dialog dismisses without changing the radio
|
||||
await page.getByTestId('drawer-reset-keep-btn').click();
|
||||
await expect(resetDialog).not.toBeVisible();
|
||||
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
|
||||
'data-state',
|
||||
'checked',
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-testid="drawer-input-cost"] input'),
|
||||
).toHaveValue('25');
|
||||
await expect(
|
||||
page.locator('[data-testid="drawer-output-cost"] input'),
|
||||
).toHaveValue('75');
|
||||
|
||||
await page.getByTestId('drawer-cancel-btn').click();
|
||||
});
|
||||
|
||||
test('TC-16 Override → Auto then Reset clears values and switches to auto mode', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId('add-model-cost-btn').click();
|
||||
await expect(page.getByText('Add model cost').first()).toBeVisible();
|
||||
|
||||
await page.getByTestId('drawer-model-id-input').fill('e2e-src-reset');
|
||||
await page.locator('[data-testid="drawer-input-cost"] input').fill('25');
|
||||
|
||||
await page.getByTestId('drawer-source-auto').click();
|
||||
const resetDialog = page.locator(
|
||||
'[role="dialog"][aria-label="Reset to default pricing"]',
|
||||
);
|
||||
await expect(resetDialog).toBeVisible();
|
||||
|
||||
await page.getByTestId('drawer-reset-confirm-btn').click();
|
||||
await expect(resetDialog).not.toBeVisible();
|
||||
|
||||
// Radio has switched to Auto
|
||||
await expect(page.getByTestId('drawer-source-auto')).toHaveAttribute(
|
||||
'data-state',
|
||||
'checked',
|
||||
);
|
||||
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
|
||||
'data-state',
|
||||
'unchecked',
|
||||
);
|
||||
|
||||
// Pricing inputs are now read-only
|
||||
await expect(
|
||||
page.locator('[data-testid="drawer-input-cost"] input'),
|
||||
).toBeDisabled();
|
||||
await expect(
|
||||
page.locator('[data-testid="drawer-output-cost"] input'),
|
||||
).toBeDisabled();
|
||||
|
||||
await page.getByTestId('drawer-cancel-btn').click();
|
||||
});
|
||||
|
||||
test('TC-17 Auto → Override switching works without confirm', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ruleId = await createPricingRuleViaApi(page, {
|
||||
modelName: 'e2e-auto-switch',
|
||||
provider: 'OpenAI',
|
||||
isOverride: false,
|
||||
inputCost: 1.0,
|
||||
outputCost: 4.0,
|
||||
});
|
||||
try {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId(`edit-rule-${ruleId}`).click();
|
||||
await expect(page.getByText('Edit model cost').first()).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('drawer-source-auto')).toHaveAttribute(
|
||||
'data-state',
|
||||
'checked',
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-testid="drawer-input-cost"] input'),
|
||||
).toBeDisabled();
|
||||
|
||||
// Click Override — no reset-confirm should appear
|
||||
await page.getByTestId('drawer-source-override').click();
|
||||
await expect(
|
||||
page.locator('[role="dialog"][aria-label="Reset to default pricing"]'),
|
||||
).not.toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('drawer-source-override')).toHaveAttribute(
|
||||
'data-state',
|
||||
'checked',
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-testid="drawer-input-cost"] input'),
|
||||
).toBeEnabled();
|
||||
} finally {
|
||||
await page
|
||||
.getByTestId('drawer-cancel-btn')
|
||||
.click()
|
||||
.catch(() => undefined);
|
||||
const token = await authToken(page);
|
||||
await deletePricingRuleViaApi(page.request, ruleId, token);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-18 source radio card highlight — checked state gets tinted border', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ruleId = await createPricingRuleViaApi(page, {
|
||||
modelName: 'e2e-border-check',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
inputCost: 1.0,
|
||||
outputCost: 4.0,
|
||||
});
|
||||
try {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId(`edit-rule-${ruleId}`).click();
|
||||
await expect(page.getByText('Edit model cost').first()).toBeVisible();
|
||||
|
||||
// Override card is checked — its border-color must be non-transparent
|
||||
const overrideCard = page.locator('.source-radio--override');
|
||||
await expect(
|
||||
overrideCard.locator('button[data-state="checked"]'),
|
||||
).toBeVisible();
|
||||
const overrideBorder = await overrideCard.evaluate(getBorderColor);
|
||||
expect(overrideBorder).not.toBe('transparent');
|
||||
expect(overrideBorder).not.toBe('rgba(0, 0, 0, 0)');
|
||||
|
||||
// Auto card is unchecked — its border-color should be transparent
|
||||
const autoCard = page.locator('.source-radio--auto');
|
||||
await expect(
|
||||
autoCard.locator('button[data-state="unchecked"]'),
|
||||
).toBeVisible();
|
||||
const autoBorder = await autoCard.evaluate(getBorderColor);
|
||||
expect(autoBorder).toBe('rgba(0, 0, 0, 0)');
|
||||
|
||||
// Switch to Auto and confirm reset
|
||||
await page.getByTestId('drawer-source-auto').click();
|
||||
const resetDialog = page.locator(
|
||||
'[role="dialog"][aria-label="Reset to default pricing"]',
|
||||
);
|
||||
await expect(resetDialog).toBeVisible();
|
||||
await page.getByTestId('drawer-reset-confirm-btn').click();
|
||||
await expect(resetDialog).not.toBeVisible();
|
||||
|
||||
// Now Auto card has the tinted border; Override card is transparent
|
||||
const autoBorderAfter = await autoCard.evaluate(getBorderColor);
|
||||
expect(autoBorderAfter).not.toBe('transparent');
|
||||
expect(autoBorderAfter).not.toBe('rgba(0, 0, 0, 0)');
|
||||
|
||||
const overrideBorderAfter = await overrideCard.evaluate(getBorderColor);
|
||||
expect(overrideBorderAfter).toBe('rgba(0, 0, 0, 0)');
|
||||
} finally {
|
||||
await page
|
||||
.getByTestId('drawer-cancel-btn')
|
||||
.click()
|
||||
.catch(() => undefined);
|
||||
const token = await authToken(page);
|
||||
await deletePricingRuleViaApi(page.request, ruleId, token);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-19 unchecked radio dot is visible — border not invisible against drawer surface', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoLlmPricingPage(page);
|
||||
await page.getByTestId('add-model-cost-btn').click();
|
||||
await expect(page.getByText('Add model cost').first()).toBeVisible();
|
||||
|
||||
// In add mode, Override is checked and Auto is unchecked
|
||||
const autoBtn = page.getByTestId('drawer-source-auto');
|
||||
await expect(autoBtn).toHaveAttribute('data-state', 'unchecked');
|
||||
|
||||
// The CSS fix sets --radio-group-item-border-color: var(--bg-slate-200)
|
||||
// on .source-radio-group; assert the computed border is not invisible.
|
||||
const borderColor = await autoBtn.evaluate(getBorderColor);
|
||||
expect(borderColor).not.toBe('transparent');
|
||||
expect(borderColor).not.toBe('rgba(0, 0, 0, 0)');
|
||||
|
||||
// Auto label text is visible alongside the dot
|
||||
await expect(page.getByText('Auto-populated').first()).toBeVisible();
|
||||
|
||||
await page.getByTestId('drawer-cancel-btn').click();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user