Ver código fonte

Explore: reuse table merge from table panel

- Extracted table panel's merge logic to combine multiple tables into one
- Put the merge logic into the table model as it merges multiple table
  models
- make use of merge in Explore's table query response handler
- copied tests over to table model spec, kept essential tests in
  transformer spec
David Kaltschmidt 7 anos atrás
pai
commit
374fe9dcb4

+ 116 - 1
public/app/core/specs/table_model.test.ts

@@ -1,4 +1,4 @@
-import TableModel from 'app/core/table_model';
+import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
 
 describe('when sorting table desc', () => {
   let table;
@@ -79,3 +79,118 @@ describe('when sorting with nulls', () => {
     expect(values).toEqual([null, null, 'd', 'c', 'b', 'a', '', '']);
   });
 });
+
+describe('mergeTables', () => {
+  const time = new Date().getTime();
+
+  const singleTable = new TableModel({
+    type: 'table',
+    columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Value' }],
+    rows: [[time, 'Label Value 1', 42]],
+  });
+
+  const multipleTablesSameColumns = [
+    new TableModel({
+      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]],
+    }),
+    new TableModel({
+      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]],
+    }),
+    new TableModel({
+      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]],
+    }),
+    new TableModel({
+      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]],
+    }),
+  ];
+
+  const multipleTablesDifferentColumns = [
+    new TableModel({
+      type: 'table',
+      columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Value #A' }],
+      rows: [[time, 'Label Value 1', 42]],
+    }),
+    new TableModel({
+      type: 'table',
+      columns: [{ text: 'Time' }, { text: 'Label Key 2' }, { text: 'Value #B' }],
+      rows: [[time, 'Label Value 2', 13]],
+    }),
+    new TableModel({
+      type: 'table',
+      columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Value #C' }],
+      rows: [[time, 'Label Value 3', 7]],
+    }),
+  ];
+
+  it('should return the single table as is', () => {
+    const table = mergeTablesIntoModel(new TableModel(), singleTable);
+    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 tables', () => {
+    const table = mergeTablesIntoModel(new TableModel(), ...multipleTablesSameColumns);
+    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 table', () => {
+    const table = mergeTablesIntoModel(new TableModel(), singleTable);
+    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 multiple tables with same column values plus one extra row', () => {
+    const table = mergeTablesIntoModel(new TableModel(), ...multipleTablesSameColumns);
+    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 multiple tables with different column values', () => {
+    const table = mergeTablesIntoModel(new TableModel(), ...multipleTablesDifferentColumns);
+    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);
+  });
+});

+ 109 - 1
public/app/core/table_model.ts

@@ -1,3 +1,5 @@
+import _ from 'lodash';
+
 interface Column {
   text: string;
   title?: string;
@@ -14,11 +16,20 @@ export default class TableModel {
   type: string;
   columnMap: any;
 
-  constructor() {
+  constructor(table?: any) {
     this.columns = [];
     this.columnMap = {};
     this.rows = [];
     this.type = 'table';
+
+    if (table) {
+      if (table.columns) {
+        table.columns.forEach(col => this.addColumn(col));
+      }
+      if (table.rows) {
+        table.rows.forEach(row => this.addRow(row));
+      }
+    }
   }
 
   sort(options) {
@@ -52,3 +63,100 @@ export default class TableModel {
     this.rows.push(row);
   }
 }
+
+// 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;
+}
+
+export function mergeTablesIntoModel(dst?: TableModel, ...tables: TableModel[]): TableModel {
+  const model = dst || new TableModel();
+
+  // Single query returns data columns and rows as is
+  if (arguments.length === 2) {
+    model.columns = [...tables[0].columns];
+    model.rows = [...tables[0].rows];
+    return model;
+  }
+
+  // Track column indexes of union: name -> index
+  const columnNames = {};
+
+  // Union of all non-value columns
+  const columnsUnion = tables.slice().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 = tables.map(series => series.columns.map(col => columnNames[col.text]));
+
+  // Flatten rows of all series and adjust new column indexes
+  const flattenedRows = tables.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;
+  }, []);
+
+  // 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];
+            }
+          }
+          // Don't 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;
+  return model;
+}

+ 5 - 2
public/app/features/explore/Explore.tsx

@@ -13,6 +13,7 @@ import ResetStyles from 'app/core/components/Picker/ResetStyles';
 import PickerOption from 'app/core/components/Picker/PickerOption';
 import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
 import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
+import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
 
 import ElapsedTime from './ElapsedTime';
 import QueryRows from './QueryRows';
@@ -389,8 +390,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
       to: parseDate(range.to, true),
     };
     const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
-    const targets = this.queryExpressions.map(q => ({
+    const targets = this.queryExpressions.map((q, i) => ({
       ...targetOptions,
+      // Target identifier is needed for table transformations
+      refId: i + 1,
       expr: q,
     }));
     return {
@@ -437,7 +440,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     });
     try {
       const res = await datasource.query(options);
-      const tableModel = res.data[0];
+      const tableModel = mergeTablesIntoModel(new TableModel(), ...res.data);
       const latency = Date.now() - now;
       this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options });
       this.onQuerySuccess(datasource.meta.id, queries);

+ 3 - 0
public/app/features/explore/Table.tsx

@@ -5,6 +5,8 @@ import ReactTable from 'react-table';
 import TableModel from 'app/core/table_model';
 
 const EMPTY_TABLE = new TableModel();
+// Identify columns that contain values
+const VALUE_REGEX = /^[Vv]alue #\d+/;
 
 interface TableProps {
   data: TableModel;
@@ -34,6 +36,7 @@ export default class Table extends PureComponent<TableProps> {
     const columns = tableModel.columns.map(({ filterable, text }) => ({
       Header: text,
       accessor: text,
+      className: VALUE_REGEX.test(text) ? 'text-right' : '',
       show: text !== 'Time',
       Cell: row => <span className={filterable ? 'link' : ''}>{row.value}</span>,
     }));

+ 0 - 48
public/app/plugins/panel/table/specs/transformers.test.ts

@@ -143,24 +143,6 @@ describe('when transforming time series table', () => {
         },
       ];
 
-      const 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', () => {
         it('should return data columns given a single query', () => {
           const columns = transformers[transform].getColumns(singleQueryData);
@@ -177,16 +159,6 @@ describe('when transforming time series table', () => {
           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', () => {
-          const 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', () => {
@@ -237,26 +209,6 @@ describe('when transforming time series table', () => {
           expect(table.rows[1][4]).toBeUndefined();
           expect(table.rows[1][5]).toBe(7);
         });
-
-        it('should return 2 rows for multiple 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);
-        });
       });
     });
   });

+ 4 - 94
public/app/plugins/panel/table/transformers.ts

@@ -1,7 +1,7 @@
 import _ from 'lodash';
-import flatten from '../../../core/utils/flatten';
-import TimeSeries from '../../../core/time_series2';
-import TableModel from '../../../core/table_model';
+import flatten from 'app/core/utils/flatten';
+import TimeSeries from 'app/core/time_series2';
+import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
 
 const transformers = {};
 
@@ -168,97 +168,7 @@ transformers['table'] = {
       };
     }
 
-    // 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];
-              }
-            }
-            // Don't 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;
+    mergeTablesIntoModel(model, ...data);
   },
 };