mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-14 02:20:32 +01:00
Compare commits
9 Commits
feat/expor
...
hotfix/spa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1361c070e0 | ||
|
|
86313ae561 | ||
|
|
f764a8d9af | ||
|
|
ebe7a264d5 | ||
|
|
3e971a902a | ||
|
|
e0a0f49fb4 | ||
|
|
dbacd34ec2 | ||
|
|
0cbf3e8ee2 | ||
|
|
1f057f041b |
@@ -1494,7 +1494,7 @@ components:
|
||||
- cosmosdb
|
||||
- cassandradb
|
||||
- redis
|
||||
- cloudsql
|
||||
- cloudsql_postgres
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
@@ -7998,6 +7998,15 @@ components:
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpantypesGettableSpanMappers:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapper'
|
||||
type: array
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpantypesGettableTraceAggregations:
|
||||
properties:
|
||||
aggregations:
|
||||
@@ -8150,7 +8159,7 @@ components:
|
||||
type: boolean
|
||||
fieldContext:
|
||||
$ref: '#/components/schemas/SpantypesFieldContext'
|
||||
group_id:
|
||||
groupId:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
@@ -8163,7 +8172,7 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- group_id
|
||||
- groupId
|
||||
- name
|
||||
- fieldContext
|
||||
- config
|
||||
@@ -13742,7 +13751,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableSpanMapperGroups'
|
||||
$ref: '#/components/schemas/SpantypesGettableSpanMappers'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
|
||||
@@ -2813,7 +2813,7 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
cosmosdb = 'cosmosdb',
|
||||
cassandradb = 'cassandradb',
|
||||
redis = 'redis',
|
||||
cloudsql = 'cloudsql',
|
||||
cloudsql_postgres = 'cloudsql_postgres',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
@@ -9194,6 +9194,76 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
items: SpantypesSpanMapperGroupDTO[];
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
}
|
||||
export interface SpantypesSpanMapperSourceDTO {
|
||||
context: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperConfigDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
sources: SpantypesSpanMapperSourceDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperDTO {
|
||||
config: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
groupId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableSpanMappersDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
items: SpantypesSpanMapperDTO[];
|
||||
}
|
||||
|
||||
export enum SpantypesSpanAggregationTypeDTO {
|
||||
span_count = 'span_count',
|
||||
execution_time_percentage = 'execution_time_percentage',
|
||||
@@ -9440,30 +9510,6 @@ export interface SpantypesPostableFlamegraphDTO {
|
||||
selectedSpanId?: string;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
}
|
||||
export interface SpantypesSpanMapperSourceDTO {
|
||||
context: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperConfigDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
sources: SpantypesSpanMapperSourceDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperDTO {
|
||||
config: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
@@ -9512,45 +9558,6 @@ export interface SpantypesPostableWaterfallDTO {
|
||||
uncollapsedSpans?: string[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperDTO {
|
||||
config: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
group_id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface SpantypesUpdatableSpanMapperDTO {
|
||||
config?: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
@@ -10852,7 +10859,7 @@ export type ListSpanMappersPathParameters = {
|
||||
groupId: string;
|
||||
};
|
||||
export type ListSpanMappers200 = {
|
||||
data: SpantypesGettableSpanMapperGroupsDTO;
|
||||
data: SpantypesGettableSpanMappersDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export enum SESSIONSTORAGE {
|
||||
RETRY_LAZY_REFRESHED = 'retry-lazy-refreshed',
|
||||
VIEW_PANEL_HANDOFF = 'view-panel-handoff',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.pageError {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import styles from './AttributeMappingsTab.module.scss';
|
||||
import MappingsTable from './components/MappingsTable/MappingsTable';
|
||||
import { useAttributeMappingStore } from './hooks/useAttributeMappingStore';
|
||||
|
||||
function AttributeMappingsTab(): JSX.Element {
|
||||
const store = useAttributeMappingStore();
|
||||
|
||||
return (
|
||||
<div data-testid="attribute-mappings-tab">
|
||||
{store.isError ? (
|
||||
<div className={styles.pageError} role="alert">
|
||||
Failed to load mapping groups. Please try again.
|
||||
</div>
|
||||
) : (
|
||||
<MappingsTable store={store} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeMappingsTab;
|
||||
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
SpantypesFieldContextDTO as FieldContext,
|
||||
SpantypesSpanMapperOperationDTO as MapperOperation,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
GROUPS_ENDPOINT,
|
||||
makeGroupsResponse,
|
||||
makeMapper,
|
||||
makeMappersResponse,
|
||||
mappersEndpoint,
|
||||
mockGroups,
|
||||
mockMappers,
|
||||
} from 'container/LLMObservability/AttributeMapping/__tests__/fixtures';
|
||||
import AttributeMappingsTab from '../AttributeMappingsTab';
|
||||
|
||||
function setupGroups(groups = mockGroups): void {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeGroupsResponse(groups))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function setupMappers(mappers = mockMappers, groupId = 'group-1'): void {
|
||||
server.use(
|
||||
rest.get(mappersEndpoint(groupId), (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeMappersResponse(mappers))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function expandGroup(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
groupId = 'group-1',
|
||||
): Promise<void> {
|
||||
await user.click(screen.getByTestId(`group-expand-${groupId}`));
|
||||
}
|
||||
|
||||
describe('AttributeMappingsTab (integration)', () => {
|
||||
beforeEach(() => {
|
||||
// Reset URL state between tests — jsdom shares window.location across a file.
|
||||
window.history.pushState(null, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders no error banner on a successful load', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error banner when the groups request fails', async () => {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await expect(screen.findByRole('alert')).resolves.toHaveTextContent(
|
||||
'Failed to load mapping groups. Please try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the empty state when there are no groups', async () => {
|
||||
setupGroups([]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('mapper-groups-empty'),
|
||||
).resolves.toHaveTextContent('No mapping groups yet.');
|
||||
});
|
||||
|
||||
it('renders each group header row with its name, condition count and status', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
// Condition filters are no longer shown inline as clauses — the header
|
||||
// carries a count instead (the keys surface in the group drawer, later PR).
|
||||
// Group headers are antd Collapse panels, so rows scope to the panel item.
|
||||
// group-1: enabled, with attribute + resource condition keys.
|
||||
const enabledRow = (await screen.findByTestId('group-name-group-1')).closest(
|
||||
'.ant-collapse-item',
|
||||
) as HTMLElement;
|
||||
expect(
|
||||
within(enabledRow).getByTestId('group-name-group-1'),
|
||||
).toHaveTextContent('demo');
|
||||
expect(
|
||||
within(enabledRow).getByTestId('group-condition-count-group-1'),
|
||||
).toHaveTextContent('2 conditions');
|
||||
expect(within(enabledRow).getByTestId('group-enabled-group-1')).toBeChecked();
|
||||
|
||||
// group-2: disabled, with no condition keys.
|
||||
const disabledRow = screen
|
||||
.getByTestId('group-name-group-2')
|
||||
.closest('.ant-collapse-item') as HTMLElement;
|
||||
expect(within(disabledRow).getByText('Tool')).toBeInTheDocument();
|
||||
expect(
|
||||
within(disabledRow).getByTestId('group-condition-count-group-2'),
|
||||
).toHaveTextContent('0 conditions');
|
||||
expect(
|
||||
within(disabledRow).getByTestId('group-enabled-group-2'),
|
||||
).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('renders the group enable state as a read-only switch', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
// The status switch reflects enabled state but is non-interactive in this
|
||||
// read-only listing — editing lands in a later PR.
|
||||
const toggle = await screen.findByTestId('group-enabled-group-1');
|
||||
expect(toggle).toBeChecked();
|
||||
expect(toggle).toBeDisabled();
|
||||
});
|
||||
|
||||
it("reveals a group's mappers on expand and hides them on collapse", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1' })]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
// The toggle is the antd Collapse header, which owns the expanded state.
|
||||
const header = screen
|
||||
.getByTestId('group-expand-group-1')
|
||||
.closest('.ant-collapse-header') as HTMLElement;
|
||||
expect(header).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
await expandGroup(user);
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(
|
||||
screen.findByTestId('mapper-target-mapper-1'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await expandGroup(user);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId('mapper-target-mapper-1'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("lazily fetches and renders a group's mappers on first expand", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([
|
||||
makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model', enabled: true }),
|
||||
]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
// Mappers are not fetched until the row is expanded.
|
||||
expect(
|
||||
screen.queryByTestId('mapper-target-mapper-1'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await expandGroup(user);
|
||||
|
||||
const target = await screen.findByTestId('mapper-target-mapper-1');
|
||||
expect(target).toHaveTextContent('gen_ai.request.model');
|
||||
const mapperRow = target.closest('tr') as HTMLElement;
|
||||
// Sources ordered by priority, highest first (see fixtures).
|
||||
const sources = within(mapperRow).getByTestId('mapper-sources-mapper-1');
|
||||
expect(sources).toHaveTextContent('genai.model');
|
||||
expect(sources).toHaveTextContent('llm.model');
|
||||
// Writes-to field context + enabled status (an inline Switch, not text).
|
||||
expect(within(mapperRow).getByText('attribute')).toBeInTheDocument();
|
||||
expect(
|
||||
within(mapperRow).getByTestId('mapper-enabled-mapper-1'),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
it("renders a mapper's enable state as a read-only switch", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1', enabled: true })]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
|
||||
// Like the group switch, a mapper's status switch reflects state without
|
||||
// accepting flips in this read-only listing.
|
||||
const toggle = await screen.findByTestId('mapper-enabled-mapper-1');
|
||||
expect(toggle).toBeChecked();
|
||||
expect(toggle).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows the mappers error state when the mappers request fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
server.use(
|
||||
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
|
||||
res(ctx.status(500)),
|
||||
),
|
||||
);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('mappers-error-group-1'),
|
||||
).resolves.toHaveTextContent('Failed to load mappings. Please try again.');
|
||||
});
|
||||
|
||||
it('shows the mappers empty state when a group has no mappers', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('mappers-empty-group-1'),
|
||||
).resolves.toHaveTextContent('No mappings in this group yet.');
|
||||
});
|
||||
|
||||
it('collapses extra mapper sources into a "+N more" label beyond the visible cap', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([
|
||||
makeMapper({
|
||||
id: 'mapper-1',
|
||||
config: {
|
||||
sources: [1, 2, 3, 4, 5].map((priority) => ({
|
||||
key: `source-${priority}`,
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
|
||||
await expect(screen.findByText('+2 more')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a muted placeholder when a mapper has no sources', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1', config: { sources: [] } })]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('mapper-sources-mapper-1')).toHaveTextContent('—'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
.groupHeaderLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.groupName {
|
||||
color: var(--l1-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.groupCount {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
import styles from './GroupHeader.module.scss';
|
||||
|
||||
interface GroupHeaderProps {
|
||||
group: MappingGroup;
|
||||
}
|
||||
|
||||
function GroupHeader({ group }: GroupHeaderProps): JSX.Element {
|
||||
const conditionCount = group.attributes.length + group.resource.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.groupHeaderLabel}
|
||||
data-testid={`group-expand-${group.id}`}
|
||||
>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
className={styles.groupName}
|
||||
testId={`group-name-${group.id}`}
|
||||
>
|
||||
{group.name}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
className={styles.groupCount}
|
||||
testId={`group-condition-count-${group.id}`}
|
||||
>
|
||||
· {conditionCount} {conditionCount === 1 ? 'condition' : 'conditions'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupHeader;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './GroupHeader';
|
||||
@@ -0,0 +1,6 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
|
||||
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import styles from './GroupHeaderActions.module.scss';
|
||||
|
||||
interface GroupHeaderActionsProps {
|
||||
group: MappingGroup;
|
||||
}
|
||||
|
||||
function GroupHeaderActions({ group }: GroupHeaderActionsProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.actions}
|
||||
onClick={(event): void => event.stopPropagation()}
|
||||
>
|
||||
<Switch
|
||||
value={group.enabled}
|
||||
// We don't yet support toggling a group's enabled state in this read-only PR, so disable the switch. A later PR will add the toggle handler and its drawer.
|
||||
disabled
|
||||
testId={`group-enabled-${group.id}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupHeaderActions;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './GroupHeaderActions';
|
||||
@@ -0,0 +1,14 @@
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.mapperStateRow .stateCell {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.stateCell {
|
||||
padding: var(--spacing-4) var(--spacing-6) var(--spacing-4) var(--spacing-12);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useListSpanMappers } from 'api/generated/services/spanmapper';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import {
|
||||
MappingGroup,
|
||||
Mapping,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { buildMappingsFromListResponse } from 'container/LLMObservability/AttributeMapping/utils';
|
||||
import { COLUMN_COUNT } from '../constants';
|
||||
import MapperRow, { MapperRowSkeleton } from '../MapperRow';
|
||||
import MappingsColgroup from '../MappingsColgroup';
|
||||
import styles from './GroupMappers.module.scss';
|
||||
|
||||
const MAPPER_SKELETON_ROWS = 1;
|
||||
|
||||
const STATE_ROW_MOTION = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
transition: { duration: 0.18, ease: 'easeOut' },
|
||||
} as const;
|
||||
|
||||
interface StateRowProps {
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
function ErrorRow({ groupId }: StateRowProps): JSX.Element {
|
||||
return (
|
||||
<motion.tr className={styles.mapperStateRow} {...STATE_ROW_MOTION}>
|
||||
<td
|
||||
colSpan={COLUMN_COUNT}
|
||||
className={styles.stateCell}
|
||||
data-testid={`mappers-error-${groupId}`}
|
||||
>
|
||||
Failed to load mappings. Please try again.
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyRow({ groupId }: StateRowProps): JSX.Element {
|
||||
return (
|
||||
<motion.tr className={styles.mapperStateRow} {...STATE_ROW_MOTION}>
|
||||
<td
|
||||
colSpan={COLUMN_COUNT}
|
||||
className={styles.stateCell}
|
||||
data-testid={`mappers-empty-${groupId}`}
|
||||
>
|
||||
No mappings in this group yet.
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupMappersProps {
|
||||
group: MappingGroup;
|
||||
}
|
||||
|
||||
function GroupMappers({ group }: GroupMappersProps): JSX.Element {
|
||||
const {
|
||||
data: mappers = [],
|
||||
isLoading,
|
||||
isError,
|
||||
} = useListSpanMappers<Mapping[]>(
|
||||
{
|
||||
groupId: group.id,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
refetchOnMount: false,
|
||||
select: buildMappingsFromListResponse,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let rows: JSX.Element[];
|
||||
if (isError) {
|
||||
rows = [<ErrorRow key="error" groupId={group.id} />];
|
||||
} else if (isLoading) {
|
||||
rows = Array.from({ length: MAPPER_SKELETON_ROWS }).map((_, index) => (
|
||||
<MapperRowSkeleton key={`mapper-skeleton-${index}`} />
|
||||
));
|
||||
} else if (mappers.length === 0) {
|
||||
rows = [<EmptyRow key="empty" groupId={group.id} />];
|
||||
} else {
|
||||
rows = mappers.map((mapper, index) => (
|
||||
<MapperRow key={mapper.id} mapper={mapper} index={index} />
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<table className={styles.table}>
|
||||
<MappingsColgroup />
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupMappers;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './GroupMappers';
|
||||
@@ -0,0 +1,65 @@
|
||||
.mapperRow {
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.cell {
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
vertical-align: middle;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
// Indent the first cell so mapper rows read as nested under their group.
|
||||
.targetCell {
|
||||
padding-left: var(--spacing-12);
|
||||
}
|
||||
|
||||
// Shorter vertical padding so the loading state reads as a compact placeholder.
|
||||
.skeletonCell {
|
||||
composes: cell;
|
||||
:global(.ant-skeleton-input) {
|
||||
min-height: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
:global(.ant-skeleton-button) {
|
||||
min-height: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.statusCell {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.sourceChipText {
|
||||
display: block;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sourceMore {
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SpantypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import { Mapping } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import styles from './MapperRow.module.scss';
|
||||
|
||||
const MAX_VISIBLE_SOURCES = 3;
|
||||
|
||||
const ROW_TRANSITION = { duration: 0.18, ease: 'easeOut' } as const;
|
||||
const MAX_STAGGERED_ROWS = 6;
|
||||
const STAGGER_STEP = 0.03;
|
||||
|
||||
interface MapperRowProps {
|
||||
mapper: Mapping;
|
||||
index: number;
|
||||
}
|
||||
|
||||
function MapperRow({ mapper, index }: MapperRowProps): JSX.Element {
|
||||
const sources = mapper.sources ?? [];
|
||||
const visibleSources = sources.slice(0, MAX_VISIBLE_SOURCES);
|
||||
const remainingSources = sources.length - visibleSources.length;
|
||||
|
||||
return (
|
||||
<motion.tr
|
||||
className={styles.mapperRow}
|
||||
data-testid={`mapper-row-${mapper.id}`}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
...ROW_TRANSITION,
|
||||
delay: Math.min(index, MAX_STAGGERED_ROWS) * STAGGER_STEP,
|
||||
}}
|
||||
>
|
||||
<td className={cx(styles.cell, styles.targetCell)}>
|
||||
<Typography.Text
|
||||
truncate={1}
|
||||
title={mapper.name}
|
||||
data-testid={`mapper-target-${mapper.id}`}
|
||||
>
|
||||
{mapper.name}
|
||||
</Typography.Text>
|
||||
</td>
|
||||
<td className={styles.cell}>
|
||||
{sources.length === 0 ? (
|
||||
<span className={styles.muted} data-testid={`mapper-sources-${mapper.id}`}>
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
className={styles.sources}
|
||||
data-testid={`mapper-sources-${mapper.id}`}
|
||||
>
|
||||
{visibleSources.map((source) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
color="vanilla"
|
||||
className={styles.sourceChip}
|
||||
key={`${source.context}:${source.key}`}
|
||||
>
|
||||
<span className={styles.sourceChipText} title={source.key}>
|
||||
{source.key}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
{remainingSources > 0 && (
|
||||
<span className={cx(styles.sourceMore, styles.muted)}>
|
||||
+{remainingSources} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.cell}>
|
||||
<Badge
|
||||
color={
|
||||
mapper.fieldContext === SpantypesFieldContextDTO.resource
|
||||
? 'amber'
|
||||
: 'robin'
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{mapper.fieldContext}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className={cx(styles.cell, styles.statusCell)}>
|
||||
<div className={styles.rowActions}>
|
||||
<Switch
|
||||
value={mapper.enabled}
|
||||
disabled
|
||||
testId={`mapper-enabled-${mapper.id}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperRow;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './MapperRow.module.scss';
|
||||
|
||||
function MapperRowSkeleton(): JSX.Element {
|
||||
return (
|
||||
<tr className={styles.mapperRow}>
|
||||
<td className={cx(styles.skeletonCell, styles.targetCell)}>
|
||||
<Skeleton.Input active size="small" style={{ width: '55%' }} />
|
||||
</td>
|
||||
<td className={styles.skeletonCell}>
|
||||
<div className={styles.sources}>
|
||||
<Skeleton.Button active size="small" style={{ width: 88 }} />
|
||||
<Skeleton.Button active size="small" style={{ width: 56 }} />
|
||||
</div>
|
||||
</td>
|
||||
<td className={styles.skeletonCell}>
|
||||
<Skeleton.Button active size="small" style={{ width: 72 }} />
|
||||
</td>
|
||||
<td className={cx(styles.skeletonCell, styles.statusCell)}>
|
||||
<div className={styles.rowActions}>
|
||||
<Skeleton.Button active size="small" shape="round" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperRowSkeleton;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './MapperRow';
|
||||
export { default as MapperRowSkeleton } from './MapperRowSkeleton';
|
||||
@@ -0,0 +1,11 @@
|
||||
.colTarget {
|
||||
width: 32%;
|
||||
}
|
||||
|
||||
.colWritesTo {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.colStatus {
|
||||
width: 120px;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import styles from './MappingsColgroup.module.scss';
|
||||
|
||||
function MappingsColgroup(): JSX.Element {
|
||||
return (
|
||||
<colgroup>
|
||||
<col className={styles.colTarget} />
|
||||
<col />
|
||||
<col className={styles.colWritesTo} />
|
||||
<col className={styles.colStatus} />
|
||||
</colgroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default MappingsColgroup;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MappingsColgroup';
|
||||
@@ -0,0 +1,124 @@
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.headerCell {
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
text-align: left;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--l2-foreground);
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.groupsCollapse:global(.ant-collapse) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
|
||||
> :global(.ant-collapse-item) {
|
||||
border-bottom: none;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
border-radius: 0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
> :global(.ant-collapse-header) {
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
background: var(--l2-background);
|
||||
border-radius: 0;
|
||||
padding: var(--spacing-3) var(--spacing-6);
|
||||
color: var(--l3-foreground);
|
||||
|
||||
:global(.ant-collapse-expand-icon) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: auto;
|
||||
padding-inline-end: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header-text) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-extra) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content) {
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
color: inherit;
|
||||
|
||||
> :global(.ant-collapse-content-box) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tableEmpty {
|
||||
padding: var(--spacing-12) var(--spacing-6);
|
||||
text-align: center;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.skeletonList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// Mirrors the Collapse header banner while groups load.
|
||||
.skeletonBanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-3) var(--spacing-6);
|
||||
background: var(--l2-background);
|
||||
border-top: 1px solid var(--l2-border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
:global(.ant-skeleton-input) {
|
||||
min-height: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
:global(.ant-skeleton-button) {
|
||||
min-height: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.skeletonGroupLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skeletonGroupRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { Collapse, type CollapseProps, Skeleton } from 'antd';
|
||||
|
||||
import { AttributeMappingStore } from 'container/LLMObservability/AttributeMapping/AttributeMappingsTab/hooks/useAttributeMappingStore';
|
||||
import GroupHeader from './GroupHeader';
|
||||
import GroupHeaderActions from './GroupHeaderActions';
|
||||
import GroupMappers from './GroupMappers';
|
||||
import MappingsColgroup from './MappingsColgroup';
|
||||
import styles from './MappingsTable.module.scss';
|
||||
|
||||
const SKELETON_ROW_COUNT = 3;
|
||||
|
||||
interface MappingsTableProps {
|
||||
store: AttributeMappingStore;
|
||||
}
|
||||
|
||||
function MappingsTable({ store }: MappingsTableProps): JSX.Element {
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||
|
||||
const isEmpty = !store.isLoading && store.groups.length === 0;
|
||||
|
||||
const items: CollapseProps['items'] = store.groups.map((group) => ({
|
||||
key: group.id,
|
||||
label: <GroupHeader group={group} />,
|
||||
extra: <GroupHeaderActions group={group} />,
|
||||
children: <GroupMappers group={group} />,
|
||||
}));
|
||||
|
||||
const skeletonBanners = (
|
||||
<div className={styles.skeletonList}>
|
||||
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, index) => (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`group-skeleton-${index}`}
|
||||
className={styles.skeletonBanner}
|
||||
>
|
||||
<div className={styles.skeletonGroupLeft}>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
style={{ width: index % 2 === 0 ? 200 : 140 }}
|
||||
/>
|
||||
<Skeleton.Input active size="small" style={{ width: 64 }} />
|
||||
</div>
|
||||
<div className={styles.skeletonGroupRight}>
|
||||
<Skeleton.Button active size="small" shape="round" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
|
||||
No mapping groups yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="mappings-table">
|
||||
<table className={styles.table}>
|
||||
<MappingsColgroup />
|
||||
<thead>
|
||||
<tr className={styles.headerRow}>
|
||||
<th className={styles.headerCell}>Target</th>
|
||||
<th className={styles.headerCell}>Sources</th>
|
||||
<th className={styles.headerCell}>Writes to</th>
|
||||
<th className={styles.headerCell}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
{store.isLoading ? (
|
||||
skeletonBanners
|
||||
) : (
|
||||
<Collapse
|
||||
className={styles.groupsCollapse}
|
||||
activeKey={expandedGroups}
|
||||
onChange={(keys): void =>
|
||||
setExpandedGroups(Array.isArray(keys) ? keys : [keys])
|
||||
}
|
||||
bordered={false}
|
||||
destroyInactivePanel
|
||||
expandIcon={({ isActive }): JSX.Element =>
|
||||
isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
items={items}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MappingsTable;
|
||||
@@ -0,0 +1 @@
|
||||
export const COLUMN_COUNT = 4;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from 'react';
|
||||
import { SpantypesSpanMapperGroupDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useListSpanMapperGroups } from 'api/generated/services/spanmapper';
|
||||
|
||||
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { buildMappingGroup } from 'container/LLMObservability/AttributeMapping/utils';
|
||||
|
||||
export interface AttributeMappingStore {
|
||||
groups: MappingGroup[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
// Read-only store for the listing view: loads the server groups only. Each
|
||||
// group's mappers are fetched lazily when its panel is expanded (see
|
||||
// GroupMappers), so page load is a single request instead of an N+1 fan-out
|
||||
// across every group. Editing (enabled toggles, save/discard) and its drawers
|
||||
// land in a later PR — this PR only lists.
|
||||
export function useAttributeMappingStore(): AttributeMappingStore {
|
||||
const groupsQuery = useListSpanMapperGroups();
|
||||
|
||||
const groups = useMemo<MappingGroup[]>(() => {
|
||||
const serverGroups: SpantypesSpanMapperGroupDTO[] =
|
||||
groupsQuery.data?.data?.items ?? [];
|
||||
return serverGroups.map((group) => buildMappingGroup(group));
|
||||
}, [groupsQuery.data]);
|
||||
|
||||
return {
|
||||
groups,
|
||||
isLoading: groupsQuery.isLoading,
|
||||
isError: groupsQuery.isError,
|
||||
};
|
||||
}
|
||||
@@ -2,12 +2,5 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
}
|
||||
|
||||
.tableEmpty {
|
||||
padding: var(--spacing-12) var(--spacing-6);
|
||||
text-align: center;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
padding: var(--spacing-0);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
|
||||
import AttributeMappingHeader from './components/AttributeMappingHeader';
|
||||
import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
|
||||
const noop = (): void => undefined;
|
||||
|
||||
function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'attribute-mappings',
|
||||
label: 'Attribute mappings',
|
||||
children: <AttributeMappingsTab />,
|
||||
},
|
||||
{
|
||||
key: 'test',
|
||||
label: 'Test',
|
||||
disabled: true,
|
||||
disabledReason: 'Coming soon',
|
||||
children: null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.llmObservabilityAttributeMapping}
|
||||
@@ -16,9 +34,11 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
onSave={noop}
|
||||
/>
|
||||
|
||||
<div className={styles.tableEmpty} data-testid="attribute-mapping-empty">
|
||||
No mapping groups configured yet.
|
||||
</div>
|
||||
<Tabs
|
||||
testId="attribute-mapping-tabs"
|
||||
defaultValue="attribute-mappings"
|
||||
items={tabItems}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
|
||||
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
|
||||
|
||||
function setupGroups(): void {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('LLMObservabilityAttributeMapping', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
setupGroups();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the page shell', () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('llm-observability-attribute-mapping-page'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the attribute-mappings and test sub-tab labels', () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Attribute mappings' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Test' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('activates the attribute-mappings tab by default and renders its content', async () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
const attributeMappingsTab = screen.getByRole('tab', {
|
||||
name: 'Attribute mappings',
|
||||
});
|
||||
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
|
||||
await expect(
|
||||
screen.findByTestId('attribute-mappings-tab'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the header with its description and 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();
|
||||
expect(screen.queryByTestId('unsaved-changes')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
SpantypesFieldContextDTO as FieldContext,
|
||||
SpantypesSpanMapperDTO as Mapper,
|
||||
SpantypesSpanMapperGroupDTO as MapperGroup,
|
||||
SpantypesSpanMapperOperationDTO as MapperOperation,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// Endpoint globs used by MSW handlers. The generated client hits relative
|
||||
// `/api/v1/span_mapper_groups[...]`, so the `*` prefix matches regardless of
|
||||
// base URL.
|
||||
export const GROUPS_ENDPOINT = '*/api/v1/span_mapper_groups';
|
||||
export function mappersEndpoint(groupId: string): string {
|
||||
return `*/api/v1/span_mapper_groups/${groupId}/span_mappers`;
|
||||
}
|
||||
|
||||
export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
|
||||
return {
|
||||
id: 'group-1',
|
||||
orgId: 'org-1',
|
||||
name: 'demo',
|
||||
enabled: true,
|
||||
condition: {
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
|
||||
return {
|
||||
id: 'mapper-1',
|
||||
group_id: 'group-1',
|
||||
name: 'gen_ai.request.model',
|
||||
enabled: true,
|
||||
fieldContext: FieldContext.attribute,
|
||||
config: {
|
||||
sources: [
|
||||
{
|
||||
key: 'genai.model',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority: 2,
|
||||
},
|
||||
{
|
||||
key: 'llm.model',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.move,
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Both list endpoints share the same `{ status, data: { items } }` envelope —
|
||||
// the generated schema mis-types the mappers response with the groups DTO
|
||||
// (see GroupMappers), but the runtime envelope shape is identical.
|
||||
export function makeGroupsResponse(groups: MapperGroup[]): {
|
||||
status: string;
|
||||
data: { items: MapperGroup[] };
|
||||
} {
|
||||
return { status: 'ok', data: { items: groups } };
|
||||
}
|
||||
|
||||
export function makeMappersResponse(mappers: Mapper[]): {
|
||||
status: string;
|
||||
data: { items: Mapper[] };
|
||||
} {
|
||||
return { status: 'ok', data: { items: mappers } };
|
||||
}
|
||||
|
||||
export const mockGroups: MapperGroup[] = [
|
||||
makeGroup({
|
||||
id: 'group-1',
|
||||
name: 'demo',
|
||||
condition: {
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
},
|
||||
}),
|
||||
makeGroup({
|
||||
id: 'group-2',
|
||||
name: 'Tool',
|
||||
enabled: false,
|
||||
condition: { attributes: null, resource: null },
|
||||
}),
|
||||
];
|
||||
|
||||
export const mockMappers: Mapper[] = [
|
||||
makeMapper({ id: 'mapper-1', group_id: 'group-1' }),
|
||||
];
|
||||
@@ -5,23 +5,6 @@
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-large);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.pageHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './AttributeMappingHeader.module.scss';
|
||||
|
||||
@@ -17,38 +18,35 @@ function AttributeMappingHeader({
|
||||
}: AttributeMappingHeaderProps): JSX.Element {
|
||||
return (
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>Attribute Mapping</h1>
|
||||
<p className={styles.description}>
|
||||
Configure source-to-target attribute remapping for LLM traces
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.pageHeaderActions}>
|
||||
{isDirty && (
|
||||
<Typography.Text as="p" size="base" color="muted">
|
||||
Configure source-to-target attribute remapping for LLM traces
|
||||
</Typography.Text>
|
||||
{isDirty && (
|
||||
<div className={styles.pageHeaderActions}>
|
||||
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onDiscard}
|
||||
disabled={!isDirty || isSaving}
|
||||
testId="discard-changes-btn"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
loading={isSaving}
|
||||
disabled={!isDirty || isSaving}
|
||||
testId="save-changes-btn"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
SpantypesFieldContextDTO,
|
||||
SpantypesSpanMapperOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export interface SourceConfig {
|
||||
key: string;
|
||||
context: SpantypesFieldContextDTO;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
}
|
||||
|
||||
export interface Mapping {
|
||||
id: string;
|
||||
name: string;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
sources: SourceConfig[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface MappingGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
ListSpanMappers200,
|
||||
SpantypesSpanMapperDTO,
|
||||
SpantypesSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { MappingGroup, Mapping, SourceConfig } from './types';
|
||||
|
||||
function getMapperSources(mapper: SpantypesSpanMapperDTO): SourceConfig[] {
|
||||
const sources = mapper.config?.sources ?? [];
|
||||
return [...sources]
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map((source) => ({
|
||||
key: source.key,
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildMapping(mapper: SpantypesSpanMapperDTO): Mapping {
|
||||
return {
|
||||
id: mapper.id,
|
||||
name: mapper.name,
|
||||
fieldContext: mapper.fieldContext,
|
||||
sources: getMapperSources(mapper),
|
||||
enabled: mapper.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMappingsFromListResponse(
|
||||
response: ListSpanMappers200,
|
||||
): Mapping[] {
|
||||
const items = (response.data?.items ??
|
||||
[]) as unknown as SpantypesSpanMapperDTO[];
|
||||
return items.map(buildMapping);
|
||||
}
|
||||
|
||||
export function buildMappingGroup(
|
||||
group: SpantypesSpanMapperGroupDTO,
|
||||
): MappingGroup {
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
attributes: group.condition?.attributes ?? [],
|
||||
resource: group.condition?.resource ?? [],
|
||||
enabled: group.enabled,
|
||||
};
|
||||
}
|
||||
@@ -191,6 +191,14 @@
|
||||
min-height: 0;
|
||||
overflow-y: visible;
|
||||
|
||||
.time-series-view-container-header {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.time-series-view {
|
||||
flex-shrink: 0;
|
||||
height: 65vh;
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getListQuery,
|
||||
getQueryByPanelType,
|
||||
} from 'container/LogsExplorerViews/explorerUtils';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
||||
@@ -460,17 +461,18 @@ function LogsExplorerViewsContainer({
|
||||
)}
|
||||
{selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && (
|
||||
<div className="time-series-view-container">
|
||||
<div className="time-series-view-container-header">
|
||||
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
|
||||
</div>
|
||||
<TimeSeriesView
|
||||
isLoading={isLoading || isFetching}
|
||||
data={data}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
yAxisUnit={yAxisUnit}
|
||||
onYAxisUnitChange={onUnitChange}
|
||||
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
|
||||
dataSource={DataSource.LOGS}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -311,7 +311,6 @@ function TimeSeries({
|
||||
dataSource={DataSource.METRICS}
|
||||
error={queries[index].error as APIError}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,14 +3,6 @@
|
||||
min-height: 350px;
|
||||
padding: 0px 12px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
height: 50vh;
|
||||
min-height: 350px;
|
||||
|
||||
@@ -16,7 +16,6 @@ import Uplot from 'components/Uplot';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
|
||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
|
||||
@@ -42,14 +41,11 @@ import { SuccessResponse, Warning } from 'types/api';
|
||||
import { LegendPosition } from 'types/api/dashboard/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import TimeseriesExportMenu from './TimeseriesExportMenu';
|
||||
|
||||
import './TimeSeriesView.styles.scss';
|
||||
|
||||
function TimeSeriesView({
|
||||
@@ -63,8 +59,6 @@ function TimeSeriesView({
|
||||
setWarning,
|
||||
panelType = PANEL_TYPES.TIME_SERIES,
|
||||
stackBarChart = false,
|
||||
allowExport = false,
|
||||
onYAxisUnitChange,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -250,33 +244,10 @@ function TimeSeriesView({
|
||||
[baseChartOptions, stackedBands],
|
||||
);
|
||||
|
||||
const showExport = allowExport && !!data?.rawV5Response;
|
||||
const showHeader = showExport || !!onYAxisUnitChange;
|
||||
|
||||
return (
|
||||
<div className="time-series-view">
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{showHeader && (
|
||||
<div className="time-series-view__header">
|
||||
<div>
|
||||
{onYAxisUnitChange && (
|
||||
<BuilderUnitsFilter onChange={onYAxisUnitChange} yAxisUnit={yAxisUnit} />
|
||||
)}
|
||||
</div>
|
||||
{showExport && data?.rawV5Response && (
|
||||
<TimeseriesExportMenu
|
||||
dataSource={dataSource}
|
||||
yAxisUnit={yAxisUnit}
|
||||
queryResponse={data.rawV5Response}
|
||||
query={currentQuery}
|
||||
legendMap={data.legendMap}
|
||||
fileName={`${dataSource}-timeseries`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="graph-container"
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
@@ -324,11 +295,7 @@ function TimeSeriesView({
|
||||
}
|
||||
|
||||
interface TimeSeriesViewProps {
|
||||
data?: SuccessResponse<MetricRangePayloadProps> & {
|
||||
warning?: Warning;
|
||||
rawV5Response?: QueryRangeResponseV5;
|
||||
legendMap?: Record<string, string>;
|
||||
};
|
||||
data?: SuccessResponse<MetricRangePayloadProps> & { warning?: Warning };
|
||||
yAxisUnit?: string;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
@@ -338,11 +305,6 @@ interface TimeSeriesViewProps {
|
||||
setWarning?: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
panelType?: PANEL_TYPES;
|
||||
stackBarChart?: boolean;
|
||||
// Opt-in: render the client-side export menu (Logs explorer for now).
|
||||
allowExport?: boolean;
|
||||
// Opt-in: render the y-axis unit selector in the header (views without their
|
||||
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
|
||||
onYAxisUnitChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
TimeSeriesView.defaultProps = {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
.timeseries-export-popover {
|
||||
width: 240px;
|
||||
padding: 0 12px 12px 12px;
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.export-format {
|
||||
padding: 12px 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// radio option labels — radix popover content inherits the root font
|
||||
// size; pin to the app's 13px base the antd popover used to impose
|
||||
label {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
|
||||
.export-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Download } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useClientExport } from 'hooks/useExportData/useClientExport';
|
||||
import { ExportFormat } from 'lib/exportData/types';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import './TimeseriesExportMenu.styles.scss';
|
||||
|
||||
interface TimeseriesExportMenuProps {
|
||||
dataSource: DataSource;
|
||||
queryResponse: QueryRangeResponseV5;
|
||||
query?: Query;
|
||||
yAxisUnit?: string;
|
||||
legendMap?: Record<string, string>;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
// Download menu for in-memory timeseries data (client-side serialization).
|
||||
// The raw/list backend export keeps its own menu in DownloadOptionsMenu.
|
||||
export default function TimeseriesExportMenu({
|
||||
dataSource,
|
||||
queryResponse,
|
||||
query,
|
||||
yAxisUnit,
|
||||
legendMap,
|
||||
fileName,
|
||||
}: TimeseriesExportMenuProps): JSX.Element {
|
||||
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const { isExporting, handleExport: handleClientExport } = useClientExport({
|
||||
response: queryResponse,
|
||||
query,
|
||||
yAxisUnit,
|
||||
legendMap,
|
||||
fileName,
|
||||
});
|
||||
|
||||
const handleExport = useCallback((): void => {
|
||||
setIsPopoverOpen(false);
|
||||
handleClientExport({ format: exportFormat as ExportFormat });
|
||||
}, [exportFormat, handleClientExport]);
|
||||
|
||||
return (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<TooltipSimple title="Download">
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Download"
|
||||
data-testid={`timeseries-export-${dataSource}`}
|
||||
disabled={isExporting}
|
||||
loading={isExporting}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipSimple>
|
||||
<PopoverContent align="end" className="timeseries-export-popover">
|
||||
<div className="export-format">
|
||||
<Typography.Text className="title">FORMAT</Typography.Text>
|
||||
<RadioGroup value={exportFormat} onChange={setExportFormat}>
|
||||
<RadioGroupItem value={ExportFormat.Csv}>csv</RadioGroupItem>
|
||||
<RadioGroupItem value={ExportFormat.Jsonl}>jsonl</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className="export-button"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
loading={isExporting}
|
||||
prefix={<Download size={16} />}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import store from 'store';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import TimeSeriesView from '../TimeSeriesView';
|
||||
|
||||
jest.mock('components/Uplot', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="uplot-chart" />,
|
||||
}));
|
||||
|
||||
jest.mock('../TimeseriesExportMenu', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="timeseries-export-menu" />,
|
||||
}));
|
||||
|
||||
jest.mock('container/QueryBuilder/filters/BuilderUnitsFilter', () => ({
|
||||
BuilderUnitsFilter: (): JSX.Element => (
|
||||
<div data-testid="builder-units-filter" />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({ currentQuery: null }),
|
||||
}));
|
||||
|
||||
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
|
||||
getUPlotChartOptions: (): unknown => ({}),
|
||||
}));
|
||||
|
||||
jest.mock('lib/uPlotLib/utils/getUplotChartData', () => ({
|
||||
getUPlotChartData: (): number[][] => [
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
|
||||
() => ({ stackSeries: (): unknown => ({ data: [], bands: [] }) }),
|
||||
);
|
||||
|
||||
jest.mock('container/GridCardLayout/GridCard/utils', () => ({
|
||||
getLocalStorageGraphVisibilityState: (): unknown => ({
|
||||
graphVisibilityStates: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): unknown => ({ timezone: { value: 'UTC' } }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDimensions', () => ({
|
||||
useResizeObserver: (): unknown => ({ width: 800, height: 400 }),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockStore = configureStore([])({ ...store.getState() });
|
||||
|
||||
const rawV5Response = {
|
||||
type: 'time_series',
|
||||
data: { results: [] },
|
||||
meta: {},
|
||||
};
|
||||
|
||||
function makeData(withRawV5: boolean): any {
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: { data: { result: [], resultType: '' } },
|
||||
...(withRawV5 ? { rawV5Response, legendMap: {} } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function renderView(props: {
|
||||
allowExport?: boolean;
|
||||
withRawV5?: boolean;
|
||||
onYAxisUnitChange?: (value: string) => void;
|
||||
}): ReturnType<typeof render> {
|
||||
const { allowExport, withRawV5 = true, onYAxisUnitChange } = props;
|
||||
return render(
|
||||
<Provider store={mockStore}>
|
||||
<MemoryRouter>
|
||||
<TimeSeriesView
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
isFilterApplied
|
||||
dataSource={DataSource.LOGS}
|
||||
data={makeData(withRawV5)}
|
||||
allowExport={allowExport}
|
||||
onYAxisUnitChange={onYAxisUnitChange}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('TimeSeriesView header gating', () => {
|
||||
it('renders the export menu when allowExport is set and raw V5 data is present', () => {
|
||||
const { queryByTestId } = renderView({ allowExport: true });
|
||||
expect(queryByTestId('timeseries-export-menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no export menu without allowExport', () => {
|
||||
const { queryByTestId } = renderView({});
|
||||
expect(queryByTestId('timeseries-export-menu')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no export menu when the raw V5 response is missing', () => {
|
||||
const { queryByTestId } = renderView({ allowExport: true, withRawV5: false });
|
||||
expect(queryByTestId('timeseries-export-menu')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the unit selector only when onYAxisUnitChange is passed', () => {
|
||||
const withUnit = renderView({ onYAxisUnitChange: jest.fn() });
|
||||
expect(withUnit.queryByTestId('builder-units-filter')).toBeInTheDocument();
|
||||
withUnit.unmount();
|
||||
|
||||
const withoutUnit = renderView({ allowExport: true });
|
||||
expect(
|
||||
withoutUnit.queryByTestId('builder-units-filter'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no header row when neither export nor unit selector is enabled', () => {
|
||||
const { container } = renderView({ withRawV5: false });
|
||||
expect(container.querySelector('.time-series-view__header')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import TimeseriesExportMenu from '../TimeseriesExportMenu';
|
||||
|
||||
const mockHandleExport = jest.fn();
|
||||
let mockIsExporting = false;
|
||||
|
||||
jest.mock('hooks/useExportData/useClientExport', () => ({
|
||||
useClientExport: (): unknown => ({
|
||||
isExporting: mockIsExporting,
|
||||
handleExport: mockHandleExport,
|
||||
}),
|
||||
}));
|
||||
|
||||
const response = {
|
||||
type: 'time_series',
|
||||
data: { results: [] },
|
||||
meta: {},
|
||||
} as unknown as QueryRangeResponseV5;
|
||||
|
||||
const TEST_ID = `timeseries-export-${DataSource.LOGS}`;
|
||||
|
||||
function renderMenu(): void {
|
||||
render(
|
||||
<TimeseriesExportMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
queryResponse={response}
|
||||
fileName="logs-timeseries"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('TimeseriesExportMenu', () => {
|
||||
beforeEach(() => {
|
||||
mockHandleExport.mockReset();
|
||||
mockIsExporting = false;
|
||||
});
|
||||
|
||||
it('renders the download trigger button', () => {
|
||||
renderMenu();
|
||||
expect(screen.getByTestId(TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only format options — no shape, row-count, or column controls', () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId(TEST_ID));
|
||||
|
||||
expect(screen.getByText('FORMAT')).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'csv' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'jsonl' })).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('Number of Rows')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('radio', { name: 'long' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('radio', { name: 'wide' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exports as csv by default', () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId(TEST_ID));
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
expect(mockHandleExport).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'csv' });
|
||||
});
|
||||
|
||||
it('exports as jsonl when selected', () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId(TEST_ID));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'jsonl' }));
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'jsonl' });
|
||||
});
|
||||
|
||||
it('disables the trigger while an export is in progress', () => {
|
||||
mockIsExporting = true;
|
||||
renderMenu();
|
||||
|
||||
expect(screen.getByTestId(TEST_ID)).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,41 +1,10 @@
|
||||
import { SuccessResponse } from 'types/api/index';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
|
||||
type ConvertibleData = SuccessResponse<MetricRangePayloadProps> & {
|
||||
rawV5Response?: QueryRangeResponseV5;
|
||||
};
|
||||
|
||||
// Applies the same ns→ms conversion to the raw V5 tree, so client-side export
|
||||
// serializes the values the chart displays (not the original nanoseconds).
|
||||
function convertRawV5ValuesToMs(
|
||||
response: QueryRangeResponseV5,
|
||||
): QueryRangeResponseV5 {
|
||||
if (response.type !== 'time_series') {
|
||||
return response;
|
||||
}
|
||||
|
||||
const results = (response.data.results as TimeSeriesData[]).map((result) => ({
|
||||
...result,
|
||||
aggregations: (result.aggregations ?? []).map((bucket) => ({
|
||||
...bucket,
|
||||
series: (bucket.series ?? []).map((series) => ({
|
||||
...series,
|
||||
values: (series.values ?? []).map((value) => ({
|
||||
...value,
|
||||
value: value.value / 1000000,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
return { ...response, data: { ...response.data, results } };
|
||||
}
|
||||
|
||||
export const convertDataValueToMs = (
|
||||
data?: ConvertibleData,
|
||||
): ConvertibleData | undefined => {
|
||||
data?: SuccessResponse<MetricRangePayloadProps>,
|
||||
): SuccessResponse<MetricRangePayloadProps> | undefined => {
|
||||
const convertedData = data;
|
||||
|
||||
const convertedResult: QueryData[] = data?.payload?.data?.result
|
||||
@@ -53,11 +22,5 @@ export const convertDataValueToMs = (
|
||||
convertedData.payload.data.result = convertedResult;
|
||||
}
|
||||
|
||||
if (convertedData?.rawV5Response) {
|
||||
convertedData.rawV5Response = convertRawV5ValuesToMs(
|
||||
convertedData.rawV5Response,
|
||||
);
|
||||
}
|
||||
|
||||
return convertedData;
|
||||
};
|
||||
|
||||
@@ -22,11 +22,7 @@ import { SuccessResponseV2, Warning } from 'types/api';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
ExecStats,
|
||||
MetricRangePayloadV5,
|
||||
QueryRangeResponseV5,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { ExecStats, MetricRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -196,8 +192,6 @@ export async function GetMetricQueryRange(
|
||||
| SuccessResponseV2<MetricRangePayloadV5>;
|
||||
let warning: Warning | undefined;
|
||||
let meta: ExecStats | undefined;
|
||||
// Raw V5 response, kept before it's converted to legacy — powers client-side export.
|
||||
let rawV5Response: QueryRangeResponseV5 | undefined;
|
||||
|
||||
const panelType = props.originalGraphType || props.graphType;
|
||||
|
||||
@@ -274,8 +268,6 @@ export async function GetMetricQueryRange(
|
||||
endTime: props.end * 1000,
|
||||
});
|
||||
|
||||
rawV5Response = publicResponse.data.data;
|
||||
|
||||
// Convert V5 response to legacy format for components
|
||||
response = convertV5ResponseToLegacy(
|
||||
{
|
||||
@@ -296,8 +288,6 @@ export async function GetMetricQueryRange(
|
||||
headers,
|
||||
);
|
||||
|
||||
rawV5Response = v5Response.data.data;
|
||||
|
||||
// Convert V5 response to legacy format for components
|
||||
response = convertV5ResponseToLegacy(
|
||||
{
|
||||
@@ -376,8 +366,6 @@ export async function GetMetricQueryRange(
|
||||
...response,
|
||||
warning,
|
||||
meta,
|
||||
rawV5Response,
|
||||
legendMap,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -81,18 +81,6 @@ describe('exportTimeseriesData', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits display-only format ids (short/none) from headers', () => {
|
||||
const data = [
|
||||
makeQuery('A', [{ series: [makeSeries({ service: 'a' }, [[1000, 1]])] }]),
|
||||
];
|
||||
|
||||
const short = exportTimeseriesData({ data, yAxisUnit: 'short' });
|
||||
expect(short.headers[short.headers.length - 1]).toBe('value');
|
||||
|
||||
const none = exportTimeseriesData({ data, yAxisUnit: 'none' });
|
||||
expect(none.headers[none.headers.length - 1]).toBe('value');
|
||||
});
|
||||
|
||||
it('multi-query: query is its own column; label keys are unioned', () => {
|
||||
const data = [
|
||||
makeQuery('A', [{ series: [makeSeries({ service: 'x' }, [[1000, 1]])] }]),
|
||||
|
||||
@@ -98,16 +98,9 @@ function flatten(
|
||||
return flat;
|
||||
}
|
||||
|
||||
// Display-format ids, not physical units — meaningful on a chart axis
|
||||
// (compact-number formatting) but misleading in an export header.
|
||||
const DISPLAY_ONLY_UNITS = new Set(['short', 'none']);
|
||||
|
||||
// Appends the y-axis unit to the value header: `value` → `value (ms)`.
|
||||
function withUnit(header: string, yAxisUnit?: string): string {
|
||||
if (!yAxisUnit || DISPLAY_ONLY_UNITS.has(yAxisUnit)) {
|
||||
return header;
|
||||
}
|
||||
return `${header} (${yAxisUnit})`;
|
||||
return yAxisUnit ? `${header} (${yAxisUnit})` : header;
|
||||
}
|
||||
|
||||
function toIso(timestamp: number): string {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
clearViewPanelHandoff,
|
||||
readViewPanelHandoff,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/ViewPanelModal/viewPanelHandoffStore';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useSwitchToViewMode } from '../useSwitchToViewMode';
|
||||
@@ -18,11 +23,16 @@ jest.mock('hooks/useUrlQuery', () => ({
|
||||
}));
|
||||
|
||||
const query = { queryType: 'builder' } as unknown as Query;
|
||||
const spec = {
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel' },
|
||||
display: { name: 'CPU' },
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
describe('useSwitchToViewMode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSearch = '';
|
||||
clearViewPanelHandoff();
|
||||
});
|
||||
|
||||
function invoke(): void {
|
||||
@@ -32,6 +42,7 @@ describe('useSwitchToViewMode', () => {
|
||||
panelId: 'panel-1',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
query,
|
||||
spec,
|
||||
}),
|
||||
);
|
||||
result.current();
|
||||
@@ -52,6 +63,21 @@ describe('useSwitchToViewMode', () => {
|
||||
).toStrictEqual(query);
|
||||
});
|
||||
|
||||
it('stashes the live draft spec in the sessionStorage handoff, not the URL', () => {
|
||||
invoke();
|
||||
|
||||
expect(readViewPanelHandoff('dash-1', 'panel-1')).toStrictEqual(spec);
|
||||
// The spec must not bloat the URL — the config-only display name never leaks into it.
|
||||
expect(mockSafeNavigate.mock.calls[0][0]).not.toContain('CPU');
|
||||
});
|
||||
|
||||
it('scopes the handoff to the exact dashboard + panel', () => {
|
||||
invoke();
|
||||
|
||||
expect(readViewPanelHandoff('dash-1', 'other-panel')).toBeNull();
|
||||
expect(readViewPanelHandoff('other-dash', 'panel-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('carries dashboard variables through and drops other editor URL state', () => {
|
||||
mockSearch = 'variables=%7B%22a%22%3A1%7D&compositeQuery=stale';
|
||||
invoke();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -7,27 +8,35 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { writeViewPanelHandoff } from '../../PanelsAndSectionsLayout/Panel/ViewPanelModal/viewPanelHandoffStore';
|
||||
|
||||
interface UseSwitchToViewModeArgs {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panelType: PANEL_TYPES;
|
||||
query: Query;
|
||||
/** Live (un-saved) draft spec — the query rides in the URL, the rest via the handoff. */
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback that leaves the editor for the dashboard with this panel expanded in the
|
||||
* View modal, seeded with the live (un-saved) query — V1's "Switch to View Mode".
|
||||
* Leaves the editor for the dashboard with this panel expanded in the View modal, seeded with
|
||||
* the live (un-saved) query + config — V1's "Switch to View Mode". The query rides in the URL
|
||||
* (`compositeQuery`); the rest of the spec rides in a tab-scoped sessionStorage handoff.
|
||||
*/
|
||||
export function useSwitchToViewMode({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panelType,
|
||||
query,
|
||||
spec,
|
||||
}: UseSwitchToViewModeArgs): () => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
return useCallback((): void => {
|
||||
writeViewPanelHandoff({ dashboardId, panelId, spec });
|
||||
|
||||
const params = new URLSearchParams();
|
||||
const variables = urlQuery.get(QueryParams.variables);
|
||||
if (variables) {
|
||||
@@ -42,5 +51,5 @@ export function useSwitchToViewMode({
|
||||
safeNavigate(
|
||||
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}?${params.toString()}`,
|
||||
);
|
||||
}, [safeNavigate, urlQuery, dashboardId, panelId, panelType, query]);
|
||||
}, [safeNavigate, urlQuery, dashboardId, panelId, panelType, query, spec]);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ import { useTableColumns } from './hooks/useTableColumns';
|
||||
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
|
||||
|
||||
import styles from './PanelEditor.module.scss';
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
import { DashboardEvents } from '../../constants/events';
|
||||
|
||||
interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
@@ -204,6 +206,7 @@ function PanelEditorContainer({
|
||||
panelId,
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
|
||||
query: currentQuery,
|
||||
spec: draft.spec,
|
||||
});
|
||||
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
@@ -234,6 +237,13 @@ function PanelEditorContainer({
|
||||
onClose();
|
||||
}, [isNew, panelId, setScrollTargetId, onClose]);
|
||||
|
||||
const switchToViewMode = useCallback((): void => {
|
||||
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
|
||||
panelId: panelId,
|
||||
});
|
||||
onSwitchToView();
|
||||
}, [onSwitchToView]);
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
<Header
|
||||
@@ -243,7 +253,7 @@ function PanelEditorContainer({
|
||||
readOnly={!isEditable}
|
||||
readOnlyReason={editDisabledReason}
|
||||
onSave={onSave}
|
||||
onSwitchToView={onSwitchToView}
|
||||
onSwitchToView={switchToViewMode}
|
||||
onClose={onCloseEditor}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
|
||||
@@ -16,6 +16,8 @@ import ViewPanelModalHeader from './ViewPanelModalHeader';
|
||||
import { useViewPanelMode } from './useViewPanelMode';
|
||||
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
interface ViewPanelModalContentProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
@@ -97,6 +99,14 @@ function ViewPanelModalContent({
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSwitchToEdit = (): void => {
|
||||
// Carry the drilldown edits so the editor opens on them, not the saved panel.
|
||||
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
|
||||
panelId: panelId,
|
||||
});
|
||||
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.content} data-testid="view-panel-modal-content">
|
||||
<ViewPanelModalHeader
|
||||
@@ -114,10 +124,7 @@ function ViewPanelModalContent({
|
||||
refreshWindow();
|
||||
}
|
||||
}}
|
||||
onSwitchToEdit={(): void =>
|
||||
// Carry the drilldown edits so the editor opens on them, not the saved panel.
|
||||
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) })
|
||||
}
|
||||
onSwitchToEdit={onSwitchToEdit}
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
queryType={queryType}
|
||||
signal={signal}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
|
||||
interface ViewPanelModalHeaderProps {
|
||||
selectedInterval: Time | CustomTimeType;
|
||||
@@ -64,6 +65,10 @@ function ViewPanelModalHeader({
|
||||
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
|
||||
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
|
||||
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
|
||||
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
|
||||
const isLocked = useDashboardStore((s) => s.isLocked);
|
||||
|
||||
const canSwitchToEdit = canEditDashboard && !isLocked;
|
||||
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
@@ -75,15 +80,17 @@ function ViewPanelModalHeader({
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
onClick={onSwitchToEdit}
|
||||
data-testid="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
{canSwitchToEdit && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
onClick={onSwitchToEdit}
|
||||
data-testid="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
|
||||
@@ -23,8 +23,11 @@ import {
|
||||
type PanelQueryTimeOverride,
|
||||
type UsePanelQueryResult,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { readViewPanelHandoff } from './viewPanelHandoffStore';
|
||||
|
||||
interface UseViewPanelModeArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
@@ -77,25 +80,33 @@ export function useViewPanelMode({
|
||||
}: UseViewPanelModeArgs): UseViewPanelModeReturn {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
// Seed the draft from the URL (`compositeQuery` + `graphType`) when present, else the saved
|
||||
// panel — mount-only, so a refresh re-seeds from the URL and in-modal edits survive (V1 parity).
|
||||
const urlQuery = useGetCompositeQueryParam();
|
||||
// Config edits from the editor's "Switch to View Mode" arrive via the handoff; the query
|
||||
// still comes from the URL. Falls back to the saved panel for a plain grid "View".
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const baseSpec = useMemo<DashboardtypesPanelSpecDTO>(
|
||||
() => readViewPanelHandoff(dashboardId, panelId) ?? panel.spec,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed
|
||||
[],
|
||||
);
|
||||
|
||||
// Mount-only so a refresh re-seeds and in-modal edits survive (V1 parity).
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const urlGraphType = useUrlQuery().get(
|
||||
QueryParams.graphType,
|
||||
) as PANEL_TYPES | null;
|
||||
const initialPanel = useMemo<DashboardtypesPanelDTO>(
|
||||
() =>
|
||||
urlQuery
|
||||
compositeQuery
|
||||
? {
|
||||
...panel,
|
||||
spec: buildViewPanelSpec({
|
||||
spec: panel.spec,
|
||||
query: urlQuery,
|
||||
spec: baseSpec,
|
||||
query: compositeQuery,
|
||||
panelType:
|
||||
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
|
||||
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[baseSpec.plugin.kind],
|
||||
}),
|
||||
}
|
||||
: panel,
|
||||
: { ...panel, spec: baseSpec },
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import getSessionStorage from 'api/browser/sessionstorage/get';
|
||||
import removeSessionStorage from 'api/browser/sessionstorage/remove';
|
||||
import setSessionStorage from 'api/browser/sessionstorage/set';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { SESSIONSTORAGE } from 'constants/sessionStorage';
|
||||
|
||||
interface ViewPanelHandoff {
|
||||
/** Correlator: the read returns the spec only for this exact dashboard + panel. */
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab-scoped handoff of the editor's un-saved draft spec to the View modal, so "Switch to View
|
||||
* Mode" carries config edits — not just the query, which stays in the URL. sessionStorage keeps
|
||||
* the link small yet survives a refresh, and clears the edits when the tab closes.
|
||||
*/
|
||||
export function writeViewPanelHandoff(handoff: ViewPanelHandoff): void {
|
||||
setSessionStorage(SESSIONSTORAGE.VIEW_PANEL_HANDOFF, JSON.stringify(handoff));
|
||||
}
|
||||
|
||||
export function readViewPanelHandoff(
|
||||
dashboardId: string,
|
||||
panelId: string,
|
||||
): DashboardtypesPanelSpecDTO | null {
|
||||
const raw = getSessionStorage(SESSIONSTORAGE.VIEW_PANEL_HANDOFF);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const handoff = JSON.parse(raw) as ViewPanelHandoff;
|
||||
return handoff.dashboardId === dashboardId && handoff.panelId === panelId
|
||||
? handoff.spec
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearViewPanelHandoff(): void {
|
||||
removeSessionStorage(SESSIONSTORAGE.VIEW_PANEL_HANDOFF);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { clearViewPanelHandoff } from '../ViewPanelModal/viewPanelHandoffStore';
|
||||
|
||||
export interface UseViewPanelApi {
|
||||
/** Panel id currently expanded in the View modal; null when none is open. */
|
||||
expandedPanelId: string | null;
|
||||
@@ -41,10 +43,11 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
// Copy before mutating: useUrlQuery returns a memoized instance.
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.set(QueryParams.expandedWidgetId, panelId);
|
||||
// Drop any leftover in-modal query/kind so a plain View opens on the saved
|
||||
// panel, not a stale URL query the modal would otherwise hydrate from.
|
||||
// Drop leftover in-modal query/kind + the editor's handoff so a plain View opens
|
||||
// on the saved panel, not stale state the modal would otherwise hydrate from.
|
||||
next.delete(QueryParams.compositeQuery);
|
||||
next.delete(QueryParams.graphType);
|
||||
clearViewPanelHandoff();
|
||||
safeNavigate(`${pathname}?${next.toString()}`);
|
||||
},
|
||||
[pathname, safeNavigate, urlQuery],
|
||||
@@ -55,6 +58,8 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.set(QueryParams.expandedWidgetId, panelId);
|
||||
next.set(QueryParams.graphType, panelType);
|
||||
// A grid drilldown opens on the saved panel, never a stale editor handoff.
|
||||
clearViewPanelHandoff();
|
||||
// Same encoding the query builder uses (see `useGetCompositeQueryParam`): the URL
|
||||
// value is `encodeURIComponent(JSON.stringify(query))`, decoded once on read.
|
||||
next.set(
|
||||
@@ -73,6 +78,7 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
// (the in-modal query builder writes compositeQuery, V1 parity).
|
||||
next.delete(QueryParams.compositeQuery);
|
||||
next.delete(QueryParams.graphType);
|
||||
clearViewPanelHandoff();
|
||||
const search = next.toString();
|
||||
safeNavigate(search ? `${pathname}?${search}` : pathname);
|
||||
}, [pathname, safeNavigate, urlQuery]);
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
} from 'constants/queryCacheTime';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
@@ -48,9 +51,10 @@ function DynamicSelector({
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: DynamicSelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
const existingQuery = useMemo(
|
||||
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
|
||||
@@ -96,8 +100,10 @@ function DynamicSelector({
|
||||
!!variable.dynamicAttribute &&
|
||||
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
|
||||
refetchOnWindowFocus: false,
|
||||
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
|
||||
cacheTime: DASHBOARD_CACHE_TIME,
|
||||
// Each cycle mints a fresh key; 0 under auto-refresh so entries don't pile up (V1 parity).
|
||||
cacheTime: isAutoRefreshDisabled
|
||||
? DASHBOARD_CACHE_TIME
|
||||
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
} from 'constants/queryCacheTime';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
@@ -44,9 +47,10 @@ function QuerySelector({
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: QuerySelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const payload = useMemo(() => selectionToPayload(selections), [selections]);
|
||||
|
||||
const {
|
||||
@@ -80,8 +84,10 @@ function QuerySelector({
|
||||
{
|
||||
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
|
||||
refetchOnWindowFocus: false,
|
||||
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
|
||||
cacheTime: DASHBOARD_CACHE_TIME,
|
||||
// Each cycle mints a fresh key; 0 under auto-refresh so entries don't pile up (V1 parity).
|
||||
cacheTime: isAutoRefreshDisabled
|
||||
? DASHBOARD_CACHE_TIME
|
||||
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
} from 'constants/queryCacheTime';
|
||||
|
||||
import { usePanelQuery } from '../usePanelQuery';
|
||||
import { useGetQueryRangeV5 } from '../useGetQueryRangeV5';
|
||||
@@ -432,4 +436,28 @@ describe('usePanelQuery', () => {
|
||||
expect(result.current.pagination?.pageIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheTime (auto-refresh OOM guard)', () => {
|
||||
const withAutoRefreshDisabled = (disabled: boolean): void => {
|
||||
mockUseSelector.mockImplementation((selector: unknown) =>
|
||||
(selector as (state: { globalTime: unknown }) => unknown)({
|
||||
globalTime: { ...DEFAULT_GLOBAL_TIME, isAutoRefreshDisabled: disabled },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
it('caches for DASHBOARD_CACHE_TIME when auto-refresh is disabled', () => {
|
||||
withAutoRefreshDisabled(true);
|
||||
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
|
||||
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME);
|
||||
});
|
||||
|
||||
it('drops cacheTime to 0 when auto-refresh is enabled', () => {
|
||||
withAutoRefreshDisabled(false);
|
||||
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
|
||||
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface UseGetQueryRangeV5Args {
|
||||
enabled: boolean;
|
||||
/** Retain prior data across a key change (list paging) so the table + pager stay mounted. */
|
||||
keepPreviousData?: boolean;
|
||||
/** Unused-entry TTL; callers drop to 0 under auto-refresh to bound cache growth (V1 parity). */
|
||||
cacheTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,6 +48,7 @@ export function useGetQueryRangeV5({
|
||||
queryKey,
|
||||
enabled,
|
||||
keepPreviousData,
|
||||
cacheTime,
|
||||
}: UseGetQueryRangeV5Args): UseQueryResult<QueryRangeV5200, Error> {
|
||||
return useQuery<QueryRangeV5200, Error>({
|
||||
queryKey,
|
||||
@@ -53,5 +56,6 @@ export function useGetQueryRangeV5({
|
||||
enabled,
|
||||
retry: retryUnlessClientError,
|
||||
keepPreviousData,
|
||||
cacheTime,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useQueryClient } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
} from 'constants/queryCacheTime';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
@@ -110,6 +114,7 @@ export function usePanelQuery({
|
||||
selectedTime: globalSelectedInterval,
|
||||
maxTime,
|
||||
minTime,
|
||||
isAutoRefreshDisabled,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
// Resolved variable values for this dashboard, published by useResolvedVariables.
|
||||
@@ -243,6 +248,10 @@ export function usePanelQuery({
|
||||
enabled: enabled && runnable && !isWaitingOnVariable,
|
||||
// Hold the current page while the next loads (offset re-keys) so the pager doesn't flash.
|
||||
keepPreviousData: isPaginated,
|
||||
// 0 under auto-refresh so time-keyed entries don't accumulate and OOM the tab (V1 parity).
|
||||
cacheTime: isAutoRefreshDisabled
|
||||
? DASHBOARD_CACHE_TIME
|
||||
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
4
frontend/src/pages/DashboardPageV2/constants/events.ts
Normal file
4
frontend/src/pages/DashboardPageV2/constants/events.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum DashboardEvents {
|
||||
SWITCH_TO_EDIT_MODE = 'View Panel: Switch to edit mode',
|
||||
SWITCH_TO_VIEW_MODE = 'Edit Panel: Switch to view mode',
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { useSelector } from 'react-redux';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
@@ -115,6 +116,9 @@ function TimeSeriesViewContainer({
|
||||
|
||||
return (
|
||||
<div className="trace-explorer-time-series-view-container">
|
||||
<div className="trace-explorer-time-series-view-container-header">
|
||||
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
|
||||
</div>
|
||||
<TimeSeriesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
isError={isError}
|
||||
@@ -122,10 +126,8 @@ function TimeSeriesViewContainer({
|
||||
isLoading={isLoading || isFetching}
|
||||
data={responseData}
|
||||
yAxisUnit={yAxisUnit}
|
||||
onYAxisUnitChange={onUnitChange}
|
||||
dataSource={dataSource}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,11 +8,7 @@ import {
|
||||
IClickHouseQuery,
|
||||
IPromQLQuery,
|
||||
} from '../queryBuilder/queryBuilderData';
|
||||
import {
|
||||
ExecStats,
|
||||
QueryRangeRequestV5,
|
||||
QueryRangeResponseV5,
|
||||
} from '../v5/queryRange';
|
||||
import { ExecStats, QueryRangeRequestV5 } from '../v5/queryRange';
|
||||
import { QueryData, QueryDataV3 } from '../widgets/getQuery';
|
||||
|
||||
export type QueryRangePayload = {
|
||||
@@ -52,9 +48,6 @@ export interface MetricQueryRangeSuccessResponse extends SuccessResponse<
|
||||
> {
|
||||
warning?: Warning;
|
||||
meta?: ExecStats;
|
||||
// Raw V5 response (pre-legacy-conversion) + per-query legend map, for client-side export.
|
||||
rawV5Response?: QueryRangeResponseV5;
|
||||
legendMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MetricRangePayloadV3 {
|
||||
|
||||
@@ -98,7 +98,7 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
|
||||
Description: "Returns all mappers belonging to a mapping group.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(spantypes.GettableSpanMapperGroups),
|
||||
Response: new(spantypes.GettableSpanMappers),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"id": "cloudsql",
|
||||
"title": "GCP Cloud SQL",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [],
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "GCP Cloud SQL Overview",
|
||||
"description": "Overview of GCP Cloud SQL metrics",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
### Monitor GCP Cloud SQL with SigNoz
|
||||
|
||||
Collect key GCP Cloud SQL metrics and view them with an out of the box dashboard.
|
||||
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 933 B After Width: | Height: | Size: 933 B |
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"id": "cloudsql_postgres",
|
||||
"title": "GCP Cloud SQL for PostgreSQL",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/up",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/cpu/utilization",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/memory/utilization",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/memory/usage",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/disk/bytes_used",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/num_backends",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/num_backends_by_state",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/transaction_count",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/deadlock_count",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/vacuum/oldest_transaction_age",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/insights/aggregate/execution_time",
|
||||
"unit": "Microseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/insights/perquery/execution_time",
|
||||
"unit": "Microseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/replication/replica_byte_lag",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "GCP Cloud SQL for PostgreSQL Overview",
|
||||
"description": "Overview of GCP Cloud SQL for PostgreSQL metrics",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
### Monitor GCP Cloud SQL for PostgreSQL with SigNoz
|
||||
|
||||
Collect key GCP Cloud SQL for PostgreSQL metrics and view them with an out of the box dashboard.
|
||||
@@ -41,7 +41,7 @@ var (
|
||||
AzureServiceRedis = ServiceID{valuer.NewString("redis")}
|
||||
|
||||
// GCP services.
|
||||
GCPServiceCloudSQL = ServiceID{valuer.NewString("cloudsql")}
|
||||
GCPServiceCloudSQLPostgres = ServiceID{valuer.NewString("cloudsql_postgres")}
|
||||
)
|
||||
|
||||
func (ServiceID) Enum() []any {
|
||||
@@ -73,7 +73,7 @@ func (ServiceID) Enum() []any {
|
||||
AzureServiceCosmosDB,
|
||||
AzureServiceCassandraDB,
|
||||
AzureServiceRedis,
|
||||
GCPServiceCloudSQL,
|
||||
GCPServiceCloudSQLPostgres,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
|
||||
AzureServiceRedis,
|
||||
},
|
||||
CloudProviderTypeGCP: {
|
||||
GCPServiceCloudSQL,
|
||||
GCPServiceCloudSQLPostgres,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -338,6 +338,7 @@ func isValidLabelValue(v string) bool {
|
||||
// validate runs during UnmarshalJSON (read + write path).
|
||||
// Preserves the original pre-existing checks only so that stored rules
|
||||
// continue to load without errors.
|
||||
// TODO(srikanthccv): remove this once v1 is deprecated and removed.
|
||||
func (r *PostableRule) validate() error {
|
||||
var errs []error
|
||||
|
||||
@@ -366,9 +367,13 @@ func (r *PostableRule) validate() error {
|
||||
|
||||
errs = append(errs, testTemplateParsing(r)...)
|
||||
|
||||
joined := errors.Join(errs...)
|
||||
if joined != nil {
|
||||
return errors.WrapInvalidInputf(joined, errors.CodeInvalidInput, "validation failed")
|
||||
if len(errs) > 0 {
|
||||
messages := make([]string, len(errs))
|
||||
for i, e := range errs {
|
||||
messages[i] = e.Error()
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "alert rule definition is not valid").
|
||||
WithAdditional(messages...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -466,9 +471,13 @@ func (r *PostableRule) Validate() error {
|
||||
|
||||
errs = append(errs, testTemplateParsing(r)...)
|
||||
|
||||
joined := errors.Join(errs...)
|
||||
if joined != nil {
|
||||
return errors.WrapInvalidInputf(joined, errors.CodeInvalidInput, "validation failed")
|
||||
if len(errs) > 0 {
|
||||
messages := make([]string, len(errs))
|
||||
for i, e := range errs {
|
||||
messages[i] = e.Error()
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "alert rule is not valid").
|
||||
WithAdditional(messages...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,8 +4,23 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
func errorContains(err error, substr string) bool {
|
||||
j := errors.AsJSON(err)
|
||||
if strings.Contains(j.Message, substr) {
|
||||
return true
|
||||
}
|
||||
for _, e := range j.Errors {
|
||||
if strings.Contains(e.Message, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validV1Builder returns a minimal valid v1 builder rule JSON.
|
||||
func validV1Builder() string {
|
||||
return `{
|
||||
@@ -494,7 +509,7 @@ func TestValidate_PostableRule_Common(t *testing.T) {
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.errSubstr)
|
||||
} else if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
|
||||
} else if tt.errSubstr != "" && !errorContains(err, tt.errSubstr) {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.errSubstr, err)
|
||||
}
|
||||
} else {
|
||||
@@ -687,7 +702,7 @@ func TestValidate_V1_ConditionFields(t *testing.T) {
|
||||
if tt.wantErr {
|
||||
if validateErr == nil {
|
||||
t.Errorf("expected Validate() error containing %q, got nil", tt.errSubstr)
|
||||
} else if tt.errSubstr != "" && !strings.Contains(validateErr.Error(), tt.errSubstr) {
|
||||
} else if tt.errSubstr != "" && !errorContains(validateErr, tt.errSubstr) {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.errSubstr, validateErr)
|
||||
}
|
||||
} else {
|
||||
@@ -1029,7 +1044,7 @@ func TestValidate_V2Alpha1(t *testing.T) {
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.errSubstr)
|
||||
} else if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
|
||||
} else if tt.errSubstr != "" && !errorContains(err, tt.errSubstr) {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.errSubstr, err)
|
||||
}
|
||||
} else {
|
||||
@@ -1337,7 +1352,7 @@ func TestValidate_MultipleErrors(t *testing.T) {
|
||||
t.Fatal("expected unmarshal error for wrong version")
|
||||
}
|
||||
// The error should mention version
|
||||
if !strings.Contains(err.Error(), "version") {
|
||||
if !errorContains(err, "version") {
|
||||
t.Errorf("expected error to mention version, got: %v", err)
|
||||
}
|
||||
})
|
||||
@@ -1355,10 +1370,9 @@ func TestValidate_MultipleErrors(t *testing.T) {
|
||||
if validateErr == nil {
|
||||
t.Fatal("expected Validate() error")
|
||||
}
|
||||
errStr := validateErr.Error()
|
||||
// Should contain errors for thresholds, evaluation, notificationSettings
|
||||
for _, substr := range []string{"evaluation", "notificationSettings"} {
|
||||
if !strings.Contains(errStr, substr) {
|
||||
if !errorContains(validateErr, substr) {
|
||||
t.Errorf("expected error to mention %q, got: %v", substr, validateErr)
|
||||
}
|
||||
}
|
||||
@@ -1469,7 +1483,7 @@ func TestValidate_V2Alpha1_CumulativeEvaluation(t *testing.T) {
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.errSubstr)
|
||||
} else if !strings.Contains(err.Error(), tt.errSubstr) {
|
||||
} else if !errorContains(err, tt.errSubstr) {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.errSubstr, err)
|
||||
}
|
||||
} else if err != nil {
|
||||
|
||||
@@ -54,7 +54,7 @@ type SpanMapper struct {
|
||||
types.UserAuditable
|
||||
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
GroupID valuer.UUID `json:"group_id" required:"true"`
|
||||
GroupID valuer.UUID `json:"groupId" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
FieldContext FieldContext `json:"fieldContext" required:"true"`
|
||||
Config SpanMapperConfig `json:"config" required:"true"`
|
||||
@@ -63,7 +63,7 @@ type SpanMapper struct {
|
||||
|
||||
type PostableSpanMapper struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
FieldContext FieldContext `json:"fieldContext" required:"true"`
|
||||
FieldContext FieldContext `json:"fieldContext" required:"true"`
|
||||
Config SpanMapperConfig `json:"config" required:"true"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user