diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index ec295760d49..f90dacc34c1 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -118,7 +118,7 @@ export class PrometheusDatasource { } if (activeTargets[index].format === "table") { - result.push(self.transformMetricDataToTable(response.data.data.result)); + result.push(self.transformMetricDataToTable(response.data.data.result, responseList.length, index)); } else { for (let metricData of response.data.data.result) { if (response.data.data.resultType === 'matrix') { @@ -301,7 +301,7 @@ export class PrometheusDatasource { return { target: metricLabel, datapoints: dps }; } - transformMetricDataToTable(md) { + transformMetricDataToTable(md, resultCount: number, resultIndex: number) { var table = new TableModel(); var i, j; var metricLabels = {}; @@ -326,7 +326,8 @@ export class PrometheusDatasource { metricLabels[label] = labelIndex + 1; table.columns.push({text: label}); }); - table.columns.push({text: 'Value'}); + let valueText = resultCount > 1 ? `Value #${String.fromCharCode(65 + resultIndex)}` : 'Value'; + table.columns.push({text: valueText}); // Populate rows, set value to empty string when label not present. _.each(md, function(series) { diff --git a/public/app/plugins/panel/table/specs/transformers.jest.ts b/public/app/plugins/panel/table/specs/transformers.jest.ts index 5f86266701e..18b3cc213d8 100644 --- a/public/app/plugins/panel/table/specs/transformers.jest.ts +++ b/public/app/plugins/panel/table/specs/transformers.jest.ts @@ -94,7 +94,232 @@ describe('when transforming time series table', () => { expect(table.columns[2].text).toBe('Min'); }); }); + }); + describe('table data sets', () => { + describe('Table', () => { + const transform = 'table'; + var panel = { + transform, + }; + var time = new Date().getTime(); + + var nonTableData = [ + { + type: 'foo', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Value' }, + ], + rows: [ + [time, 'Label Value 1', 42], + ], + } + ]; + + var singleQueryData = [ + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Value' }, + ], + rows: [ + [time, 'Label Value 1', 42], + ], + } + ]; + + var multipleQueriesDataSameLabels = [ + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Label Key 2' }, + { text: 'Value #A' }, + ], + rows: [ + [time, 'Label Value 1', 'Label Value 2', 42], + ], + }, + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Label Key 2' }, + { text: 'Value #B' }, + ], + rows: [ + [time, 'Label Value 1', 'Label Value 2', 13], + ], + }, + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Label Key 2' }, + { text: 'Value #C' }, + ], + rows: [ + [time, 'Label Value 1', 'Label Value 2', 4], + ], + }, + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Label Key 2' }, + { text: 'Value #C' }, + ], + rows: [ + [time, 'Label Value 1', 'Label Value 2', 7], + ], + } + ]; + + var multipleQueriesDataDifferentLabels = [ + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Value #A' }, + ], + rows: [ + [time, 'Label Value 1', 42], + ], + }, + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 2' }, + { text: 'Value #B' }, + ], + rows: [ + [time, 'Label Value 2', 13], + ], + }, + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Value #C' }, + ], + rows: [ + [time, 'Label Value 3', 7], + ], + } + ]; + + describe('getColumns', function() { + it('should return data columns given a single query', function() { + var columns = transformers[transform].getColumns(singleQueryData); + expect(columns[0].text).toBe('Time'); + expect(columns[1].text).toBe('Label Key 1'); + expect(columns[2].text).toBe('Value'); + }); + + it('should return the union of data columns given a multiple queries', function() { + var columns = transformers[transform].getColumns(multipleQueriesDataSameLabels); + expect(columns[0].text).toBe('Time'); + expect(columns[1].text).toBe('Label Key 1'); + expect(columns[2].text).toBe('Label Key 2'); + expect(columns[3].text).toBe('Value #A'); + expect(columns[4].text).toBe('Value #B'); + }); + + it('should return the union of data columns given a multiple queries with different labels', function() { + var columns = transformers[transform].getColumns(multipleQueriesDataDifferentLabels); + expect(columns[0].text).toBe('Time'); + expect(columns[1].text).toBe('Label Key 1'); + expect(columns[2].text).toBe('Value #A'); + expect(columns[3].text).toBe('Label Key 2'); + expect(columns[4].text).toBe('Value #B'); + expect(columns[5].text).toBe('Value #C'); + }); + }); + + describe('transform', function() { + it ('should throw an error with non-table data', () => { + expect(() => transformDataToTable(nonTableData, panel)).toThrow(); + }); + + it ('should return 3 columns for single queries', () => { + table = transformDataToTable(singleQueryData, panel); + expect(table.columns.length).toBe(3); + expect(table.columns[0].text).toBe('Time'); + expect(table.columns[1].text).toBe('Label Key 1'); + expect(table.columns[2].text).toBe('Value'); + }); + + it ('should return the union of columns for multiple queries', () => { + table = transformDataToTable(multipleQueriesDataSameLabels, panel); + expect(table.columns.length).toBe(6); + expect(table.columns[0].text).toBe('Time'); + expect(table.columns[1].text).toBe('Label Key 1'); + expect(table.columns[2].text).toBe('Label Key 2'); + expect(table.columns[3].text).toBe('Value #A'); + expect(table.columns[4].text).toBe('Value #B'); + expect(table.columns[5].text).toBe('Value #C'); + }); + + it ('should return 1 row for a single query', () => { + table = transformDataToTable(singleQueryData, panel); + expect(table.rows.length).toBe(1); + expect(table.rows[0][0]).toBe(time); + expect(table.rows[0][1]).toBe('Label Value 1'); + expect(table.rows[0][2]).toBe(42); + }); + + it ('should return 2 rows for a mulitple queries with same label values plus one extra row', () => { + table = transformDataToTable(multipleQueriesDataSameLabels, panel); + expect(table.rows.length).toBe(2); + expect(table.rows[0][0]).toBe(time); + expect(table.rows[0][1]).toBe('Label Value 1'); + expect(table.rows[0][2]).toBe('Label Value 2'); + expect(table.rows[0][3]).toBe(42); + expect(table.rows[0][4]).toBe(13); + expect(table.rows[0][5]).toBe(4); + expect(table.rows[1][0]).toBe(time); + expect(table.rows[1][1]).toBe('Label Value 1'); + expect(table.rows[1][2]).toBe('Label Value 2'); + expect(table.rows[1][3]).toBeUndefined(); + expect(table.rows[1][4]).toBeUndefined(); + expect(table.rows[1][5]).toBe(7); + }); + + it ('should return 2 rows for mulitple queries with different label values', () => { + table = transformDataToTable(multipleQueriesDataDifferentLabels, panel); + expect(table.rows.length).toBe(2); + expect(table.columns.length).toBe(6); + + expect(table.rows[0][0]).toBe(time); + expect(table.rows[0][1]).toBe('Label Value 1'); + expect(table.rows[0][2]).toBe(42); + expect(table.rows[0][3]).toBe('Label Value 2'); + expect(table.rows[0][4]).toBe(13); + expect(table.rows[0][5]).toBeUndefined(); + + expect(table.rows[1][0]).toBe(time); + expect(table.rows[1][1]).toBe('Label Value 3'); + expect(table.rows[1][2]).toBeUndefined(); + expect(table.rows[1][3]).toBeUndefined(); + expect(table.rows[1][4]).toBeUndefined(); + expect(table.rows[1][5]).toBe(7); + }); + }); + }); + }); + + describe('doc data sets', () => { describe('JSON Data', () => { var panel = { transform: 'json', @@ -148,7 +373,9 @@ describe('when transforming time series table', () => { }); }); }); + }); + describe('annotation data', () => { describe('Annnotations', () => { var panel = {transform: 'annotations'}; var rawData = { diff --git a/public/app/plugins/panel/table/transformers.ts b/public/app/plugins/panel/table/transformers.ts index 9a94f191646..a1d49453bcb 100644 --- a/public/app/plugins/panel/table/transformers.ts +++ b/public/app/plugins/panel/table/transformers.ts @@ -135,19 +135,134 @@ transformers['table'] = { if (!data || data.length === 0) { return []; } - return data[0].columns; + + // Single query returns data columns as is + if (data.length === 1) { + return [...data[0].columns]; + } + + // Track column indexes: name -> index + const columnNames = {}; + + // Union of all columns + const columns = data.reduce((acc, series) => { + series.columns.forEach(col => { + const { text } = col; + if (columnNames[text] === undefined) { + columnNames[text] = acc.length; + acc.push(col); + } + }); + return acc; + }, []); + + return columns; }, transform: function(data, panel, model) { if (!data || data.length === 0) { return; } - if (data[0].type !== 'table') { - throw {message: 'Query result is not in table format, try using another transform.'}; + const noTableIndex = _.findIndex(data, d => d.type !== 'table'); + if (noTableIndex > -1) { + throw {message: `Result of query #${String.fromCharCode(65 + noTableIndex)} is not in table format, try using another transform.`}; } - model.columns = data[0].columns; - model.rows = data[0].rows; + // Single query returns data columns and rows as is + if (data.length === 1) { + model.columns = [...data[0].columns]; + model.rows = [...data[0].rows]; + return; + } + + // Track column indexes of union: name -> index + const columnNames = {}; + + // Union of all non-value columns + const columnsUnion = data.reduce((acc, series) => { + series.columns.forEach(col => { + const { text } = col; + if (columnNames[text] === undefined) { + columnNames[text] = acc.length; + acc.push(col); + } + }); + return acc; + }, []); + + // Map old column index to union index per series, e.g., + // given columnNames {A: 0, B: 1} and + // data [{columns: [{ text: 'A' }]}, {columns: [{ text: 'B' }]}] => [[0], [1]] + const columnIndexMapper = data.map(series => + series.columns.map(col => columnNames[col.text]) + ); + + // Flatten rows of all series and adjust new column indexes + const flattenedRows = data.reduce((acc, series, seriesIndex) => { + const mapper = columnIndexMapper[seriesIndex]; + series.rows.forEach(row => { + const alteredRow = []; + // Shifting entries according to index mapper + mapper.forEach((to, from) => { + alteredRow[to] = row[from]; + }); + acc.push(alteredRow); + }); + return acc; + }, []); + + // Returns true if both rows have matching non-empty fields as well as matching + // indexes where one field is empty and the other is not + function areRowsMatching(columns, row, otherRow) { + let foundFieldToMatch = false; + for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) { + if (row[columnIndex] !== undefined && otherRow[columnIndex] !== undefined) { + if (row[columnIndex] !== otherRow[columnIndex]) { + return false; + } + } else if (row[columnIndex] === undefined || otherRow[columnIndex] === undefined) { + foundFieldToMatch = true; + } + } + return foundFieldToMatch; + } + + // Merge rows that have same values for columns + const mergedRows = {}; + const compactedRows = flattenedRows.reduce((acc, row, rowIndex) => { + if (!mergedRows[rowIndex]) { + // Look from current row onwards + let offset = rowIndex + 1; + // More than one row can be merged into current row + while (offset < flattenedRows.length) { + // Find next row that could be merged + const match = _.findIndex(flattenedRows, + otherRow => areRowsMatching(columnsUnion, row, otherRow), + offset); + if (match > -1) { + const matchedRow = flattenedRows[match]; + // Merge values from match into current row if there is a gap in the current row + for (let columnIndex = 0; columnIndex < columnsUnion.length; columnIndex++) { + if (row[columnIndex] === undefined && matchedRow[columnIndex] !== undefined) { + row[columnIndex] = matchedRow[columnIndex]; + } + } + // Dont visit this row again + mergedRows[match] = matchedRow; + // Keep looking for more rows to merge + offset = match + 1; + } else { + // No match found, stop looking + break; + } + } + acc.push(row); + } + return acc; + }, []); + + model.columns = columnsUnion; + model.rows = compactedRows; } };