mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-18 17:30:41 +01:00
Compare commits
153 Commits
chore/enab
...
nv/heatmap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff8ec64c2e | ||
|
|
02069e0a5e | ||
|
|
4260363b45 | ||
|
|
19c4aed27c | ||
|
|
9f1b1476f4 | ||
|
|
0bea671a2d | ||
|
|
0b1695c9c2 | ||
|
|
b4f42cf388 | ||
|
|
65a8a58600 | ||
|
|
a3c90ab132 | ||
|
|
da4c5578d3 | ||
|
|
bf01867c3c | ||
|
|
41187a5fca | ||
|
|
d04dc6f7db | ||
|
|
71badcf65c | ||
|
|
68fe15d5fc | ||
|
|
f8aded8c8e | ||
|
|
f6a6793e96 | ||
|
|
452046d70c | ||
|
|
2e9c9067f3 | ||
|
|
2ddd45cb8c | ||
|
|
8809d8a7ac | ||
|
|
21edb05f2b | ||
|
|
f659205866 | ||
|
|
215f6f8e0f | ||
|
|
802f7e4a3d | ||
|
|
81f1105fbc | ||
|
|
575aa57426 | ||
|
|
f75d3d8724 | ||
|
|
43538b92db | ||
|
|
ef65c2c08c | ||
|
|
8e41d3d30d | ||
|
|
b249ab8275 | ||
|
|
61bca035a3 | ||
|
|
9983b3344b | ||
|
|
1313a289b6 | ||
|
|
82a789c19e | ||
|
|
ec09873573 | ||
|
|
00cc773792 | ||
|
|
cf5dd79216 | ||
|
|
6546a0abc6 | ||
|
|
3874f3a6ec | ||
|
|
86db0fd26b | ||
|
|
f382a958db | ||
|
|
7c3481b230 | ||
|
|
24f3116527 | ||
|
|
17f0f49398 | ||
|
|
f681bacdcb | ||
|
|
ded4ed3d76 | ||
|
|
1f84f9c166 | ||
|
|
87127b580c | ||
|
|
fc4bd13fce | ||
|
|
075a4b164d | ||
|
|
2170189d14 | ||
|
|
b7720b655e | ||
|
|
f34973b897 | ||
|
|
d9d041a8fa | ||
|
|
a0e66e3592 | ||
|
|
a0dff26fbc | ||
|
|
db0b1b2b2a | ||
|
|
fd8ed18592 | ||
|
|
392b5826fd | ||
|
|
4b4023d706 | ||
|
|
f8d803d787 | ||
|
|
a33320c665 | ||
|
|
695a481989 | ||
|
|
3fc763b95b | ||
|
|
027b65574d | ||
|
|
43ec98b309 | ||
|
|
a196a436be | ||
|
|
3f456b6602 | ||
|
|
f777e068c3 | ||
|
|
5a690695e6 | ||
|
|
bf362d15db | ||
|
|
7e0f70ffa7 | ||
|
|
590046adf3 | ||
|
|
106cb9f6c2 | ||
|
|
f95699b1f7 | ||
|
|
1e75b66379 | ||
|
|
b80013d075 | ||
|
|
4a6e25708d | ||
|
|
3b8f9a7830 | ||
|
|
68bff5b053 | ||
|
|
732c8ae18a | ||
|
|
6911630c2a | ||
|
|
2d78d8add9 | ||
|
|
8e47c7d337 | ||
|
|
991e734c33 | ||
|
|
40c881b8f5 | ||
|
|
a2c8dd5275 | ||
|
|
32ae7a26d5 | ||
|
|
4067b04fa2 | ||
|
|
70409f5ee3 | ||
|
|
5d9953bb7e | ||
|
|
edfff0ee72 | ||
|
|
44556060a3 | ||
|
|
545e0342d2 | ||
|
|
2f559f34d3 | ||
|
|
e4d90b91b7 | ||
|
|
f33616a598 | ||
|
|
2ac94fc2fc | ||
|
|
7292a6946b | ||
|
|
dff49a4135 | ||
|
|
690c207519 | ||
|
|
d9b6d1f425 | ||
|
|
1eb974d91a | ||
|
|
1141f6bf14 | ||
|
|
a3fb926726 | ||
|
|
fe1e86f385 | ||
|
|
e30d9a1a14 | ||
|
|
c9a345713c | ||
|
|
22f8eba44a | ||
|
|
e8ad9044d5 | ||
|
|
576cd5052a | ||
|
|
4214ccf93d | ||
|
|
63f5c17238 | ||
|
|
fd6abd67dd | ||
|
|
a78c35d61c | ||
|
|
b2c4916a86 | ||
|
|
feff1a0179 | ||
|
|
eafac1be69 | ||
|
|
510d6985d0 | ||
|
|
758aacb065 | ||
|
|
5599c352d0 | ||
|
|
96d0a2340c | ||
|
|
c6b415ad04 | ||
|
|
9b922f297f | ||
|
|
e81a4c4046 | ||
|
|
9ba13848ea | ||
|
|
2578f25dd4 | ||
|
|
82352973bb | ||
|
|
3eb55692f1 | ||
|
|
61ceb6d300 | ||
|
|
e33fc20579 | ||
|
|
b130a61049 | ||
|
|
132e9bb740 | ||
|
|
9d577731e1 | ||
|
|
4d54568fe1 | ||
|
|
6f82624523 | ||
|
|
da0bc4f438 | ||
|
|
2dcb764632 | ||
|
|
1bc25e16b8 | ||
|
|
eebc6b400d | ||
|
|
30c4508113 | ||
|
|
c4fe619920 | ||
|
|
d597adddda | ||
|
|
9a05f33e7a | ||
|
|
3812937c6f | ||
|
|
34aa719cba | ||
|
|
cdfce4c4ab | ||
|
|
d8ef672f83 | ||
|
|
00efffc127 | ||
|
|
dec922a83f |
@@ -384,49 +384,13 @@ components:
|
||||
required:
|
||||
- routingKey
|
||||
type: object
|
||||
AlertmanagertypesChannelSlackAction:
|
||||
properties:
|
||||
confirm:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackConfirmation'
|
||||
name:
|
||||
type: string
|
||||
style:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- text
|
||||
type: object
|
||||
AlertmanagertypesChannelSlackConfig:
|
||||
properties:
|
||||
actions:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackAction'
|
||||
type: array
|
||||
apiUrl:
|
||||
format: password
|
||||
type: string
|
||||
channel:
|
||||
type: string
|
||||
color:
|
||||
type: string
|
||||
fallback:
|
||||
type: string
|
||||
fields:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackField'
|
||||
type: array
|
||||
footer:
|
||||
type: string
|
||||
pretext:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
@@ -434,37 +398,9 @@ components:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
titleLink:
|
||||
type: string
|
||||
required:
|
||||
- apiUrl
|
||||
type: object
|
||||
AlertmanagertypesChannelSlackConfirmation:
|
||||
properties:
|
||||
dismissText:
|
||||
type: string
|
||||
okText:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
required:
|
||||
- text
|
||||
type: object
|
||||
AlertmanagertypesChannelSlackField:
|
||||
properties:
|
||||
short:
|
||||
nullable: true
|
||||
type: boolean
|
||||
title:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- title
|
||||
- value
|
||||
type: object
|
||||
AlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
bearerToken:
|
||||
@@ -3616,6 +3552,79 @@ components:
|
||||
hide:
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesHeatmapAxes:
|
||||
properties:
|
||||
yScale:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapYScale'
|
||||
type: object
|
||||
DashboardtypesHeatmapChartAppearance:
|
||||
properties:
|
||||
colors:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColors'
|
||||
type: object
|
||||
DashboardtypesHeatmapColorMode:
|
||||
enum:
|
||||
- palette
|
||||
- opacity
|
||||
type: string
|
||||
DashboardtypesHeatmapColorScale:
|
||||
enum:
|
||||
- log
|
||||
- sqrt
|
||||
- linear
|
||||
type: string
|
||||
DashboardtypesHeatmapColors:
|
||||
properties:
|
||||
fill:
|
||||
type: string
|
||||
maxCount:
|
||||
nullable: true
|
||||
type: number
|
||||
minCount:
|
||||
nullable: true
|
||||
type: number
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
|
||||
palette:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
|
||||
steps:
|
||||
type: integer
|
||||
type: object
|
||||
DashboardtypesHeatmapPalette:
|
||||
enum:
|
||||
- ice
|
||||
- moss
|
||||
- rust
|
||||
- graphite
|
||||
- ember
|
||||
- lagoon
|
||||
- orchid
|
||||
- verdant
|
||||
- lava
|
||||
- beacon
|
||||
type: string
|
||||
DashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapChartAppearance'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesHeatmapYScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -3968,6 +3977,7 @@ components:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
@@ -3985,6 +3995,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -3996,6 +4007,7 @@ components:
|
||||
- signoz/HistogramPanel
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
@@ -4009,6 +4021,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/HeatmapPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
|
||||
@@ -40,73 +40,7 @@ export interface AlertmanagertypesChannelDTO {
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind {
|
||||
slack = 'slack',
|
||||
}
|
||||
export interface AlertmanagertypesChannelSlackConfirmationDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
dismissText?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
okText?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelSlackActionDTO {
|
||||
confirm?: AlertmanagertypesChannelSlackConfirmationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
style?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelSlackFieldDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
short?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
actions?: AlertmanagertypesChannelSlackActionDTO[];
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
@@ -116,26 +50,6 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
* @type string
|
||||
*/
|
||||
channel?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
fallback?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
fields?: AlertmanagertypesChannelSlackFieldDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
footer?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
pretext?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
@@ -148,10 +62,6 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
titleLink?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import type {
|
||||
GetAIObservabilityFieldsKeys200,
|
||||
GetAIObservabilityFieldsValues200,
|
||||
GetAIObservabilityFieldsKeysParams,
|
||||
GetAIObservabilityFieldsValuesParams,
|
||||
GetFieldsKeys200,
|
||||
GetFieldsKeysParams,
|
||||
GetFieldsValues200,
|
||||
GetFieldsValuesParams,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type FieldKeysConfig = GetFieldsKeysParams;
|
||||
export type FieldKeysConfig =
|
||||
| GetFieldsKeysParams
|
||||
| GetAIObservabilityFieldsKeysParams;
|
||||
|
||||
export type FieldValuesConfig = GetFieldsValuesParams;
|
||||
export type FieldValuesConfig =
|
||||
| GetFieldsValuesParams
|
||||
| GetAIObservabilityFieldsValuesParams;
|
||||
|
||||
export type FieldKeysConfigProp = Omit<
|
||||
FieldKeysConfig,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import CheckboxFilterHeader from './CheckboxFilterHeader';
|
||||
import CheckboxValueRow from './CheckboxValueRow';
|
||||
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
|
||||
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
|
||||
import useActiveQueryIndex from './useActiveQueryIndex';
|
||||
import useCheckboxDisclosure from './useCheckboxDisclosure';
|
||||
import useCheckboxFilterActions from './useCheckboxFilterActions';
|
||||
import useCheckboxFilterState from './useCheckboxFilterState';
|
||||
|
||||
@@ -15,21 +15,13 @@ function useActiveQueryIndex(source: QuickFiltersSource): number {
|
||||
const isListView = panelType === PANEL_TYPES.LIST;
|
||||
|
||||
return useMemo(() => {
|
||||
// AI observability builds a single query in the row-level views, so its
|
||||
// filters always drive the first one there.
|
||||
if (source === QuickFiltersSource.AI_OBSERVABILITY) {
|
||||
return isListView || panelType === PANEL_TYPES.TRACE
|
||||
? 0
|
||||
: lastUsedQuery || 0;
|
||||
}
|
||||
|
||||
if (isListView) {
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, panelType, source, lastUsedQuery]);
|
||||
}, [isListView, source, lastUsedQuery]);
|
||||
}
|
||||
|
||||
export default useActiveQueryIndex;
|
||||
@@ -56,57 +56,6 @@ export function mockFieldsValuesAPI(response: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records every request the AI observability values endpoint receives, so a test
|
||||
* can assert both the routing and the query params it was called with.
|
||||
*/
|
||||
export function mockAIObservabilityFieldsValuesAPI(response: {
|
||||
relatedValues?: (string | null)[];
|
||||
stringValues?: (string | null)[];
|
||||
numberValues?: (number | null)[];
|
||||
}): { requests: URLSearchParams[] } {
|
||||
const requests: URLSearchParams[] = [];
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
'http://localhost/api/v1/ai_observability/fields/values',
|
||||
(req, res, ctx) => {
|
||||
requests.push(req.url.searchParams);
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: response.relatedValues ?? [],
|
||||
stringValues: response.stringValues ?? [],
|
||||
numberValues: response.numberValues ?? [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return { requests };
|
||||
}
|
||||
|
||||
/** Fails the test if the signal-wide values endpoint is hit at all. */
|
||||
export function forbidFieldsValuesAPI(): { called: boolean } {
|
||||
const state = { called: false };
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) => {
|
||||
state.called = true;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function mockFieldsValuesAPILoading(): void {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
|
||||
|
||||
@@ -16,7 +16,7 @@ import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
|
||||
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
|
||||
import useActiveQueryIndex from '../useActiveQueryIndex';
|
||||
import useCheckboxDisclosure from '../useCheckboxDisclosure';
|
||||
import useCheckboxFilterActions from '../useCheckboxFilterActions';
|
||||
import useCheckboxFilterState from '../useCheckboxFilterState';
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
forbidFieldsValuesAPI,
|
||||
mockAIObservabilityFieldsValuesAPI,
|
||||
mockFieldsValuesAPI,
|
||||
setupServer,
|
||||
} from '../CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - AI observability routing', () => {
|
||||
it('reads values from the AI observability endpoint and never the signal-wide one', async () => {
|
||||
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
|
||||
stringValues: ['openai', 'anthropic'],
|
||||
});
|
||||
const fieldsEndpoint = forbidFieldsValuesAPI();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect(screen.findByText('openai')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('anthropic')).toBeInTheDocument();
|
||||
expect(fieldsEndpoint.called).toBe(false);
|
||||
expect(aiEndpoint.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('forwards the filter key and the time range to the AI observability endpoint', async () => {
|
||||
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
|
||||
stringValues: ['openai'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('openai');
|
||||
|
||||
const params = aiEndpoint.requests[0];
|
||||
expect(params.get('name')).toBe(DEFAULT_FILTER.attributeKey.key);
|
||||
expect(params.get('startUnixMilli')).toBe(
|
||||
String(DEFAULT_USE_FIELD_APIS.startUnixMilli),
|
||||
);
|
||||
expect(params.get('endUnixMilli')).toBe(
|
||||
String(DEFAULT_USE_FIELD_APIS.endUnixMilli),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps non-AI sources on the signal-wide endpoint', async () => {
|
||||
mockFieldsValuesAPI({ stringValues: ['production'] });
|
||||
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
|
||||
stringValues: ['should-not-be-used'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect(screen.findByText('production')).resolves.toBeInTheDocument();
|
||||
await waitFor(() => expect(aiEndpoint.requests).toHaveLength(0));
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetFieldsValues } from 'api/generated/services/fields';
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { FieldValuesConfig } from 'api/querySuggestions/types';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { useFieldValuesSuggestion } from 'hooks/querySuggestions/useFieldValuesSuggestion';
|
||||
import { BuilderQueryType } from 'types/api/v5/queryRange';
|
||||
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
import { DATA_SOURCE_TO_SIGNAL } from 'types/common/queryBuilder';
|
||||
|
||||
interface UseFieldValuesProps {
|
||||
@@ -43,43 +42,32 @@ export function useFieldValues({
|
||||
endUnixMilli,
|
||||
enabled,
|
||||
}: UseFieldValuesProps): UseFieldValuesReturn {
|
||||
const isAIObservability = source === QuickFiltersSource.AI_OBSERVABILITY;
|
||||
|
||||
const builderQueryType: BuilderQueryType | undefined = isAIObservability
|
||||
? 'builder_ai_query'
|
||||
: undefined;
|
||||
|
||||
// The AI values endpoint is already gen_ai-scoped: no signal, no source.
|
||||
const fieldValuesConfig: FieldValuesConfig = isAIObservability
|
||||
? {
|
||||
name: filter.attributeKey.key,
|
||||
searchText,
|
||||
existingQuery,
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
}
|
||||
: {
|
||||
signal: filter.dataSource
|
||||
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
|
||||
: undefined,
|
||||
name: filter.attributeKey.key,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
|
||||
startUnixMilli,
|
||||
// This field does not affect the backend but I wanted to keep it here
|
||||
// in case we add the support in the future
|
||||
endUnixMilli,
|
||||
};
|
||||
|
||||
const {
|
||||
data: values,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useFieldValuesSuggestion(fieldValuesConfig, builderQueryType, { enabled });
|
||||
const { data, isLoading, isFetching } = useGetFieldsValues(
|
||||
{
|
||||
signal: filter.dataSource
|
||||
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
|
||||
: undefined,
|
||||
name: filter.attributeKey.key,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
|
||||
startUnixMilli,
|
||||
// This field does not affect the backend but I wanted to keep it here
|
||||
// in case we add the support in the future
|
||||
endUnixMilli,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled,
|
||||
cacheTime: FIELD_API_CACHE_TIME,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const relatedValues: string[] = useMemo(() => {
|
||||
const values = data?.data?.values;
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
@@ -90,9 +78,10 @@ export function useFieldValues({
|
||||
value !== null && value !== undefined && value !== '',
|
||||
) || []
|
||||
);
|
||||
}, [values]);
|
||||
}, [data]);
|
||||
|
||||
const allValues: string[] = useMemo(() => {
|
||||
const values = data?.data?.values;
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
@@ -112,7 +101,7 @@ export function useFieldValues({
|
||||
.map((value) => value.toString()) || [];
|
||||
|
||||
return [...stringValues, ...numberValues, ...boolValues];
|
||||
}, [values]);
|
||||
}, [data]);
|
||||
|
||||
return { relatedValues, allValues, isLoading, isFetching };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Collapse } from 'antd';
|
||||
import { Undo2 } from '@signozhq/icons';
|
||||
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -39,7 +39,7 @@ function Duration({
|
||||
}: {
|
||||
filter: IQuickFiltersConfig;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
source: QuickFiltersSource;
|
||||
source?: QuickFiltersSource;
|
||||
}): JSX.Element {
|
||||
const [selectedFilters, setSelectedFilters] =
|
||||
useState<
|
||||
@@ -52,11 +52,26 @@ function Duration({
|
||||
filter.defaultOpen ? 'durationNano' : '',
|
||||
]);
|
||||
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const {
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
lastUsedQuery,
|
||||
panelType,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
const isListView = panelType === PANEL_TYPES.LIST;
|
||||
// In ListView mode, use index 0 for most sources; for TRACES_EXPLORER, use lastUsedQuery
|
||||
// Otherwise use lastUsedQuery for non-ListView modes
|
||||
const activeQueryIndex = useMemo(() => {
|
||||
if (isListView) {
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, source, lastUsedQuery]);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const syncSelectedFilters = useMemo((): FilterType => {
|
||||
|
||||
@@ -35,7 +35,6 @@ import { isFunction } from 'lodash-es';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
|
||||
import useActiveQueryIndex from './hooks/useActiveQueryIndex';
|
||||
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
|
||||
import Duration from './FilterRenderers/Duration/Duration';
|
||||
import Slider from './FilterRenderers/Slider/Slider';
|
||||
@@ -114,13 +113,14 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
const shouldShowDropdownInListView =
|
||||
isListView && source === QuickFiltersSource.TRACES_EXPLORER;
|
||||
|
||||
// AI observability builds a single query in the row-level views, so there is
|
||||
// no query for the selector to switch between.
|
||||
const isAIObservabilityRowView =
|
||||
source === QuickFiltersSource.AI_OBSERVABILITY &&
|
||||
(isListView || panelType === PANEL_TYPES.TRACE);
|
||||
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
const activeQueryIndex = useMemo(() => {
|
||||
if (isListView) {
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, source, lastUsedQuery]);
|
||||
|
||||
// clear all the filters for the query which is in sync with filters
|
||||
const handleReset = (): void => {
|
||||
@@ -167,10 +167,9 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName;
|
||||
|
||||
// In ListView, always show the 0th query's name; otherwise use the active query's name
|
||||
const displayedQueryName =
|
||||
isListView || isAIObservabilityRowView
|
||||
? showQueryName && currentQuery.builder.queryData?.[0]?.queryName
|
||||
: lastQueryName;
|
||||
const displayedQueryName = isListView
|
||||
? showQueryName && currentQuery.builder.queryData?.[0]?.queryName
|
||||
: lastQueryName;
|
||||
|
||||
const handleQueryChange = (value: number): void => {
|
||||
setLastUsedQuery(value);
|
||||
@@ -183,9 +182,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
<Typography.Text className="text">
|
||||
{displayedQueryName ? 'Filters for' : 'Filters'}
|
||||
</Typography.Text>
|
||||
{queryOptions.length > 1 &&
|
||||
!isAIObservabilityRowView &&
|
||||
(!isListView || shouldShowDropdownInListView) ? (
|
||||
{queryOptions.length > 1 && (!isListView || shouldShowDropdownInListView) ? (
|
||||
<Combobox open={open} onOpenChange={setOpen}>
|
||||
<ComboboxTrigger
|
||||
placeholder="Select a query"
|
||||
@@ -321,7 +318,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
return (
|
||||
<Duration
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { useGetFieldsKeys } from 'api/generated/services/fields';
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { FieldKeysConfig } from 'api/querySuggestions/types';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import {
|
||||
BuilderQueryType,
|
||||
FieldContext,
|
||||
FieldDataType,
|
||||
TelemetryFieldKey,
|
||||
@@ -43,31 +41,23 @@ function OtherFilters({
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
}): JSX.Element {
|
||||
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
|
||||
const isAIObservability = signal === SignalType.AI_OBSERVABILITY;
|
||||
|
||||
const builderQueryType: BuilderQueryType | undefined = isAIObservability
|
||||
? 'builder_ai_query'
|
||||
: undefined;
|
||||
|
||||
const fieldKeysConfig: FieldKeysConfig = isAIObservability
|
||||
? { searchText: inputValue }
|
||||
: {
|
||||
searchText: inputValue,
|
||||
signal: signal
|
||||
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
|
||||
: undefined,
|
||||
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
|
||||
};
|
||||
|
||||
const { data: fetchedKeys, isFetching } = useFieldKeysSuggestion(
|
||||
fieldKeysConfig,
|
||||
builderQueryType,
|
||||
const { data, isFetching } = useGetFieldsKeys(
|
||||
{
|
||||
searchText: inputValue,
|
||||
signal: signal
|
||||
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
|
||||
: undefined,
|
||||
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
|
||||
},
|
||||
{ query: { enabled: !!signal } },
|
||||
);
|
||||
|
||||
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
|
||||
const rawSuggestions = Object.values(data?.data?.keys ?? {}).flat();
|
||||
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
|
||||
// add, render) can trust it.
|
||||
const suggestions: TelemetryFieldKey[] = (fetchedKeys ?? []).map((attr) => ({
|
||||
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
|
||||
name: attr.name,
|
||||
signal: attr.signal as TelemetryFieldKey['signal'],
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
@@ -81,7 +71,7 @@ function OtherFilters({
|
||||
),
|
||||
);
|
||||
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
|
||||
}, [fetchedKeys, addedFilters]);
|
||||
}, [data, addedFilters]);
|
||||
|
||||
const handleAddFilter = (filter: TelemetryFieldKey): void => {
|
||||
setAddedFilters((prev) => [...prev, filter]);
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { SignalType } from '../../types';
|
||||
import OtherFilters from '../OtherFilters';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const FIELDS_KEYS_URL = `${BASE_URL}/api/v1/fields/keys`;
|
||||
const AI_KEYS_URL = `${BASE_URL}/api/v1/ai_observability/fields/keys`;
|
||||
|
||||
function keysResponse(name: string): Record<string, unknown> {
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: {
|
||||
[name]: [{ name, fieldContext: 'attribute', fieldDataType: 'string' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('OtherFilters - AI observability keys', () => {
|
||||
let fieldsKeysCalled: boolean;
|
||||
let aiKeysParams: URLSearchParams | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
fieldsKeysCalled = false;
|
||||
aiKeysParams = undefined;
|
||||
|
||||
server.use(
|
||||
rest.get(FIELDS_KEYS_URL, (_, res, ctx) => {
|
||||
fieldsKeysCalled = true;
|
||||
return res(ctx.status(200), ctx.json(keysResponse('http.route')));
|
||||
}),
|
||||
rest.get(AI_KEYS_URL, (req, res, ctx) => {
|
||||
aiKeysParams = req.url.searchParams;
|
||||
return res(ctx.status(200), ctx.json(keysResponse('gen_ai.request.model')));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
function renderOtherFilters(signal: SignalType): void {
|
||||
render(
|
||||
<OtherFilters
|
||||
signal={signal}
|
||||
inputValue=""
|
||||
addedFilters={[]}
|
||||
setAddedFilters={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('reads AI observability keys from their own endpoint', async () => {
|
||||
renderOtherFilters(SignalType.AI_OBSERVABILITY);
|
||||
|
||||
await expect(
|
||||
screen.findByText('gen_ai.request.model'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(fieldsKeysCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('does not narrow the AI keys by fieldContext', async () => {
|
||||
renderOtherFilters(SignalType.AI_OBSERVABILITY);
|
||||
|
||||
// A `trace` context would return only the computed per-trace aggregates,
|
||||
// which cannot be filtered on.
|
||||
await waitFor(() => expect(aiKeysParams).toBeDefined());
|
||||
expect(aiKeysParams?.get('fieldContext')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps other signals on the signal-wide keys endpoint', async () => {
|
||||
renderOtherFilters(SignalType.TRACES);
|
||||
|
||||
await expect(screen.findByText('http.route')).resolves.toBeInTheDocument();
|
||||
await waitFor(() => expect(aiKeysParams).toBeUndefined());
|
||||
});
|
||||
});
|
||||
@@ -7,5 +7,4 @@ export const SIGNAL_DATA_SOURCE_MAP = {
|
||||
[SignalType.EXCEPTIONS]: DataSource.TRACES,
|
||||
[SignalType.API_MONITORING]: DataSource.TRACES,
|
||||
[SignalType.METER_EXPLORER]: DataSource.METRICS,
|
||||
[SignalType.AI_OBSERVABILITY]: DataSource.TRACES,
|
||||
};
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import { QuickFiltersSource } from '../../types';
|
||||
import useActiveQueryIndex from '../useActiveQueryIndex';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
|
||||
const LAST_USED_QUERY = 2;
|
||||
|
||||
function mockQueryBuilder(panelType: PANEL_TYPES): void {
|
||||
(useQueryBuilder as jest.Mock).mockReturnValue({
|
||||
lastUsedQuery: LAST_USED_QUERY,
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
|
||||
describe('useActiveQueryIndex', () => {
|
||||
describe('AI observability builds a single query in the row-level views', () => {
|
||||
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
|
||||
'drives the first query in %s',
|
||||
(panelType) => {
|
||||
mockQueryBuilder(panelType);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useActiveQueryIndex(QuickFiltersSource.AI_OBSERVABILITY),
|
||||
);
|
||||
|
||||
expect(result.current).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
|
||||
'follows the last used query in %s',
|
||||
(panelType) => {
|
||||
mockQueryBuilder(panelType);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useActiveQueryIndex(QuickFiltersSource.AI_OBSERVABILITY),
|
||||
);
|
||||
|
||||
expect(result.current).toBe(LAST_USED_QUERY);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('other sources are unchanged', () => {
|
||||
it('lets the traces explorer track the last used query in list view', () => {
|
||||
mockQueryBuilder(PANEL_TYPES.LIST);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useActiveQueryIndex(QuickFiltersSource.TRACES_EXPLORER),
|
||||
);
|
||||
|
||||
expect(result.current).toBe(LAST_USED_QUERY);
|
||||
});
|
||||
|
||||
it('pins single-query sources to the first query in list view', () => {
|
||||
mockQueryBuilder(PANEL_TYPES.LIST);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useActiveQueryIndex(QuickFiltersSource.INFRA_MONITORING),
|
||||
);
|
||||
|
||||
expect(result.current).toBe(0);
|
||||
});
|
||||
|
||||
it('tracks the last used query outside list view', () => {
|
||||
mockQueryBuilder(PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useActiveQueryIndex(QuickFiltersSource.LOGS_EXPLORER),
|
||||
);
|
||||
|
||||
expect(result.current).toBe(LAST_USED_QUERY);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,6 @@ export enum SignalType {
|
||||
API_MONITORING = 'api_monitoring',
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
AI_OBSERVABILITY = 'ai_observability',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,7 +69,6 @@ export enum QuickFiltersSource {
|
||||
API_MONITORING = 'api-monitoring',
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
AI_OBSERVABILITY = 'ai-observability',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -109,9 +109,6 @@ export const REACT_QUERY_KEY = {
|
||||
// Field Keys Suggestion Query Keys
|
||||
FIELD_KEYS_SUGGESTION: 'FIELD_KEYS_SUGGESTION',
|
||||
|
||||
// Field Values Suggestion Query Keys
|
||||
FIELD_VALUES_SUGGESTION: 'FIELD_VALUES_SUGGESTION',
|
||||
|
||||
// AI Assistant Query Keys
|
||||
AI_ASSISTANT_EMPTY_STATE_CHIPS: 'AI_ASSISTANT_EMPTY_STATE_CHIPS',
|
||||
} as const;
|
||||
|
||||
@@ -8,13 +8,18 @@ import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { initialQueryAIWithType } from 'constants/queryBuilder';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
@@ -23,6 +28,7 @@ import {
|
||||
useHandleExplorerTabChange,
|
||||
} from 'hooks/useHandleExplorerTabChange';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
@@ -31,7 +37,7 @@ import {
|
||||
tracesChangeViewAction,
|
||||
tracesRunQueryAction,
|
||||
tracesSaveViewAction,
|
||||
} from './aiActions';
|
||||
} from 'pages/TracesExplorer/aiActions';
|
||||
import { Warning } from 'types/api';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -39,10 +45,12 @@ import {
|
||||
explorerViewToPanelType,
|
||||
getExplorerViewFromUrl,
|
||||
} from 'utils/explorerUtils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import LeftToolbarActions from '../ToolbarActions/LeftToolbarActions';
|
||||
import { DEFAULT_PANEL_TYPE, TOOLBAR_VIEWS } from './constants';
|
||||
import { TOOLBAR_VIEWS } from './constants';
|
||||
import { getExportQueryData, getQueryByPanelType } from './explorerUtils';
|
||||
import ListView from './ListView/ListView';
|
||||
import { defaultSelectedColumns } from './ListView/configs';
|
||||
import QuerySection from './QuerySection/QuerySection';
|
||||
import TableView from './TableView/TableView';
|
||||
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
|
||||
@@ -52,6 +60,7 @@ import './Explorer.styles.scss';
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
panelType,
|
||||
updateAllQueriesOperators,
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
@@ -63,12 +72,20 @@ function Explorer(): JSX.Element {
|
||||
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const listQueryKeyRef = useRef<any>();
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
@@ -95,24 +112,19 @@ function Explorer(): JSX.Element {
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
const [isOpen, setOpen] = useState<boolean>(true);
|
||||
|
||||
const { startUnixMilli, endUnixMilli } = useSignalFieldApis();
|
||||
// existingQuery is left unset so related values auto-extract from the current query
|
||||
const quickFiltersFieldApis = useMemo(
|
||||
() => ({ startUnixMilli, endUnixMilli }),
|
||||
[startUnixMilli, endUnixMilli],
|
||||
);
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
(): Query =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueryAIWithType,
|
||||
DEFAULT_PANEL_TYPE,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.TRACES,
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const handleChangeSelectedView = useCallback(
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
@@ -127,7 +139,7 @@ function Explorer(): JSX.Element {
|
||||
},
|
||||
[handleExplorerTabChange, handleSetConfig],
|
||||
);
|
||||
//TODO: check if we need to enable AI Assistant page actions on LLM o11y
|
||||
|
||||
// ─── AI Assistant page actions (only when license feature is on) ───────────
|
||||
const aiActions = useMemo(
|
||||
() =>
|
||||
@@ -167,6 +179,59 @@ function Explorer(): JSX.Element {
|
||||
usePageActions('traces-explorer', aiActions);
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
getQueryByPanelType(
|
||||
stagedQuery || initialQueryAIWithType,
|
||||
panelType || PANEL_TYPES.LIST,
|
||||
),
|
||||
[stagedQuery, panelType],
|
||||
);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
|
||||
if (!dashboard || !panelType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
|
||||
? panelType
|
||||
: PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
const widgetId = v4();
|
||||
|
||||
const query = getExportQueryData(
|
||||
exportDefaultQuery,
|
||||
panelTypeParam,
|
||||
options,
|
||||
);
|
||||
|
||||
logEvent('Traces Explorer: Add to dashboard successful', {
|
||||
panelType,
|
||||
isNewDashboard,
|
||||
dashboardName: dashboard?.title,
|
||||
});
|
||||
|
||||
const dashboardEditView = getExportToDashboardLink({
|
||||
query,
|
||||
panelType: panelTypeParam,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId,
|
||||
});
|
||||
|
||||
if (dashboardEditView) {
|
||||
safeNavigate(dashboardEditView);
|
||||
}
|
||||
},
|
||||
[
|
||||
exportDefaultQuery,
|
||||
panelType,
|
||||
safeNavigate,
|
||||
options,
|
||||
getExportToDashboardLink,
|
||||
],
|
||||
);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
@@ -195,9 +260,8 @@ function Explorer(): JSX.Element {
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
signal={SignalType.AI_OBSERVABILITY}
|
||||
useFieldApis={quickFiltersFieldApis}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
@@ -290,6 +354,14 @@ function Explorer(): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.TRACES}
|
||||
onExport={handleExport}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -12,17 +12,25 @@ import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { getTraceLink, transformSpanRows } from './utils';
|
||||
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import TracesTable from '../TracesTable/TracesTable';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import {
|
||||
getTraceLink,
|
||||
transformSpanRows,
|
||||
} from 'container/TracesExplorer/ListView/utils';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
@@ -34,7 +42,6 @@ import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import {
|
||||
defaultSelectedColumns,
|
||||
@@ -72,6 +79,14 @@ function ListView({
|
||||
loading: timeRangeUpdateLoading,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
@@ -83,6 +98,19 @@ function ListView({
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// Stable sorted-name signature for the queryKey.
|
||||
// - Drag updates selectColumns; raw queryKey would churn on reorder.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch.
|
||||
// - Sorted-name signature: stable on reorder, changes on add/remove.
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
(options?.selectColumns ?? [])
|
||||
.map((c) => c.name)
|
||||
.sort()
|
||||
.join(','),
|
||||
[options?.selectColumns],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
@@ -92,6 +120,7 @@ function ListView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
@@ -99,6 +128,7 @@ function ListView({
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
orderBy,
|
||||
@@ -120,7 +150,7 @@ function ListView({
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
selectColumns: defaultSelectedColumns,
|
||||
selectColumns: options?.selectColumns,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
@@ -128,7 +158,10 @@ function ListView({
|
||||
queryKey,
|
||||
enabled:
|
||||
// don't make api call while the time range state in redux is loading
|
||||
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
|
||||
!timeRangeUpdateLoading &&
|
||||
!!stagedQuery &&
|
||||
panelType === PANEL_TYPES.LIST &&
|
||||
!!options?.selectColumns?.length,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -153,20 +186,28 @@ function ListView({
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
// TODO(ai-explorer): static columns until the preferences framework lands.
|
||||
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(
|
||||
() =>
|
||||
[TIMESTAMP_FIELD, ...defaultSelectedColumns].map((field) =>
|
||||
getFieldColumn(field),
|
||||
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
|
||||
const fields = [
|
||||
TIMESTAMP_FIELD,
|
||||
...(options?.selectColumns ?? []).filter(
|
||||
(field) => field.name !== TIMESTAMP_FIELD.name,
|
||||
),
|
||||
[],
|
||||
);
|
||||
];
|
||||
return fields.map((field) => getFieldColumn(field));
|
||||
}, [options?.selectColumns]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => transformSpanRows(queryTableData),
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(reordered: TableColumnDef<TracesTableRow>[]): void => {
|
||||
config?.addColumn?.onReorder(reordered.map((column) => column.id));
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
@@ -194,9 +235,15 @@ function ListView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching}
|
||||
totalCount={rows.length}
|
||||
config={config}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
@@ -204,8 +251,6 @@ function ListView({
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
|
||||
respectColumnOrder
|
||||
panelType="LIST"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
@@ -213,6 +258,8 @@ function ListView({
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,41 +1,19 @@
|
||||
import type { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const defaultSelectedColumns: string[] = [
|
||||
'service.name',
|
||||
'name',
|
||||
'duration_nano',
|
||||
'http_method',
|
||||
'response_status_code',
|
||||
'timestamp',
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
// Pinned timestamp column
|
||||
// The list query returns timestamp, trace_id and span_id whether or not they are selected.
|
||||
export const TIMESTAMP_FIELD = {
|
||||
name: 'timestamp',
|
||||
fieldContext: 'span',
|
||||
} as TelemetryFieldKey;
|
||||
|
||||
export const defaultSelectedColumns: TelemetryFieldKey[] = [
|
||||
{
|
||||
name: 'service.name',
|
||||
signal: 'traces',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
{
|
||||
name: 'duration_nano',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{
|
||||
name: 'http_method',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{
|
||||
name: 'response_status_code',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { formUrlParams } from 'container/TraceDetail/utils';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
export function BlockLink({
|
||||
children,
|
||||
to,
|
||||
openInNewTab,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to: string;
|
||||
openInNewTab: boolean;
|
||||
}): any {
|
||||
// Display block to make the whole cell clickable
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
style={{ display: 'block' }}
|
||||
target={openInNewTab ? '_blank' : '_self'}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export const transformDataWithDate = (
|
||||
data: QueryDataV3[],
|
||||
): Omit<ILog, 'timestamp'>[] =>
|
||||
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
|
||||
[];
|
||||
|
||||
export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
function readId(value: unknown): string {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
@@ -21,6 +60,95 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
})}`;
|
||||
};
|
||||
|
||||
export const getListColumns = (
|
||||
selectedColumns: TelemetryFieldKey[],
|
||||
formatTimezoneAdjustedTimestamp: (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string | number,
|
||||
): ColumnsType<RowData> => {
|
||||
const initialColumns: ColumnsType<RowData> = [
|
||||
{
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
title: 'Timestamp',
|
||||
width: 145,
|
||||
render: (value, item): JSX.Element => {
|
||||
const date =
|
||||
typeof value === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
value,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
value / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography.Text>{date}</Typography.Text>
|
||||
</BlockLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const columns: ColumnsType<RowData> =
|
||||
selectedColumns.map((props) => {
|
||||
const name = props?.name || (props as any)?.key;
|
||||
const fieldContext = props?.fieldContext || (props as any)?.type;
|
||||
return {
|
||||
title: name,
|
||||
dataIndex: name,
|
||||
key: buildCompositeKey(name, fieldContext),
|
||||
width: 145,
|
||||
render: (value, item): JSX.Element => {
|
||||
if (value === '') {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography data-testid={name}>N/A</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
name === 'httpMethod' ||
|
||||
name === 'responseStatusCode' ||
|
||||
name === 'response_status_code' ||
|
||||
name === 'http_method'
|
||||
) {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Badge data-testid={name} color="sakura" variant="outline">
|
||||
{value}
|
||||
</Badge>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'durationNano' || name === 'duration_nano') {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography data-testid={name}>{getMs(value)}ms</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography data-testid={name}>
|
||||
<LineClampedText text={value} lines={3} />
|
||||
</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
},
|
||||
responsive: ['md'],
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return [...initialColumns, ...columns];
|
||||
};
|
||||
|
||||
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
|
||||
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
|
||||
// positional ids; `timestamp` is lifted from the wrapping ListItem.
|
||||
|
||||
@@ -4,10 +4,8 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { DEFAULT_PANEL_TYPE } from '../constants';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const isRawQuery = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
|
||||
@@ -107,7 +107,7 @@ function TableView({
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="ai-traces-table"
|
||||
fileName="traces-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -126,7 +126,6 @@ function TimeSeriesViewContainer({
|
||||
dataSource={dataSource}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
exportFileName="ai-traces-timeseries"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -55,9 +55,6 @@ function TracesTable({
|
||||
const isDataAbsent =
|
||||
!isLoading && !isFetching && !isError && data.length === 0;
|
||||
|
||||
// Rows can land before the field keys, and mounting then renders a partial column set.
|
||||
const canMountTable = !isError && !isLoading && data.length !== 0;
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: TracesTableRow): void => {
|
||||
history.push(getRowHref(row));
|
||||
@@ -86,7 +83,7 @@ function TracesTable({
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
|
||||
)}
|
||||
|
||||
{canMountTable && (
|
||||
{!isError && data.length !== 0 && (
|
||||
<div className={styles.tableWrapper}>
|
||||
<TanStackTable<TracesTableRow>
|
||||
data={data}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { buildTraceViewColumns } from '../../TracesView/configs';
|
||||
import TracesTable from '../TracesTable';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
|
||||
|
||||
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
|
||||
|
||||
const COLUMNS = buildTraceViewColumns([
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'start_time' },
|
||||
]);
|
||||
|
||||
function RaceHarness(): JSX.Element {
|
||||
const [columnsReady, setColumnsReady] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={(): void => setColumnsReady(true)}>
|
||||
columns-ready
|
||||
</button>
|
||||
<TracesTable
|
||||
data={ROWS}
|
||||
columns={columnsReady ? COLUMNS : []}
|
||||
columnStorageKey={STORAGE_KEY}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={(): string => '/trace/abc'}
|
||||
isLoading={!columnsReady}
|
||||
isFetching={false}
|
||||
isError={false}
|
||||
error={null}
|
||||
isFilterApplied={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const persistedState = (): { hiddenColumnIds: string[] } | null => {
|
||||
const raw = localStorage.getItem(PERSISTED_KEY);
|
||||
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
|
||||
};
|
||||
|
||||
describe('TracesTable column-init race', () => {
|
||||
beforeEach(() => {
|
||||
useColumnStore.setState({ tables: {} });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('does not persist empty defaults when rows land before columns', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<RaceHarness />);
|
||||
|
||||
expect(screen.getByText(/pending_data_placeholder/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
expect(persistedState()).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'columns-ready' }));
|
||||
|
||||
await expect(screen.findByRole('table')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('trace_id')).toBeInTheDocument();
|
||||
expect(screen.queryByText('start_time')).not.toBeInTheDocument();
|
||||
expect(persistedState()?.hiddenColumnIds).toStrictEqual(['start_time']);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,6 @@
|
||||
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
|
||||
// camelCase and snake_case variants are listed because the API has shipped both.
|
||||
|
||||
// start/end/last_activity_time come from the per-trace query, unlike span timestamp.
|
||||
export const TIMESTAMP_FIELD_NAMES = new Set([
|
||||
'timestamp',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'last_activity_time',
|
||||
]);
|
||||
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
|
||||
|
||||
export const STATUS_FIELD_NAMES = new Set([
|
||||
'httpMethod',
|
||||
@@ -20,12 +13,6 @@ export const STATUS_FIELD_NAMES = new Set([
|
||||
'http.response.status_code',
|
||||
]);
|
||||
|
||||
// trace_/max_llm_duration_nano are trace-level durations the per-trace query computes.
|
||||
export const DURATION_FIELD_NAMES = new Set([
|
||||
'durationNano',
|
||||
'duration_nano',
|
||||
'trace_duration_nano',
|
||||
'max_llm_duration_nano',
|
||||
]);
|
||||
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
|
||||
|
||||
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);
|
||||
|
||||
@@ -67,7 +67,6 @@ function TracesView({
|
||||
onFieldsChange,
|
||||
requiredFields,
|
||||
isLoading: isColumnsLoading,
|
||||
canPersistColumns,
|
||||
} = useTraceViewColumns();
|
||||
|
||||
const {
|
||||
@@ -169,22 +168,9 @@ function TracesView({
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
// Without the full column set there is no pool to pick from, so the control is dropped.
|
||||
const fieldsSelectorConfig = useMemo(
|
||||
() =>
|
||||
canPersistColumns
|
||||
? { fieldsSelector: { value: selectedFields, onFieldsChange } }
|
||||
: null,
|
||||
[canPersistColumns, selectedFields, onFieldsChange],
|
||||
);
|
||||
|
||||
// Rendering the pool unfiltered would surface columns the defaults keep hidden.
|
||||
const tableColumns = useMemo(
|
||||
() =>
|
||||
canPersistColumns
|
||||
? columns
|
||||
: columns.filter((column) => column.defaultVisibility !== false),
|
||||
[canPersistColumns, columns],
|
||||
() => ({ fieldsSelector: { value: selectedFields, onFieldsChange } }),
|
||||
[selectedFields, onFieldsChange],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -221,12 +207,8 @@ function TracesView({
|
||||
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={
|
||||
canPersistColumns
|
||||
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
|
||||
: undefined
|
||||
}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import TracesView from '../TracesView';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
|
||||
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
|
||||
const FIELD_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`;
|
||||
|
||||
const OPTIONS_TRIGGER = 'options_menu.options';
|
||||
|
||||
const ROWS = [
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:58.735245Z',
|
||||
data: {
|
||||
'service.name': 'checkout',
|
||||
root_span_name: 'HTTP GET',
|
||||
trace_duration_nano: 55306000,
|
||||
span_count: 8,
|
||||
trace_id: '0000000000000000344ded1387b08a7e',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockRows = (): void => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: {
|
||||
type: 'trace',
|
||||
data: { results: [{ queryName: 'A', rows: ROWS }] },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const mockFieldKeys = (names: string[]): void => {
|
||||
server.use(
|
||||
rest.get(FIELD_KEYS_URL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
[
|
||||
{
|
||||
name,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
|
||||
},
|
||||
],
|
||||
]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const mockFieldKeysFailure = (): void => {
|
||||
server.use(
|
||||
rest.get(FIELD_KEYS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const persistedState = (): { hiddenColumnIds: string[] } | null => {
|
||||
const raw = localStorage.getItem(PERSISTED_KEY);
|
||||
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
|
||||
};
|
||||
|
||||
const renderTracesView = (): ReturnType<typeof render> =>
|
||||
render(
|
||||
<TracesView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
setIsLoadingQueries={jest.fn()}
|
||||
/>,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/llm-observability/traces',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
stagedQuery: initialQueryAIWithType,
|
||||
currentQuery: initialQueryAIWithType,
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
describe('TracesView column persistence', () => {
|
||||
beforeEach(() => {
|
||||
useColumnStore.setState({ tables: {} });
|
||||
localStorage.clear();
|
||||
mockRows();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// Rows are virtualised, so a mounted table stands in for "rows arrived".
|
||||
const findTable = (): Promise<HTMLElement> => screen.findByRole('table');
|
||||
|
||||
it('seeds the persisted defaults once the field keys arrive', async () => {
|
||||
mockFieldKeys(['llm_call_count', 'tool_call_count']);
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(persistedState()?.hiddenColumnIds).toStrictEqual([
|
||||
'start_time',
|
||||
'end_time',
|
||||
'error_count',
|
||||
'input',
|
||||
'output',
|
||||
'trace:tool_call_count:float64',
|
||||
]);
|
||||
});
|
||||
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
|
||||
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists nothing when the field keys fail', async () => {
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
expect(persistedState()).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the column picker when the field keys fail', async () => {
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders only the default-visible columns when the field keys fail', async () => {
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(screen.getByText('root_span_name')).toBeInTheDocument();
|
||||
expect(screen.getByText('trace_id')).toBeInTheDocument();
|
||||
expect(screen.queryByText('input')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('output')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves an existing selection untouched while the field keys fail', async () => {
|
||||
const existing = {
|
||||
hiddenColumnIds: ['trace:tool_call_count:float64', 'input', 'output'],
|
||||
columnOrder: ['trace_id', 'resource:service.name'],
|
||||
columnSizing: {},
|
||||
};
|
||||
localStorage.setItem(PERSISTED_KEY, JSON.stringify(existing));
|
||||
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(persistedState()).toStrictEqual(existing);
|
||||
});
|
||||
});
|
||||
@@ -149,62 +149,6 @@ describe('useTraceViewColumns', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('when the keys fetch fails', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
|
||||
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not persist defaults', async () => {
|
||||
await renderColumns();
|
||||
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
expect(
|
||||
localStorage.getItem(`@signoz/table-columns/${STORAGE_KEY}`),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('reports the column state as not persistable', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(result.current.canPersistColumns).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a selection change instead of persisting a partial set', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'trace_id' }]);
|
||||
});
|
||||
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('seeds the defaults once a later fetch succeeds', async () => {
|
||||
const { unmount } = await renderColumns();
|
||||
unmount();
|
||||
|
||||
mockAggregateKeys(AGGREGATE_KEYS);
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(result.current.canPersistColumns).toBe(true);
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'trace_id',
|
||||
'llm_call_count',
|
||||
'total_tokens',
|
||||
'estimated_total_cost',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the columns dropped from the selection', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
/** Always visible: it is the row's link to the trace. */
|
||||
export const TRACE_ID_COLUMN_ID = 'trace_id';
|
||||
|
||||
/** Everything else starts hidden; only applied at first init, since the store persists hidden ids. */
|
||||
/** Everything else starts hidden, including any aggregate the endpoint adds later. */
|
||||
const DEFAULT_VISIBLE_FIELDS = new Set([
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
|
||||
@@ -35,17 +35,11 @@ interface UseTraceViewColumns {
|
||||
onFieldsChange: (next: TelemetryFieldKey[]) => void;
|
||||
requiredFields: readonly string[];
|
||||
isLoading: boolean;
|
||||
/** False until the keys fetch lands; a partial set must not reach the persisted store. */
|
||||
canPersistColumns: boolean;
|
||||
}
|
||||
|
||||
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
|
||||
export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
const {
|
||||
data: fetchedFields = [],
|
||||
isFetched,
|
||||
isSuccess,
|
||||
} = useFieldKeysSuggestion(
|
||||
const { data: fetchedFields = [], isFetched } = useFieldKeysSuggestion(
|
||||
{
|
||||
...TRACE_VIEW_FIELD_KEYS,
|
||||
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
|
||||
@@ -66,10 +60,10 @@ export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
|
||||
// Defaults from a partial column set would persist as the user's own choice.
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
if (isFetched) {
|
||||
initializeFromDefaults(STORAGE_KEY, columns);
|
||||
}
|
||||
}, [isSuccess, columns]);
|
||||
}, [isFetched, columns]);
|
||||
|
||||
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
|
||||
const columnOrder = useColumnOrder(STORAGE_KEY);
|
||||
@@ -89,10 +83,6 @@ export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
|
||||
const onFieldsChange = useCallback(
|
||||
(next: TelemetryFieldKey[]): void => {
|
||||
if (!isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keptIds = new Set(next.map(columnIdOf));
|
||||
|
||||
columns.forEach((column) => {
|
||||
@@ -106,7 +96,7 @@ export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
// Columns missing from the order sort last, so the visible ones suffice.
|
||||
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
|
||||
},
|
||||
[columns, isSuccess],
|
||||
[columns],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -115,6 +105,5 @@ export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
onFieldsChange,
|
||||
requiredFields: [TRACE_ID_COLUMN_ID],
|
||||
isLoading: !isFetched,
|
||||
canPersistColumns: isSuccess,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
|
||||
|
||||
export const TOOLBAR_VIEWS = {
|
||||
trace: {
|
||||
name: 'trace',
|
||||
label: 'Trace',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'trace',
|
||||
},
|
||||
list: {
|
||||
name: 'list',
|
||||
label: 'List',
|
||||
@@ -25,6 +15,13 @@ export const TOOLBAR_VIEWS = {
|
||||
show: true,
|
||||
key: 'timeseries',
|
||||
},
|
||||
trace: {
|
||||
name: 'trace',
|
||||
label: 'Trace',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'trace',
|
||||
},
|
||||
table: {
|
||||
name: 'table',
|
||||
label: 'Table',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { cloneDeep, set } from 'lodash-es';
|
||||
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const getListViewQuery = (
|
||||
@@ -30,3 +31,31 @@ export const getListViewQuery = (
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const getQueryByPanelType = (
|
||||
stagedQuery: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
): Query => {
|
||||
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
|
||||
return getListViewQuery(stagedQuery);
|
||||
}
|
||||
return stagedQuery;
|
||||
};
|
||||
|
||||
export const getExportQueryData = (
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
options: OptionsQuery,
|
||||
): Query => {
|
||||
if (panelType === PANEL_TYPES.LIST) {
|
||||
const updatedQuery = cloneDeep(query);
|
||||
set(
|
||||
updatedQuery,
|
||||
'builder.queryData[0].selectColumns',
|
||||
options.selectColumns,
|
||||
);
|
||||
|
||||
return updatedQuery;
|
||||
}
|
||||
return query;
|
||||
};
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
import {
|
||||
ArrowUpToLine,
|
||||
Atom,
|
||||
Filter,
|
||||
SquareMousePointer,
|
||||
Terminal,
|
||||
Binoculars,
|
||||
} from '@signozhq/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
|
||||
import { TOOLBAR_VIEW_CONFIG } from './toolbarViewsConfig';
|
||||
|
||||
import './ToolbarActions.styles.scss';
|
||||
|
||||
interface ToolbarViewItem {
|
||||
name: string;
|
||||
key: string;
|
||||
show?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface LeftToolbarActionsProps {
|
||||
items: Record<string, ToolbarViewItem>;
|
||||
items: any;
|
||||
selectedView: string;
|
||||
onChangeSelectedView: (view: ExplorerViews) => void;
|
||||
showFilter: boolean;
|
||||
@@ -31,6 +29,8 @@ export default function LeftToolbarActions({
|
||||
showFilter,
|
||||
handleFilterVisibilityChange,
|
||||
}: LeftToolbarActionsProps): JSX.Element {
|
||||
const { clickhouse, list, timeseries, table, trace } = items;
|
||||
|
||||
return (
|
||||
<div className="left-toolbar">
|
||||
{!showFilter && (
|
||||
@@ -41,34 +41,91 @@ export default function LeftToolbarActions({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Buttons render in the order the caller declares its views. */}
|
||||
<div className="left-toolbar-query-actions">
|
||||
{Object.values(items).map((item) => {
|
||||
const config = TOOLBAR_VIEW_CONFIG[item?.key];
|
||||
{list?.show && (
|
||||
<Tooltip title="List View">
|
||||
<Button
|
||||
disabled={list.disabled}
|
||||
className={cx(
|
||||
'list-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === list.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(list.key)}
|
||||
>
|
||||
<SquareMousePointer size={14} data-testid="search-view" />
|
||||
List View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
if (!item?.show || !config) {
|
||||
return null;
|
||||
}
|
||||
{trace?.show && (
|
||||
<Tooltip title="Trace View">
|
||||
<Button
|
||||
disabled={trace.disabled}
|
||||
className={cx(
|
||||
'trace-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === trace.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(trace.key)}
|
||||
>
|
||||
<SquareMousePointer size={14} data-testid="trace-view" />
|
||||
Trace View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
const { icon: Icon, label, className, testId } = config;
|
||||
{timeseries?.show && (
|
||||
<Tooltip title="Time Series">
|
||||
<Button
|
||||
disabled={timeseries.disabled}
|
||||
className={cx(
|
||||
'timeseries-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === timeseries.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(timeseries.key)}
|
||||
>
|
||||
<Atom size={14} data-testid="query-builder-view" />
|
||||
Time Series
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
return (
|
||||
<Tooltip key={item.key} title={label}>
|
||||
<Button
|
||||
disabled={item.disabled}
|
||||
className={cx(
|
||||
className,
|
||||
'explorer-view-option',
|
||||
selectedView === item.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(item.key as ExplorerViews)}
|
||||
>
|
||||
<Icon size={14} data-testid={testId} />
|
||||
{label}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{clickhouse?.show && (
|
||||
<Tooltip title="Clickhouse">
|
||||
<Button
|
||||
disabled={clickhouse.disabled}
|
||||
className={cx(
|
||||
'clickhouse-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === clickhouse.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(clickhouse.key)}
|
||||
>
|
||||
<Terminal size={14} data-testid="clickhouse-view" />
|
||||
Clickhouse
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{table?.show && (
|
||||
<Tooltip title="Table">
|
||||
<Button
|
||||
disabled={table.disabled}
|
||||
className={cx(
|
||||
'table-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === table.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(table.key)}
|
||||
>
|
||||
<Binoculars size={14} data-testid="query-builder-view-v2" />
|
||||
Table
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import {
|
||||
Atom,
|
||||
Binoculars,
|
||||
SquareMousePointer,
|
||||
Terminal,
|
||||
} from '@signozhq/icons';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
|
||||
export interface ToolbarViewConfig {
|
||||
icon: typeof Atom;
|
||||
label: string;
|
||||
className: string;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
export const TOOLBAR_VIEW_CONFIG: Record<string, ToolbarViewConfig> = {
|
||||
[ExplorerViews.LIST]: {
|
||||
icon: SquareMousePointer,
|
||||
label: 'List View',
|
||||
className: 'list-view-tab',
|
||||
testId: 'search-view',
|
||||
},
|
||||
[ExplorerViews.TRACE]: {
|
||||
icon: SquareMousePointer,
|
||||
label: 'Trace View',
|
||||
className: 'trace-view-tab',
|
||||
testId: 'trace-view',
|
||||
},
|
||||
[ExplorerViews.TIMESERIES]: {
|
||||
icon: Atom,
|
||||
label: 'Time Series',
|
||||
className: 'timeseries-view-tab',
|
||||
testId: 'query-builder-view',
|
||||
},
|
||||
[ExplorerViews.CLICKHOUSE]: {
|
||||
icon: Terminal,
|
||||
label: 'Clickhouse',
|
||||
className: 'clickhouse-view-tab',
|
||||
testId: 'clickhouse-view',
|
||||
},
|
||||
[ExplorerViews.TABLE]: {
|
||||
icon: Binoculars,
|
||||
label: 'Table',
|
||||
className: 'table-view-tab',
|
||||
testId: 'query-builder-view-v2',
|
||||
},
|
||||
};
|
||||
@@ -64,7 +64,6 @@ function TimeSeriesView({
|
||||
panelType = PANEL_TYPES.TIME_SERIES,
|
||||
stackBarChart = false,
|
||||
allowExport = false,
|
||||
exportFileName,
|
||||
onYAxisUnitChange,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
@@ -271,7 +270,7 @@ function TimeSeriesView({
|
||||
yAxisUnit={yAxisUnit}
|
||||
data={data}
|
||||
query={currentQuery}
|
||||
fileName={exportFileName ?? `${dataSource}-timeseries`}
|
||||
fileName={`${dataSource}-timeseries`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -340,7 +339,6 @@ interface TimeSeriesViewProps {
|
||||
stackBarChart?: boolean;
|
||||
// Opt-in: render the client-side export menu (Logs explorer for now).
|
||||
allowExport?: boolean;
|
||||
exportFileName?: string;
|
||||
// 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;
|
||||
@@ -353,7 +351,6 @@ TimeSeriesView.defaultProps = {
|
||||
setWarning: undefined,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
stackBarChart: false,
|
||||
exportFileName: undefined,
|
||||
};
|
||||
|
||||
export default TimeSeriesView;
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import {
|
||||
QueryKey,
|
||||
useQuery,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
TelemetrytypesTelemetryFieldValuesDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getFieldValueSuggestions } from 'api/querySuggestions/getFieldValueSuggestions';
|
||||
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
FieldValuesConfig,
|
||||
FieldValuesResponse,
|
||||
} from 'api/querySuggestions/types';
|
||||
import { BuilderQueryType } from 'types/api/v5/queryRange';
|
||||
|
||||
export type FieldValuesQueryOptions = UseQueryOptions<
|
||||
FieldValuesResponse,
|
||||
ErrorType<RenderErrorResponseDTO>,
|
||||
TelemetrytypesTelemetryFieldValuesDTO
|
||||
> & { queryKey: QueryKey };
|
||||
|
||||
const EMPTY_FIELD_VALUES: TelemetrytypesTelemetryFieldValuesDTO = {};
|
||||
|
||||
export const toFieldValues = (
|
||||
res: FieldValuesResponse | undefined,
|
||||
): TelemetrytypesTelemetryFieldValuesDTO =>
|
||||
res?.data?.values ?? EMPTY_FIELD_VALUES;
|
||||
|
||||
export const getFieldValuesQueryOptions = (
|
||||
fieldValuesConfig: FieldValuesConfig,
|
||||
builderQueryType?: BuilderQueryType,
|
||||
): FieldValuesQueryOptions => ({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.FIELD_VALUES_SUGGESTION,
|
||||
builderQueryType,
|
||||
fieldValuesConfig,
|
||||
],
|
||||
queryFn: ({ signal }): Promise<FieldValuesResponse> =>
|
||||
getFieldValueSuggestions(fieldValuesConfig, builderQueryType, signal),
|
||||
select: toFieldValues,
|
||||
cacheTime: FIELD_API_CACHE_TIME,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
export const useFieldValuesSuggestion = (
|
||||
fieldValuesConfig: FieldValuesConfig,
|
||||
builderQueryType?: BuilderQueryType,
|
||||
options?: Pick<FieldValuesQueryOptions, 'enabled'>,
|
||||
): UseQueryResult<
|
||||
TelemetrytypesTelemetryFieldValuesDTO,
|
||||
ErrorType<RenderErrorResponseDTO>
|
||||
> =>
|
||||
useQuery({
|
||||
...getFieldValuesQueryOptions(fieldValuesConfig, builderQueryType),
|
||||
...options,
|
||||
});
|
||||
@@ -7,10 +7,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
@@ -24,10 +26,11 @@ const unmappedModelsLookback = time.Hour
|
||||
type module struct {
|
||||
store llmpricingruletypes.Store
|
||||
querier querier.Querier
|
||||
flagger flagger.Flagger
|
||||
}
|
||||
|
||||
func NewModule(store llmpricingruletypes.Store, querier querier.Querier) llmpricingrule.Module {
|
||||
return &module{store: store, querier: querier}
|
||||
func NewModule(store llmpricingruletypes.Store, flagger flagger.Flagger, querier querier.Querier) llmpricingrule.Module {
|
||||
return &module{store: store, flagger: flagger, querier: querier}
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
|
||||
@@ -120,6 +123,16 @@ func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
|
||||
func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []byte, configVersion *opamptypes.AgentConfigVersion) ([]byte, string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Skip the llm pricing processor unless AI observability is enabled for the org.
|
||||
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
|
||||
enabled, err := module.flagger.Boolean(ctx, flagger.FeatureEnableAIObservability, evalCtx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !enabled {
|
||||
return currentConfYaml, "", nil
|
||||
}
|
||||
|
||||
rules, err := module.getEnabledRules(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
||||
@@ -5,19 +5,22 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store spantypes.SpanMapperStore
|
||||
store spantypes.SpanMapperStore
|
||||
flagger flagger.Flagger
|
||||
}
|
||||
|
||||
func NewModule(store spantypes.SpanMapperStore) spanmapper.Module {
|
||||
return &module{store: store}
|
||||
func NewModule(store spantypes.SpanMapperStore, flagger flagger.Flagger) spanmapper.Module {
|
||||
return &module{store: store, flagger: flagger}
|
||||
}
|
||||
|
||||
func (module *module) ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error) {
|
||||
@@ -165,6 +168,16 @@ func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
|
||||
func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []byte, configVersion *opamptypes.AgentConfigVersion) ([]byte, string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Skip the llm pricing processor unless AI observability is enabled for the org.
|
||||
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
|
||||
enabled, err := module.flagger.Boolean(ctx, flagger.FeatureEnableAIObservability, evalCtx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !enabled {
|
||||
return currentConfYaml, "", nil
|
||||
}
|
||||
|
||||
enabledMappers, err := module.listEnabledGroupsWithMappers(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
@@ -175,7 +188,7 @@ func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
serialized, err := json.Marshal(enabledMappers)
|
||||
serialized, err := json.Marshal(enabled)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -162,8 +162,8 @@ func NewModules(
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore)),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
|
||||
Tag: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,27 +45,11 @@ func TestPostableChannelValidate(t *testing.T) {
|
||||
postable PostableNotificationChannel
|
||||
}{
|
||||
{
|
||||
description: "webhook basic auth combined with bearer token",
|
||||
description: "webhook password without username",
|
||||
postable: PostableNotificationChannel{
|
||||
Name: "hook",
|
||||
DisplayName: "hook",
|
||||
Config: ChannelConfig{Kind: ChannelKindWebhook, Spec: &ChannelWebhookConfig{URL: "https://a", Password: "p", BearerToken: "t"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "slack field without a value",
|
||||
postable: PostableNotificationChannel{
|
||||
Name: "slack",
|
||||
DisplayName: "slack",
|
||||
Config: ChannelConfig{Kind: ChannelKindSlack, Spec: &ChannelSlackConfig{APIURL: "https://a", Fields: []ChannelSlackField{{Title: "Severity"}}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "slack action with neither url nor name",
|
||||
postable: PostableNotificationChannel{
|
||||
Name: "slack",
|
||||
DisplayName: "slack",
|
||||
Config: ChannelConfig{Kind: ChannelKindSlack, Spec: &ChannelSlackConfig{APIURL: "https://a", Actions: []ChannelSlackAction{{Type: "button", Text: "Open"}}}},
|
||||
Config: ChannelConfig{Kind: ChannelKindWebhook, Spec: &ChannelWebhookConfig{URL: "https://a", Password: "p"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -211,38 +211,6 @@ type ChannelSlackConfig struct {
|
||||
Channel string `json:"channel"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text"`
|
||||
Color valuer.UnsetOrNonEmptyString `json:"color"`
|
||||
TitleLink valuer.UnsetOrNonEmptyString `json:"titleLink"`
|
||||
Pretext valuer.UnsetOrNonEmptyString `json:"pretext"`
|
||||
Fallback valuer.UnsetOrNonEmptyString `json:"fallback"`
|
||||
Footer valuer.UnsetOrNonEmptyString `json:"footer"`
|
||||
Fields []ChannelSlackField `json:"fields,omitempty"`
|
||||
Actions []ChannelSlackAction `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelSlackField struct {
|
||||
Title string `json:"title" required:"true"`
|
||||
Value string `json:"value" required:"true"`
|
||||
Short *bool `json:"short,omitempty"`
|
||||
}
|
||||
|
||||
// ChannelSlackAction is a link button when URL is set, otherwise a message
|
||||
// button that needs Name. Upstream clears whichever side is not in use.
|
||||
type ChannelSlackAction struct {
|
||||
Type string `json:"type" required:"true"`
|
||||
Text string `json:"text" required:"true"`
|
||||
URL string `json:"url"`
|
||||
Style string `json:"style"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Confirm *ChannelSlackConfirmation `json:"confirm,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelSlackConfirmation struct {
|
||||
Text string `json:"text" required:"true"`
|
||||
Title string `json:"title"`
|
||||
OkText string `json:"okText"`
|
||||
DismissText string `json:"dismissText"`
|
||||
}
|
||||
|
||||
func (c ChannelSlackConfig) Validate() error {
|
||||
@@ -250,24 +218,6 @@ func (c ChannelSlackConfig) Validate() error {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiUrl is required for a slack channel")
|
||||
}
|
||||
|
||||
for i, field := range c.Fields {
|
||||
if field.Title == "" || field.Value == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.fields[%d] requires title and value", i)
|
||||
}
|
||||
}
|
||||
|
||||
for i, action := range c.Actions {
|
||||
if action.Type == "" || action.Text == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d] requires type and text", i)
|
||||
}
|
||||
if action.URL == "" && action.Name == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d] requires url or name", i)
|
||||
}
|
||||
if action.Confirm != nil && action.Confirm.Text == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d].confirm requires text", i)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -285,13 +235,6 @@ func (c ChannelSlackConfig) toUndefaultedReceiver(displayName string) (*Receiver
|
||||
Channel: c.Channel,
|
||||
Title: c.Title.StringValue(),
|
||||
Text: c.Text.StringValue(),
|
||||
Color: c.Color.StringValue(),
|
||||
TitleLink: c.TitleLink.StringValue(),
|
||||
Pretext: c.Pretext.StringValue(),
|
||||
Fallback: c.Fallback.StringValue(),
|
||||
Footer: c.Footer.StringValue(),
|
||||
Fields: newUpstreamSlackFields(c.Fields),
|
||||
Actions: newUpstreamSlackActions(c.Actions),
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
@@ -310,76 +253,9 @@ func newChannelSlackConfigFromReceiver(name string, receiver *Receiver) (Channel
|
||||
Channel: slack.Channel,
|
||||
Title: valuer.UnsetIfEmpty(slack.Title),
|
||||
Text: valuer.UnsetIfEmpty(slack.Text),
|
||||
Color: valuer.UnsetIfEmpty(slack.Color),
|
||||
TitleLink: valuer.UnsetIfEmpty(slack.TitleLink),
|
||||
Pretext: valuer.UnsetIfEmpty(slack.Pretext),
|
||||
Fallback: valuer.UnsetIfEmpty(slack.Fallback),
|
||||
Footer: valuer.UnsetIfEmpty(slack.Footer),
|
||||
Fields: newChannelSlackFields(slack.Fields),
|
||||
Actions: newChannelSlackActions(slack.Actions),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newUpstreamSlackFields(fields []ChannelSlackField) []*config.SlackField {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
upstream := make([]*config.SlackField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
upstream = append(upstream, &config.SlackField{Title: field.Title, Value: field.Value, Short: field.Short})
|
||||
}
|
||||
|
||||
return upstream
|
||||
}
|
||||
|
||||
func newChannelSlackFields(upstream []*config.SlackField) []ChannelSlackField {
|
||||
if len(upstream) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := make([]ChannelSlackField, 0, len(upstream))
|
||||
for _, field := range upstream {
|
||||
fields = append(fields, ChannelSlackField{Title: field.Title, Value: field.Value, Short: field.Short})
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func newUpstreamSlackActions(actions []ChannelSlackAction) []*config.SlackAction {
|
||||
if len(actions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
upstream := make([]*config.SlackAction, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
upstreamAction := &config.SlackAction{Type: action.Type, Text: action.Text, URL: action.URL, Style: action.Style, Name: action.Name, Value: action.Value}
|
||||
if action.Confirm != nil {
|
||||
upstreamAction.ConfirmField = &config.SlackConfirmationField{Text: action.Confirm.Text, Title: action.Confirm.Title, OkText: action.Confirm.OkText, DismissText: action.Confirm.DismissText}
|
||||
}
|
||||
upstream = append(upstream, upstreamAction)
|
||||
}
|
||||
|
||||
return upstream
|
||||
}
|
||||
|
||||
func newChannelSlackActions(upstream []*config.SlackAction) []ChannelSlackAction {
|
||||
if len(upstream) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
actions := make([]ChannelSlackAction, 0, len(upstream))
|
||||
for _, upstreamAction := range upstream {
|
||||
action := ChannelSlackAction{Type: upstreamAction.Type, Text: upstreamAction.Text, URL: upstreamAction.URL, Style: upstreamAction.Style, Name: upstreamAction.Name, Value: upstreamAction.Value}
|
||||
if upstreamAction.ConfirmField != nil {
|
||||
action.Confirm = &ChannelSlackConfirmation{Text: upstreamAction.ConfirmField.Text, Title: upstreamAction.ConfirmField.Title, OkText: upstreamAction.ConfirmField.OkText, DismissText: upstreamAction.ConfirmField.DismissText}
|
||||
}
|
||||
actions = append(actions, action)
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
// ChannelEmailConfig carries no SMTP transport fields: the smarthost,
|
||||
// credentials and TLS settings come from the deployment's global config, so a
|
||||
// channel can only choose recipients and body.
|
||||
@@ -433,8 +309,7 @@ func newChannelEmailConfigFromReceiver(_ string, receiver *Receiver) (ChannelSpe
|
||||
|
||||
// ChannelWebhookConfig splits apart the two authentication modes the legacy API
|
||||
// overloaded onto one password field, where an empty username meant the password
|
||||
// was really a bearer token. Username or Password may be set without the other,
|
||||
// as upstream allows, but not together with BearerToken.
|
||||
// was really a bearer token.
|
||||
type ChannelWebhookConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
URL string `json:"url" required:"true" format:"password"`
|
||||
@@ -454,6 +329,10 @@ func (c ChannelWebhookConfig) Validate() error {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.bearerToken cannot be combined with config.spec.username or config.spec.password")
|
||||
}
|
||||
|
||||
if usesBasicAuth && (c.Username == "" || c.Password == "") {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.username and config.spec.password must both be set for basic auth")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -467,7 +346,7 @@ func (c ChannelWebhookConfig) toUndefaultedReceiver(displayName string) (*Receiv
|
||||
// and EnableHTTP2 marshal unconditionally, so a zero value would persist
|
||||
// them as false and read back as a config ChannelWebhookConfig cannot represent.
|
||||
switch {
|
||||
case c.Username != "" || c.Password != "":
|
||||
case c.Username != "":
|
||||
httpConfig := commoncfg.DefaultHTTPClientConfig
|
||||
httpConfig.BasicAuth = &commoncfg.BasicAuth{
|
||||
Username: c.Username,
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
// mutually exclusive and so cannot all be set at once.
|
||||
func TestChannelToPostableChannelRoundTripsEveryFieldOfEveryKind(t *testing.T) {
|
||||
sendResolved := true
|
||||
short := true
|
||||
|
||||
testCases := []struct {
|
||||
description string
|
||||
@@ -38,16 +37,6 @@ func TestChannelToPostableChannelRoundTripsEveryFieldOfEveryKind(t *testing.T) {
|
||||
Channel: "#alerts",
|
||||
Title: valuer.MustNewUnsetOrNonEmptyString("slack title"),
|
||||
Text: valuer.MustNewUnsetOrNonEmptyString("slack text"),
|
||||
Color: valuer.MustNewUnsetOrNonEmptyString("#439FE0"),
|
||||
TitleLink: valuer.MustNewUnsetOrNonEmptyString("{{ .CommonLabels.ruleSource }}"),
|
||||
Pretext: valuer.MustNewUnsetOrNonEmptyString("slack pretext"),
|
||||
Fallback: valuer.MustNewUnsetOrNonEmptyString("slack fallback"),
|
||||
Footer: valuer.MustNewUnsetOrNonEmptyString("slack footer"),
|
||||
Fields: []ChannelSlackField{{Title: "Severity", Value: "{{ .CommonLabels.severity }}", Short: &short}},
|
||||
Actions: []ChannelSlackAction{
|
||||
{Type: "button", Text: "Open in SigNoz", URL: "{{ .CommonLabels.ruleSource }}", Style: "primary"},
|
||||
{Type: "button", Text: "Acknowledge", Name: "ack", Value: "ack", Confirm: &ChannelSlackConfirmation{Text: "Acknowledge this alert?", Title: "Confirm", OkText: "Yes", DismissText: "No"}},
|
||||
},
|
||||
},
|
||||
expectedRoundTrip: &ChannelSlackConfig{
|
||||
SendResolved: &sendResolved,
|
||||
@@ -55,16 +44,6 @@ func TestChannelToPostableChannelRoundTripsEveryFieldOfEveryKind(t *testing.T) {
|
||||
Channel: "#alerts",
|
||||
Title: valuer.MustNewUnsetOrNonEmptyString("slack title"),
|
||||
Text: valuer.MustNewUnsetOrNonEmptyString("slack text"),
|
||||
Color: valuer.MustNewUnsetOrNonEmptyString("#439FE0"),
|
||||
TitleLink: valuer.MustNewUnsetOrNonEmptyString("{{ .CommonLabels.ruleSource }}"),
|
||||
Pretext: valuer.MustNewUnsetOrNonEmptyString("slack pretext"),
|
||||
Fallback: valuer.MustNewUnsetOrNonEmptyString("slack fallback"),
|
||||
Footer: valuer.MustNewUnsetOrNonEmptyString("slack footer"),
|
||||
Fields: []ChannelSlackField{{Title: "Severity", Value: "{{ .CommonLabels.severity }}", Short: &short}},
|
||||
Actions: []ChannelSlackAction{
|
||||
{Type: "button", Text: "Open in SigNoz", URL: "{{ .CommonLabels.ruleSource }}", Style: "primary"},
|
||||
{Type: "button", Text: "Acknowledge", Name: "ack", Value: "ack", Confirm: &ChannelSlackConfirmation{Text: "Acknowledge this alert?", Title: "Confirm", OkText: "Yes", DismissText: "No"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -524,6 +525,149 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeatmapPanelQueryKinds pins the panel allowlist to what validateHeatmap
|
||||
// accepts in querybuildertypesv5: everything but a trace operator.
|
||||
func TestHeatmapPanelQueryKinds(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
queryPluginKind string
|
||||
queryPluginSpec string
|
||||
expectedAllowed bool
|
||||
}{
|
||||
{
|
||||
description: "a metrics builder query is allowed",
|
||||
queryPluginKind: "signoz/BuilderQuery",
|
||||
queryPluginSpec: `{"name": "A", "signal": "metrics", "aggregations": [
|
||||
{"metricName": "http.server.request.duration", "timeAggregation": "increase", "spaceAggregation": "sum"}
|
||||
]}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
description: "a promql query is allowed",
|
||||
queryPluginKind: "signoz/PromQLQuery",
|
||||
queryPluginSpec: `{"name": "A", "query": "sum by (le) (increase(signoz_latency_bucket[5m]))"}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
description: "a clickhouse query is allowed",
|
||||
queryPluginKind: "signoz/ClickHouseSQL",
|
||||
queryPluginSpec: `{"name": "A", "query": "SELECT ts, bucket, value FROM cells"}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
description: "a formula is allowed",
|
||||
queryPluginKind: "signoz/Formula",
|
||||
queryPluginSpec: `{"name": "F1", "expression": "A / B"}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
description: "a composite query is allowed, since a formula needs its disabled inputs alongside it",
|
||||
queryPluginKind: "signoz/CompositeQuery",
|
||||
queryPluginSpec: `{"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "metrics", "disabled": true, "aggregations": [
|
||||
{"metricName": "http.server.request.duration", "timeAggregation": "increase", "spaceAggregation": "sum"}
|
||||
]}},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A * 2"}}
|
||||
]}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
description: "a trace operator is refused",
|
||||
queryPluginKind: "signoz/TraceOperator",
|
||||
queryPluginSpec: `{"name": "T1", "expression": "A => B"}`,
|
||||
expectedAllowed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
data := fmt.Sprintf(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/HeatmapPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "heatmap",
|
||||
"spec": {
|
||||
"plugin": {"kind": %q, "spec": %s}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`, testCase.queryPluginKind, testCase.queryPluginSpec)
|
||||
|
||||
_, err := unmarshalDashboard([]byte(data))
|
||||
|
||||
if testCase.expectedAllowed {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "is not supported by panel kind")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateHeatmapDashboard(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/perses_heatmap_panel.json")
|
||||
require.NoError(t, err, "reading example file")
|
||||
|
||||
spec, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &HeatmapPanelSpec{}, spec.Panels["p1"].Spec.Plugin.Spec)
|
||||
panelSpec := spec.Panels["p1"].Spec.Plugin.Spec.(*HeatmapPanelSpec)
|
||||
assert.Equal(t, "log", panelSpec.Axes.YScale.ValueOrDefault())
|
||||
assert.Equal(t, "ember", panelSpec.ChartAppearance.Colors.Palette.ValueOrDefault())
|
||||
assert.Equal(t, "sqrt", panelSpec.ChartAppearance.Colors.Scale.ValueOrDefault())
|
||||
assert.Equal(t, 8, panelSpec.ChartAppearance.Colors.Steps)
|
||||
|
||||
dashboard := &DashboardV2{Spec: *spec}
|
||||
request, err := dashboard.GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the panel's query failed")
|
||||
assert.Equal(t, qb.RequestTypeHeatmap, request.RequestType)
|
||||
|
||||
require.Len(t, request.CompositeQuery.Queries, 3)
|
||||
numerator, ok := request.CompositeQuery.Queries[0].Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
|
||||
require.True(t, ok, "expected a metrics builder query")
|
||||
assert.True(t, numerator.Disabled)
|
||||
require.NotNil(t, numerator.BucketOptions)
|
||||
require.IsType(t, qb.LogBucketsSpec{}, numerator.BucketOptions.Spec)
|
||||
assert.Equal(t, 4, *numerator.BucketOptions.Spec.(qb.LogBucketsSpec).Scale)
|
||||
|
||||
denominator, ok := request.CompositeQuery.Queries[1].Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
|
||||
require.True(t, ok, "expected a metrics builder query")
|
||||
assert.True(t, denominator.Disabled)
|
||||
require.NotNil(t, denominator.BucketOptions)
|
||||
require.IsType(t, qb.LinearBucketsSpec{}, denominator.BucketOptions.Spec)
|
||||
assert.Equal(t, float64(1000), denominator.BucketOptions.Spec.(qb.LinearBucketsSpec).MaxValue)
|
||||
|
||||
formula, ok := request.CompositeQuery.Queries[2].Spec.(qb.QueryBuilderFormula)
|
||||
require.True(t, ok, "expected a formula")
|
||||
require.NotNil(t, formula.BucketOptions)
|
||||
assert.Equal(t, qb.BucketsKindLog, formula.BucketOptions.Kind)
|
||||
require.IsType(t, qb.LogBucketsSpec{}, formula.BucketOptions.Spec)
|
||||
assert.Equal(t, 2, *formula.BucketOptions.Spec.(qb.LogBucketsSpec).Scale)
|
||||
|
||||
require.NoError(t, request.Validate(), "the request built from the panel is not a valid heatmap request")
|
||||
|
||||
// the panel read back out of storage draws the same heatmap
|
||||
stored, err := json.Marshal(spec)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
reread, err := unmarshalDashboard(stored)
|
||||
require.NoError(t, err, "the stored dashboard does not validate")
|
||||
rereadRequest, err := (&DashboardV2{Spec: *reread}).GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the stored panel's query failed")
|
||||
assert.Equal(t, request, rereadRequest)
|
||||
}
|
||||
|
||||
func TestInvalidateOneInvalidPanel(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
|
||||
@@ -36,6 +36,7 @@ func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,6 +68,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[HistogramPanelSpec]{Kind: string(PanelKindHistogram)},
|
||||
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +233,7 @@ var (
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -254,6 +257,7 @@ var (
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -174,10 +174,11 @@ const (
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
@@ -242,6 +243,56 @@ type ListPanelSpec struct {
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero" validate:"dive"`
|
||||
}
|
||||
|
||||
type HeatmapPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
Axes HeatmapAxes `json:"axes"`
|
||||
Legend Legend `json:"legend"`
|
||||
ChartAppearance HeatmapChartAppearance `json:"chartAppearance"`
|
||||
}
|
||||
|
||||
// HeatmapAxes carries only the Y scale. The shared Axes type models a value
|
||||
// axis with soft bounds, where a heatmap's Y axis is the bucket boundaries the
|
||||
// response already fixed.
|
||||
type HeatmapAxes struct {
|
||||
YScale HeatmapYScale `json:"yScale"`
|
||||
}
|
||||
|
||||
type HeatmapChartAppearance struct {
|
||||
Colors HeatmapColors `json:"colors"`
|
||||
}
|
||||
|
||||
type HeatmapColors struct {
|
||||
Mode HeatmapColorMode `json:"mode"`
|
||||
Palette HeatmapPalette `json:"palette"`
|
||||
Scale HeatmapColorScale `json:"scale"`
|
||||
Steps int `json:"steps" validate:"omitempty,min=2,max=128"`
|
||||
// MinCount and MaxCount clamp the colour scale; nil derives them from the
|
||||
// grid, 0 and the highest count in it.
|
||||
MinCount *float64 `json:"minCount"`
|
||||
MaxCount *float64 `json:"maxCount"`
|
||||
// Fill applies in opacity mode; empty means the selected group's legend colour.
|
||||
Fill string `json:"fill"`
|
||||
}
|
||||
|
||||
func (c *HeatmapColors) UnmarshalJSON(data []byte) error {
|
||||
type alias HeatmapColors
|
||||
var tmp alias
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid heatmap colors")
|
||||
}
|
||||
*c = HeatmapColors(tmp)
|
||||
return c.validate()
|
||||
}
|
||||
|
||||
func (c HeatmapColors) validate() error {
|
||||
if c.MinCount != nil && c.MaxCount != nil && *c.MinCount > *c.MaxCount {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
|
||||
"heatmap colors.minCount %v is greater than colors.maxCount %v", *c.MinCount, *c.MaxCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TextPanelSpec struct {
|
||||
Mode TextMode `json:"mode"`
|
||||
Text string `json:"text"`
|
||||
@@ -846,3 +897,168 @@ func (p *PrecisionOption) UnmarshalJSON(data []byte) error {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid precision option %q: must be `0`, `1`, `2`, `3`, `4`, or `full`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type HeatmapColorMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
HeatmapColorModePalette = HeatmapColorMode{valuer.NewString("palette")} // default
|
||||
HeatmapColorModeOpacity = HeatmapColorMode{valuer.NewString("opacity")}
|
||||
)
|
||||
|
||||
func (HeatmapColorMode) Enum() []any {
|
||||
return []any{HeatmapColorModePalette, HeatmapColorModeOpacity}
|
||||
}
|
||||
|
||||
func (m HeatmapColorMode) ValueOrDefault() string {
|
||||
if m.IsZero() {
|
||||
return HeatmapColorModePalette.StringValue()
|
||||
}
|
||||
return m.StringValue()
|
||||
}
|
||||
|
||||
func (m HeatmapColorMode) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(m.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (m *HeatmapColorMode) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid heatmap color mode: must be a string, one of `palette` or `opacity`")
|
||||
}
|
||||
mode := HeatmapColorMode{valuer.NewString(v)}
|
||||
switch mode {
|
||||
case HeatmapColorModePalette, HeatmapColorModeOpacity:
|
||||
*m = mode
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid heatmap color mode %q: must be `palette` or `opacity`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type HeatmapPalette struct{ valuer.String }
|
||||
|
||||
var (
|
||||
HeatmapPaletteIce = HeatmapPalette{valuer.NewString("ice")}
|
||||
HeatmapPaletteMoss = HeatmapPalette{valuer.NewString("moss")}
|
||||
HeatmapPaletteRust = HeatmapPalette{valuer.NewString("rust")}
|
||||
HeatmapPaletteGraphite = HeatmapPalette{valuer.NewString("graphite")}
|
||||
HeatmapPaletteEmber = HeatmapPalette{valuer.NewString("ember")}
|
||||
HeatmapPaletteLagoon = HeatmapPalette{valuer.NewString("lagoon")}
|
||||
HeatmapPaletteOrchid = HeatmapPalette{valuer.NewString("orchid")}
|
||||
HeatmapPaletteVerdant = HeatmapPalette{valuer.NewString("verdant")}
|
||||
HeatmapPaletteLava = HeatmapPalette{valuer.NewString("lava")} // default
|
||||
HeatmapPaletteBeacon = HeatmapPalette{valuer.NewString("beacon")}
|
||||
)
|
||||
|
||||
func (HeatmapPalette) Enum() []any {
|
||||
return []any{
|
||||
HeatmapPaletteIce, HeatmapPaletteMoss, HeatmapPaletteRust, HeatmapPaletteGraphite,
|
||||
HeatmapPaletteEmber, HeatmapPaletteLagoon, HeatmapPaletteOrchid, HeatmapPaletteVerdant,
|
||||
HeatmapPaletteLava, HeatmapPaletteBeacon,
|
||||
}
|
||||
}
|
||||
|
||||
func (p HeatmapPalette) ValueOrDefault() string {
|
||||
if p.IsZero() {
|
||||
return HeatmapPaletteLava.StringValue()
|
||||
}
|
||||
return p.StringValue()
|
||||
}
|
||||
|
||||
func (p HeatmapPalette) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(p.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (p *HeatmapPalette) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid heatmap palette: must be a string, one of `ice`, `moss`, `rust`, `graphite`, `ember`, `lagoon`, `orchid`, `verdant`, `lava`, or `beacon`")
|
||||
}
|
||||
palette := HeatmapPalette{valuer.NewString(v)}
|
||||
switch palette {
|
||||
case HeatmapPaletteIce, HeatmapPaletteMoss, HeatmapPaletteRust, HeatmapPaletteGraphite,
|
||||
HeatmapPaletteEmber, HeatmapPaletteLagoon, HeatmapPaletteOrchid, HeatmapPaletteVerdant,
|
||||
HeatmapPaletteLava, HeatmapPaletteBeacon:
|
||||
*p = palette
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid heatmap palette %q: must be `ice`, `moss`, `rust`, `graphite`, `ember`, `lagoon`, `orchid`, `verdant`, `lava`, or `beacon`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type HeatmapYScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
HeatmapYScaleAuto = HeatmapYScale{valuer.NewString("auto")} // default
|
||||
HeatmapYScaleLinear = HeatmapYScale{valuer.NewString("linear")}
|
||||
HeatmapYScaleLog = HeatmapYScale{valuer.NewString("log")}
|
||||
HeatmapYScaleSymlog = HeatmapYScale{valuer.NewString("symlog")}
|
||||
)
|
||||
|
||||
func (HeatmapYScale) Enum() []any {
|
||||
return []any{HeatmapYScaleAuto, HeatmapYScaleLinear, HeatmapYScaleLog, HeatmapYScaleSymlog}
|
||||
}
|
||||
|
||||
func (s HeatmapYScale) ValueOrDefault() string {
|
||||
if s.IsZero() {
|
||||
return HeatmapYScaleAuto.StringValue()
|
||||
}
|
||||
return s.StringValue()
|
||||
}
|
||||
|
||||
func (s HeatmapYScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(s.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (s *HeatmapYScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid heatmap y scale: must be a string, one of `auto`, `linear`, `log`, or `symlog`")
|
||||
}
|
||||
scale := HeatmapYScale{valuer.NewString(v)}
|
||||
switch scale {
|
||||
case HeatmapYScaleAuto, HeatmapYScaleLinear, HeatmapYScaleLog, HeatmapYScaleSymlog:
|
||||
*s = scale
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid heatmap y scale %q: must be `auto`, `linear`, `log`, or `symlog`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type HeatmapColorScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
HeatmapColorScaleLog = HeatmapColorScale{valuer.NewString("log")} // default
|
||||
HeatmapColorScaleSqrt = HeatmapColorScale{valuer.NewString("sqrt")}
|
||||
HeatmapColorScaleLinear = HeatmapColorScale{valuer.NewString("linear")}
|
||||
)
|
||||
|
||||
func (HeatmapColorScale) Enum() []any {
|
||||
return []any{HeatmapColorScaleLog, HeatmapColorScaleSqrt, HeatmapColorScaleLinear}
|
||||
}
|
||||
|
||||
func (s HeatmapColorScale) ValueOrDefault() string {
|
||||
if s.IsZero() {
|
||||
return HeatmapColorScaleLog.StringValue()
|
||||
}
|
||||
return s.StringValue()
|
||||
}
|
||||
|
||||
func (s HeatmapColorScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(s.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (s *HeatmapColorScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid heatmap color scale: must be a string, one of `log`, `sqrt`, or `linear`")
|
||||
}
|
||||
scale := HeatmapColorScale{valuer.NewString(v)}
|
||||
switch scale {
|
||||
case HeatmapColorScaleLog, HeatmapColorScaleSqrt, HeatmapColorScaleLinear:
|
||||
*s = scale
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid heatmap color scale %q: must be `log`, `sqrt`, or `linear`", v)
|
||||
}
|
||||
}
|
||||
|
||||
149
pkg/types/dashboardtypes/testdata/perses_heatmap_panel.json
vendored
Normal file
149
pkg/types/dashboardtypes/testdata/perses_heatmap_panel.json
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"display": {
|
||||
"name": "latency",
|
||||
"description": "how request duration is distributed"
|
||||
},
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "request duration",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/HeatmapPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time"
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "s",
|
||||
"decimalPrecision": "3"
|
||||
},
|
||||
"axes": {
|
||||
"yScale": "log"
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "table"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"colors": {
|
||||
"mode": "palette",
|
||||
"palette": "ember",
|
||||
"scale": "sqrt",
|
||||
"steps": 8,
|
||||
"minCount": 0,
|
||||
"maxCount": 500
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"queries": [
|
||||
{
|
||||
"kind": "heatmap",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/CompositeQuery",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"disabled": true,
|
||||
"stepInterval": 60,
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "http.server.request.duration",
|
||||
"timeAggregation": "increase",
|
||||
"spaceAggregation": "sum"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name"
|
||||
}
|
||||
],
|
||||
"bucketOptions": {
|
||||
"kind": "log",
|
||||
"spec": {
|
||||
"scale": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "metrics",
|
||||
"disabled": true,
|
||||
"stepInterval": 60,
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "http.server.request.count",
|
||||
"timeAggregation": "increase",
|
||||
"spaceAggregation": "sum"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name"
|
||||
}
|
||||
],
|
||||
"bucketOptions": {
|
||||
"kind": "linear",
|
||||
"spec": {
|
||||
"maxValue": 1000,
|
||||
"numBuckets": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_formula",
|
||||
"spec": {
|
||||
"name": "F1",
|
||||
"expression": "A / B",
|
||||
"bucketOptions": {
|
||||
"kind": "log",
|
||||
"spec": {
|
||||
"scale": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 12,
|
||||
"height": 8,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/p1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -27,21 +27,6 @@ _PASSWORD = "password123Z$"
|
||||
"kind,spec,assert_field,assert_value",
|
||||
[
|
||||
pytest.param("slack", {"apiUrl": "https://hooks.slack.test/services/T/B/X", "channel": "#alerts", "title": "Alert", "text": "{{ .CommonLabels.alertname }}"}, "channel", "#alerts", id="slack"),
|
||||
pytest.param(
|
||||
"slack",
|
||||
{
|
||||
"apiUrl": "https://hooks.slack.test/services/T/B/X",
|
||||
"channel": "#alerts",
|
||||
"color": "#439FE0",
|
||||
"titleLink": "{{ .CommonLabels.ruleSource }}",
|
||||
"footer": "platform · terraform",
|
||||
"fields": [{"title": "Severity", "value": "{{ .CommonLabels.severity }}", "short": True}],
|
||||
"actions": [{"type": "button", "text": "Open in SigNoz", "url": "{{ .CommonLabels.ruleSource }}"}],
|
||||
},
|
||||
"fields",
|
||||
[{"title": "Severity", "value": "{{ .CommonLabels.severity }}", "short": True}],
|
||||
id="slack-attachment",
|
||||
),
|
||||
pytest.param("email", {"to": "oncall@integration.test", "html": "<p>{{ .CommonLabels.alertname }}</p>"}, "to", "oncall@integration.test", id="email"),
|
||||
pytest.param("webhook", {"url": "https://webhook.test/hook", "username": "bob", "password": "s3cret"}, "username", "bob", id="webhook"),
|
||||
pytest.param("pagerduty", {"routingKey": "pd-routing-key", "severity": "critical", "class": "db", "description": "{{ .CommonLabels.alertname }}"}, "severity", "critical", id="pagerduty"),
|
||||
@@ -328,12 +313,9 @@ def test_create_rejects_a_duplicate_display_name(
|
||||
pytest.param({"name": "telegram-kind", "config": {"kind": "telegram", "spec": {"chatId": 1}}}, id="unmodelled_kind"),
|
||||
pytest.param({"name": "slack-unknown-field", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "channel": "#a", "text": "body", "iconEmoji": ":tada:"}}}, id="unknown_spec_field"),
|
||||
pytest.param({"name": "slack-with-email-spec", "config": {"kind": "slack", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}}, id="spec_of_another_kind"),
|
||||
pytest.param({"name": "slack-field-without-value", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "fields": [{"title": "Severity"}]}}}, id="slack_field_without_value"),
|
||||
pytest.param({"name": "slack-action-without-text", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "actions": [{"type": "button", "url": "https://signoz.test"}]}}}, id="slack_action_without_text"),
|
||||
pytest.param({"name": "slack-action-without-target", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "actions": [{"type": "button", "text": "Open"}]}}}, id="slack_action_without_url_or_name"),
|
||||
pytest.param({"name": "slack-confirm-without-text", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "actions": [{"type": "button", "text": "Ack", "name": "ack", "confirm": {"title": "Sure?"}}]}}}, id="slack_action_confirm_without_text"),
|
||||
pytest.param({"name": "extra-field", "config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}, "type": "email"}, id="unknown_envelope_field"),
|
||||
pytest.param({"name": "webhook-both-auth", "config": {"kind": "webhook", "spec": {"url": "https://webhook.test/hook", "username": "u", "password": "p", "bearerToken": "t"}}}, id="webhook_basic_auth_with_bearer_token"),
|
||||
pytest.param({"name": "webhook-half-auth", "config": {"kind": "webhook", "spec": {"url": "https://webhook.test/hook", "username": "u"}}}, id="webhook_basic_auth_without_password"),
|
||||
# The last three reach the notifier's own validation rather than the
|
||||
# spec's, so they assert it still surfaces as a 400 through v2.
|
||||
pytest.param({"name": "jira-server-site", "config": {"kind": "jira", "spec": {"site": "https://jira.acme.com", "project": "OPS", "issueType": "Bug", "email": "a@integration.test", "apiToken": "t", "summary": "Alert", "description": "body"}}}, id="jira_site_not_jira_cloud"),
|
||||
|
||||
Reference in New Issue
Block a user