mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-22 11:20:43 +01:00
Compare commits
8 Commits
chore/remo
...
fix/ui-tes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d7c08172a | ||
|
|
d879273e44 | ||
|
|
a4314382d1 | ||
|
|
9844e81c36 | ||
|
|
fa6197ced1 | ||
|
|
89bb599cbe | ||
|
|
e3bc7fa8e8 | ||
|
|
d61b558334 |
@@ -1,7 +1,6 @@
|
||||
.tableWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.fieldLabel {
|
||||
composes: fieldLabel from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.drawerSection {
|
||||
composes: drawerSection from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.fieldLabel {
|
||||
composes: fieldLabel from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.drawerSection {
|
||||
composes: drawerSection from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.fieldLabel {
|
||||
composes: fieldLabel from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.drawerSection {
|
||||
composes: drawerSection from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
*
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.fieldLabel {
|
||||
composes: fieldLabel from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.drawerSection {
|
||||
composes: drawerSection from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 "{modelName}"
|
||||
</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>
|
||||
|
||||
@@ -22,5 +22,6 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-1) var(--spacing-0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user