import { css } from '@emotion/css'; import React, { useCallback, useEffect, useState } from 'react'; import { DataFrame, ExploreLogsPanelState, GrafanaTheme2, Labels, LogsSortOrder, SelectableValue, SplitOpen, TimeRange, } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime/src'; import { InlineField, Select, Themeable2 } from '@grafana/ui/'; import { parseLogsFrame } from '../../logs/logsFrame'; import { LogsColumnSearch } from './LogsColumnSearch'; import { LogsTable } from './LogsTable'; import { LogsTableMultiSelect } from './LogsTableMultiSelect'; import { fuzzySearch } from './utils/uFuzzy'; interface Props extends Themeable2 { logsFrames: DataFrame[]; width: number; timeZone: string; splitOpen: SplitOpen; range: TimeRange; logsSortOrder: LogsSortOrder; panelState: ExploreLogsPanelState | undefined; updatePanelState: (panelState: Partial) => void; onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void; onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void; datasourceType?: string; } export type fieldNameMeta = { percentOfLinesWithLabel: number; active: boolean | undefined; type?: 'BODY_FIELD' | 'TIME_FIELD'; }; type fieldName = string; type fieldNameMetaStore = Record; export function LogsTableWrap(props: Props) { const { logsFrames, updatePanelState, panelState } = props; const propsColumns = panelState?.columns; // Save the normalized cardinality of each label const [columnsWithMeta, setColumnsWithMeta] = useState(undefined); // Filtered copy of columnsWithMeta that only includes matching results const [filteredColumnsWithMeta, setFilteredColumnsWithMeta] = useState(undefined); const [searchValue, setSearchValue] = useState(''); const height = getLogsTableHeight(); const panelStateRefId = props?.panelState?.refId; // The current dataFrame containing the refId of the current query const [currentDataFrame, setCurrentDataFrame] = useState( logsFrames.find((f) => f.refId === panelStateRefId) ?? logsFrames[0] ); const getColumnsFromProps = useCallback( (fieldNames: fieldNameMetaStore) => { const previouslySelected = props.panelState?.columns; if (previouslySelected) { Object.values(previouslySelected).forEach((key) => { if (fieldNames[key]) { fieldNames[key].active = true; } }); } return fieldNames; }, [props.panelState?.columns] ); const logsFrame = parseLogsFrame(currentDataFrame); useEffect(() => { if (logsFrame?.timeField.name && logsFrame?.bodyField.name && !propsColumns) { const defaultColumns = { 0: logsFrame?.timeField.name ?? '', 1: logsFrame?.bodyField.name ?? '' }; updatePanelState({ columns: Object.values(defaultColumns), visualisationType: 'table', labelFieldName: logsFrame?.getLabelFieldName() ?? undefined, }); } }, [logsFrame, propsColumns, updatePanelState]); /** * When logs frame updates (e.g. query|range changes), we need to set the selected frame to state */ useEffect(() => { const newFrame = logsFrames.find((f) => f.refId === panelStateRefId) ?? logsFrames[0]; if (newFrame) { setCurrentDataFrame(newFrame); } }, [logsFrames, panelStateRefId]); /** * Keeps the filteredColumnsWithMeta state in sync with the columnsWithMeta state, * which can be updated by explore browser history state changes * This prevents an edge case bug where the user is navigating while a search is open. */ useEffect(() => { if (!columnsWithMeta || !filteredColumnsWithMeta) { return; } let newFiltered = { ...filteredColumnsWithMeta }; let flag = false; Object.keys(columnsWithMeta).forEach((key) => { if (newFiltered[key] && newFiltered[key].active !== columnsWithMeta[key].active) { newFiltered[key] = columnsWithMeta[key]; flag = true; } }); if (flag) { setFilteredColumnsWithMeta(newFiltered); } }, [columnsWithMeta, filteredColumnsWithMeta]); /** * when the query results change, we need to update the columnsWithMeta state * and reset any local search state * * This will also find all the unique labels, and calculate how many log lines have each label into the labelCardinality Map * Then it normalizes the counts * */ useEffect(() => { // If the data frame is empty, there's nothing to viz, it could mean the user has unselected all columns if (!currentDataFrame.length) { return; } const numberOfLogLines = currentDataFrame ? currentDataFrame.length : 0; const logsFrame = parseLogsFrame(currentDataFrame); const labels = logsFrame?.getLogFrameLabelsAsLabels(); const otherFields = []; if (logsFrame) { otherFields.push(...logsFrame.extraFields.filter((field) => !field?.config?.custom?.hidden)); } if (logsFrame?.severityField) { otherFields.push(logsFrame?.severityField); } if (logsFrame?.bodyField) { otherFields.push(logsFrame?.bodyField); } if (logsFrame?.timeField) { otherFields.push(logsFrame?.timeField); } // Use a map to dedupe labels and count their occurrences in the logs const labelCardinality = new Map(); // What the label state will look like let pendingLabelState: fieldNameMetaStore = {}; // If we have labels and log lines if (labels?.length && numberOfLogLines) { // Iterate through all of Labels labels.forEach((labels: Labels) => { const labelsArray = Object.keys(labels); // Iterate through the label values labelsArray.forEach((label) => { // If it's already in our map, increment the count if (labelCardinality.has(label)) { const value = labelCardinality.get(label); if (value) { labelCardinality.set(label, { percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1, active: value?.active, }); } // Otherwise add it } else { labelCardinality.set(label, { percentOfLinesWithLabel: 1, active: undefined }); } }); }); // Converting the map to an object pendingLabelState = Object.fromEntries(labelCardinality); // Convert count to percent of log lines Object.keys(pendingLabelState).forEach((key) => { pendingLabelState[key].percentOfLinesWithLabel = normalize( pendingLabelState[key].percentOfLinesWithLabel, numberOfLogLines ); }); } // Normalize the other fields otherFields.forEach((field) => { pendingLabelState[field.name] = { percentOfLinesWithLabel: normalize( field.values.filter((value) => value !== null && value !== undefined).length, numberOfLogLines ), active: pendingLabelState[field.name]?.active, }; }); pendingLabelState = getColumnsFromProps(pendingLabelState); // Get all active columns const active = Object.keys(pendingLabelState).filter((key) => pendingLabelState[key].active); // If nothing is selected, then select the default columns if (active.length === 0) { if (logsFrame?.bodyField?.name) { pendingLabelState[logsFrame.bodyField.name].active = true; } if (logsFrame?.timeField?.name) { pendingLabelState[logsFrame.timeField.name].active = true; } } if (logsFrame?.bodyField?.name && logsFrame?.timeField?.name) { pendingLabelState[logsFrame.bodyField.name].type = 'BODY_FIELD'; pendingLabelState[logsFrame.timeField.name].type = 'TIME_FIELD'; } setColumnsWithMeta(pendingLabelState); // The panel state is updated when the user interacts with the multi-select sidebar }, [currentDataFrame, getColumnsFromProps]); if (!columnsWithMeta) { return null; } function columnFilterEvent(columnName: string) { if (columnsWithMeta) { const newState = !columnsWithMeta[columnName]?.active; const priorActiveCount = Object.keys(columnsWithMeta).filter((column) => columnsWithMeta[column]?.active)?.length; const event = { columnAction: newState ? 'add' : 'remove', columnCount: newState ? priorActiveCount + 1 : priorActiveCount - 1, datasourceType: props.datasourceType, }; reportInteraction('grafana_explore_logs_table_column_filter_clicked', event); } } function searchFilterEvent(searchResultCount: number) { reportInteraction('grafana_explore_logs_table_text_search_result_count', { resultCount: searchResultCount, datasourceType: props.datasourceType ?? 'unknown', }); } const clearSelection = () => { const pendingLabelState = { ...columnsWithMeta }; Object.keys(pendingLabelState).forEach((key) => { pendingLabelState[key].active = !!pendingLabelState[key].type; }); setColumnsWithMeta(pendingLabelState); }; // Toggle a column on or off when the user interacts with an element in the multi-select sidebar const toggleColumn = (columnName: fieldName) => { if (!columnsWithMeta || !(columnName in columnsWithMeta)) { console.warn('failed to get column', columnsWithMeta); return; } const pendingLabelState = { ...columnsWithMeta, [columnName]: { ...columnsWithMeta[columnName], active: !columnsWithMeta[columnName]?.active }, }; // Analytics columnFilterEvent(columnName); // Set local state setColumnsWithMeta(pendingLabelState); // If user is currently filtering, update filtered state if (filteredColumnsWithMeta) { const pendingFilteredLabelState = { ...filteredColumnsWithMeta, [columnName]: { ...filteredColumnsWithMeta[columnName], active: !filteredColumnsWithMeta[columnName]?.active }, }; setFilteredColumnsWithMeta(pendingFilteredLabelState); } const newColumns: Record = Object.assign( {}, // Get the keys of the object as an array Object.keys(pendingLabelState) // Only include active filters .filter((key) => pendingLabelState[key]?.active) ); const defaultColumns = { 0: logsFrame?.timeField.name ?? '', 1: logsFrame?.bodyField.name ?? '' }; const newPanelState: ExploreLogsPanelState = { ...props.panelState, // URL format requires our array of values be an object, so we convert it using object.assign columns: Object.keys(newColumns).length ? newColumns : defaultColumns, refId: currentDataFrame.refId, visualisationType: 'table', labelFieldName: logsFrame?.getLabelFieldName() ?? undefined, }; // Update url state updatePanelState(newPanelState); }; // uFuzzy search dispatcher, adds any matches to the local state const dispatcher = (data: string[][]) => { const matches = data[0]; let newColumnsWithMeta: fieldNameMetaStore = {}; let numberOfResults = 0; matches.forEach((match) => { if (match in columnsWithMeta) { newColumnsWithMeta[match] = columnsWithMeta[match]; numberOfResults++; } }); setFilteredColumnsWithMeta(newColumnsWithMeta); searchFilterEvent(numberOfResults); }; // uFuzzy search const search = (needle: string) => { fuzzySearch(Object.keys(columnsWithMeta), needle, dispatcher); }; // onChange handler for search input const onSearchInputChange = (e: React.FormEvent) => { const value = e.currentTarget?.value; setSearchValue(value); if (value) { search(value); } else { // If the search input is empty, reset the local search state. setFilteredColumnsWithMeta(undefined); } }; const onFrameSelectorChange = (value: SelectableValue) => { const matchingDataFrame = logsFrames.find((frame) => frame.refId === value.value); if (matchingDataFrame) { setCurrentDataFrame(logsFrames.find((frame) => frame.refId === value.value) ?? logsFrames[0]); } props.updatePanelState({ refId: value.value, labelFieldName: logsFrame?.getLabelFieldName() ?? undefined }); }; const sidebarWidth = 220; const totalWidth = props.width; const tableWidth = totalWidth - sidebarWidth; const styles = getStyles(props.theme, height, sidebarWidth); return ( <>
{logsFrames.length > 1 && (