Compare commits

...

2 Commits

Author SHA1 Message Date
aks07
851ecba738 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-10 12:56:53 +05:30
aks07
02a0d2e21f 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-10 12:56:53 +05:30
11 changed files with 221 additions and 92 deletions

View File

@@ -1,4 +1,4 @@
.timeseries-export-popover {
.export-menu-popover {
.ant-popover-inner {
border-radius: 4px;
background-color: var(--l2-background);

View File

@@ -2,42 +2,43 @@ import { Download, LoaderCircle } from '@signozhq/icons';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { Button, Popover, Tooltip } from 'antd';
import { useClientExport } from 'hooks/useExportData/useClientExport';
import {
ClientExportData,
useClientExport,
} from 'hooks/useExportData/useClientExport';
import { ExportFormat } from 'lib/exportData/types';
import { useCallback, useMemo, 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,
});
@@ -85,7 +86,7 @@ export default function TimeseriesExportMenu({
arrow={false}
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
rootClassName="timeseries-export-popover"
rootClassName="export-menu-popover"
>
<Tooltip title="Download" placement="top">
<Button
@@ -97,7 +98,7 @@ export default function TimeseriesExportMenu({
<Download size={14} />
)
}
data-testid={`timeseries-export-${dataSource}`}
data-testid={`export-menu-${dataSource}`}
disabled={isExporting}
/>
</Tooltip>

View File

@@ -1,8 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
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

@@ -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

@@ -1,7 +1,8 @@
import { act, renderHook } from '@testing-library/react';
import { downloadFile } 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';
@@ -21,43 +22,95 @@ 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', () => {
beforeEach(() => jest.clearAllMocks());
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(() => {
@@ -69,12 +122,28 @@ describe('useClientExport', () => {
expect(name).toMatch(/^chart-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.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).toMatch(/^table-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.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(() => {
@@ -87,8 +156,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 });
@@ -98,13 +167,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

@@ -3,11 +3,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';
@@ -19,35 +22,47 @@ 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 === 'time_series') {
if (data.rawV5Response?.type === '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;
// The builder query behind the response — series names resolve aggregation
// aliases/expressions from it, exactly like the chart legend.
// The queryRange response object the view already holds — the hook picks
// the serializer from what it carries.
data?: ClientExportData;
// The builder query behind the response — series/column names resolve
// aggregation aliases/expressions from it, exactly like the chart does.
query?: Query;
yAxisUnit?: string;
fileName?: string;
legendMap?: Record<string, string>;
}
interface ClientExportOptions {
@@ -62,23 +77,22 @@ interface UseClientExportReturn {
// Frontend-driven export: serializes in-memory query results and downloads them
// client-side. Backend-driven export lives in useServerExport.
export function useClientExport({
response,
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];
@@ -89,7 +103,7 @@ export function useClientExport({
setIsExporting(false);
}
},
[response, query, yAxisUnit, fileName, legendMap],
[data, query, yAxisUnit, fileName],
);
return { isExporting, handleExport };