Compare commits

..

6 Commits

Author SHA1 Message Date
aks07
5330496257 test(data-export): assert scalar export filename via the naming helper
Same review cleanup as the foundation PR: frozen clock + delegation to getTimestampedFileName instead of a duplicated format regex.
2026-07-14 13:10:41 +05:30
aks07
8822364a31 feat(explorer): enable table export in Logs and Traces table tabs
Mounts ExportMenu on the Logs and Traces Table tabs, feeding the same query the rendered table uses (stagedQuery fallback parity) — exports match the on-screen table's merge, naming and column order. Header rows are class-driven, right-aligned, gated on data presence.
2026-07-14 13:10:41 +05:30
aks07
b63d1e7ce0 feat(data-export): dispatch client exports from the queryRange response
useClientExport now takes the queryRange response object views already hold and picks the serializer from what it carries: rawV5Response (time_series) or the formatForWeb scalar payload (tables) — mounts pass their data without choosing serializers. TimeseriesExportMenu generalizes into components/ExportMenu with the same uniform inputs, so the timeseries views and the table tabs share one menu.
2026-07-14 13:10:41 +05:30
aks07
f9c4e96656 feat(data-export): add scalar queryRange serializer
exportScalarData takes the queryRange response object (formatForWeb webTables payload) + the builder query and serializes via createTableColumnsFromQuery — the exact preparer QueryTable renders from — so exports inherit the on-screen merge, naming and column order 1:1.
2026-07-14 13:10:21 +05:30
aks07
13760e8e2b feat(data-export): add generic table-model serializer
exportTableData serializes any prepared antd-style table model ({name, key, isValueColumn} columns + record rows) into a SerializedTable — raw values in display order, units on value columns only, blanks for missing cells. Surface-agnostic: QueryTable-based tables, dashboard tables and plain antd tables all adapt in a line or two.
2026-07-14 13:10:21 +05:30
aks07
a257184a19 refactor(data-export): extract the withUnit header helper
Both the timeseries and the upcoming table serializers append units to headers (and skip display-only ids like 'short'/'none') — move the helper to its own module.
2026-07-14 13:10:19 +05:30
53 changed files with 669 additions and 981 deletions

View File

@@ -8048,15 +8048,6 @@ components:
required:
- items
type: object
SpantypesGettableSpanMappers:
properties:
items:
items:
$ref: '#/components/schemas/SpantypesSpanMapper'
type: array
required:
- items
type: object
SpantypesGettableTraceAggregations:
properties:
aggregations:
@@ -8209,7 +8200,7 @@ components:
type: boolean
fieldContext:
$ref: '#/components/schemas/SpantypesFieldContext'
groupId:
group_id:
type: string
id:
type: string
@@ -8222,7 +8213,7 @@ components:
type: string
required:
- id
- groupId
- group_id
- name
- fieldContext
- config
@@ -13801,7 +13792,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableSpanMappers'
$ref: '#/components/schemas/SpantypesGettableSpanMapperGroups'
status:
type: string
required:

View File

