Compare commits

...

8 Commits

Author SHA1 Message Date
Gaurav Tewari
6d7c08172a feat: maketrace columns appear in a specific order 2026-09-22 15:48:20 +05:30
Gaurav Tewari
d879273e44 chore: allow users to move trace id 2026-09-22 15:48:20 +05:30
Gaurav Tewari
a4314382d1 chore: update colors for drawers inputs 2026-09-22 15:48:20 +05:30
Gaurav Tewari
9844e81c36 chore: removed unused padding in mapping tabel 2026-09-22 15:48:20 +05:30
Gaurav Tewari
fa6197ced1 chore: migrate tabs in model pricing & attribute mapping to antD tabs 2026-09-22 15:48:11 +05:30
Gaurav Tewari
89bb599cbe chore: make label of add model cost look same 2026-09-22 15:29:30 +05:30
Gaurav Tewari
e3bc7fa8e8 fix: create new mapping color 2026-09-22 15:29:09 +05:30
Gaurav Tewari
d61b558334 fix: test json being send 2026-09-22 10:35:54 +05:30
36 changed files with 380 additions and 193 deletions

View File

@@ -1,7 +1,6 @@
.tableWrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.toolbar {

View File

@@ -2,11 +2,10 @@
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-2) var(--spacing-8);
--tabs-content-padding: 0;
--tabs-text-color: var(--l1-foreground);
--tabs-active-text-color: var(--l1-foreground);
:global(.ant-tabs-tabpane) {
padding: var(--spacing-0) var(--spacing-8);
}
}
.pageError {

View File

@@ -1,9 +1,8 @@
import { useCallback } from 'react';
import { Divider } from '@signozhq/ui/divider';
import { Tabs } from '@signozhq/ui/tabs';
import { Tabs } from 'antd';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
import AttributeMappingHeader from './components/AttributeMappingHeader/AttributeMappingHeader';
import AttributeMappingActions from './components/AttributeMappingActions/AttributeMappingActions';
import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
import DiscardChangesDialog from './components/DiscardChangesDialog/DiscardChangesDialog';
import GroupFormDrawer from './components/GroupFormDrawer/GroupFormDrawer';
@@ -59,24 +58,23 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
className={styles.llmObservabilityAttributeMapping}
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={editor.isDirty}
isSaving={editor.isSaving}
onDiscard={discardConfirm.request}
onSave={editor.save}
/>
{editor.saveError && (
<div className={styles.pageError} role="alert">
{editor.saveError}
</div>
)}
<Divider />
<Tabs
testId="attribute-mapping-tabs"
defaultValue={MAPPINGS_TAB_KEY}
defaultActiveKey={MAPPINGS_TAB_KEY}
items={tabItems}
tabBarExtraContent={
<AttributeMappingActions
isDirty={editor.isDirty}
isSaving={editor.isSaving}
onDiscard={discardConfirm.request}
onSave={editor.save}
/>
}
/>
{groupDrawer.isOpen && (
<GroupFormDrawer

View File

@@ -63,6 +63,26 @@ const EDITED_SPAN_JSON = `{
}
}`;
const SPAN_WITH_EXTRA_KEY_JSON = `{
"attributes": {
"input.value": "What is quantum computing?"
},
"resource": {
"service.name": "llm-gateway"
},
"demo": {
"name": "demo"
}
}`;
const EXTRA_KEY_RESULT_SPAN = {
attributes: {
'input.value': 'What is quantum computing?',
[MAPPED_ATTRIBUTE_KEY]: 'What is quantum computing?',
},
resource: { 'service.name': 'llm-gateway' },
};
const SPAN_INPUT_KEY = LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN;
describe('TestTab — sample-span flow', () => {
@@ -104,6 +124,47 @@ describe('TestTab — sample-span flow', () => {
expect(screen.queryByTestId('test-error')).not.toBeInTheDocument();
});
it('trims extra top-level keys and sends only the envelope', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let body: { spans?: { attributes?: Record<string, unknown> }[] } | undefined;
server.use(
rest.post(TEST_ENDPOINT, async (req, res, ctx) => {
body = await req.json();
return res(
ctx.status(200),
ctx.json(makeTestResponse([EXTRA_KEY_RESULT_SPAN])),
);
}),
);
render(<LLMObservabilityAttributeMapping />);
await user.click(screen.getByRole('tab', { name: 'Test' }));
const runBtn = await screen.findByTestId('run-test-button');
await user.clear(screen.getByTestId('monaco'));
await user.paste(SPAN_WITH_EXTRA_KEY_JSON);
await waitFor(() =>
expect(screen.getByTestId('monaco')).toHaveValue(SPAN_WITH_EXTRA_KEY_JSON),
);
expect(screen.queryByTestId('test-input-error')).not.toBeInTheDocument();
await user.click(runBtn);
await expect(
screen.findByTestId('test-results'),
).resolves.toBeInTheDocument();
expect(body?.spans?.[0]?.attributes).toStrictEqual({
'input.value': 'What is quantum computing?',
});
expect(screen.getByTestId('test-result-0-attributes')).toHaveTextContent(
MAPPED_ATTRIBUTE_KEY,
);
expect(screen.getByTestId('test-result-0-resource')).toBeInTheDocument();
expect(screen.getByText('populated')).toBeInTheDocument();
});
it('surfaces a backend error and renders no results', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(

View File

@@ -0,0 +1,51 @@
import { parseSpanInput } from '../testPayload';
describe('parseSpanInput', () => {
it('reads the envelope and trims extra top-level keys', () => {
const span = parseSpanInput(`{
"attributes": { "llm.model_name": "gpt-4o" },
"resource": { "service.name": "llm-gateway" },
"demo": { "name": "demo" }
}`);
expect(span.attributes).toStrictEqual({ 'llm.model_name': 'gpt-4o' });
expect(span.resource).toStrictEqual({ 'service.name': 'llm-gateway' });
});
it('reads a clean envelope', () => {
const span = parseSpanInput(`{
"attributes": { "llm.model_name": "gpt-4o" },
"resource": { "service.name": "llm-gateway" }
}`);
expect(span.attributes).toStrictEqual({ 'llm.model_name': 'gpt-4o' });
expect(span.resource).toStrictEqual({ 'service.name': 'llm-gateway' });
});
it('treats an envelope-less object as a bare attribute map', () => {
const span = parseSpanInput('{ "llm.model_name": "gpt-4o", "demo": "x" }');
expect(span.attributes).toStrictEqual({
'llm.model_name': 'gpt-4o',
demo: 'x',
});
expect(span.resource).toStrictEqual({});
});
it('drops an envelope key that is not an object', () => {
const span = parseSpanInput(
'{ "attributes": { "llm.provider": "openai" }, "resource": "oops" }',
);
expect(span.attributes).toStrictEqual({ 'llm.provider': 'openai' });
expect(span.resource).toStrictEqual({});
});
it.each([
[' ', 'Paste a JSON span object to run the test.'],
['{ "a": }', 'Invalid JSON — check for trailing commas or missing quotes.'],
['[1, 2]', 'Span must be a JSON object of attribute key-value pairs.'],
])('rejects %p', (input, message) => {
expect(() => parseSpanInput(input)).toThrow(message);
});
});

View File

@@ -51,13 +51,9 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
// Any other top-level key (a real span carries name, spanId, kind...) is trimmed.
function isSpanEnvelope(parsed: Record<string, unknown>): boolean {
const keys = Object.keys(parsed);
return (
keys.length > 0 &&
keys.every((key) => key === 'attributes' || key === 'resource') &&
(isPlainObject(parsed.attributes) || isPlainObject(parsed.resource))
);
return isPlainObject(parsed.attributes) || isPlainObject(parsed.resource);
}
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {

View File

@@ -72,20 +72,15 @@ describe('LLMObservabilityAttributeMapping', () => {
const attributeMappingsTab = screen.getByRole('tab', {
name: 'Attribute Mappings',
});
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
expect(attributeMappingsTab).toHaveAttribute('aria-selected', 'true');
await expect(
screen.findByTestId('attribute-mappings-tab'),
).resolves.toBeInTheDocument();
});
it('renders the header with its description and no Save/Discard while pristine', () => {
it('renders no Save/Discard while pristine', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByText(
'Configure source-to-target attribute remapping for LLM traces',
),
).toBeInTheDocument();
// The actions only appear once there are staged changes.
expect(screen.queryByTestId('save-changes-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
@@ -124,7 +119,11 @@ describe('LLMObservabilityAttributeMapping', () => {
await user.click(screen.getByRole('tab', { name: 'Attribute Mappings' }));
await screen.findByTestId('attribute-mappings-tab');
expect(screen.queryByTestId('span-json-editor')).not.toBeInTheDocument();
// antd keeps a visited pane mounted and marks it aria-hidden, rather than
// unmounting it the way the previous tabs did.
expect(
screen.getByTestId('span-json-editor').closest('[role="tabpanel"]'),
).toHaveAttribute('aria-hidden', 'true');
await user.click(screen.getByRole('tab', { name: 'Test' }));

View File

@@ -0,0 +1,10 @@
.actions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedChanges {
font-size: var(--periscope-font-size-base);
color: var(--accent-amber);
}

View File

@@ -0,0 +1,53 @@
import { Button } from '@signozhq/ui/button';
import { useCanManageAttributeMapping } from '../../hooks/useCanManageAttributeMapping';
import styles from './AttributeMappingActions.module.scss';
interface AttributeMappingActionsProps {
isDirty: boolean;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function AttributeMappingActions({
isDirty,
isSaving,
onDiscard,
onSave,
}: AttributeMappingActionsProps): JSX.Element | null {
const canManage = useCanManageAttributeMapping();
if (!canManage || !isDirty) {
return null;
}
return (
<div className={styles.actions}>
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
);
}
export default AttributeMappingActions;

View File

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

View File

@@ -1,56 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { useCanManageAttributeMapping } from '../../hooks/useCanManageAttributeMapping';
import styles from './AttributeMappingHeader.module.scss';
interface AttributeMappingHeaderProps {
isDirty: boolean;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function AttributeMappingHeader({
isDirty,
isSaving,
onDiscard,
onSave,
}: AttributeMappingHeaderProps): JSX.Element {
const canManage = useCanManageAttributeMapping();
return (
<header className={styles.pageHeader}>
<Typography.Text as="p" size="base" color="muted">
Configure source-to-target attribute remapping for LLM traces
</Typography.Text>
{canManage && isDirty && (
<div className={styles.pageHeaderActions}>
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
)}
</header>
);
}
export default AttributeMappingHeader;

View File

@@ -1,4 +1,7 @@
.groupForm {
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
display: flex;
flex-direction: column;
gap: var(--spacing-10);
@@ -18,11 +21,8 @@
}
.groupFormLabel {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.groupFormHint {

View File

@@ -5,17 +5,12 @@
}
.label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.labelHint {
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
color: var(--l3-foreground);
}
.keys {

View File

@@ -1,4 +1,6 @@
.form {
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
display: flex;
flex-direction: column;
gap: var(--spacing-10);
@@ -12,17 +14,12 @@
}
.label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.labelHint {
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
color: var(--l3-foreground);
}
.hint {

View File

@@ -11,10 +11,12 @@ const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
// An aggregate outside the default order starts hidden, so the persisted
// defaults are observable.
const COLUMNS = buildTraceViewColumns([
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'start_time' },
{ name: 'unlisted_aggregate' },
]);
function RaceHarness(): JSX.Element {
@@ -66,7 +68,9 @@ describe('TracesTable column-init race', () => {
await expect(screen.findByRole('table')).resolves.toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('start_time')).not.toBeInTheDocument();
expect(persistedState()?.hiddenColumnIds).toStrictEqual(['start_time']);
expect(screen.queryByText('unlisted_aggregate')).not.toBeInTheDocument();
expect(persistedState()?.hiddenColumnIds).toStrictEqual([
'unlisted_aggregate',
]);
});
});

View File

@@ -128,14 +128,7 @@ describe('TracesView column persistence', () => {
await findTable();
await waitFor(() => {
expect(persistedState()?.hiddenColumnIds).toStrictEqual([
'start_time',
'end_time',
'error_count',
'input',
'output',
'trace:tool_call_count:float64',
]);
expect(persistedState()?.hiddenColumnIds).toStrictEqual([]);
});
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
@@ -160,7 +153,7 @@ describe('TracesView column persistence', () => {
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
});
it('renders only the default-visible columns when the field keys fail', async () => {
it('renders the display-only columns when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
@@ -168,8 +161,8 @@ describe('TracesView column persistence', () => {
expect(screen.getByText('root_span_name')).toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument();
expect(screen.queryByText('output')).not.toBeInTheDocument();
expect(screen.getByText('input')).toBeInTheDocument();
expect(screen.queryByText('llm_call_count')).not.toBeInTheDocument();
});
it('leaves an existing selection untouched while the field keys fail', async () => {

View File

@@ -109,17 +109,24 @@ describe('useTraceViewColumns', () => {
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'trace_id',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
...AGGREGATE_KEYS,
'max_llm_duration_nano',
]);
});
@@ -127,14 +134,24 @@ describe('useTraceViewColumns', () => {
const { result } = await renderColumns();
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'trace_id',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
]);
});
@@ -193,14 +210,24 @@ describe('useTraceViewColumns', () => {
expect(result.current.canPersistColumns).toBe(true);
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'trace_id',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
]);
});
});

View File

@@ -5,20 +5,43 @@ import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
/** Always visible: it is the row's link to the trace. */
/** Always present: it is the row's link to the trace, but it can be reordered. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
/** Everything else starts hidden; only applied at first init, since the store persists hidden ids. */
const DEFAULT_VISIBLE_FIELDS = new Set([
/** Fallback order, until the user drags a column; unlisted fields keep the order the keys endpoint returns them in. */
const DEFAULT_COLUMN_ORDER = [
TRACE_ID_COLUMN_ID,
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
TRACE_ID_COLUMN_ID,
]);
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
];
const orderRank = (field: TelemetryFieldKey): number => {
const index = DEFAULT_COLUMN_ORDER.indexOf(field.name);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
};
export const sortByDefaultOrder = (
fields: TelemetryFieldKey[],
): TelemetryFieldKey[] =>
[...fields].sort((a, b) => orderRank(a) - orderRank(b));
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied at first init, since the store persists hidden ids. */
const DEFAULT_VISIBLE_FIELDS = new Set(DEFAULT_COLUMN_ORDER);
export const buildTraceViewColumns = (
fields: TelemetryFieldKey[],
@@ -27,7 +50,7 @@ export const buildTraceViewColumns = (
...getFieldColumn(field),
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
enableMove: field.name !== TRACE_ID_COLUMN_ID,
enableMove: true,
enableRemove: field.name !== TRACE_ID_COLUMN_ID,
canBeHidden: field.name !== TRACE_ID_COLUMN_ID,
}));

View File

@@ -21,7 +21,11 @@ import {
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
TRACE_VIEW_FIELD_KEYS,
} from '../constants';
import { buildTraceViewColumns, TRACE_ID_COLUMN_ID } from './configs';
import {
buildTraceViewColumns,
sortByDefaultOrder,
TRACE_ID_COLUMN_ID,
} from './configs';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
@@ -55,7 +59,10 @@ export function useTraceViewColumns(): UseTraceViewColumns {
);
const availableFields = useMemo(
() => mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
() =>
sortByDefaultOrder(
mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
),
[fetchedFields],
);

View File

@@ -2,11 +2,10 @@
display: flex;
flex-direction: column;
gap: var(--spacing-8);
--tabs-content-padding: 0;
margin-top: var(--spacing-3);
padding: var(--spacing-0) var(--spacing-8);
--tabs-text-color: var(--l1-foreground);
--tabs-active-text-color: var(--l1-foreground);
:global(.ant-tabs-tabpane) {
padding: var(--spacing-0) var(--spacing-8);
}
}
.tabLabel {

View File

@@ -1,5 +1,5 @@
import { Badge } from '@signozhq/ui/badge';
import { Tabs } from '@signozhq/ui/tabs';
import { Tabs } from 'antd';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import { parseAsStringEnum, useQueryState } from 'nuqs';
@@ -26,7 +26,7 @@ function LLMObservabilityModelPricing(): JSX.Element {
data-testid="llm-observability-model-pricing-page"
>
<Tabs
value={activeTab}
activeKey={activeTab}
onChange={(key): void => {
void setActiveTab(key as typeof activeTab);
}}

View File

@@ -1,3 +1,7 @@
.fieldLabel {
composes: fieldLabel from './shared.module.scss';
}
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
@@ -17,6 +21,9 @@
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
display: flex;
overflow: hidden;

View File

@@ -109,7 +109,7 @@ function ModelCostDrawer({
drawerHeaderProps={{ className: styles.title }}
>
<div className={styles.drawerSection}>
<label htmlFor="billing-model-id">
<label htmlFor="billing-model-id" className={styles.fieldLabel}>
Billing Model ID{' '}
<span className={styles.required} aria-hidden="true">
*
@@ -144,7 +144,9 @@ function ModelCostDrawer({
</div>
<div className={styles.drawerSection}>
<label htmlFor="provider-select">Provider</label>
<label htmlFor="provider-select" className={styles.fieldLabel}>
Provider
</label>
<Controller
name="provider"
control={control}

View File

@@ -1,3 +1,7 @@
.fieldLabel {
composes: fieldLabel from '../../shared.module.scss';
}
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}

View File

@@ -67,9 +67,7 @@ function ExtraPricingBuckets({
return (
<div className={cx(styles.extraBucketsSection, styles.drawerSection)}>
<div className={styles.extraBucketsSectionHead}>
<Typography.Text as="span" size="small" color="muted">
Extra Pricing Buckets
</Typography.Text>
<span className={styles.fieldLabel}>Extra Pricing Buckets</span>
<Typography.Text as="span" size="small" color="muted">
Optional
</Typography.Text>
@@ -116,7 +114,9 @@ function ExtraPricingBuckets({
{addedBuckets.length > 0 && (
<div className={cx(styles.pricingField, styles.cacheModeField)}>
<label htmlFor="cache-mode">Cache mode</label>
<label htmlFor="cache-mode" className={styles.fieldLabel}>
Cache mode
</label>
<SelectSimple
id="cache-mode"
value={pricing.cacheMode}

View File

@@ -1,3 +1,7 @@
.fieldLabel {
composes: fieldLabel from '../../shared.module.scss';
}
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}

View File

@@ -37,12 +37,12 @@ function PatternEditor({
return (
<div className={styles.drawerSection}>
<Typography.Text as="span">
<span className={styles.fieldLabel}>
Model name patterns{' '}
<Typography.Text as="span" color="muted">
(prefix match)
</Typography.Text>
</Typography.Text>
</span>
<div className={styles.patternBox}>
<div className={styles.patternChips}>
{patterns.map((pattern) => (

View File

@@ -1,3 +1,7 @@
.fieldLabel {
composes: fieldLabel from '../../shared.module.scss';
}
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}

View File

@@ -24,9 +24,7 @@ function PricingFields({
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text size="base" weight="bold">
Pricing (per 1M tokens, USD)
</Typography.Text>
<span className={styles.fieldLabel}>Pricing (per 1M tokens, USD)</span>
{isReadOnly && (
<span className={styles.managedLabel} data-testid="drawer-readonly-label">
@@ -38,7 +36,7 @@ function PricingFields({
</div>
<div className={styles.pricingGrid}>
<div className={styles.pricingField}>
<label htmlFor="input-cost">
<label htmlFor="input-cost" className={styles.fieldLabel}>
Input Cost{' '}
<span className={styles.required} aria-hidden="true">
*
@@ -58,7 +56,7 @@ function PricingFields({
/>
</div>
<div className={styles.pricingField}>
<label htmlFor="output-cost">
<label htmlFor="output-cost" className={styles.fieldLabel}>
Output Cost{' '}
<span className={styles.required} aria-hidden="true">
*

View File

@@ -1,3 +1,7 @@
.fieldLabel {
composes: fieldLabel from '../../shared.module.scss';
}
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}

View File

@@ -2,7 +2,6 @@ import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Lock } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './SourceSelector.module.scss';
@@ -42,9 +41,7 @@ function SourceSelector({
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text weight="bold" size="base">
Source
</Typography.Text>
<span className={styles.fieldLabel}>Source</span>
{isReadOnly && (
<span className={styles.managedLabel} data-testid="drawer-managed-label">

View File

@@ -47,6 +47,14 @@
color: var(--accent-cherry);
}
/* Single treatment for every label in the drawer, so field labels and the */
.fieldLabel {
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--spacing-10);
color: var(--l2-foreground);
}
.pricingField {
display: flex;
flex-direction: column;

View File

@@ -167,7 +167,7 @@ describe('UnpricedModelsTab (integration)', () => {
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
// Open the row's dropdown and take the "Create pricing for …" escape hatch
// Open the row's dropdown and take the "Create a new pricing model" escape hatch
// instead of mapping onto an existing billing model.
await user.click(screen.getByTestId(`map-to-select-${MODEL}`));
await user.click(await screen.findByTestId(`map-to-create-${MODEL}`));

View File

@@ -14,6 +14,24 @@
width: 280px;
}
.footer {
padding: var(--spacing-2);
background-color: var(--l2-background);
}
.createItem {
gap: var(--spacing-4);
font-style: normal;
color: var(--accent-primary);
--command-item-cursor: pointer;
--command-item-svg-size: var(--spacing-7);
&[data-selected='true'] {
background-color: var(--callout-primary-background);
color: var(--accent-primary);
}
}
.skeletonList {
display: flex;
flex-direction: column;

View File

@@ -119,15 +119,18 @@ function MapToBillingModelSelect({
options scroll. Escape hatch when no existing billing model fits:
define this model's own pricing rather than mapping onto another. */}
<ComboboxSeparator alwaysRender />
<ComboboxCreateItem
inputValue={modelName}
value={`create-pricing-${modelName}`}
prefix={<Plus size={14} />}
onSelect={handleCreateNew}
testId={`map-to-create-${modelName}`}
>
Create pricing for &quot;{modelName}&quot;
</ComboboxCreateItem>
<div className={styles.footer}>
<ComboboxCreateItem
className={styles.createItem}
inputValue={modelName}
value={`create-pricing-${modelName}`}
prefix={<Plus size={14} />}
onSelect={handleCreateNew}
testId={`map-to-create-${modelName}`}
>
Create a new pricing model
</ComboboxCreateItem>
</div>
</ComboboxCommand>
</ComboboxContent>
</Combobox>

View File

@@ -22,5 +22,6 @@
justify-content: center;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-1) var(--spacing-0);
}
}