Compare commits

..

5 Commits

Author SHA1 Message Date
aks07
851ecba738 feat(explorer): enable table export in Logs and Traces table tabs
Mounts ExportMenu on the Logs and Traces Table tabs, feeding the same query the rendered table uses (stagedQuery fallback parity) — exports match the on-screen table's merge, naming and column order. Header rows are class-driven, right-aligned, gated on data presence.
2026-07-10 12:56:53 +05:30
aks07
02a0d2e21f feat(data-export): dispatch client exports from the queryRange response
useClientExport now takes the queryRange response object views already hold and picks the serializer from what it carries: rawV5Response (time_series) or the formatForWeb scalar payload (tables) — mounts pass their data without choosing serializers. TimeseriesExportMenu generalizes into components/ExportMenu with the same uniform inputs, so the timeseries views and the table tabs share one menu.
2026-07-10 12:56:53 +05:30
aks07
1e5cd9e85e feat(data-export): add scalar queryRange serializer
exportScalarData takes the queryRange response object (formatForWeb webTables payload) + the builder query and serializes via createTableColumnsFromQuery — the exact preparer QueryTable renders from — so exports inherit the on-screen merge, naming and column order 1:1.
2026-07-10 12:14:50 +05:30
aks07
b96177d3c6 feat(data-export): add generic table-model serializer
exportTableData serializes any prepared antd-style table model ({name, key, isValueColumn} columns + record rows) into a SerializedTable — raw values in display order, units on value columns only, blanks for missing cells. Surface-agnostic: QueryTable-based tables, dashboard tables and plain antd tables all adapt in a line or two.
2026-07-10 12:14:50 +05:30
aks07
14f34eb07e refactor(data-export): extract the withUnit header helper
Both the timeseries and the upcoming table serializers append units to headers (and skip display-only ids like 'short'/'none') — move the helper to its own module.
2026-07-10 12:14:50 +05:30
17 changed files with 479 additions and 104 deletions

View File

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

View File

@@ -2,42 +2,43 @@ import { Download, LoaderCircle } from '@signozhq/icons';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { Button, Popover, Tooltip } from 'antd';
import { useClientExport } from 'hooks/useExportData/useClientExport';
import {
ClientExportData,
useClientExport,
} from 'hooks/useExportData/useClientExport';
import { ExportFormat } from 'lib/exportData/types';
import { useCallback, useMemo, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import './TimeseriesExportMenu.styles.scss';
import './ExportMenu.styles.scss';
interface TimeseriesExportMenuProps {
interface ExportMenuProps {
dataSource: DataSource;
queryResponse: QueryRangeResponseV5;
// The queryRange response object the view holds — the hook picks the
// serializer (timeseries / table) from what it carries.
data: ClientExportData;
query?: Query;
yAxisUnit?: string;
legendMap?: Record<string, string>;
fileName?: string;
}
// Download menu for in-memory timeseries data (client-side serialization).
// Download menu for in-memory query results (client-side serialization).
// The raw/list backend export keeps its own menu in DownloadOptionsMenu.
export default function TimeseriesExportMenu({
export default function ExportMenu({
dataSource,
queryResponse,
data,
query,
yAxisUnit,
legendMap,
fileName,
}: TimeseriesExportMenuProps): JSX.Element {
}: ExportMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const { isExporting, handleExport: handleClientExport } = useClientExport({
response: queryResponse,
data,
query,
yAxisUnit,
legendMap,
fileName,
});
@@ -85,7 +86,7 @@ export default function TimeseriesExportMenu({
arrow={false}
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
rootClassName="timeseries-export-popover"
rootClassName="export-menu-popover"
>
<Tooltip title="Download" placement="top">
<Button
@@ -97,7 +98,7 @@ export default function TimeseriesExportMenu({
<Download size={14} />
)
}
data-testid={`timeseries-export-${dataSource}`}
data-testid={`export-menu-${dataSource}`}
disabled={isExporting}
/>
</Tooltip>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,11 +3,14 @@ import {
downloadFile,
getTimestampedFileName,
} from 'lib/exportData/downloadFile';
import { exportScalarData } from 'lib/exportData/exportScalarData';
import { exportTimeseriesData } from 'lib/exportData/exportTimeseriesData';
import { toCsv } from 'lib/exportData/toCsv';
import { toJsonl } from 'lib/exportData/toJsonl';
import { ExportFormat, SerializedTable } from 'lib/exportData/types';
import { useCallback, useState } from 'react';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
@@ -19,35 +22,47 @@ const FORMAT_META: Record<ExportFormat, { mime: string; extension: string }> = {
},
};
// Picks the serializer for the response's request type. Narrows the results
// union via the response discriminant. scalar lands with #5591; raw/trace are
// server-exported, distribution is never emitted.
/** The queryRange response object views hold — structural (params left
* unconstrained) so both explorer variants assign cleanly. */
export type ClientExportData = SuccessResponse<MetricRangePayloadProps> & {
rawV5Response?: QueryRangeResponseV5;
legendMap?: Record<string, string>;
};
// Picks the serializer from what the queryRange response carries: timeseries
// queries surface the raw V5 tree (rawV5Response); table queries carry the
// formatForWeb webTables payload (resultType 'scalar'). raw/trace stay
// server-exported via useServerExport.
function serialize(
response: QueryRangeResponseV5,
data: ClientExportData,
yAxisUnit?: string,
legendMap?: Record<string, string>,
query?: Query,
): SerializedTable {
if (response.type === 'time_series') {
if (data.rawV5Response?.type === 'time_series') {
return exportTimeseriesData({
data: response.data.results as TimeSeriesData[],
data: data.rawV5Response.data.results as TimeSeriesData[],
yAxisUnit,
legendMap,
legendMap: data.legendMap,
query,
});
}
throw new Error(`Export is not supported for "${response.type}" results`);
if (data.payload?.data?.resultType === 'scalar' && query) {
return exportScalarData({ data, query });
}
throw new Error('Export is not supported for this result type');
}
interface UseClientExportProps {
response?: QueryRangeResponseV5;
// The builder query behind the response — series names resolve aggregation
// aliases/expressions from it, exactly like the chart legend.
// The queryRange response object the view already holds — the hook picks
// the serializer from what it carries.
data?: ClientExportData;
// The builder query behind the response — series/column names resolve
// aggregation aliases/expressions from it, exactly like the chart does.
query?: Query;
yAxisUnit?: string;
fileName?: string;
legendMap?: Record<string, string>;
}
interface ClientExportOptions {
@@ -62,23 +77,22 @@ interface UseClientExportReturn {
// Frontend-driven export: serializes in-memory query results and downloads them
// client-side. Backend-driven export lives in useServerExport.
export function useClientExport({
response,
data,
query,
yAxisUnit,
fileName = 'export',
legendMap,
}: UseClientExportProps): UseClientExportReturn {
const [isExporting, setIsExporting] = useState<boolean>(false);
const handleExport = useCallback(
({ format }: ClientExportOptions): void => {
if (!response) {
if (!data) {
return;
}
setIsExporting(true);
try {
const table = serialize(response, yAxisUnit, legendMap, query);
const table = serialize(data, yAxisUnit, query);
const content =
format === ExportFormat.Jsonl ? toJsonl(table) : toCsv(table);
const { mime, extension } = FORMAT_META[format];
@@ -89,7 +103,7 @@ export function useClientExport({
setIsExporting(false);
}
},
[response, query, yAxisUnit, fileName, legendMap],
[data, query, yAxisUnit, fileName],
);
return { isExporting, handleExport };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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