@@ -9258,76 +9258,6 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
items: SpantypesSpanMapperGroupDTO[];
}
export enum SpantypesSpanMapperOperationDTO {
move = 'move',
copy = 'copy',
}
export interface SpantypesSpanMapperSourceDTO {
context: SpantypesFieldContextDTO;
/**
* @type string
*/
key: string;
operation: SpantypesSpanMapperOperationDTO;
/**
* @type integer
*/
priority: number;
}
export interface SpantypesSpanMapperConfigDTO {
/**
* @type array,null
*/
sources: SpantypesSpanMapperSourceDTO[] | null;
}
export interface SpantypesSpanMapperDTO {
config: SpantypesSpanMapperConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type boolean
*/
enabled: boolean;
fieldContext: SpantypesFieldContextDTO;
/**
* @type string
*/
groupId: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface SpantypesGettableSpanMappersDTO {
/**
* @type array
*/
items: SpantypesSpanMapperDTO[];
}
export enum SpantypesSpanAggregationTypeDTO {
span_count = 'span_count',
execution_time_percentage = 'execution_time_percentage',
@@ -9574,6 +9504,30 @@ export interface SpantypesPostableFlamegraphDTO {
selectedSpanId?: string;
}
export enum SpantypesSpanMapperOperationDTO {
move = 'move',
copy = 'copy',
}
export interface SpantypesSpanMapperSourceDTO {
context: SpantypesFieldContextDTO;
/**
* @type string
*/
key: string;
operation: SpantypesSpanMapperOperationDTO;
/**
* @type integer
*/
priority: number;
}
export interface SpantypesSpanMapperConfigDTO {
/**
* @type array,null
*/
sources: SpantypesSpanMapperSourceDTO[] | null;
}
export interface SpantypesPostableSpanMapperDTO {
config: SpantypesSpanMapperConfigDTO;
/**
@@ -9622,6 +9576,45 @@ export interface SpantypesPostableWaterfallDTO {
uncollapsedSpans?: string[] | null;
}
export interface SpantypesSpanMapperDTO {
config: SpantypesSpanMapperConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type boolean
*/
enabled: boolean;
fieldContext: SpantypesFieldContextDTO;
/**
* @type string
*/
group_id: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface SpantypesUpdatableSpanMapperDTO {
config?: SpantypesSpanMapperConfigDTO;
/**
@@ -10923,7 +10916,7 @@ export type ListSpanMappersPathParameters = {
groupId: string;
};
export type ListSpanMappers200 = {
data: SpantypesGettableSpanMappersDTO;
data: SpantypesGettableSpanMapperGroupsDTO;
/**
* @type string
*/

View File

@@ -3,7 +3,6 @@ import { Button, Popover, Tooltip } from 'antd';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useExportRawData } from 'hooks/useExportData/useServerExport';
import { Download, LoaderCircle } from '@signozhq/icons';
import { DataSource } from 'types/common/queryBuilder';
@@ -19,13 +18,11 @@ import './DownloadOptionsMenu.styles.scss';
interface DownloadOptionsMenuProps {
dataSource: DataSource;
selectedColumns?: TelemetryFieldKey[];
panelType?: PANEL_TYPES;
}
export default function DownloadOptionsMenu({
dataSource,
selectedColumns,
panelType,
}: DownloadOptionsMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
@@ -36,7 +33,6 @@ export default function DownloadOptionsMenu({
const { isDownloading, handleExportRawData } = useExportRawData({
dataSource,
panelType,
});
const handleExport = useCallback(async (): Promise<void> => {

View File

@@ -1,4 +1,4 @@
.timeseries-export-popover {
.export-menu-popover {
width: 240px;
padding: 0 12px 12px 12px;

View File

@@ -4,42 +4,43 @@ import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { useClientExport } from 'hooks/useExportData/useClientExport';
import {
ClientExportData,
useClientExport,
} from 'hooks/useExportData/useClientExport';
import { ExportFormat } from 'lib/exportData/types';
import { useCallback, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import './TimeseriesExportMenu.styles.scss';
import './ExportMenu.styles.scss';
interface TimeseriesExportMenuProps {
interface ExportMenuProps {
dataSource: DataSource;
queryResponse: QueryRangeResponseV5;
// The queryRange response object the view holds — the hook picks the
// serializer (timeseries / table) from what it carries.
data: ClientExportData;
query?: Query;
yAxisUnit?: string;
legendMap?: Record<string, string>;
fileName?: string;
}
// Download menu for in-memory timeseries data (client-side serialization).
// Download menu for in-memory query results (client-side serialization).
// The raw/list backend export keeps its own menu in DownloadOptionsMenu.
export default function TimeseriesExportMenu({
export default function ExportMenu({
dataSource,
queryResponse,
data,
query,
yAxisUnit,
legendMap,
fileName,
}: TimeseriesExportMenuProps): JSX.Element {
}: ExportMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const { isExporting, handleExport: handleClientExport } = useClientExport({
response: queryResponse,
data,
query,
yAxisUnit,
legendMap,
fileName,
});
@@ -57,7 +58,7 @@ export default function TimeseriesExportMenu({
color="secondary"
size="icon"
aria-label="Download"
data-testid={`timeseries-export-${dataSource}`}
data-testid={`export-menu-${dataSource}`}
disabled={isExporting}
loading={isExporting}
>
@@ -65,7 +66,7 @@ export default function TimeseriesExportMenu({
</Button>
</PopoverTrigger>
</TooltipSimple>
<PopoverContent align="end" className="timeseries-export-popover">
<PopoverContent align="end" className="export-menu-popover">
<div className="export-format">
<Typography.Text className="title">FORMAT</Typography.Text>
<RadioGroup value={exportFormat} onChange={setExportFormat}>

View File

@@ -1,8 +1,8 @@
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { fireEvent, render, screen } from 'tests/test-utils';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import TimeseriesExportMenu from '../TimeseriesExportMenu';
import ExportMenu from '../ExportMenu';
const mockHandleExport = jest.fn();
let mockIsExporting = false;
@@ -14,25 +14,26 @@ jest.mock('hooks/useExportData/useClientExport', () => ({
}),
}));
const response = {
type: 'time_series',
data: { results: [] },
meta: {},
} as unknown as QueryRangeResponseV5;
const data = {
statusCode: 200,
error: null,
message: '',
payload: { data: { result: [], resultType: 'time_series' } },
} as unknown as MetricQueryRangeSuccessResponse;
const TEST_ID = `timeseries-export-${DataSource.LOGS}`;
const TEST_ID = `export-menu-${DataSource.LOGS}`;
function renderMenu(): void {
render(
<TimeseriesExportMenu
<ExportMenu
dataSource={DataSource.LOGS}
queryResponse={response}
data={data}
fileName="logs-timeseries"
/>,
);
}
describe('TimeseriesExportMenu', () => {
describe('ExportMenu', () => {
beforeEach(() => {
mockHandleExport.mockReset();
mockIsExporting = false;

View File

@@ -139,31 +139,21 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
</ColumnHeader>
),
accessorFn: (row): number => row.currentNodes,
width: { min: 210 },
width: { min: 180 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => (
<GroupedStatusCounts
items={[
{
value: row.readyNodes,
label: 'Ready',
color: Color.BG_FOREST_500,
},
{
value: row.currentNodes,
label: 'Current',
color: Color.BG_ROBIN_500,
color: Color.BG_FOREST_500,
},
{
value: row.desiredNodes,
label: 'Desired',
color: Color.BG_SAKURA_400,
},
{
value: row.misscheduledNodes,
label: 'Misscheduled',
color: Color.BG_AMBER_500,
color: Color.BG_ROBIN_500,
},
]}
/>
@@ -322,30 +312,6 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
);
},
},
{
id: 'ready_nodes',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#ready">
Ready Nodes
</ColumnHeader>
),
accessorFn: (row): number => row.readyNodes,
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const readyNodes = value as number;
return (
<ValidateColumnValueWrapper
value={readyNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="ready node"
>
<TanStackTable.Text>{readyNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'current_nodes',
header: (): React.ReactNode => (
@@ -394,28 +360,4 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
);
},
},
{
id: 'misscheduled_nodes',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#misscheduled">
Misscheduled Nodes
</ColumnHeader>
),
accessorFn: (row): number => row.misscheduledNodes,
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const misscheduledNodes = value as number;
return (
<ValidateColumnValueWrapper
value={misscheduledNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="misscheduled node"
>
<TanStackTable.Text>{misscheduledNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -30,7 +30,7 @@ export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
return {
id: 'mapper-1',
groupId: 'group-1',
group_id: 'group-1',
name: 'gen_ai.request.model',
enabled: true,
fieldContext: FieldContext.attribute,
@@ -89,5 +89,5 @@ export const mockGroups: MapperGroup[] = [
];
export const mockMappers: Mapper[] = [
makeMapper({ id: 'mapper-1', groupId: 'group-1' }),
makeMapper({ id: 'mapper-1', group_id: 'group-1' }),
];

View File

@@ -89,20 +89,21 @@ describe('UnpricedModelsTab (integration)', () => {
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
await selectRule(user, MODEL, 'rule-openai');
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
expect(
within(screen.getByTestId(`map-to-select-${MODEL}`)).getByText(
'openai:gpt-4o ($3.00/$9.00)',
),
within(trigger).getByText('openai:gpt-4o ($3.00/$9.00)'),
).toBeInTheDocument();
await user.click(await screen.findByTestId('unpriced-map-cancel-btn'));
await waitFor(() => {
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
await waitFor(() =>
expect(
within(trigger).getByText('Select / Create a pricing model'),
).toBeInTheDocument();
});
within(trigger).queryByText('openai:gpt-4o ($3.00/$9.00)'),
).not.toBeInTheDocument(),
);
expect(
within(trigger).getByText('Select / Create a pricing model'),
).toBeInTheDocument();
});
it('commits the mapping in one request when confirmed', async () => {

View File

@@ -182,6 +182,14 @@
flex: 1;
min-height: 0;
overflow-y: visible;
.table-view-container-header {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 12px;
flex-shrink: 0;
}
}
.time-series-view-container {

View File

@@ -18,13 +18,18 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { QueryParams } from 'constants/query';
import { initialFilters, PANEL_TYPES } from 'constants/queryBuilder';
import {
initialFilters,
initialQueriesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import GoToTop from 'container/GoToTop';
import LogsExplorerChart from 'container/LogsExplorerChart';
import LogsExplorerList from 'container/LogsExplorerList';
import ExportMenu from 'components/ExportMenu/ExportMenu';
import LogsExplorerTable from 'container/LogsExplorerTable';
import {
getExportQueryData,
@@ -476,6 +481,16 @@ function LogsExplorerViewsContainer({
)}
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
<div className="table-view-container">
{data && !isError && (
<div className="table-view-container-header">
<ExportMenu
dataSource={DataSource.LOGS}
data={data}
query={stagedQuery || initialQueriesMap.metrics}
fileName="logs-table"
/>
</div>
)}
<LogsExplorerTable
data={
(data?.payload?.data?.newResult?.data?.result ||

View File

@@ -48,7 +48,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import TimeseriesExportMenu from './TimeseriesExportMenu';
import ExportMenu from 'components/ExportMenu/ExportMenu';
import './TimeSeriesView.styles.scss';
@@ -265,12 +265,11 @@ function TimeSeriesView({
)}
</div>
{showExport && data?.rawV5Response && (
<TimeseriesExportMenu
<ExportMenu
dataSource={dataSource}
yAxisUnit={yAxisUnit}
queryResponse={data.rawV5Response}
data={data}
query={currentQuery}
legendMap={data.legendMap}
fileName={`${dataSource}-timeseries`}
/>
)}

View File

@@ -13,7 +13,7 @@ jest.mock('components/Uplot', () => ({
default: (): JSX.Element => <div data-testid="uplot-chart" />,
}));
jest.mock('../TimeseriesExportMenu', () => ({
jest.mock('components/ExportMenu/ExportMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="timeseries-export-menu" />,
}));

View File

@@ -0,0 +1,7 @@
.traces-table-view-header {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 12px;
flex-shrink: 0;
}

View File

@@ -10,6 +10,7 @@ import {
import { useSelector } from 'react-redux';
import { Space } from 'antd';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ExportMenu from 'components/ExportMenu/ExportMenu';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
@@ -20,8 +21,11 @@ import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import './TableView.styles.scss';
function TableView({
setWarning,
setIsLoadingQueries,
@@ -97,6 +101,16 @@ function TableView({
return (
<Space.Compact block direction="vertical">
{isError && error && <ErrorInPlace error={error as APIError} />}
{!isError && data && (
<div className="traces-table-view-header">
<ExportMenu
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="traces-table"
/>
</div>
)}
{!isError && (
<QueryTable
query={stagedQuery || initialQueriesMap.traces}

View File

@@ -11,7 +11,6 @@ import {
import { useSelector } from 'react-redux';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { ResizeTable } from 'components/ResizeTable';
import { ENTITY_VERSION_V5 } from 'constants/app';
@@ -153,11 +152,6 @@ function TracesView({
</Typography>
<div className="trace-explorer-controls">
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
/>
<TraceExplorerControls
isLoading={isLoading}
totalCount={responseData?.length || 0}

View File

@@ -4,7 +4,8 @@ import {
getTimestampedFileName,
} from 'lib/exportData/downloadFile';
import { ExportFormat } from 'lib/exportData/types';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useClientExport } from '../useClientExport';
@@ -24,31 +25,87 @@ jest.mock('antd', () => {
const mockDownloadFile = downloadFile as jest.Mock;
function timeSeriesResponse(): QueryRangeResponseV5 {
const query = {
queryType: 'builder',
builder: {
queryData: [
{
queryName: 'A',
dataSource: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [],
legend: '',
},
],
queryFormulas: [],
},
} as unknown as Query;
function timeSeriesData(): MetricQueryRangeSuccessResponse {
return {
type: 'time_series',
data: {
results: [
{
queryName: 'A',
aggregations: [
{
index: 0,
alias: '',
meta: {},
series: [
{
labels: [{ key: { name: 'service' }, value: 'a' }],
values: [{ timestamp: 1000, value: 12 }],
},
],
},
],
},
],
statusCode: 200,
error: null,
message: '',
payload: { data: { result: [], resultType: 'time_series' } },
legendMap: { A: '{{service}}' },
rawV5Response: {
type: 'time_series',
data: {
results: [
{
queryName: 'A',
aggregations: [
{
index: 0,
alias: '',
meta: {},
series: [
{
labels: [{ key: { name: 'service' }, value: 'a' }],
values: [{ timestamp: 1000, value: 12 }],
},
],
},
],
},
],
},
meta: {},
},
meta: {},
} as unknown as QueryRangeResponseV5;
} as unknown as MetricQueryRangeSuccessResponse;
}
function scalarData(): MetricQueryRangeSuccessResponse {
return {
statusCode: 200,
error: null,
message: '',
payload: {
data: {
resultType: 'scalar',
result: [
{
queryName: 'A',
legend: '',
series: null,
list: null,
table: {
columns: [
{
name: 'service.name',
id: 'service.name',
queryName: 'A',
isValueColumn: false,
},
{ name: 'count()', id: 'A', queryName: 'A', isValueColumn: true },
],
rows: [{ data: { 'service.name': 'frontend', A: 120 } }],
},
},
],
},
},
} as unknown as MetricQueryRangeSuccessResponse;
}
describe('useClientExport', () => {
@@ -64,13 +121,9 @@ describe('useClientExport', () => {
jest.useRealTimers();
});
it('exports time_series as CSV to a timestamped <fileName>.csv', () => {
it('dispatches timeseries data to the timeseries serializer (csv)', () => {
const { result } = renderHook(() =>
useClientExport({
response: timeSeriesResponse(),
fileName: 'chart',
legendMap: { A: '{{service}}' },
}),
useClientExport({ data: timeSeriesData(), query, fileName: 'chart' }),
);
act(() => {
@@ -83,12 +136,28 @@ describe('useClientExport', () => {
expect(name).toBe(getTimestampedFileName('chart', 'csv'));
expect(mime).toContain('text/csv');
expect(content).toContain('service');
expect(content).toContain('a');
});
it('exports as JSONL to a timestamped <fileName>.jsonl with the ndjson mime', () => {
it('dispatches scalar (table) data to the table serializer', () => {
const { result } = renderHook(() =>
useClientExport({ response: timeSeriesResponse() }),
useClientExport({ data: scalarData(), query, fileName: 'table' }),
);
act(() => {
result.current.handleExport({ format: ExportFormat.Csv });
});
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
const [content, name] = mockDownloadFile.mock.calls[0];
expect(name).toBe(getTimestampedFileName('table', 'csv'));
expect(content).toContain('service.name');
expect(content).toContain('frontend');
expect(content).toContain('120');
});
it('exports as JSONL with the ndjson mime', () => {
const { result } = renderHook(() =>
useClientExport({ data: timeSeriesData(), query }),
);
act(() => {
@@ -101,8 +170,8 @@ describe('useClientExport', () => {
expect(content).toContain('"series"');
});
it('does nothing when there is no response', () => {
const { result } = renderHook(() => useClientExport({}));
it('does nothing when there is no data', () => {
const { result } = renderHook(() => useClientExport({ query }));
act(() => {
result.current.handleExport({ format: ExportFormat.Csv });
@@ -112,13 +181,14 @@ describe('useClientExport', () => {
expect(mockMessageError).not.toHaveBeenCalled();
});
it('shows an error and does not download for unsupported result types', () => {
it('shows an error for unsupported result types', () => {
const raw = {
type: 'raw',
data: { results: [] },
meta: {},
} as unknown as QueryRangeResponseV5;
const { result } = renderHook(() => useClientExport({ response: raw }));
statusCode: 200,
error: null,
message: '',
payload: { data: { result: [], resultType: '' } },
} as unknown as MetricQueryRangeSuccessResponse;
const { result } = renderHook(() => useClientExport({ data: raw, query }));
act(() => {
result.current.handleExport({ format: ExportFormat.Csv });

View File

@@ -4,11 +4,14 @@ import {
downloadFile,
getTimestampedFileName,
} from 'lib/exportData/downloadFile';
import { exportScalarData } from 'lib/exportData/exportScalarData';
import { exportTimeseriesData } from 'lib/exportData/exportTimeseriesData';
import { toCsv } from 'lib/exportData/toCsv';
import { toJsonl } from 'lib/exportData/toJsonl';
import { ExportFormat, SerializedTable } from 'lib/exportData/types';
import { useCallback, useState } from 'react';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
@@ -20,33 +23,44 @@ const FORMAT_META: Record<ExportFormat, { mime: string; extension: string }> = {
},
};
// Picks the serializer for the response's request type. Narrows the results
// union via the response discriminant. scalar lands with #5591; raw/trace are
// server-exported, distribution is never emitted.
/** The queryRange response object views hold — structural (params left
* unconstrained) so both explorer variants assign cleanly. */
export type ClientExportData = SuccessResponse<MetricRangePayloadProps> & {
rawV5Response?: QueryRangeResponseV5;
legendMap?: Record<string, string>;
};
// Picks the serializer from what the queryRange response carries: timeseries
// queries surface the raw V5 tree (rawV5Response); table queries carry the
// formatForWeb webTables payload (resultType 'scalar'). raw/trace stay
// server-exported via useServerExport.
function serialize(
response: QueryRangeResponseV5,
data: ClientExportData,
yAxisUnit?: string,
legendMap?: Record<string, string>,
query?: Query,
): SerializedTable {
if (response.type === REQUEST_TYPES.TIME_SERIES) {
if (data.rawV5Response?.type === REQUEST_TYPES.TIME_SERIES) {
return exportTimeseriesData({
data: response.data.results as TimeSeriesData[],
data: data.rawV5Response.data.results as TimeSeriesData[],
yAxisUnit,
legendMap,
legendMap: data.legendMap,
query,
});
}
throw new Error(`Export is not supported for "${response.type}" results`);
if (data.payload?.data?.resultType === 'scalar' && query) {
return exportScalarData({ data, query });
}
throw new Error('Export is not supported for this result type');
}
interface UseClientExportProps {
response?: QueryRangeResponseV5;
// currently supports only qb v5 responses. Can extend to support future responses.
data?: ClientExportData;
query?: Query;
yAxisUnit?: string;
fileName?: string;
legendMap?: Record<string, string>;
}
interface ClientExportOptions {
@@ -59,23 +73,22 @@ interface UseClientExportReturn {
}
export function useClientExport({
response, // currently supports only qb v5 response. Can extend to support future responses.
data,
query,
yAxisUnit,
fileName = 'export',
legendMap,
}: UseClientExportProps): UseClientExportReturn {
const [isExporting, setIsExporting] = useState<boolean>(false);
const handleExport = useCallback(
({ format }: ClientExportOptions): void => {
if (!response) {
if (!data) {
return;
}
setIsExporting(true);
try {
const table = serialize(response, yAxisUnit, legendMap, query);
const table = serialize(data, yAxisUnit, query);
const content =
format === ExportFormat.Jsonl ? toJsonl(table) : toCsv(table);
const { mime, extension } = FORMAT_META[format];
@@ -86,7 +99,7 @@ export function useClientExport({
setIsExporting(false);
}
},
[response, query, yAxisUnit, fileName, legendMap],
[data, query, yAxisUnit, fileName],
);
return { isExporting, handleExport };

View File

@@ -19,7 +19,6 @@ interface ExportOptions {
interface UseExportRawDataProps {
dataSource: DataSource;
panelType?: PANEL_TYPES;
}
interface UseExportRawDataReturn {
@@ -29,7 +28,6 @@ interface UseExportRawDataReturn {
export function useExportRawData({
dataSource,
panelType = PANEL_TYPES.LIST,
}: UseExportRawDataProps): UseExportRawDataReturn {
const [isDownloading, setIsDownloading] = useState<boolean>(false);
@@ -85,7 +83,7 @@ export function useExportRawData({
const { queryPayload } = prepareQueryRangePayloadV5({
query: exportQuery,
graphType: panelType,
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval,
});
@@ -98,7 +96,7 @@ export function useExportRawData({
setIsDownloading(false);
}
},
[stagedQuery, globalSelectedInterval, dataSource, panelType],
[stagedQuery, globalSelectedInterval, dataSource],
);
return { isDownloading, handleExportRawData };

View File

@@ -4,8 +4,6 @@ export function useIntersectionObserver<T extends HTMLElement>(
ref: RefObject<T>,
options?: IntersectionObserverInit,
isObserverOnce?: boolean,
/** Defer observation by this many ms to let a transient mount layout settle. */
startDelayMs = 0,
): boolean {
const [isIntersecting, setIntersecting] = useState(false);
@@ -25,28 +23,16 @@ export function useIntersectionObserver<T extends HTMLElement>(
}
}, options);
const startObserving = (): void => {
if (currentReference) {
observer.observe(currentReference);
}
};
let timer: ReturnType<typeof setTimeout> | undefined;
if (startDelayMs > 0) {
timer = setTimeout(startObserving, startDelayMs);
} else {
startObserving();
if (currentReference) {
observer.observe(currentReference);
}
return (): void => {
if (timer) {
clearTimeout(timer);
}
if (currentReference) {
observer.unobserve(currentReference);
}
};
}, [ref, options, isObserverOnce, startDelayMs]);
}, [ref, options, isObserverOnce]);
return isIntersecting;
}

View File

@@ -0,0 +1,94 @@
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { exportScalarData } from '../exportScalarData';
const query = {
queryType: 'builder',
builder: {
queryData: [
{
queryName: 'A',
dataSource: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [
{ key: 'service.name', dataType: 'string', type: 'tag', id: 'svc' },
],
legend: '',
},
],
queryFormulas: [],
},
} as unknown as Query;
function makeResponse(
tables: {
queryName: string;
columns: { name: string; id?: string; isValueColumn: boolean }[];
rows: Record<string, string | number>[];
}[],
): SuccessResponse<MetricRangePayloadProps> {
return {
statusCode: 200,
error: null,
message: '',
payload: {
data: {
resultType: 'scalar',
result: tables.map((table) => ({
queryName: table.queryName,
legend: '',
series: null,
list: null,
table: {
columns: table.columns.map((col) => ({
...col,
queryName: table.queryName,
})),
rows: table.rows.map((row) => ({ data: row })),
},
})),
},
},
} as unknown as SuccessResponse<MetricRangePayloadProps>;
}
describe('exportScalarData', () => {
it('serializes the table exactly as QueryTable prepares it', () => {
const data = makeResponse([
{
queryName: 'A',
columns: [
{ name: 'service.name', id: 'service.name', isValueColumn: false },
{ name: 'count()', id: 'A', isValueColumn: true },
],
rows: [
{ 'service.name': 'frontend', A: 120 },
{ 'service.name': 'cart', A: 80 },
],
},
]);
const table = exportScalarData({ data, query });
// group + aggregation columns, raw values, on-screen order — inherited
// 1:1 from createTableColumnsFromQuery (the renderer's own preparer)
expect(table).toStrictEqual({
headers: ['service.name', 'count()'],
rows: [
['frontend', 120],
['cart', 80],
],
});
});
it('returns an empty table for an empty response', () => {
const table = exportScalarData({
data: makeResponse([]),
query,
});
expect(table.rows).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,58 @@
import { exportTableData } from '../exportTableData';
const columns = [
{ name: 'service.name', key: 'service.name' },
{ name: 'count()', key: 'A', isValueColumn: true },
{ name: 'avg(duration)', key: 'B', isValueColumn: true },
];
describe('exportTableData', () => {
it('serializes raw values in display column order', () => {
const table = exportTableData({
columns,
dataSource: [
{ 'service.name': 'frontend', A: 120, B: 45.5 },
{ 'service.name': 'cart', A: 80, B: 12 },
],
});
expect(table).toStrictEqual({
headers: ['service.name', 'count()', 'avg(duration)'],
rows: [
['frontend', 120, 45.5],
['cart', 80, 12],
],
});
});
it('appends column units to value columns only, skipping display-only ids', () => {
const table = exportTableData({
columns,
dataSource: [{ 'service.name': 'frontend', A: 120, B: 45.5 }],
columnUnits: { A: 'short', B: 'ms', 'service.name': 'ms' },
});
// group column never gets a unit; 'short' is display-only and skipped
expect(table.headers).toStrictEqual([
'service.name',
'count()',
'avg(duration) (ms)',
]);
});
it('marks missing cells as blank gaps', () => {
const table = exportTableData({
columns,
dataSource: [{ 'service.name': 'frontend', A: 120 }],
});
expect(table.rows).toStrictEqual([['frontend', 120, '']]);
});
it('returns a headers-only table for empty data', () => {
expect(exportTableData({ columns, dataSource: [] })).toStrictEqual({
headers: ['service.name', 'count()', 'avg(duration)'],
rows: [],
});
});
});

View File

@@ -0,0 +1,48 @@
import { createTableColumnsFromQuery } from 'lib/query/createTableColumnsFromQuery';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { exportTableData } from './exportTableData';
import { SerializedTable } from './types';
interface ExportScalarDataArgs {
// The queryRange response object the table mount already holds (the
// formatForWeb payload carrying webTables).
data?: SuccessResponse<MetricRangePayloadProps>;
query: Query;
}
/**
* Serializes a scalar/table queryRange response into a table — via
* createTableColumnsFromQuery, the exact preparer QueryTable renders from, so
* the export inherits the on-screen merge, naming and column order 1:1.
*/
export function exportScalarData({
data,
query,
}: ExportScalarDataArgs): SerializedTable {
const queryTableData = (data?.payload?.data?.newResult?.data?.result ||
data?.payload?.data?.result ||
[]) as QueryDataV3[];
const { columns, dataSource } = createTableColumnsFromQuery({
query,
queryTableData,
});
return exportTableData({
// antd widens title/dataIndex; createTableColumnsFromQuery always sets strings
columns: columns.map((column) => {
const rawIndex = 'dataIndex' in column ? column.dataIndex : undefined;
const key =
typeof rawIndex === 'string' || typeof rawIndex === 'number'
? String(rawIndex)
: '';
const name = typeof column.title === 'string' ? column.title : key;
return { name, key: key || name };
}),
dataSource: dataSource as unknown as Record<string, unknown>[],
});
}

View File

@@ -0,0 +1,46 @@
import { SerializedTable } from './types';
import { withUnit } from './withUnit';
/** Generic table-model column — any prepared table (QueryTable, dashboard
* tables, plain antd tables) adapts to this in a line or two. */
export interface ExportTableColumn {
/** Display name, used as the export header (column order = array order). */
name: string;
/** Key into each dataSource record. */
key: string;
isValueColumn?: boolean;
}
interface ExportTableDataArgs {
columns: ExportTableColumn[];
dataSource: Record<string, unknown>[];
/** Per-column display unit, keyed by column key (dashboards; absent in explorer). */
columnUnits?: Record<string, string>;
}
/**
* Serializes a prepared table model into a format-agnostic table — raw values
* in display column order (lossless; no cell formatting applied).
*/
export function exportTableData({
columns,
dataSource,
columnUnits,
}: ExportTableDataArgs): SerializedTable {
const headers = columns.map((column) =>
column.isValueColumn
? withUnit(column.name, columnUnits?.[column.key])
: column.name,
);
const rows = dataSource.map((record) =>
columns.map((column) => {
const value = record[column.key];
return value === undefined || value === null
? ''
: (value as string | number);
}),
);
return { headers, rows };
}

View File

@@ -5,6 +5,7 @@ import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import { SerializedTable } from './types';
import { withUnit } from './withUnit';
interface ExportTimeseriesDataArgs {
data: TimeSeriesData[];
@@ -98,18 +99,6 @@ function flatten(
return flat;
}
// Display-format ids, not physical units — meaningful on a chart axis
// (compact-number formatting) but misleading in an export header.
const DISPLAY_ONLY_UNITS = new Set(['short', 'none']);
// Appends the y-axis unit to the value header: `value` → `value (ms)`.
function withUnit(header: string, yAxisUnit?: string): string {
if (!yAxisUnit || DISPLAY_ONLY_UNITS.has(yAxisUnit)) {
return header;
}
return `${header} (${yAxisUnit})`;
}
function toIso(timestamp: number): string {
return new Date(timestamp).toISOString();
}

View File

@@ -0,0 +1,11 @@
// Display-format ids, not physical units — meaningful on a chart axis
// (compact-number formatting) but misleading in an export header.
const DISPLAY_ONLY_UNITS = new Set(['short', 'none']);
/** Appends a unit to a header: `value` → `value (ms)`. Skips display-only ids. */
export function withUnit(header: string, unit?: string): string {
if (!unit || DISPLAY_ONLY_UNITS.has(unit)) {
return header;
}
return `${header} (${unit})`;
}

View File

@@ -92,7 +92,6 @@ function Panel({
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}

View File

@@ -23,8 +23,6 @@ interface PanelBodyProps {
panelId: string;
data: PanelQueryData;
isFetching: boolean;
/** Panel not yet scrolled into view — its fetch is deferred, so show the loader rather than NoData. */
isVisible?: boolean;
/** Showing a prior page's data while the next loads; forwarded so list renderers can show skeletons. */
isPreviousData?: boolean;
error: Error | null;
@@ -56,7 +54,6 @@ function PanelBody({
panelId,
data,
isFetching,
isVisible,
isPreviousData,
error,
refetch,
@@ -108,9 +105,9 @@ function PanelBody({
);
}
// Full-panel loader on first fetch or while the fetch is deferred (off-screen); a refetch
// over existing data keeps the renderer mounted, empty data loads via NoData.
if ((isFetching || isVisible === false) && !hasData) {
// Full-panel loader only on first fetch; a refetch over existing data keeps the renderer
// mounted (e.g. list page change). A refetch over empty data loads via NoData.
if (isFetching && !hasData) {
return <PanelLoader />;
}

View File

@@ -79,21 +79,6 @@ describe('PanelBody', () => {
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('shows the loader while the fetch is deferred (panel not yet scrolled into view)', () => {
render(
<PanelBody
{...baseProps}
panel={runnablePanel()}
data={{} as PanelQueryData}
isFetching={false}
isVisible={false}
/>,
);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('keeps the renderer mounted during a refetch over existing data (e.g. list page change)', () => {
render(
<PanelBody

View File

@@ -113,8 +113,8 @@ function ViewPanelModalHeader({
/>
<Button
size="icon"
variant="outlined"
color="secondary"
variant="solid"
color="primary"
onClick={onRefresh}
disabled={isFetching}
aria-label="Refresh"

View File

@@ -22,7 +22,6 @@ function SectionGrid({
sections,
}: SectionGridProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({

View File

@@ -10,10 +10,6 @@ const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
rootMargin: '200px',
};
// Start observing after RGL's mount unfold settles, so a panel that only
// transiently overlaps the viewport during layout doesn't fire a throwaway fetch.
const OBSERVER_START_DELAY_MS = 350;
interface SectionGridItemProps {
panel: DashboardtypesPanelDTO;
panelId: string;
@@ -21,9 +17,9 @@ interface SectionGridItemProps {
}
/**
* Lazy-loads a single panel: tracks its live viewport intersection and passes it to
* the presentational Panel as `isVisible`, so a board of many panels only fetches
* (and refetches on time change / auto-refresh) what's on screen.
* Lazy-loads a single panel: watches its own viewport intersection (latched) and
* passes it to the presentational Panel as `isVisible`, so a board of many panels
* only fetches what's on screen.
*/
function SectionGridItem({
panel,
@@ -34,10 +30,7 @@ function SectionGridItem({
const isVisible = useIntersectionObserver(
containerRef,
VIEWPORT_OBSERVER_OPTIONS,
// Not once: track the live viewport so a time change / auto-refresh only
// refetches on-screen panels (off-screen ones stay query-disabled).
false,
OBSERVER_START_DELAY_MS,
true,
);
useScrollIntoView(panelId, containerRef);

View File

@@ -23,7 +23,7 @@ describe('useScrollIntoView', () => {
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
block: 'start',
});
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});
@@ -38,7 +38,7 @@ describe('useScrollIntoView', () => {
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
block: 'start',
});
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});

View File

@@ -9,7 +9,7 @@ import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
export function useScrollIntoView(
id: string,
ref: RefObject<HTMLElement>,
block: ScrollLogicalPosition = 'center',
block: ScrollLogicalPosition = 'start',
): void {
const scrollTargetId = useScrollIntoViewStore((s) => s.scrollTargetId);
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);

View File

@@ -57,8 +57,5 @@ export function useGetQueryRangeV5({
retry: retryUnlessClientError,
keepPreviousData,
cacheTime,
// A resolved window is immutable per key, so a panel scrolled back into view
// serves cache instead of refetching; a key change or manual refetch still runs.
staleTime: Infinity,
});
}

View File

@@ -107,9 +107,6 @@ export function ContextMenu({
}
}}
trigger="click"
// Anchor to body (like the backdrop), not the host container: a modal's
// transformed dialog would break `position: fixed` and trap the menu below it.
getPopupContainer={(): HTMLElement => document.body}
overlayStyle={{
position: 'fixed',
left: position.left,

View File

@@ -77,11 +77,6 @@
color: var(--muted-foreground);
}
// Body-portaled overlay: stay clickable when a modal sets `body { pointer-events: none }`.
.context-menu {
pointer-events: auto;
}
.context-menu .ant-popover-inner {
padding: 0;
border-radius: 6px;

2
go.mod
View File

@@ -83,7 +83,7 @@ require (
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.52.0
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
golang.org/x/net v0.55.0
golang.org/x/net v0.54.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
golang.org/x/text v0.37.0

4
go.sum
View File

@@ -1488,8 +1488,8 @@ golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View File

@@ -98,7 +98,7 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
Description: "Returns all mappers belonging to a mapping group.",
Request: nil,
RequestContentType: "",
Response: new(spantypes.GettableSpanMappers),
Response: new(spantypes.GettableSpanMapperGroups),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},

View File

@@ -20,21 +20,21 @@ func (c Compiled) IsEmpty() bool {
// Compile always returns a non-nil *Compiled. An empty query (or one that
// produces no SQL) yields a Compiled with an empty SQL — callers gate on
// SQL != "" rather than a nil check.
//
// A `key OP value` term compiles to a DSL predicate; a bare word is a
// case-insensitive substring search over the dashboard name, description, and tag
// keys/values. They compose through AND/OR/NOT, so `prod payment` matches both
// words (implicit AND) and `prod OR name = 'x'` mixes free text with a filter. A
// quoted token matches literally, e.g. `"prod payment"`.
func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
if len(strings.TrimSpace(query)) == 0 {
if len(query) == 0 {
return &Compiled{}, nil
}
sql, args, errs := newVisitor(formatter).compile(query)
if len(errs) > 0 {
queryVisitor := newVisitor(formatter)
sql, args, syntaxErrs := queryVisitor.compile(query)
if len(syntaxErrs) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(errs, "; "))
"invalid filter query: %s", strings.Join(syntaxErrs, "; "))
}
if len(queryVisitor.errors) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(queryVisitor.errors, "; "))
}
return &Compiled{

View File

@@ -460,83 +460,6 @@ func TestCompile_ComplexExamples(t *testing.T) {
})
}
func TestCompile_FreeText(t *testing.T) {
// freeTextSQL is the predicate every free-text query compiles to; only the
// bound pattern differs.
freeTextSQL := `
(
lower(COALESCE(json_extract("dashboard"."data", '$.spec.display.name'), '')) LIKE LOWER(?) ESCAPE '\'
OR lower(COALESCE(json_extract("dashboard"."data", '$.spec.display.description'), '')) LIKE LOWER(?) ESCAPE '\'
OR EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND (lower(COALESCE(t.key, '')) LIKE LOWER(?) ESCAPE '\' OR lower(COALESCE(t.value, '')) LIKE LOWER(?) ESCAPE '\')
))`
freeTextArgs := func(pattern string) []any {
return []any{pattern, pattern, kindArg, pattern, pattern}
}
runCompileCases(t, []compileCase{
{
subtestName: "single bare word",
dslQueryToCompile: `payment`,
expectedSQL: freeTextSQL,
expectedArgs: freeTextArgs("%payment%"),
},
{
// consecutive words are implicit-AND per the grammar, so each is its
// own term; `"prod payment"` (below) is the way to match the phrase
subtestName: "words are separate terms AND'd together",
dslQueryToCompile: `prod payment`,
expectedSQL: "(" + freeTextSQL + " AND " + freeTextSQL + ")",
expectedArgs: append(freeTextArgs("%prod%"), freeTextArgs("%payment%")...),
},
{
subtestName: "a quoted token matches the whole phrase",
dslQueryToCompile: `"prod payment"`,
expectedSQL: freeTextSQL,
expectedArgs: freeTextArgs("%prod payment%"),
},
{
subtestName: "quoting is the escape hatch for a DSL-like literal",
dslQueryToCompile: `"team = prod"`,
expectedSQL: freeTextSQL,
expectedArgs: freeTextArgs("%team = prod%"),
},
{
subtestName: "LIKE wildcards in the term are escaped to match literally",
dslQueryToCompile: `"50%"`,
expectedSQL: freeTextSQL,
expectedArgs: freeTextArgs(`%50\%%`),
},
{
subtestName: "surrounding whitespace is trimmed",
dslQueryToCompile: ` payment `,
expectedSQL: freeTextSQL,
expectedArgs: freeTextArgs("%payment%"),
},
{
subtestName: "free-text term composes with a comparison via AND",
dslQueryToCompile: `prod AND name CONTAINS 'signoz'`,
expectedSQL: "(" + freeTextSQL + ` AND json_extract("dashboard"."data", '$.spec.display.name') LIKE ? ESCAPE '\')`,
expectedArgs: append(freeTextArgs("%prod%"), "%signoz%"),
},
{
subtestName: "free-text words compose with a comparison via OR",
dslQueryToCompile: `prod payment OR name = 'x'`,
expectedSQL: "((" + freeTextSQL + " AND " + freeTextSQL + `) OR json_extract("dashboard"."data", '$.spec.display.name') = ?)`,
expectedArgs: append(append(freeTextArgs("%prod%"), freeTextArgs("%payment%")...), "x"),
},
{
subtestName: "NOT negates a free-text term",
dslQueryToCompile: `NOT payment`,
expectedSQL: "NOT (" + freeTextSQL + ")",
expectedArgs: freeTextArgs("%payment%"),
},
})
}
func TestCompile_Rejections(t *testing.T) {
runCompileCases(t, []compileCase{
{

View File

@@ -32,19 +32,13 @@ func newVisitor(formatter sqlstore.SQLFormatter) *visitor {
}
}
// compile builds `?`-placeholder WHERE SQL + args for bun. Each term is either a
// `key OP value` comparison or a bare token that becomes a free-text search; the
// two compose through the boolean grammar (AND/OR/NOT). Malformed input is
// returned as errors.
// compile turns the parse tree into `?`-placeholder WHERE SQL + arguments for bun.
func (v *visitor) compile(query string) (string, []any, []string) {
tree, _, collector := filterquery.Parse(query)
if len(collector.Errors) > 0 {
return "", nil, collector.Errors
}
condition, _ := v.visit(tree).(string)
if len(v.errors) > 0 {
return "", nil, v.errors
}
if condition == "" {
return "", nil, nil
}
@@ -125,10 +119,10 @@ func (v *visitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
if ctx.Comparison() != nil {
return v.visit(ctx.Comparison())
}
// A lone key/value/full-text token is a free-text term, composed with any
// comparisons through the boolean grammar. A quoted token matches its contents
// literally — the escape hatch for a phrase or a term that looks like DSL.
return v.buildFreeTextTerm(trimQuotes(ctx.GetText()))
// Bare keys, values, full text, and function calls are not part of the
// dashboard list DSL.
v.addError("unsupported expression %q — every term must be of the form `key OP value`", ctx.GetText())
return ""
}
// VisitComparison dispatches a single `key OP value` term. A key that matches
@@ -407,50 +401,6 @@ func buildSubqueryForTagKeyAndValue(subqueryBuilder *sqlbuilder.SelectBuilder, t
return buildSubqueryForTagKey(subqueryBuilder, tagKey).Where(valuePredicate)
}
// ─── free-text search ────────────────────────────────────────────────────────
// buildFreeTextTerm matches value as a case-insensitive substring of the
// dashboard name, description, or any tag key/value.
func (v *visitor) buildFreeTextTerm(value string) string {
nameColumn := string(v.formatter.JSONExtractString("dashboard.data", "$.spec.display.name"))
descriptionColumn := string(v.formatter.JSONExtractString("dashboard.data", "$.spec.display.description"))
namePredicate := v.buildFreeTextContains(v.selectBuilder, nameColumn, value)
descriptionPredicate := v.buildFreeTextContains(v.selectBuilder, descriptionColumn, value)
subqueryBuilder := sqlbuilder.NewSelectBuilder()
keyPredicate := v.buildFreeTextContains(subqueryBuilder, "t.key", value)
valuePredicate := v.buildFreeTextContains(subqueryBuilder, "t.value", value)
buildSubqueryForFreeTextTag(subqueryBuilder, keyPredicate, valuePredicate)
tagPredicate := v.selectBuilder.Exists(subqueryBuilder)
return v.selectBuilder.Or(namePredicate, descriptionPredicate, tagPredicate)
}
// buildFreeTextContains emits a case-insensitive contains as
// LOWER(COALESCE(col, '')) LIKE LOWER(?), identical on SQLite and Postgres.
// COALESCE keeps a NULL column (an absent description) false rather than NULL —
// otherwise `NOT (…)` goes NULL and drops every description-less dashboard. The
// value's % and _ are escaped, and ESCAPE pins backslash as the escape char.
func (v *visitor) buildFreeTextContains(builder *sqlbuilder.SelectBuilder, columnExpression, value string) string {
lowerColumn := string(v.formatter.LowerExpression("COALESCE(" + columnExpression + ", '')"))
pattern := "%" + v.formatter.EscapeLikePattern(value) + "%"
return fmt.Sprintf("%s LIKE LOWER(%s) ESCAPE '\\'", lowerColumn, builder.Var(pattern))
}
func buildSubqueryForFreeTextTag(subqueryBuilder *sqlbuilder.SelectBuilder, keyPredicate, valuePredicate string) *sqlbuilder.SelectBuilder {
const dashboardTagKind = `"dashboard"`
return subqueryBuilder.
Select("1").
From("tag_relation tr").
Join("tag t", "t.id = tr.tag_id").
Where(
subqueryBuilder.Equal("tr.kind", dashboardTagKind),
"tr.resource_id = dashboard.id",
subqueryBuilder.Or(keyPredicate, valuePredicate),
)
}
// ─── value extraction helpers ───────────────────────────────────────────────
func (v *visitor) addError(format string, arguments ...any) {

View File

@@ -98,7 +98,7 @@ func (s *store) CreateOrGet(ctx context.Context, tags []*tagtypes.Tag) ([]*tagty
BunDBCtx(ctx).
NewInsert().
Model(&tags).
On("CONFLICT (org_id, kind, (LOWER(key)), (LOWER(value))) DO UPDATE").
// On("CONFLICT (org_id, kind, (LOWER(key)), (LOWER(value))) DO UPDATE").
Set("key = tag.key").
Returning("*").
Scan(ctx)

View File

@@ -88,60 +88,62 @@ func TestStore_Create_PopulatesIDsOnFreshInsert(t *testing.T) {
assert.Equal(t, preIDB, stored["team\x00blr"].ID)
}
func TestStore_Create_ConflictReturnsExistingRowID(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
// todo (@namanverma): uncomment once unique index is there.
//
// func TestStore_Create_ConflictReturnsExistingRowID(t *testing.T) {
// ctx := context.Background()
// sqlstore := newTestStore(t)
// s := NewStore(sqlstore)
orgID := valuer.GenerateUUID()
// orgID := valuer.GenerateUUID()
// Simulate a concurrent insert: someone else has already inserted "tag:Database".
winner := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
_, err := s.CreateOrGet(ctx, []*tagtypes.Tag{winner})
require.NoError(t, err)
winnerID := winner.ID
// // Simulate a concurrent insert: someone else has already inserted "tag:Database".
// winner := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
// _, err := s.CreateOrGet(ctx, []*tagtypes.Tag{winner})
// require.NoError(t, err)
// winnerID := winner.ID
// Now our request runs with a different pre-generated ID for the same
// (key, value) — case differs but the functional unique index collapses
// them. RETURNING should overwrite our stale ID with winner's ID.
loser := tagtypes.NewTag(orgID, dashboardKind, "TAG", "DATABASE")
loserPreID := loser.ID
require.NotEqual(t, winnerID, loserPreID, "pre-generated IDs must differ for this test to be meaningful")
// // Now our request runs with a different pre-generated ID for the same
// // (key, value) — case differs but the functional unique index collapses
// // them. RETURNING should overwrite our stale ID with winner's ID.
// loser := tagtypes.NewTag(orgID, dashboardKind, "TAG", "DATABASE")
// loserPreID := loser.ID
// require.NotEqual(t, winnerID, loserPreID, "pre-generated IDs must differ for this test to be meaningful")
got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{loser})
require.NoError(t, err)
require.Len(t, got, 1)
// got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{loser})
// require.NoError(t, err)
// require.Len(t, got, 1)
assert.Equal(t, winnerID, got[0].ID, "returned slice should carry the existing row's ID, not our stale one")
assert.Equal(t, winnerID, loser.ID, "input slice element is mutated in place")
// assert.Equal(t, winnerID, got[0].ID, "returned slice should carry the existing row's ID, not our stale one")
// assert.Equal(t, winnerID, loser.ID, "input slice element is mutated in place")
// And the DB still has exactly one row for that (lower(key), lower(value)) — winner's, with winner's casing.
stored := tagsByLowerKeyValue(t, sqlstore.BunDB())
require.Len(t, stored, 1)
assert.Equal(t, winnerID, stored["tag\x00database"].ID)
assert.Equal(t, "tag", stored["tag\x00database"].Key, "winner's casing preserved in key")
assert.Equal(t, "Database", stored["tag\x00database"].Value, "winner's casing preserved in value")
}
// // And the DB still has exactly one row for that (lower(key), lower(value)) — winner's, with winner's casing.
// stored := tagsByLowerKeyValue(t, sqlstore.BunDB())
// require.Len(t, stored, 1)
// assert.Equal(t, winnerID, stored["tag\x00database"].ID)
// assert.Equal(t, "tag", stored["tag\x00database"].Key, "winner's casing preserved in key")
// assert.Equal(t, "Database", stored["tag\x00database"].Value, "winner's casing preserved in value")
// }
func TestStore_Create_MixedFreshAndConflict(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
// func TestStore_Create_MixedFreshAndConflict(t *testing.T) {
// ctx := context.Background()
// sqlstore := newTestStore(t)
// s := NewStore(sqlstore)
orgID := valuer.GenerateUUID()
pre := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
_, err := s.CreateOrGet(ctx, []*tagtypes.Tag{pre})
require.NoError(t, err)
preExistingID := pre.ID
// orgID := valuer.GenerateUUID()
// pre := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
// _, err := s.CreateOrGet(ctx, []*tagtypes.Tag{pre})
// require.NoError(t, err)
// preExistingID := pre.ID
conflict := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
fresh := tagtypes.NewTag(orgID, dashboardKind, "team", "BLR")
freshPreID := fresh.ID
// conflict := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
// fresh := tagtypes.NewTag(orgID, dashboardKind, "team", "BLR")
// freshPreID := fresh.ID
got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{conflict, fresh})
require.NoError(t, err)
require.Len(t, got, 2)
// got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{conflict, fresh})
// require.NoError(t, err)
// require.Len(t, got, 2)
assert.Equal(t, preExistingID, got[0].ID, "conflicting row's ID overwritten with the existing row's")
assert.Equal(t, freshPreID, got[1].ID, "fresh row's pre-generated ID is preserved")
}
// assert.Equal(t, preExistingID, got[0].ID, "conflicting row's ID overwritten with the existing row's")
// assert.Equal(t, freshPreID, got[1].ID, "fresh row's pre-generated ID is preserved")
// }

View File

@@ -219,7 +219,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddMetricReductionRulesFactory(sqlstore, sqlschema),
sqlmigration.NewRemoveOrganizationTuplesFactory(sqlstore),
sqlmigration.NewAddRoleTransactionGroupsFactory(sqlstore, sqlschema),
sqlmigration.NewAddTagUniqueIndexFactory(sqlstore, sqlschema),
)
}

View File

@@ -1,59 +0,0 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addTagUniqueIndex struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddTagUniqueIndexFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_tag_unique_index"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addTagUniqueIndex{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addTagUniqueIndex) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addTagUniqueIndex) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
sqls := migration.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndexWithExpressions{
TableName: "tag",
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
},
)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addTagUniqueIndex) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -76,9 +76,9 @@ type PostableRule struct {
}
type NotificationSettings struct {
GroupBy []string `json:"groupBy,omitempty"`
Renotify *Renotify `json:"renotify,omitempty"`
UsePolicy bool `json:"usePolicy,omitempty"`
GroupBy []string `json:"groupBy,omitempty"`
Renotify Renotify `json:"renotify,omitzero"`
UsePolicy bool `json:"usePolicy,omitempty"`
// NewGroupEvalDelay is the grace period for new series to be excluded from alerts evaluation
NewGroupEvalDelay valuer.TextDuration `json:"newGroupEvalDelay,omitzero"`
}
@@ -92,7 +92,7 @@ type Renotify struct {
func (ns *NotificationSettings) GetAlertManagerNotificationConfig() alertmanagertypes.NotificationConfig {
var renotifyInterval time.Duration
var noDataRenotifyInterval time.Duration
if ns.Renotify != nil && ns.Renotify.Enabled {
if ns.Renotify.Enabled {
if slices.Contains(ns.Renotify.AlertStates, StateNoData) {
noDataRenotifyInterval = ns.Renotify.ReNotifyInterval.Duration()
}
@@ -204,12 +204,10 @@ func (ns *NotificationSettings) UnmarshalJSON(data []byte) error {
}
// Validate states after unmarshaling
if ns.Renotify != nil {
for _, state := range ns.Renotify.AlertStates {
if state != StateFiring && state != StateNoData {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid alert state: %s", state)
for _, state := range ns.Renotify.AlertStates {
if state != StateFiring && state != StateNoData {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid alert state: %s", state)
}
}
}
return nil
@@ -222,11 +220,6 @@ func (r *PostableRule) processRuleDefaults() {
r.SchemaVersion = DefaultSchemaVersion
}
// TODO(srikanthccv): remove as this is now a legacy field
if r.Version == "" {
r.Version = "v5"
}
// v2alpha1 uses the Evaluation envelope for window/frequency;
// only default top-level fields for v1.
if r.SchemaVersion != SchemaVersionV2Alpha1 {
@@ -278,7 +271,7 @@ func (r *PostableRule) processRuleDefaults() {
r.RuleCondition.Thresholds = &thresholdData
r.Evaluation = &EvaluationEnvelope{RollingEvaluation, RollingWindow{EvalWindow: r.EvalWindow, Frequency: r.Frequency}}
r.NotificationSettings = &NotificationSettings{
Renotify: &Renotify{
Renotify: Renotify{
Enabled: true,
ReNotifyInterval: valuer.MustParseTextDuration("4h"),
AlertStates: []AlertState{StateFiring},
@@ -564,7 +557,7 @@ func (r *PostableRule) validateV2Alpha1() []error {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"notificationSettings: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
} else {
if r.NotificationSettings.Renotify != nil && r.NotificationSettings.Renotify.Enabled && !r.NotificationSettings.Renotify.ReNotifyInterval.IsPositive() {
if r.NotificationSettings.Renotify.Enabled && !r.NotificationSettings.Renotify.ReNotifyInterval.IsPositive() {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"notificationSettings.renotify.interval: must be a positive duration when renotify is enabled"))
}

View File

@@ -6,7 +6,6 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -1328,27 +1327,3 @@ func TestAnomalyNegationEval(t *testing.T) {
})
}
}
func TestVersionDefaultsToV5(t *testing.T) {
content := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"schemaVersion": "v2alpha1",
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "up"}}],
"panelType": "graph",
"queryType": "promql"
},
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "1", "op": "1"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"usePolicy": false}
}`
rule := PostableRule{}
require.NoError(t, json.Unmarshal([]byte(content), &rule))
assert.Equal(t, "v5", rule.Version)
assert.NoError(t, rule.Validate())
}

View File

@@ -1,258 +0,0 @@
package ruletypes
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func readBody(t *testing.T, stored string) map[string]json.RawMessage {
t.Helper()
g := GettableRule{}
require.NoError(t, json.Unmarshal([]byte(stored), &g))
out, err := json.Marshal(NewRule(&g))
require.NoError(t, err)
var body map[string]json.RawMessage
require.NoError(t, json.Unmarshal(out, &body))
return body
}
func TestV2RoundTripRolling(t *testing.T) {
thresholds := `{
"kind": "basic",
"spec": [
{"name": "critical", "target": 90.5, "targetUnit": "%", "recoveryTarget": 80.5, "matchType": "at_least_once", "op": "above", "channels": ["slack-critical"]},
{"name": "warning", "target": 75, "targetUnit": "%", "matchType": "at_least_once", "op": "above", "channels": ["slack-warnings", "email-oncall"]}
]
}`
thresholdsEchoed := `{
"kind": "basic",
"spec": [
{"name": "critical", "target": 90.5, "targetUnit": "%", "recoveryTarget": 80.5, "matchType": "at_least_once", "op": "above", "channels": ["slack-critical"]},
{"name": "warning", "target": 75, "targetUnit": "%", "recoveryTarget": null, "matchType": "at_least_once", "op": "above", "channels": ["slack-warnings", "email-oncall"]}
]
}`
evaluation := `{"kind": "rolling", "spec": {"evalWindow": "90m", "frequency": "90s"}}`
notificationSettings := `{
"groupBy": ["service.name", "deployment.environment"],
"renotify": {"enabled": true, "interval": "45m", "alertStates": ["firing", "nodata"]},
"usePolicy": true,
"newGroupEvalDelay": "10m"
}`
labels := `{"team": "infra", "severity": "critical"}`
annotations := `{"summary": "CPU above {{$threshold}}", "description": "value {{$value}}"}`
stored := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"description": "watches cpu",
"ruleType": "promql_rule",
"schemaVersion": "v2alpha1",
"version": "v5",
"disabled": true,
"labels": ` + labels + `,
"annotations": ` + annotations + `,
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "avg(cpu_usage)"}}],
"panelType": "graph",
"queryType": "promql",
"unit": "percent"
},
"selectedQueryName": "A",
"alertOnAbsent": true,
"absentFor": 10,
"requireMinPoints": true,
"requiredNumPoints": 4,
"thresholds": ` + thresholds + `
},
"evaluation": ` + evaluation + `,
"notificationSettings": ` + notificationSettings + `
}`
body := readBody(t, stored)
assert.JSONEq(t, `"cpu high"`, string(body["alert"]))
assert.JSONEq(t, `"METRIC_BASED_ALERT"`, string(body["alertType"]))
assert.JSONEq(t, `"watches cpu"`, string(body["description"]))
assert.JSONEq(t, `"promql_rule"`, string(body["ruleType"]))
assert.JSONEq(t, `"v2alpha1"`, string(body["schemaVersion"]))
assert.JSONEq(t, `true`, string(body["disabled"]))
assert.JSONEq(t, labels, string(body["labels"]))
assert.JSONEq(t, annotations, string(body["annotations"]))
assert.JSONEq(t, evaluation, string(body["evaluation"]))
assert.JSONEq(t, notificationSettings, string(body["notificationSettings"]))
var condition map[string]json.RawMessage
require.NoError(t, json.Unmarshal(body["condition"], &condition))
assert.JSONEq(t, thresholdsEchoed, string(condition["thresholds"]))
assert.JSONEq(t, `"A"`, string(condition["selectedQueryName"]))
assert.JSONEq(t, `true`, string(condition["alertOnAbsent"]))
assert.JSONEq(t, `10`, string(condition["absentFor"]))
assert.JSONEq(t, `true`, string(condition["requireMinPoints"]))
assert.JSONEq(t, `4`, string(condition["requiredNumPoints"]))
}
func TestV2RoundTripCumulative(t *testing.T) {
evaluation := `{
"kind": "cumulative",
"spec": {
"schedule": {"type": "daily", "minute": 30, "hour": 9},
"frequency": "5m",
"timezone": "America/New_York"
}
}`
stored := `{
"alert": "daily budget",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"schemaVersion": "v2alpha1",
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "sum(cost)"}}],
"panelType": "graph",
"queryType": "promql"
},
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 100, "matchType": "at_least_once", "op": "above"}]}
},
"evaluation": ` + evaluation + `,
"notificationSettings": {"usePolicy": false}
}`
body := readBody(t, stored)
assert.JSONEq(t, evaluation, string(body["evaluation"]))
}
func TestV2MinimalReadShape(t *testing.T) {
stored := `{
"alert": "minimal",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"schemaVersion": "v2alpha1",
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "up"}}],
"panelType": "graph",
"queryType": "promql"
},
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"usePolicy": false}
}`
body := readBody(t, stored)
for _, field := range []string{"labels", "annotations", "description", "preferredChannels", "evalWindow", "frequency"} {
assert.NotContains(t, body, field)
}
var ns map[string]json.RawMessage
require.NoError(t, json.Unmarshal(body["notificationSettings"], &ns))
for _, field := range []string{"renotify", "groupBy", "newGroupEvalDelay", "usePolicy"} {
assert.NotContains(t, ns, field, "notificationSettings.%s", field)
}
assert.JSONEq(t, `false`, string(body["disabled"]))
assert.JSONEq(t, `"v5"`, string(body["version"]))
var condition struct {
Thresholds struct {
Spec []map[string]json.RawMessage `json:"spec"`
} `json:"thresholds"`
}
require.NoError(t, json.Unmarshal(body["condition"], &condition))
require.Len(t, condition.Thresholds.Spec, 1)
spec := condition.Thresholds.Spec[0]
assert.JSONEq(t, `""`, string(spec["targetUnit"]))
assert.JSONEq(t, `null`, string(spec["channels"]))
assert.JSONEq(t, `null`, string(spec["recoveryTarget"]))
}
func TestRenotifyRoundTrip(t *testing.T) {
base := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"schemaVersion": "v2alpha1",
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "up"}}],
"panelType": "graph",
"queryType": "promql"
},
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": %s
}`
cases := []struct {
name string
settings string
wantRenotify string
}{
{
name: "absent renotify stays absent",
settings: `{"usePolicy": false}`,
},
{
name: "explicitly disabled renotify is echoed",
settings: `{"renotify": {"enabled": false}}`,
wantRenotify: `{"enabled": false}`,
},
{
name: "enabled renotify with states is echoed",
settings: `{"renotify": {"enabled": true, "interval": "30m", "alertStates": ["firing"]}}`,
wantRenotify: `{"enabled": true, "interval": "30m", "alertStates": ["firing"]}`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := readBody(t, strings.Replace(base, "%s", tc.settings, 1))
var ns map[string]json.RawMessage
require.NoError(t, json.Unmarshal(body["notificationSettings"], &ns))
if tc.wantRenotify == "" {
assert.NotContains(t, ns, "renotify")
} else {
assert.JSONEq(t, tc.wantRenotify, string(ns["renotify"]))
}
})
}
}
func TestPatchMergePreservesUnpatchedFields(t *testing.T) {
stored := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"schemaVersion": "v2alpha1",
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "up"}}],
"panelType": "graph",
"queryType": "promql"
},
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "30m", "alertStates": ["firing"]}}
}`
rule := PostableRule{}
require.NoError(t, json.Unmarshal([]byte(stored), &rule))
require.NoError(t, json.Unmarshal([]byte(`{"disabled": true}`), &rule))
require.NoError(t, rule.Validate())
assert.True(t, rule.Disabled)
assert.NotNil(t, rule.RuleCondition.Thresholds)
assert.NotNil(t, rule.Evaluation)
require.NotNil(t, rule.NotificationSettings)
require.NotNil(t, rule.NotificationSettings.Renotify)
assert.True(t, rule.NotificationSettings.Renotify.Enabled)
}

View File

@@ -169,10 +169,12 @@ func TestValidate_PostableRule_Common(t *testing.T) {
errSubstr: "alert",
},
// only "v5" is allowed; missing/empty defaults to "v5"
// only "v5" is allowed
{
name: "missing version defaults to v5",
json: removeField(validV1Builder(), "version"),
name: "missing version",
json: removeField(validV1Builder(), "version"),
wantErr: true,
errSubstr: "version",
},
{
name: "wrong version v4",
@@ -187,8 +189,10 @@ func TestValidate_PostableRule_Common(t *testing.T) {
errSubstr: "version",
},
{
name: "empty version defaults to v5",
json: patchJSON(validV1Builder(), `{"version": ""}`),
name: "empty version",
json: patchJSON(validV1Builder(), `{"version": ""}`),
wantErr: true,
errSubstr: "version",
},
// alert type, capital case to avoid breaking changes

View File

@@ -54,7 +54,7 @@ type SpanMapper struct {
types.UserAuditable
ID valuer.UUID `json:"id" required:"true"`
GroupID valuer.UUID `json:"groupId" required:"true"`
GroupID valuer.UUID `json:"group_id" required:"true"`
Name string `json:"name" required:"true"`
FieldContext FieldContext `json:"fieldContext" required:"true"`
Config SpanMapperConfig `json:"config" required:"true"`
@@ -63,7 +63,7 @@ type SpanMapper struct {
type PostableSpanMapper struct {
Name string `json:"name" required:"true"`
FieldContext FieldContext `json:"fieldContext" required:"true"`
FieldContext FieldContext `json:"fieldContext" required:"true"`
Config SpanMapperConfig `json:"config" required:"true"`
Enabled bool `json:"enabled"`
}

View File

@@ -723,74 +723,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
assert response.status_code == HTTPStatus.OK, response.text
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == expected, query
# ── stage 5: free-text search (bare-word terms) ──────────────────────────
# A bare word is a case-insensitive substring search over the name and every
# tag key/value. Consecutive words are separate terms AND'd together (implicit
# AND); a quoted token matches the whole phrase; and a term composes with
# comparisons via AND/OR.
free_text_cases = [
# name substring, matched case-insensitively
("overview", {"Alpha Overview", "Beta Overview", "Zeta Overview"}),
# matches a name substring on some rows and a tag value on the same rows
("storage", {"Gamma Storage", "Delta Storage"}),
# tag value only (no name contains "pulse")
("pulse", {"Alpha Overview", "Beta Overview", "Zeta Overview"}),
# tag value only
("critical", {"Delta Storage", "Epsilon Metrics"}),
# tag key only
("tier", {"Delta Storage", "Epsilon Metrics"}),
# tag key present on every dashboard
(
"env",
{
"Alpha Overview",
"Beta Overview",
"Gamma Storage",
"Delta Storage",
"Epsilon Metrics",
"Zeta Overview",
},
),
# two words AND'd: only Delta matches both "delta" and "storage"
("delta storage", {"Delta Storage"}),
# two words AND'd with no dashboard matching both
("overview storage", set()),
# a quoted token matches the whole phrase
('"Alpha Overview"', {"Alpha Overview"}),
# a free-text term AND'd with a comparison (the reviewer's case)
("pulse AND env = 'prod'", {"Alpha Overview"}),
# a free-text term OR'd with a comparison
(
"storage OR env = 'staging'",
{"Gamma Storage", "Delta Storage", "Epsilon Metrics", "Zeta Overview"},
),
# no match anywhere
("nonexistent", set()),
# NOT over a term nothing matches returns everything — including these
# description-less dashboards, which a negated search must not exclude.
(
"NOT payment",
{
"Alpha Overview",
"Beta Overview",
"Gamma Storage",
"Delta Storage",
"Epsilon Metrics",
"Zeta Overview",
},
),
]
for query, expected in free_text_cases:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"query": query, "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == expected, query
# ── stage 6: name sort honours order ─────────────────────────────────────
# ── stage 5: name sort honours order ─────────────────────────────────────
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"sort": "name", "order": "asc", "limit": 200},
@@ -820,7 +753,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Alpha Overview",
]
# ── stage 7: pinning floats a dashboard to the top of any ordering ───────
# ── stage 6: pinning floats a dashboard to the top of any ordering ───────
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids['lc-gamma']}/pins"),
@@ -858,7 +791,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
]
assert all("pinned" not in d for d in response.json()["data"]["dashboards"])
# ── stage 8: unpinning restores the natural ordering ─────────────────────
# ── stage 7: unpinning restores the natural ordering ─────────────────────
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids['lc-gamma']}/pins"),
@@ -882,7 +815,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Zeta Overview",
]
# ── stage 9: update mutates the spec but keeps the immutable name ────────
# ── stage 8: update mutates the spec but keeps the immutable name ────────
update_body = {
"schemaVersion": "v6",
"name": "lc-alpha",
@@ -907,18 +840,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
)
assert response.json()["data"]["spec"]["display"]["description"] == "now with a description"
# free-text search also matches the description (only Alpha has one now);
# quoted so the phrase matches as one substring rather than per-word
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"query": '"now with a description"', "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == {"Alpha Overview"}
# ── stage 10: a locked dashboard rejects updates until unlocked ──────────
# ── stage 9: a locked dashboard rejects updates until unlocked ───────────
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}/lock"),
@@ -958,7 +880,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
== HTTPStatus.OK
)
# ── stage 11: delete removes the dashboard from get and list ─────────────
# ── stage 10: delete removes the dashboard from get and list ─────────────
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-gamma']}"),
@@ -990,7 +912,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Zeta Overview",
}
# ── stage 12: clone suffixes the display name and mints a new, retrievable one ─
# ── stage 11: clone suffixes the display name and mints a new, retrievable one ─
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-alpha']}/clone"),
headers={"Authorization": f"Bearer {token}"},