mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-08 23:50:40 +01:00
Compare commits
4 Commits
issue_5123
...
feat/expor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6298fd564 | ||
|
|
b37617bdb3 | ||
|
|
ebb60b99b5 | ||
|
|
9b9c461fbd |
@@ -3,7 +3,7 @@ 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 { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
|
||||
import { useExportRawData } from 'hooks/useExportData/useServerExport';
|
||||
import { Download, LoaderCircle } from '@signozhq/icons';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { downloadFile } from 'lib/exportData/downloadFile';
|
||||
import { ExportFormat, TimeseriesShape } from 'lib/exportData/types';
|
||||
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import { useClientExport } from '../useClientExport';
|
||||
|
||||
jest.mock('lib/exportData/downloadFile', () => ({
|
||||
...jest.requireActual('lib/exportData/downloadFile'),
|
||||
downloadFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockMessageError = jest.fn();
|
||||
jest.mock('antd', () => {
|
||||
const actual = jest.requireActual('antd');
|
||||
return {
|
||||
...actual,
|
||||
message: { error: (...args: unknown[]): void => mockMessageError(...args) },
|
||||
};
|
||||
});
|
||||
|
||||
const mockDownloadFile = downloadFile as jest.Mock;
|
||||
|
||||
function timeSeriesResponse(): QueryRangeResponseV5 {
|
||||
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 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {},
|
||||
} as unknown as QueryRangeResponseV5;
|
||||
}
|
||||
|
||||
describe('useClientExport', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('exports time_series as CSV to a timestamped <fileName>.csv', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useClientExport({
|
||||
response: timeSeriesResponse(),
|
||||
fileName: 'chart',
|
||||
legendMap: { A: '{{service}}' },
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleExport({
|
||||
format: ExportFormat.Csv,
|
||||
shape: TimeseriesShape.Long,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
|
||||
const [content, name, mime] = mockDownloadFile.mock.calls[0];
|
||||
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', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useClientExport({ response: timeSeriesResponse() }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleExport({ format: ExportFormat.Jsonl });
|
||||
});
|
||||
|
||||
const [content, name, mime] = mockDownloadFile.mock.calls[0];
|
||||
expect(name).toMatch(/^export-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.jsonl$/);
|
||||
expect(mime).toContain('ndjson');
|
||||
expect(content).toContain('"series"');
|
||||
});
|
||||
|
||||
it('does nothing when there is no response', () => {
|
||||
const { result } = renderHook(() => useClientExport({}));
|
||||
|
||||
act(() => {
|
||||
result.current.handleExport({ format: ExportFormat.Csv });
|
||||
});
|
||||
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockMessageError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error and does not download for unsupported result types', () => {
|
||||
const raw = {
|
||||
type: 'raw',
|
||||
data: { results: [] },
|
||||
meta: {},
|
||||
} as unknown as QueryRangeResponseV5;
|
||||
const { result } = renderHook(() => useClientExport({ response: raw }));
|
||||
|
||||
act(() => {
|
||||
result.current.handleExport({ format: ExportFormat.Csv });
|
||||
});
|
||||
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockMessageError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
103
frontend/src/hooks/useExportData/useClientExport.ts
Normal file
103
frontend/src/hooks/useExportData/useClientExport.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { message } from 'antd';
|
||||
import {
|
||||
downloadFile,
|
||||
getTimestampedFileName,
|
||||
} from 'lib/exportData/downloadFile';
|
||||
import { exportTimeseriesData } from 'lib/exportData/exportTimeseriesData';
|
||||
import { toCsv } from 'lib/exportData/toCsv';
|
||||
import { toJsonl } from 'lib/exportData/toJsonl';
|
||||
import {
|
||||
ExportFormat,
|
||||
SerializedTable,
|
||||
TimeseriesShape,
|
||||
} from 'lib/exportData/types';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
|
||||
|
||||
const FORMAT_META: Record<ExportFormat, { mime: string; extension: string }> = {
|
||||
[ExportFormat.Csv]: { mime: 'text/csv;charset=utf-8;', extension: 'csv' },
|
||||
[ExportFormat.Jsonl]: {
|
||||
mime: 'application/x-ndjson;charset=utf-8;',
|
||||
extension: 'jsonl',
|
||||
},
|
||||
};
|
||||
|
||||
// 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.
|
||||
function serialize(
|
||||
response: QueryRangeResponseV5,
|
||||
shape: TimeseriesShape,
|
||||
yAxisUnit?: string,
|
||||
legendMap?: Record<string, string>,
|
||||
query?: Query,
|
||||
): SerializedTable {
|
||||
if (response.type === 'time_series') {
|
||||
return exportTimeseriesData({
|
||||
data: response.data.results as TimeSeriesData[],
|
||||
shape,
|
||||
yAxisUnit,
|
||||
legendMap,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Export is not supported for "${response.type}" results`);
|
||||
}
|
||||
|
||||
interface UseClientExportProps {
|
||||
response?: QueryRangeResponseV5;
|
||||
// The builder query behind the response — series names resolve aggregation
|
||||
// aliases/expressions from it, exactly like the chart legend.
|
||||
query?: Query;
|
||||
yAxisUnit?: string;
|
||||
fileName?: string;
|
||||
legendMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ClientExportOptions {
|
||||
format: ExportFormat;
|
||||
shape?: TimeseriesShape;
|
||||
}
|
||||
|
||||
interface UseClientExportReturn {
|
||||
isExporting: boolean;
|
||||
handleExport: (options: ClientExportOptions) => void;
|
||||
}
|
||||
|
||||
// Frontend-driven export: serializes in-memory query results and downloads them
|
||||
// client-side. Backend-driven export lives in useServerExport.
|
||||
export function useClientExport({
|
||||
response,
|
||||
query,
|
||||
yAxisUnit,
|
||||
fileName = 'export',
|
||||
legendMap,
|
||||
}: UseClientExportProps): UseClientExportReturn {
|
||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||
|
||||
const handleExport = useCallback(
|
||||
({ format, shape = TimeseriesShape.Long }: ClientExportOptions): void => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const table = serialize(response, shape, yAxisUnit, legendMap, query);
|
||||
const content =
|
||||
format === ExportFormat.Jsonl ? toJsonl(table) : toCsv(table);
|
||||
const { mime, extension } = FORMAT_META[format];
|
||||
downloadFile(content, getTimestampedFileName(fileName, extension), mime);
|
||||
} catch {
|
||||
message.error('Failed to export data. Please try again.');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
},
|
||||
[response, query, yAxisUnit, fileName, legendMap],
|
||||
);
|
||||
|
||||
return { isExporting, handleExport };
|
||||
}
|
||||
56
frontend/src/lib/exportData/__tests__/downloadFile.test.ts
Normal file
56
frontend/src/lib/exportData/__tests__/downloadFile.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { downloadFile, getTimestampedFileName } from '../downloadFile';
|
||||
|
||||
// jsdom doesn't implement the object-URL APIs; define stubs so jest.spyOn can wrap them.
|
||||
if (typeof URL.createObjectURL !== 'function') {
|
||||
URL.createObjectURL = (): string => '';
|
||||
}
|
||||
if (typeof URL.revokeObjectURL !== 'function') {
|
||||
URL.revokeObjectURL = (): void => undefined;
|
||||
}
|
||||
|
||||
describe('downloadFile', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('builds a blob anchor, clicks it, and revokes the object URL', () => {
|
||||
const click = jest.fn();
|
||||
const remove = jest.fn();
|
||||
const anchor = {
|
||||
href: '',
|
||||
download: '',
|
||||
click,
|
||||
remove,
|
||||
} as unknown as HTMLAnchorElement;
|
||||
|
||||
(
|
||||
jest.spyOn(document, 'createElement') as unknown as jest.Mock
|
||||
).mockReturnValue(anchor);
|
||||
const createObjectURL = jest
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob:mock');
|
||||
const revokeObjectURL = jest.spyOn(URL, 'revokeObjectURL');
|
||||
|
||||
downloadFile('hello', 'export.csv', 'text/csv');
|
||||
|
||||
expect(anchor.download).toBe('export.csv');
|
||||
expect(anchor.href).toBe('blob:mock');
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
expect(createObjectURL).toHaveBeenCalled();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimestampedFileName', () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('appends a local timestamp between base and extension', () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date(2026, 6, 8, 14, 32, 5));
|
||||
|
||||
expect(getTimestampedFileName('logs-timeseries', 'csv')).toBe(
|
||||
'logs-timeseries-2026-07-08_14-32-05.csv',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
|
||||
|
||||
import { exportTimeseriesData } from '../exportTimeseriesData';
|
||||
import { TimeseriesShape } from '../types';
|
||||
|
||||
const iso = (ms: number): string => new Date(ms).toISOString();
|
||||
|
||||
function makeSeries(
|
||||
labels: Record<string, string>,
|
||||
values: [number, number][],
|
||||
): TimeSeries {
|
||||
return {
|
||||
labels: Object.entries(labels).map(([name, value]) => ({
|
||||
key: { name },
|
||||
value,
|
||||
})),
|
||||
values: values.map(([timestamp, value]) => ({ timestamp, value })),
|
||||
};
|
||||
}
|
||||
|
||||
function makeQuery(
|
||||
queryName: string,
|
||||
buckets: { index?: number; alias?: string; series: TimeSeries[] }[],
|
||||
): TimeSeriesData {
|
||||
return {
|
||||
queryName,
|
||||
aggregations: buckets.map((bucket, i) => ({
|
||||
index: bucket.index ?? i,
|
||||
alias: bucket.alias ?? '',
|
||||
meta: {},
|
||||
series: bucket.series,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('exportTimeseriesData', () => {
|
||||
it('LONG: query column, label column, unit in value header, legend naming', () => {
|
||||
const data = [
|
||||
makeQuery('A', [
|
||||
{
|
||||
series: [
|
||||
makeSeries({ service_name: 'frontend' }, [
|
||||
[1000, 12],
|
||||
[2000, 15],
|
||||
]),
|
||||
],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const table = exportTimeseriesData({
|
||||
data,
|
||||
shape: TimeseriesShape.Long,
|
||||
yAxisUnit: 'ms',
|
||||
legendMap: { A: '{{service_name}}' },
|
||||
});
|
||||
|
||||
expect(table.headers).toStrictEqual([
|
||||
'timestamp',
|
||||
'query',
|
||||
'series',
|
||||
'service_name',
|
||||
'value (ms)',
|
||||
]);
|
||||
expect(table.rows).toStrictEqual([
|
||||
[iso(1000), 'A', 'frontend', 'frontend', 12],
|
||||
[iso(2000), 'A', 'frontend', 'frontend', 15],
|
||||
]);
|
||||
});
|
||||
|
||||
it('LONG: no legend falls back to the label-set name from getLabelName', () => {
|
||||
const data = [
|
||||
makeQuery('A', [
|
||||
{ series: [makeSeries({ service_name: 'frontend' }, [[1000, 12]])] },
|
||||
]),
|
||||
];
|
||||
|
||||
const table = exportTimeseriesData({ data, shape: TimeseriesShape.Long });
|
||||
|
||||
expect(table.headers).toStrictEqual([
|
||||
'timestamp',
|
||||
'query',
|
||||
'series',
|
||||
'service_name',
|
||||
'value',
|
||||
]);
|
||||
expect(table.rows).toStrictEqual([
|
||||
[iso(1000), 'A', '{service_name="frontend"}', 'frontend', 12],
|
||||
]);
|
||||
});
|
||||
|
||||
it('WIDE: one column per series, sorted timestamps, blank for gaps', () => {
|
||||
const data = [
|
||||
makeQuery('A', [
|
||||
{
|
||||
series: [
|
||||
makeSeries({ service: 'a' }, [
|
||||
[1000, 1],
|
||||
[2000, 2],
|
||||
]),
|
||||
makeSeries({ service: 'b' }, [[2000, 20]]),
|
||||
],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const table = exportTimeseriesData({
|
||||
data,
|
||||
shape: TimeseriesShape.Wide,
|
||||
legendMap: { A: '{{service}}' },
|
||||
});
|
||||
|
||||
expect(table.headers).toStrictEqual(['timestamp', 'a', 'b']);
|
||||
expect(table.rows).toStrictEqual([
|
||||
[iso(1000), 1, ''],
|
||||
[iso(2000), 2, 20],
|
||||
]);
|
||||
});
|
||||
|
||||
it('WIDE: appends the y-axis unit to every series header', () => {
|
||||
const data = [
|
||||
makeQuery('A', [{ series: [makeSeries({ service: 'a' }, [[1000, 1]])] }]),
|
||||
];
|
||||
|
||||
const table = exportTimeseriesData({
|
||||
data,
|
||||
shape: TimeseriesShape.Wide,
|
||||
yAxisUnit: 'ms',
|
||||
legendMap: { A: '{{service}}' },
|
||||
});
|
||||
|
||||
expect(table.headers).toStrictEqual(['timestamp', 'a (ms)']);
|
||||
expect(table.rows).toStrictEqual([[iso(1000), 1]]);
|
||||
});
|
||||
|
||||
it('LONG multi-query: series column is not query-prefixed (query has its own column)', () => {
|
||||
const data = [
|
||||
makeQuery('A', [{ series: [makeSeries({ service: 'x' }, [[1000, 1]])] }]),
|
||||
makeQuery('B', [{ series: [makeSeries({ service: 'y' }, [[1000, 2]])] }]),
|
||||
];
|
||||
|
||||
const table = exportTimeseriesData({
|
||||
data,
|
||||
shape: TimeseriesShape.Long,
|
||||
legendMap: { A: '{{service}}', B: '{{service}}' },
|
||||
});
|
||||
|
||||
expect(table.headers).toStrictEqual([
|
||||
'timestamp',
|
||||
'query',
|
||||
'series',
|
||||
'service',
|
||||
'value',
|
||||
]);
|
||||
expect(table.rows).toStrictEqual([
|
||||
[iso(1000), 'A', 'x', 'x', 1],
|
||||
[iso(1000), 'B', 'y', 'y', 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('WIDE multi-query: series headers stay distinct via their names', () => {
|
||||
const data = [
|
||||
makeQuery('A', [{ series: [makeSeries({ service: 'x' }, [[1000, 1]])] }]),
|
||||
makeQuery('B', [{ series: [makeSeries({ service: 'y' }, [[1000, 2]])] }]),
|
||||
];
|
||||
|
||||
const table = exportTimeseriesData({
|
||||
data,
|
||||
shape: TimeseriesShape.Wide,
|
||||
legendMap: { A: '{{service}}', B: '{{service}}' },
|
||||
});
|
||||
|
||||
expect(table.headers).toStrictEqual(['timestamp', 'x', 'y']);
|
||||
expect(table.rows).toStrictEqual([[iso(1000), 1, 2]]);
|
||||
});
|
||||
|
||||
it('multi-aggregation with the builder query: names match the chart legend', () => {
|
||||
const data = [
|
||||
makeQuery('A', [
|
||||
{ index: 0, alias: '__result_0', series: [makeSeries({}, [[1000, 5]])] },
|
||||
{
|
||||
index: 1,
|
||||
alias: '__result_1',
|
||||
series: [makeSeries({}, [[1000, 300]])],
|
||||
},
|
||||
]),
|
||||
makeQuery('B', [
|
||||
{
|
||||
index: 0,
|
||||
alias: '__result_0',
|
||||
series: [
|
||||
makeSeries({ 'cloud.account.id': 'signoz-staging' }, [[1000, 7]]),
|
||||
],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const query = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: 'logs',
|
||||
aggregations: [
|
||||
{ expression: 'count()' },
|
||||
{ expression: 'avg(code.lineno)' },
|
||||
],
|
||||
groupBy: [],
|
||||
},
|
||||
{
|
||||
queryName: 'B',
|
||||
dataSource: 'logs',
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
groupBy: [{ key: 'cloud.account.id' }],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
} as unknown as Query;
|
||||
|
||||
const table = exportTimeseriesData({
|
||||
data,
|
||||
shape: TimeseriesShape.Wide,
|
||||
query,
|
||||
});
|
||||
|
||||
expect(table.headers).toStrictEqual([
|
||||
'timestamp',
|
||||
'count()-A',
|
||||
'avg(code.lineno)-A',
|
||||
'{cloud.account.id="signoz-staging"}',
|
||||
]);
|
||||
});
|
||||
|
||||
it('multi-aggregation without the builder query: falls back to base names, deduped', () => {
|
||||
const data = [
|
||||
makeQuery('A', [
|
||||
{ index: 0, alias: '__result_0', series: [makeSeries({}, [[1000, 5]])] },
|
||||
{
|
||||
index: 1,
|
||||
alias: '__result_1',
|
||||
series: [makeSeries({}, [[1000, 300]])],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const table = exportTimeseriesData({ data, shape: TimeseriesShape.Wide });
|
||||
|
||||
expect(table.headers).toStrictEqual(['timestamp', 'A', 'A (2)']);
|
||||
});
|
||||
|
||||
it('empty data: returns a headers-only table', () => {
|
||||
expect(
|
||||
exportTimeseriesData({ data: [], shape: TimeseriesShape.Long }),
|
||||
).toStrictEqual({
|
||||
headers: ['timestamp', 'query', 'series', 'value'],
|
||||
rows: [],
|
||||
});
|
||||
expect(
|
||||
exportTimeseriesData({ data: [], shape: TimeseriesShape.Wide }),
|
||||
).toStrictEqual({
|
||||
headers: ['timestamp'],
|
||||
rows: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
42
frontend/src/lib/exportData/__tests__/formatters.test.ts
Normal file
42
frontend/src/lib/exportData/__tests__/formatters.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { toCsv } from '../toCsv';
|
||||
import { toJsonl } from '../toJsonl';
|
||||
import { SerializedTable } from '../types';
|
||||
|
||||
const table: SerializedTable = {
|
||||
headers: ['timestamp', 'value'],
|
||||
rows: [
|
||||
['t1', 12],
|
||||
['t2', 15],
|
||||
],
|
||||
};
|
||||
|
||||
describe('toCsv', () => {
|
||||
it('emits a header row then one row per record, in column order', () => {
|
||||
expect(toCsv(table).split(/\r?\n/)).toStrictEqual([
|
||||
'timestamp,value',
|
||||
't1,12',
|
||||
't2,15',
|
||||
]);
|
||||
});
|
||||
|
||||
it('quotes values containing the delimiter', () => {
|
||||
const csv = toCsv({ headers: ['name', 'value'], rows: [['a,b', 1]] });
|
||||
expect(csv.split(/\r?\n/)).toStrictEqual(['name,value', '"a,b",1']);
|
||||
});
|
||||
|
||||
it('emits only the header row when there are no data rows', () => {
|
||||
expect(toCsv({ headers: ['timestamp'], rows: [] })).toBe('timestamp\r\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toJsonl', () => {
|
||||
it('emits one JSON object per row keyed by header', () => {
|
||||
expect(toJsonl(table)).toBe(
|
||||
'{"timestamp":"t1","value":12}\n{"timestamp":"t2","value":15}',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits an empty string when there are no rows', () => {
|
||||
expect(toJsonl({ headers: ['timestamp'], rows: [] })).toBe('');
|
||||
});
|
||||
});
|
||||
29
frontend/src/lib/exportData/downloadFile.ts
Normal file
29
frontend/src/lib/exportData/downloadFile.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/** Triggers a browser download of in-memory string content as a file. */
|
||||
export function downloadFile(
|
||||
content: string,
|
||||
fileName: string,
|
||||
mime: string,
|
||||
): void {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** `base` + local timestamp + extension, e.g. `logs-timeseries-2026-07-08_14-32-05.csv`.
|
||||
* Keeps repeated exports from colliding and records when the export was taken. */
|
||||
export function getTimestampedFileName(
|
||||
base: string,
|
||||
extension: string,
|
||||
): string {
|
||||
const now = new Date();
|
||||
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
|
||||
now.getDate(),
|
||||
)}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
|
||||
return `${base}-${stamp}.${extension}`;
|
||||
}
|
||||
204
frontend/src/lib/exportData/exportTimeseriesData.ts
Normal file
204
frontend/src/lib/exportData/exportTimeseriesData.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
|
||||
import { SerializedTable, TimeseriesShape } from './types';
|
||||
|
||||
interface ExportTimeseriesDataArgs {
|
||||
data: TimeSeriesData[];
|
||||
shape: TimeseriesShape;
|
||||
yAxisUnit?: string;
|
||||
legendMap?: Record<string, string>;
|
||||
// The builder query that produced the data — lets series names resolve
|
||||
// aggregation aliases/expressions exactly like the chart legend does.
|
||||
query?: Query;
|
||||
}
|
||||
|
||||
// One row of the flattened V5 tree: a single (query, aggregation, label-set) series.
|
||||
interface FlatSeries {
|
||||
queryName: string;
|
||||
labels: Record<string, string>;
|
||||
name: string;
|
||||
values: { timestamp: number; value: number }[];
|
||||
}
|
||||
|
||||
// V5 labels [{key:{name}, value}] → {name: value} (the getLabelName contract).
|
||||
function foldLabels(labels: TimeSeries['labels']): Record<string, string> {
|
||||
const record: Record<string, string> = {};
|
||||
(labels ?? []).forEach((label) => {
|
||||
if (label.key?.name) {
|
||||
record[label.key.name] = String(label.value);
|
||||
}
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
// Series display name, matching the chart legend: getLabelName for the base
|
||||
// (legend template / label-set), then getLegend to resolve the aggregation
|
||||
// alias/expression from the builder query (the response only carries
|
||||
// auto-generated `__result_N` aliases). Same chain the uPlot layer uses.
|
||||
function seriesName(args: {
|
||||
labels: Record<string, string>;
|
||||
queryName: string;
|
||||
legend: string;
|
||||
aggIndex: number;
|
||||
alias: string;
|
||||
query?: Query;
|
||||
}): string {
|
||||
const { labels, queryName, legend, aggIndex, alias, query } = args;
|
||||
const baseName = getLabelName(labels, queryName, legend);
|
||||
|
||||
if (!query) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
const legacySeries = {
|
||||
queryName,
|
||||
metric: labels,
|
||||
values: [],
|
||||
metaData: { alias, index: aggIndex, queryName },
|
||||
} as QueryData;
|
||||
|
||||
return getLegend(legacySeries, query, baseName);
|
||||
}
|
||||
|
||||
// Walk results → aggregations → series into a flat, named list.
|
||||
function flatten(
|
||||
data: TimeSeriesData[],
|
||||
legendMap?: Record<string, string>,
|
||||
query?: Query,
|
||||
): FlatSeries[] {
|
||||
const flat: FlatSeries[] = [];
|
||||
data.forEach((result) => {
|
||||
const queryName = result.queryName ?? '';
|
||||
const legend = legendMap?.[queryName] ?? '';
|
||||
(result.aggregations ?? []).forEach((bucket) => {
|
||||
(bucket.series ?? []).forEach((series) => {
|
||||
const labels = foldLabels(series.labels);
|
||||
flat.push({
|
||||
queryName,
|
||||
labels,
|
||||
name: seriesName({
|
||||
labels,
|
||||
queryName,
|
||||
legend,
|
||||
aggIndex: bucket.index ?? 0,
|
||||
alias: bucket.alias ?? '',
|
||||
query,
|
||||
}),
|
||||
values: (series.values ?? []).map((value) => ({
|
||||
timestamp: value.timestamp,
|
||||
value: value.value,
|
||||
})),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return flat;
|
||||
}
|
||||
|
||||
// Guarantee unique column headers if two series share a display name.
|
||||
function dedupeHeaders(names: string[]): string[] {
|
||||
const counts: Record<string, number> = {};
|
||||
return names.map((name) => {
|
||||
if (counts[name] === undefined) {
|
||||
counts[name] = 1;
|
||||
return name;
|
||||
}
|
||||
counts[name] += 1;
|
||||
return `${name} (${counts[name]})`;
|
||||
});
|
||||
}
|
||||
|
||||
// Appends the y-axis unit to a header: `value` → `value (ms)`, `frontend` →
|
||||
// `frontend (ms)`. Shared by LONG's value column and every WIDE series column.
|
||||
function withUnit(header: string, yAxisUnit?: string): string {
|
||||
return yAxisUnit ? `${header} (${yAxisUnit})` : header;
|
||||
}
|
||||
|
||||
function toIso(timestamp: number): string {
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
// LONG (tidy): one row per (series, timestamp). query is its own column.
|
||||
function buildLong(flat: FlatSeries[], yAxisUnit?: string): SerializedTable {
|
||||
const labelKeySet = new Set<string>();
|
||||
flat.forEach((series) => {
|
||||
Object.keys(series.labels).forEach((key) => labelKeySet.add(key));
|
||||
});
|
||||
const labelKeys = Array.from(labelKeySet).sort();
|
||||
|
||||
const headers = [
|
||||
'timestamp',
|
||||
'query',
|
||||
'series',
|
||||
...labelKeys,
|
||||
withUnit('value', yAxisUnit),
|
||||
];
|
||||
|
||||
const rows: (string | number)[][] = [];
|
||||
flat.forEach((series) => {
|
||||
series.values.forEach(({ timestamp, value }) => {
|
||||
rows.push([
|
||||
toIso(timestamp),
|
||||
series.queryName,
|
||||
series.name,
|
||||
...labelKeys.map((key) => series.labels[key] ?? ''),
|
||||
value,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
// WIDE (pivot): one row per timestamp, one column per series.
|
||||
function buildWide(flat: FlatSeries[], yAxisUnit?: string): SerializedTable {
|
||||
const timestampSet = new Set<number>();
|
||||
flat.forEach((series) => {
|
||||
series.values.forEach((value) => timestampSet.add(value.timestamp));
|
||||
});
|
||||
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
|
||||
|
||||
const valueBySeries = flat.map((series) => {
|
||||
const map = new Map<number, number>();
|
||||
series.values.forEach((value) => map.set(value.timestamp, value.value));
|
||||
return map;
|
||||
});
|
||||
|
||||
const seriesHeaders = dedupeHeaders(flat.map((series) => series.name)).map(
|
||||
(header) => withUnit(header, yAxisUnit),
|
||||
);
|
||||
|
||||
const headers = ['timestamp', ...seriesHeaders];
|
||||
const rows: (string | number)[][] = timestamps.map((timestamp) => {
|
||||
const row: (string | number)[] = [toIso(timestamp)];
|
||||
valueBySeries.forEach((map) => {
|
||||
const value = map.get(timestamp);
|
||||
row.push(value === undefined ? '' : value);
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a V5 time_series result into a format-agnostic table (LONG or WIDE).
|
||||
* Pure — walks the V5 tree directly; series names match the chart legend.
|
||||
*/
|
||||
export function exportTimeseriesData({
|
||||
data,
|
||||
shape,
|
||||
yAxisUnit,
|
||||
legendMap,
|
||||
query,
|
||||
}: ExportTimeseriesDataArgs): SerializedTable {
|
||||
const flat = flatten(data, legendMap, query);
|
||||
|
||||
return shape === TimeseriesShape.Wide
|
||||
? buildWide(flat, yAxisUnit)
|
||||
: buildLong(flat, yAxisUnit);
|
||||
}
|
||||
8
frontend/src/lib/exportData/toCsv.ts
Normal file
8
frontend/src/lib/exportData/toCsv.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { unparse } from 'papaparse';
|
||||
|
||||
import { SerializedTable } from './types';
|
||||
|
||||
/** Serializes a table to CSV. `fields` pins column order regardless of row keys. */
|
||||
export function toCsv(table: SerializedTable): string {
|
||||
return unparse({ fields: table.headers, data: table.rows });
|
||||
}
|
||||
12
frontend/src/lib/exportData/toJsonl.ts
Normal file
12
frontend/src/lib/exportData/toJsonl.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { SerializedTable } from './types';
|
||||
|
||||
/** Serializes a table to newline-delimited JSON: one object per row, keyed by header. */
|
||||
export function toJsonl(table: SerializedTable): string {
|
||||
return table.rows
|
||||
.map((row) =>
|
||||
JSON.stringify(
|
||||
Object.fromEntries(table.headers.map((header, i) => [header, row[i]])),
|
||||
),
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
19
frontend/src/lib/exportData/types.ts
Normal file
19
frontend/src/lib/exportData/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/** Format-agnostic tabular result produced by every exporter. Consumed by the
|
||||
* CSV/JSONL formatters */
|
||||
export interface SerializedTable {
|
||||
headers: string[];
|
||||
// One entry per header, in header order. Empty string marks a gap.
|
||||
rows: (string | number)[][];
|
||||
}
|
||||
|
||||
/** TimeSeries export layouts: LONG (tidy, one row per point) or WIDE (pivot). */
|
||||
export enum TimeseriesShape {
|
||||
Long = 'long',
|
||||
Wide = 'wide',
|
||||
}
|
||||
|
||||
/** File formats a client-side export can be downloaded as. */
|
||||
export enum ExportFormat {
|
||||
Csv = 'csv',
|
||||
Jsonl = 'jsonl',
|
||||
}
|
||||
Reference in New Issue
Block a user