import { css } from '@emotion/css'; import { Resizable, ResizeCallback } from 're-resizable'; import { useCallback, useEffect, useState } from 'react'; import * as React 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; } type ActiveFieldMeta = { active: false; index: undefined; // if undefined the column is not selected }; type InactiveFieldMeta = { active: true; index: number; // if undefined the column is not selected }; type GenericMeta = { percentOfLinesWithLabel: number; type?: 'BODY_FIELD' | 'TIME_FIELD'; }; export type FieldNameMeta = (InactiveFieldMeta | ActiveFieldMeta) & GenericMeta; 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, index) => { if (fieldNames[key]) { fieldNames[key].active = true; fieldNames[key].index = index; } }); } 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) { if (value?.active) { labelCardinality.set(label, { percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1, active: true, index: value.index, }); } else { labelCardinality.set(label, { percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1, active: false, index: undefined, }); } } // Otherwise add it } else { labelCardinality.set(label, { percentOfLinesWithLabel: 1, active: false, index: 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) => { const isActive = pendingLabelState[field.name]?.active; const index = pendingLabelState[field.name]?.index; if (isActive && index !== undefined) { pendingLabelState[field.name] = { percentOfLinesWithLabel: normalize( field.values.filter((value) => value !== null && value !== undefined).length, numberOfLogLines ), active: true, index: index, }; } else { pendingLabelState[field.name] = { percentOfLinesWithLabel: normalize( field.values.filter((value) => value !== null && value !== undefined).length, numberOfLogLines ), active: false, index: undefined, }; } }); 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]); const [sidebarWidth, setSidebarWidth] = useState(220); const tableWidth = props.width - sidebarWidth; 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 }; let index = 0; Object.keys(pendingLabelState).forEach((key) => { const isDefaultField = !!pendingLabelState[key].type; // after reset the only active fields are the special time and body fields pendingLabelState[key].active = isDefaultField; // reset the index pendingLabelState[key].index = isDefaultField ? index++ : undefined; }); setColumnsWithMeta(pendingLabelState); }; const reorderColumn = (sourceIndex: number, destinationIndex: number) => { if (sourceIndex === destinationIndex) { return; } const pendingLabelState = { ...columnsWithMeta }; const keys = Object.keys(pendingLabelState) .filter((key) => pendingLabelState[key].active) .map((key) => ({ fieldName: key, index: pendingLabelState[key].index ?? 0, })) .sort((a, b) => a.index - b.index); const [source] = keys.splice(sourceIndex, 1); keys.splice(destinationIndex, 0, source); keys.forEach((key, index) => { pendingLabelState[key.fieldName].index = index; }); // Set local state setColumnsWithMeta(pendingLabelState); // Sync the explore state updateExploreState(pendingLabelState); }; function updateExploreState(pendingLabelState: FieldNameMetaStore) { // Get all active columns and sort by index const newColumnsArray = Object.keys(pendingLabelState) // Only include active filters .filter((key) => pendingLabelState[key]?.active) .sort((a, b) => { const pa = pendingLabelState[a]; const pb = pendingLabelState[b]; if (pa.index !== undefined && pb.index !== undefined) { return pa.index - pb.index; // sort by index } return 0; }); const newColumns: Record = Object.assign( {}, // Get the keys of the object as an array newColumnsArray ); 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); } // 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 length = Object.keys(columnsWithMeta).filter((c) => columnsWithMeta[c].active).length; const isActive = !columnsWithMeta[columnName].active ? true : undefined; let pendingLabelState: FieldNameMetaStore; if (isActive) { pendingLabelState = { ...columnsWithMeta, [columnName]: { ...columnsWithMeta[columnName], active: isActive, index: length, }, }; } else { pendingLabelState = { ...columnsWithMeta, [columnName]: { ...columnsWithMeta[columnName], active: false, index: undefined, }, }; } // Analytics columnFilterEvent(columnName); // Set local state setColumnsWithMeta(pendingLabelState); // If user is currently filtering, update filtered state if (filteredColumnsWithMeta) { const active = !filteredColumnsWithMeta[columnName]?.active; let pendingFilteredLabelState: FieldNameMetaStore; if (active) { pendingFilteredLabelState = { ...filteredColumnsWithMeta, [columnName]: { ...filteredColumnsWithMeta[columnName], active: active, index: length, }, }; } else { pendingFilteredLabelState = { ...filteredColumnsWithMeta, [columnName]: { ...filteredColumnsWithMeta[columnName], active: false, index: undefined, }, }; } setFilteredColumnsWithMeta(pendingFilteredLabelState); } updateExploreState(pendingLabelState); }; // 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 styles = getStyles(props.theme, height, sidebarWidth); const getOnResize: ResizeCallback = (event, direction, ref) => { const newSidebarWidth = Number(ref.style.width.slice(0, -2)); if (!isNaN(newSidebarWidth)) { setSidebarWidth(newSidebarWidth); } }; return ( <>
{logsFrames.length > 1 && (