mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-08 07:30:40 +01:00
Compare commits
6 Commits
fix/query-
...
feat/expor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b4a0d0e36 | ||
|
|
a542da58e0 | ||
|
|
87d0289715 | ||
|
|
5b6a05dcf9 | ||
|
|
83815d3392 | ||
|
|
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,116 @@
|
||||
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', () => ({ 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 <fileName>.csv', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useClientExport({
|
||||
response: timeSeriesResponse(),
|
||||
fileName: 'chart',
|
||||
legendMap: { A: '{{service}}' },
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExport({
|
||||
format: ExportFormat.Csv,
|
||||
shape: TimeseriesShape.Long,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
|
||||
const [content, name, mime] = mockDownloadFile.mock.calls[0];
|
||||
expect(name).toBe('chart.csv');
|
||||
expect(mime).toContain('text/csv');
|
||||
expect(content).toContain('service');
|
||||
expect(content).toContain('a');
|
||||
});
|
||||
|
||||
it('exports as JSONL to <fileName>.jsonl with the ndjson mime', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useClientExport({ response: timeSeriesResponse() }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExport({ format: ExportFormat.Jsonl });
|
||||
});
|
||||
|
||||
const [content, name, mime] = mockDownloadFile.mock.calls[0];
|
||||
expect(name).toBe('export.jsonl');
|
||||
expect(mime).toContain('ndjson');
|
||||
expect(content).toContain('"series"');
|
||||
});
|
||||
|
||||
it('does nothing when there is no response', async () => {
|
||||
const { result } = renderHook(() => useClientExport({}));
|
||||
|
||||
await act(async () => {
|
||||
await 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', async () => {
|
||||
const raw = {
|
||||
type: 'raw',
|
||||
data: { results: [] },
|
||||
meta: {},
|
||||
} as unknown as QueryRangeResponseV5;
|
||||
const { result } = renderHook(() => useClientExport({ response: raw }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExport({ format: ExportFormat.Csv });
|
||||
});
|
||||
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockMessageError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
106
frontend/src/hooks/useExportData/useClientExport.ts
Normal file
106
frontend/src/hooks/useExportData/useClientExport.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { message } from 'antd';
|
||||
import { downloadFile } 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,
|
||||
TimeseriesShape,
|
||||
} from 'lib/exportData/types';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
QueryRangeResponseV5,
|
||||
ScalarData,
|
||||
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 (raw/trace/distribution aren't client-exported).
|
||||
function serialize(
|
||||
response: QueryRangeResponseV5,
|
||||
shape: TimeseriesShape,
|
||||
yAxisUnit?: string,
|
||||
legendMap?: Record<string, string>,
|
||||
): SerializedTable {
|
||||
switch (response.type) {
|
||||
case 'time_series':
|
||||
return exportTimeseriesData({
|
||||
data: response.data.results as TimeSeriesData[],
|
||||
shape,
|
||||
yAxisUnit,
|
||||
legendMap,
|
||||
});
|
||||
case 'scalar':
|
||||
return exportScalarData({
|
||||
data: response.data.results as ScalarData[],
|
||||
yAxisUnit,
|
||||
});
|
||||
default:
|
||||
throw new Error(`Export is not supported for "${response.type}" results`);
|
||||
}
|
||||
}
|
||||
|
||||
interface UseClientExportProps {
|
||||
response?: QueryRangeResponseV5;
|
||||
yAxisUnit?: string;
|
||||
fileName?: string;
|
||||
legendMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ClientExportOptions {
|
||||
format: ExportFormat;
|
||||
shape?: TimeseriesShape;
|
||||
}
|
||||
|
||||
interface UseClientExportReturn {
|
||||
isExporting: boolean;
|
||||
handleExport: (options: ClientExportOptions) => Promise<void>;
|
||||
}
|
||||
|
||||
// Frontend-driven export: serializes in-memory query results and downloads them
|
||||
// client-side. Backend-driven export lives in useServerExport.
|
||||
export function useClientExport({
|
||||
response,
|
||||
yAxisUnit,
|
||||
fileName = 'export',
|
||||
legendMap,
|
||||
}: UseClientExportProps): UseClientExportReturn {
|
||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||
|
||||
const handleExport = useCallback(
|
||||
async ({
|
||||
format,
|
||||
shape = TimeseriesShape.Long,
|
||||
}: ClientExportOptions): Promise<void> => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const table = serialize(response, shape, yAxisUnit, legendMap);
|
||||
const content =
|
||||
format === ExportFormat.Jsonl ? toJsonl(table) : toCsv(table);
|
||||
const { mime, extension } = FORMAT_META[format];
|
||||
await downloadFile(content, `${fileName}.${extension}`, mime);
|
||||
} catch {
|
||||
message.error('Failed to export data. Please try again.');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
},
|
||||
[response, yAxisUnit, fileName, legendMap],
|
||||
);
|
||||
|
||||
return { isExporting, handleExport };
|
||||
}
|
||||
75
frontend/src/lib/exportData/__tests__/downloadFile.test.ts
Normal file
75
frontend/src/lib/exportData/__tests__/downloadFile.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { downloadFile } 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;
|
||||
}
|
||||
|
||||
type PickerWindow = { showSaveFilePicker?: unknown };
|
||||
|
||||
describe('downloadFile', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
delete (window as unknown as PickerWindow).showSaveFilePicker;
|
||||
});
|
||||
|
||||
it('writes via the Save-As dialog when available, prefilled with the file name', async () => {
|
||||
const write = jest.fn().mockResolvedValue(undefined);
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
const createWritable = jest.fn().mockResolvedValue({ write, close });
|
||||
const showSaveFilePicker = jest.fn().mockResolvedValue({ createWritable });
|
||||
(window as unknown as PickerWindow).showSaveFilePicker = showSaveFilePicker;
|
||||
const createElement = jest.spyOn(document, 'createElement');
|
||||
|
||||
await downloadFile('hello', 'export.csv', 'text/csv');
|
||||
|
||||
expect(showSaveFilePicker).toHaveBeenCalledWith({
|
||||
suggestedName: 'export.csv',
|
||||
});
|
||||
expect(write).toHaveBeenCalledWith('hello');
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(createElement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the user cancels the Save-As dialog', async () => {
|
||||
const showSaveFilePicker = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new DOMException('aborted', 'AbortError'));
|
||||
(window as unknown as PickerWindow).showSaveFilePicker = showSaveFilePicker;
|
||||
const createElement = jest.spyOn(document, 'createElement');
|
||||
|
||||
await downloadFile('hello', 'export.csv', 'text/csv');
|
||||
|
||||
expect(createElement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to an anchor download when the Save-As API is unavailable', async () => {
|
||||
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');
|
||||
|
||||
await 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
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: headers are prefixed with queryName', () => {
|
||||
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', 'A: x', 'B: y']);
|
||||
expect(table.rows).toStrictEqual([[iso(1000), 1, 2]]);
|
||||
});
|
||||
|
||||
it('WIDE multi-aggregation: headers are prefixed with the aggregation alias', () => {
|
||||
const data = [
|
||||
makeQuery('A', [
|
||||
{ index: 0, alias: 'count', series: [makeSeries({}, [[1000, 5]])] },
|
||||
{ index: 1, alias: 'p99', series: [makeSeries({}, [[1000, 300]])] },
|
||||
]),
|
||||
];
|
||||
|
||||
const table = exportTimeseriesData({ data, shape: TimeseriesShape.Wide });
|
||||
|
||||
expect(table.headers).toStrictEqual(['timestamp', 'count: A', 'p99: A']);
|
||||
expect(table.rows).toStrictEqual([[iso(1000), 5, 300]]);
|
||||
});
|
||||
|
||||
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('');
|
||||
});
|
||||
});
|
||||
64
frontend/src/lib/exportData/downloadFile.ts
Normal file
64
frontend/src/lib/exportData/downloadFile.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
interface FileSystemWritableLike {
|
||||
write: (data: string) => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface FileSystemFileHandleLike {
|
||||
createWritable: () => Promise<FileSystemWritableLike>;
|
||||
}
|
||||
|
||||
interface SaveFilePickerWindow {
|
||||
showSaveFilePicker: (options: {
|
||||
suggestedName?: string;
|
||||
}) => Promise<FileSystemFileHandleLike>;
|
||||
}
|
||||
|
||||
function supportsSaveFilePicker(): boolean {
|
||||
return (
|
||||
typeof (window as unknown as Partial<SaveFilePickerWindow>)
|
||||
.showSaveFilePicker === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for browsers without the File System Access API (Firefox/Safari):
|
||||
// download straight to the default Downloads folder.
|
||||
function anchorDownload(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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts a Save-As dialog (prefilled with fileName) via the File System Access
|
||||
* API, falling back to a direct download where it isn't supported. Silently
|
||||
* returns if the user cancels the dialog.
|
||||
*/
|
||||
export async function downloadFile(
|
||||
content: string,
|
||||
fileName: string,
|
||||
mime: string,
|
||||
): Promise<void> {
|
||||
if (supportsSaveFilePicker()) {
|
||||
try {
|
||||
const picker = window as unknown as SaveFilePickerWindow;
|
||||
const handle = await picker.showSaveFilePicker({ suggestedName: fileName });
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
return;
|
||||
} catch (error) {
|
||||
// User dismissed the dialog — nothing to download.
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
// Any other failure: fall back to a direct download.
|
||||
}
|
||||
}
|
||||
|
||||
anchorDownload(content, fileName, mime);
|
||||
}
|
||||
16
frontend/src/lib/exportData/exportScalarData.ts
Normal file
16
frontend/src/lib/exportData/exportScalarData.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ScalarData } from 'types/api/v5/queryRange';
|
||||
|
||||
import { SerializedTable } from './types';
|
||||
|
||||
interface ExportScalarDataArgs {
|
||||
data: ScalarData[];
|
||||
yAxisUnit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a V5 scalar (table/value/pie) result into a table.
|
||||
* TODO(#5591): implement — stubbed so useClientExport's dispatch compiles.
|
||||
*/
|
||||
export function exportScalarData(_args: ExportScalarDataArgs): SerializedTable {
|
||||
throw new Error('Scalar export is not implemented yet');
|
||||
}
|
||||
223
frontend/src/lib/exportData/exportTimeseriesData.ts
Normal file
223
frontend/src/lib/exportData/exportTimeseriesData.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
|
||||
|
||||
import { SerializedTable, TimeseriesShape } from './types';
|
||||
|
||||
interface ExportTimeseriesDataArgs {
|
||||
data: TimeSeriesData[];
|
||||
shape: TimeseriesShape;
|
||||
yAxisUnit?: string;
|
||||
legendMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
// One row of the flattened V5 tree: a single (query, aggregation, label-set) series.
|
||||
interface FlatSeries {
|
||||
queryName: string;
|
||||
aggIndex: number;
|
||||
alias: string;
|
||||
labels: Record<string, string>;
|
||||
baseName: 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;
|
||||
}
|
||||
|
||||
// Walk results → aggregations → series into a flat, named list.
|
||||
function flatten(
|
||||
data: TimeSeriesData[],
|
||||
legendMap?: Record<string, string>,
|
||||
): FlatSeries[] {
|
||||
const flat: FlatSeries[] = [];
|
||||
data.forEach((query) => {
|
||||
const queryName = query.queryName ?? '';
|
||||
const legend = legendMap?.[queryName] ?? '';
|
||||
(query.aggregations ?? []).forEach((bucket) => {
|
||||
(bucket.series ?? []).forEach((series) => {
|
||||
const labels = foldLabels(series.labels);
|
||||
flat.push({
|
||||
queryName,
|
||||
aggIndex: bucket.index ?? 0,
|
||||
alias: bucket.alias ?? '',
|
||||
labels,
|
||||
baseName: getLabelName(labels, queryName, legend),
|
||||
values: (series.values ?? []).map((value) => ({
|
||||
timestamp: value.timestamp,
|
||||
value: value.value,
|
||||
})),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return flat;
|
||||
}
|
||||
|
||||
interface Qualifiers {
|
||||
manyQueries: boolean;
|
||||
multiAggByQuery: Record<string, boolean>;
|
||||
}
|
||||
|
||||
// A series name only needs a query/aggregation prefix when that dimension is
|
||||
// ambiguous — >1 query overall, or >1 aggregation within its query.
|
||||
function computeQualifiers(flat: FlatSeries[]): Qualifiers {
|
||||
const queries = new Set<string>();
|
||||
const aggsByQuery: Record<string, Set<number>> = {};
|
||||
flat.forEach((series) => {
|
||||
queries.add(series.queryName);
|
||||
if (!aggsByQuery[series.queryName]) {
|
||||
aggsByQuery[series.queryName] = new Set<number>();
|
||||
}
|
||||
aggsByQuery[series.queryName].add(series.aggIndex);
|
||||
});
|
||||
|
||||
const multiAggByQuery: Record<string, boolean> = {};
|
||||
Object.keys(aggsByQuery).forEach((query) => {
|
||||
multiAggByQuery[query] = aggsByQuery[query].size > 1;
|
||||
});
|
||||
|
||||
return { manyQueries: queries.size > 1, multiAggByQuery };
|
||||
}
|
||||
|
||||
function qualifiedName(
|
||||
series: FlatSeries,
|
||||
opts: { includeQuery: boolean; includeAlias: boolean },
|
||||
): string {
|
||||
let prefix = '';
|
||||
if (opts.includeQuery) {
|
||||
prefix += `${series.queryName}: `;
|
||||
}
|
||||
if (opts.includeAlias) {
|
||||
const aggLabel = series.alias || `agg[${series.aggIndex}]`;
|
||||
prefix += `${aggLabel}: `;
|
||||
}
|
||||
return `${prefix}${series.baseName}`;
|
||||
}
|
||||
|
||||
// Guarantee unique column headers if two series still collide after qualifying.
|
||||
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[],
|
||||
qualifiers: Qualifiers,
|
||||
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) => {
|
||||
const seriesName = qualifiedName(series, {
|
||||
includeQuery: false,
|
||||
includeAlias: qualifiers.multiAggByQuery[series.queryName],
|
||||
});
|
||||
series.values.forEach(({ timestamp, value }) => {
|
||||
rows.push([
|
||||
toIso(timestamp),
|
||||
series.queryName,
|
||||
seriesName,
|
||||
...labelKeys.map((key) => series.labels[key] ?? ''),
|
||||
value,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
// WIDE (pivot): one row per timestamp, one column per series.
|
||||
function buildWide(
|
||||
flat: FlatSeries[],
|
||||
qualifiers: Qualifiers,
|
||||
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) =>
|
||||
qualifiedName(series, {
|
||||
includeQuery: qualifiers.manyQueries,
|
||||
includeAlias: qualifiers.multiAggByQuery[series.queryName],
|
||||
}),
|
||||
),
|
||||
).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 and names series via the shared getLabelName.
|
||||
*/
|
||||
export function exportTimeseriesData({
|
||||
data,
|
||||
shape,
|
||||
yAxisUnit,
|
||||
legendMap,
|
||||
}: ExportTimeseriesDataArgs): SerializedTable {
|
||||
const flat = flatten(data, legendMap);
|
||||
const qualifiers = computeQualifiers(flat);
|
||||
|
||||
return shape === TimeseriesShape.Wide
|
||||
? buildWide(flat, qualifiers, yAxisUnit)
|
||||
: buildLong(flat, qualifiers, 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