mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-10 08:30:33 +01:00
Compare commits
5 Commits
feat/expor
...
feat/expor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
851ecba738 | ||
|
|
02a0d2e21f | ||
|
|
1e5cd9e85e | ||
|
|
b96177d3c6 | ||
|
|
14f34eb07e |
@@ -1,4 +1,4 @@
|
||||
.timeseries-export-popover {
|
||||
.export-menu-popover {
|
||||
.ant-popover-inner {
|
||||
border-radius: 4px;
|
||||
background-color: var(--l2-background);
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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" />,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.traces-table-view-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
48
frontend/src/lib/exportData/exportScalarData.ts
Normal file
48
frontend/src/lib/exportData/exportScalarData.ts
Normal 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>[],
|
||||
});
|
||||
}
|
||||
46
frontend/src/lib/exportData/exportTableData.ts
Normal file
46
frontend/src/lib/exportData/exportTableData.ts
Normal 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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
11
frontend/src/lib/exportData/withUnit.ts
Normal file
11
frontend/src/lib/exportData/withUnit.ts
Normal 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})`;
|
||||
}
|
||||
Reference in New Issue
Block a user