From 522bdf04ef98e593b866444d2f99398f65fdbf41 Mon Sep 17 00:00:00 2001 From: Yevhen Shevchenko <90138953+yeshev@users.noreply.github.com> Date: Fri, 23 Jun 2023 10:15:09 +0300 Subject: [PATCH] feat: Add Generic Table View in the logs explorer (#2936) * feat: add dynamic table based on query * fix: group by repeating * fix: change view when groupBy exist in the list * fix: table scroll * fix: filters for explorer page (#2959) --------- Co-authored-by: Palash Gupta --- .../components/ResizeTable/ResizeTable.tsx | 16 +- .../LogsExplorerTable/LogsExplorerTable.tsx | 44 +++ .../src/container/LogsExplorerTable/index.ts | 1 + .../LogsExplorerViews/LogsExplorerViews.tsx | 22 +- .../QueryBuilder/components/Query/Query.tsx | 49 ++- .../filters/GroupByFilter/GroupByFilter.tsx | 10 +- .../QueryTable/QueryTable.intefaces.ts | 14 + .../src/container/QueryTable/QueryTable.tsx | 52 ++++ frontend/src/container/QueryTable/index.ts | 1 + .../newQueryBuilder/convertNewDataToOld.ts | 6 +- .../lib/query/createTableColumnsFromQuery.ts | 294 ++++++++++++++++++ frontend/src/lib/toCapitalize.ts | 2 + .../src/types/api/metrics/getQueryRange.ts | 1 + frontend/src/types/api/widgets/getQuery.ts | 2 +- 14 files changed, 500 insertions(+), 14 deletions(-) create mode 100644 frontend/src/container/LogsExplorerTable/LogsExplorerTable.tsx create mode 100644 frontend/src/container/LogsExplorerTable/index.ts create mode 100644 frontend/src/container/QueryTable/QueryTable.intefaces.ts create mode 100644 frontend/src/container/QueryTable/QueryTable.tsx create mode 100644 frontend/src/container/QueryTable/index.ts create mode 100644 frontend/src/lib/query/createTableColumnsFromQuery.ts create mode 100644 frontend/src/lib/toCapitalize.ts diff --git a/frontend/src/components/ResizeTable/ResizeTable.tsx b/frontend/src/components/ResizeTable/ResizeTable.tsx index 681d8b8670..d6898d0815 100644 --- a/frontend/src/components/ResizeTable/ResizeTable.tsx +++ b/frontend/src/components/ResizeTable/ResizeTable.tsx @@ -1,14 +1,20 @@ import { Table } from 'antd'; import type { TableProps } from 'antd/es/table'; import { ColumnsType } from 'antd/lib/table'; -import { SyntheticEvent, useCallback, useMemo, useState } from 'react'; +import { + SyntheticEvent, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { ResizeCallbackData } from 'react-resizable'; import ResizableHeader from './ResizableHeader'; // eslint-disable-next-line @typescript-eslint/no-explicit-any function ResizeTable({ columns, ...restprops }: TableProps): JSX.Element { - const [columnsData, setColumns] = useState(columns || []); + const [columnsData, setColumns] = useState([]); const handleResize = useCallback( (index: number) => ( @@ -37,6 +43,12 @@ function ResizeTable({ columns, ...restprops }: TableProps): JSX.Element { [columnsData, handleResize], ); + useEffect(() => { + if (columns) { + setColumns(columns); + } + }, [columns]); + return ( ( + (state) => state.globalTime, + ); + + const panelTypeParam = useGetPanelTypesQueryParam(PANEL_TYPES.LIST); + + const { data, isFetching } = useGetQueryRange( + { + query: stagedQuery || initialQueriesMap.metrics, + graphType: panelTypeParam, + globalSelectedInterval: selectedTime, + selectedTime: 'GLOBAL_TIME', + }, + { + queryKey: [ + REACT_QUERY_KEY.GET_QUERY_RANGE, + selectedTime, + stagedQuery, + panelTypeParam, + ], + enabled: !!stagedQuery, + }, + ); + return ( + + ); +} diff --git a/frontend/src/container/LogsExplorerTable/index.ts b/frontend/src/container/LogsExplorerTable/index.ts new file mode 100644 index 0000000000..4a9b82e594 --- /dev/null +++ b/frontend/src/container/LogsExplorerTable/index.ts @@ -0,0 +1 @@ +export { LogsExplorerTable } from './LogsExplorerTable'; diff --git a/frontend/src/container/LogsExplorerViews/LogsExplorerViews.tsx b/frontend/src/container/LogsExplorerViews/LogsExplorerViews.tsx index 303db79f01..76765959b0 100644 --- a/frontend/src/container/LogsExplorerViews/LogsExplorerViews.tsx +++ b/frontend/src/container/LogsExplorerViews/LogsExplorerViews.tsx @@ -1,6 +1,7 @@ import { TabsProps } from 'antd'; import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES_QUERY } from 'constants/queryBuilderQueryNames'; +import { LogsExplorerTable } from 'container/LogsExplorerTable'; import { GRAPH_TYPES } from 'container/NewDashboard/ComponentsSlider'; import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; @@ -25,17 +26,26 @@ export function LogsExplorerViews(): JSX.Element { [currentQuery], ); + const isGroupByExist = useMemo(() => { + const groupByCount: number = currentQuery.builder.queryData.reduce( + (acc, query) => acc + query.groupBy.length, + 0, + ); + + return groupByCount > 0; + }, [currentQuery]); + const tabsItems: TabsProps['items'] = useMemo( () => [ { label: 'List View', key: PANEL_TYPES.LIST, - disabled: isMultipleQueries, + disabled: isMultipleQueries || isGroupByExist, }, { label: 'TimeSeries', key: PANEL_TYPES.TIME_SERIES }, - { label: 'Table', key: PANEL_TYPES.TABLE }, + { label: 'Table', key: PANEL_TYPES.TABLE, children: }, ], - [isMultipleQueries], + [isMultipleQueries, isGroupByExist], ); const handleChangeView = useCallback( @@ -57,10 +67,12 @@ export function LogsExplorerViews(): JSX.Element { ); useEffect(() => { - if (panelTypeParams === 'list' && isMultipleQueries) { + const shouldChangeView = isMultipleQueries || isGroupByExist; + + if (panelTypeParams === 'list' && shouldChangeView) { handleChangeView(PANEL_TYPES.TIME_SERIES); } - }, [panelTypeParams, isMultipleQueries, handleChangeView]); + }, [panelTypeParams, isMultipleQueries, isGroupByExist, handleChangeView]); return (
diff --git a/frontend/src/container/QueryBuilder/components/Query/Query.tsx b/frontend/src/container/QueryBuilder/components/Query/Query.tsx index 5db70136cc..ae2fb929b1 100644 --- a/frontend/src/container/QueryBuilder/components/Query/Query.tsx +++ b/frontend/src/container/QueryBuilder/components/Query/Query.tsx @@ -196,7 +196,54 @@ export const Query = memo(function Query({ } default: { - return null; + return ( + <> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } } }, [ diff --git a/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx b/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx index 111ff2ea5c..62bd165ffb 100644 --- a/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx @@ -102,10 +102,12 @@ export const GroupByFilter = memo(function GroupByFilter({ return { id, - key, - dataType: dataType as DataType, - type: type as AutocompleteType, - isColumn: isColumn === 'true', + key: key || currentValue, + dataType: (dataType as DataType) || initialAutocompleteData.dataType, + type: (type as AutocompleteType) || initialAutocompleteData.type, + isColumn: isColumn + ? isColumn === 'true' + : initialAutocompleteData.isColumn, }; } diff --git a/frontend/src/container/QueryTable/QueryTable.intefaces.ts b/frontend/src/container/QueryTable/QueryTable.intefaces.ts new file mode 100644 index 0000000000..2abc353997 --- /dev/null +++ b/frontend/src/container/QueryTable/QueryTable.intefaces.ts @@ -0,0 +1,14 @@ +import { TableProps } from 'antd'; +import { RowData } from 'lib/query/createTableColumnsFromQuery'; +import { ReactNode } from 'react'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import { QueryDataV3 } from 'types/api/widgets/getQuery'; + +export type QueryTableProps = Omit< + TableProps, + 'columns' | 'dataSource' +> & { + queryTableData: QueryDataV3[]; + query: Query; + renderActionCell?: (record: RowData) => ReactNode; +}; diff --git a/frontend/src/container/QueryTable/QueryTable.tsx b/frontend/src/container/QueryTable/QueryTable.tsx new file mode 100644 index 0000000000..8265a8494d --- /dev/null +++ b/frontend/src/container/QueryTable/QueryTable.tsx @@ -0,0 +1,52 @@ +import type { ColumnsType } from 'antd/es/table'; +import { ResizeTable } from 'components/ResizeTable'; +import dayjs from 'dayjs'; +import { + createTableColumnsFromQuery, + RowData, +} from 'lib/query/createTableColumnsFromQuery'; +import { useMemo } from 'react'; + +import { QueryTableProps } from './QueryTable.intefaces'; + +export function QueryTable({ + queryTableData, + query, + renderActionCell, + ...props +}: QueryTableProps): JSX.Element { + const { columns, dataSource } = useMemo( + () => + createTableColumnsFromQuery({ + query, + queryTableData, + renderActionCell, + }), + [query, queryTableData, renderActionCell], + ); + + const modifiedColumns = useMemo(() => { + const currentColumns: ColumnsType = columns.map((column) => + column.key === 'timestamp' + ? { + ...column, + render: (_, record): string => + dayjs(new Date(record.timestamp)).format('MMM DD, YYYY, HH:mm:ss'), + } + : column, + ); + + return currentColumns; + }, [columns]); + + return ( + + ); +} diff --git a/frontend/src/container/QueryTable/index.ts b/frontend/src/container/QueryTable/index.ts new file mode 100644 index 0000000000..bb785ff437 --- /dev/null +++ b/frontend/src/container/QueryTable/index.ts @@ -0,0 +1 @@ +export { QueryTable } from './QueryTable'; diff --git a/frontend/src/lib/newQueryBuilder/convertNewDataToOld.ts b/frontend/src/lib/newQueryBuilder/convertNewDataToOld.ts index dcb103d6cb..eed4f83da3 100644 --- a/frontend/src/lib/newQueryBuilder/convertNewDataToOld.ts +++ b/frontend/src/lib/newQueryBuilder/convertNewDataToOld.ts @@ -35,5 +35,9 @@ export const convertNewDataToOld = ( }); const oldResultType = resultType; - return { data: { result: oldResult, resultType: oldResultType } }; + // TODO: fix it later for using only v3 version of api + + return { + data: { result: oldResult, resultType: oldResultType, newResult: newData }, + }; }; diff --git a/frontend/src/lib/query/createTableColumnsFromQuery.ts b/frontend/src/lib/query/createTableColumnsFromQuery.ts new file mode 100644 index 0000000000..bbb03e284d --- /dev/null +++ b/frontend/src/lib/query/createTableColumnsFromQuery.ts @@ -0,0 +1,294 @@ +import { ColumnsType } from 'antd/es/table'; +import { ColumnType } from 'antd/lib/table'; +import { FORMULA_REGEXP } from 'constants/regExp'; +import { QueryTableProps } from 'container/QueryTable/QueryTable.intefaces'; +import { toCapitalize } from 'lib/toCapitalize'; +import { ReactNode } from 'react'; +import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData'; +import { QueryDataV3, SeriesItem } from 'types/api/widgets/getQuery'; +import { v4 as uuid } from 'uuid'; + +type CreateTableDataFromQueryParams = Pick< + QueryTableProps, + 'queryTableData' | 'query' | 'renderActionCell' +>; + +export type RowData = { + timestamp: number; + key: string; + [key: string]: string | number; +}; + +type DynamicColumn = { + key: keyof RowData; + data: (string | number)[]; + type: 'field' | 'operator'; + sortable: boolean; +}; + +type DynamicColumns = DynamicColumn[]; + +type CreateTableDataFromQuery = ( + params: CreateTableDataFromQueryParams, +) => { + columns: ColumnsType; + dataSource: RowData[]; + rowsLength: number; +}; + +type FillColumnData = ( + queryTableData: QueryDataV3[], + dynamicColumns: DynamicColumns, + query: Query, +) => { filledDynamicColumns: DynamicColumns; rowsLength: number }; + +type GetDynamicColumns = ( + queryTableData: QueryDataV3[], + query: Query, +) => DynamicColumns; + +const isFormula = (queryName: string): boolean => + FORMULA_REGEXP.test(queryName); + +const isColumnExist = ( + columnName: string, + columns: DynamicColumns, +): boolean => { + const columnKeys = columns.map((item) => item.key); + + return columnKeys.includes(columnName); +}; + +const prepareColumnTitle = (title: string): string => { + const haveUnderscore = title.includes('_'); + + if (haveUnderscore) { + return title + .split('_') + .map((str) => toCapitalize(str)) + .join(' '); + } + + return toCapitalize(title); +}; + +const getDynamicColumns: GetDynamicColumns = (queryTableData, query) => { + const dynamicColumns: DynamicColumns = []; + + queryTableData.forEach((currentQuery) => { + if (!currentQuery.series) return; + + if (!isColumnExist('timestamp', dynamicColumns)) { + dynamicColumns.push({ + key: 'timestamp', + data: [], + type: 'field', + sortable: true, + }); + } + + currentQuery.series.forEach((seria) => { + Object.keys(seria.labels).forEach((label) => { + if (isColumnExist(label, dynamicColumns)) return; + if (isFormula(label)) return; + + const labelValue = seria.labels[label]; + + const isNumber = !Number.isNaN(parseFloat(labelValue)); + + const fieldObj: DynamicColumn = { + key: label, + data: [], + type: 'field', + sortable: isNumber, + }; + + dynamicColumns.push(fieldObj); + }); + }); + + if (!isFormula(currentQuery.queryName)) { + const builderQuery = query.builder.queryData.find( + (q) => q.queryName === currentQuery.queryName, + ); + + const operator = builderQuery ? builderQuery.aggregateOperator : ''; + + if (isColumnExist(operator, dynamicColumns)) return; + + const operatorColumn: DynamicColumn = { + key: operator, + data: [], + type: 'operator', + sortable: true, + }; + dynamicColumns.push(operatorColumn); + } + }); + + return dynamicColumns; +}; + +const getQueryOperator = ( + queryData: IBuilderQuery[], + currentQueryName: string, +): string => { + const builderQuery = queryData.find((q) => q.queryName === currentQueryName); + + return builderQuery ? builderQuery.aggregateOperator : ''; +}; + +const fillEmptyRowCells = ( + unusedColumnsKeys: Set, + sourceColumns: DynamicColumns, + currentColumn: DynamicColumn, +): void => { + unusedColumnsKeys.forEach((key) => { + if (key === currentColumn.key) { + const unusedCol = sourceColumns.find((item) => item.key === key); + + if (unusedCol) { + unusedCol.data.push('N/A'); + unusedColumnsKeys.delete(key); + } + } + }); +}; + +const fillDataFromSeria = ( + seria: SeriesItem, + columns: DynamicColumns, + currentOperator: string, +): void => { + const labelEntries = Object.entries(seria.labels); + + seria.values.forEach((value) => { + const unusedColumnsKeys = new Set( + columns.map((item) => item.key), + ); + + columns.forEach((column) => { + if (isFormula(column.key as string)) return; + + if (column.key === 'timestamp') { + column.data.push(value.timestamp); + unusedColumnsKeys.delete('timestamp'); + return; + } + + if (column.key === currentOperator) { + column.data.push(parseFloat(value.value).toFixed(2)); + unusedColumnsKeys.delete(column.key); + return; + } + + labelEntries.forEach(([key, currentValue]) => { + if (column.key === key) { + column.data.push(currentValue); + unusedColumnsKeys.delete(key); + } + }); + + fillEmptyRowCells(unusedColumnsKeys, columns, column); + }); + }); +}; + +const fillColumnsData: FillColumnData = (queryTableData, cols, query) => { + const fields = cols.filter((item) => item.type === 'field'); + const operators = cols.filter((item) => item.type === 'operator'); + const resultColumns = [...fields, ...operators]; + + queryTableData.forEach((currentQuery) => { + if (!currentQuery.series) return; + + const currentOperator = getQueryOperator( + query.builder.queryData, + currentQuery.queryName, + ); + + currentQuery.series.forEach((seria) => { + fillDataFromSeria(seria, resultColumns, currentOperator); + }); + }); + + const rowsLength = resultColumns.length > 0 ? resultColumns[0].data.length : 0; + + return { filledDynamicColumns: resultColumns, rowsLength }; +}; + +const generateData = ( + dynamicColumns: DynamicColumns, + rowsLength: number, +): RowData[] => { + const data: RowData[] = []; + + for (let i = 0; i < rowsLength; i += 1) { + const rowData: RowData = dynamicColumns.reduce((acc, item) => { + const { key } = item; + + acc[key] = item.data[i]; + acc.key = uuid(); + + return acc; + }, {} as RowData); + + data.push(rowData); + } + + return data; +}; + +const generateTableColumns = ( + dynamicColumns: DynamicColumns, +): ColumnsType => { + const columns: ColumnsType = dynamicColumns.reduce< + ColumnsType + >((acc, item) => { + const column: ColumnType = { + dataIndex: item.key, + key: item.key, + title: prepareColumnTitle(item.key as string), + sorter: item.sortable + ? (a: RowData, b: RowData): number => + (a[item.key] as number) - (b[item.key] as number) + : false, + }; + + return [...acc, column]; + }, []); + + return columns; +}; + +export const createTableColumnsFromQuery: CreateTableDataFromQuery = ({ + query, + queryTableData, + renderActionCell, +}) => { + const dynamicColumns = getDynamicColumns(queryTableData, query); + + const { filledDynamicColumns, rowsLength } = fillColumnsData( + queryTableData, + dynamicColumns, + query, + ); + + const dataSource = generateData(filledDynamicColumns, rowsLength); + + const columns = generateTableColumns(filledDynamicColumns); + + const actionsCell: ColumnType | null = renderActionCell + ? { + key: 'actions', + title: 'Actions', + render: (_, record): ReactNode => renderActionCell(record), + } + : null; + + if (actionsCell && dataSource.length > 0) { + columns.push(actionsCell); + } + + return { columns, dataSource, rowsLength }; +}; diff --git a/frontend/src/lib/toCapitalize.ts b/frontend/src/lib/toCapitalize.ts new file mode 100644 index 0000000000..a42a7336bf --- /dev/null +++ b/frontend/src/lib/toCapitalize.ts @@ -0,0 +1,2 @@ +export const toCapitalize = (str: string): string => + str[0].toUpperCase() + str.slice(1); diff --git a/frontend/src/types/api/metrics/getQueryRange.ts b/frontend/src/types/api/metrics/getQueryRange.ts index f8c32c29a3..5dd80a451f 100644 --- a/frontend/src/types/api/metrics/getQueryRange.ts +++ b/frontend/src/types/api/metrics/getQueryRange.ts @@ -5,6 +5,7 @@ export interface MetricRangePayloadProps { data: { result: QueryData[]; resultType: string; + newResult: MetricRangePayloadV3; }; } diff --git a/frontend/src/types/api/widgets/getQuery.ts b/frontend/src/types/api/widgets/getQuery.ts index 60d679c36e..7452b52dda 100644 --- a/frontend/src/types/api/widgets/getQuery.ts +++ b/frontend/src/types/api/widgets/getQuery.ts @@ -23,7 +23,7 @@ export interface QueryDataV3 { list: null; queryName: string; legend?: string; - series: SeriesItem[]; + series: SeriesItem[] | null; } export interface Props {