Jelajahi Sumber

Merge pull request #10050 from davkal/davkal/multi-query-table

Add support to render values of multiple queries in the same table
Carl Bergquist 8 tahun lalu
induk
melakukan
f5d26bfcc9

+ 4 - 3
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) {

+ 227 - 0
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 = {

+ 120 - 5
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.`};
+    }
+
+    // Single query returns data columns and rows as is
+    if (data.length === 1) {
+      model.columns = [...data[0].columns];
+      model.rows = [...data[0].rows];
+      return;
     }
 
-    model.columns = data[0].columns;
-    model.rows = data[0].rows;
+    // 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;
   }
 };