Compare commits

...

3 Commits

Author SHA1 Message Date
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
6 changed files with 258 additions and 12 deletions

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})`;
}