2023-10-26 12:06:41 -05:00
import { css } from '@emotion/css' ;
2023-11-16 10:42:28 -06:00
import React , { useCallback , useEffect , useState } from 'react' ;
2023-10-26 12:06:41 -05:00
import {
DataFrame ,
ExploreLogsPanelState ,
GrafanaTheme2 ,
Labels ,
LogsSortOrder ,
2023-11-16 10:42:28 -06:00
SelectableValue ,
2023-10-26 12:06:41 -05:00
SplitOpen ,
TimeRange ,
} from '@grafana/data' ;
import { reportInteraction } from '@grafana/runtime/src' ;
2023-11-16 10:42:28 -06:00
import { InlineField , Select , Themeable2 } from '@grafana/ui/' ;
2023-10-26 12:06:41 -05:00
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 < ExploreLogsPanelState > ) = > void ;
2023-11-27 07:29:00 -06:00
onClickFilterLabel ? : ( key : string , value : string , frame? : DataFrame ) = > void ;
onClickFilterOutLabel ? : ( key : string , value : string , frame? : DataFrame ) = > void ;
2023-11-29 13:50:32 -06:00
datasourceType? : string ;
2023-10-26 12:06:41 -05:00
}
2023-11-07 08:00:06 -06:00
export type fieldNameMeta = {
percentOfLinesWithLabel : number ;
active : boolean | undefined ;
type ? : 'BODY_FIELD' | 'TIME_FIELD' ;
} ;
2023-10-26 12:06:41 -05:00
type fieldName = string ;
type fieldNameMetaStore = Record < fieldName , fieldNameMeta > ;
export function LogsTableWrap ( props : Props ) {
2023-11-29 10:01:28 -06:00
const { logsFrames , updatePanelState , panelState } = props ;
const propsColumns = panelState ? . columns ;
2023-10-26 12:06:41 -05:00
// Save the normalized cardinality of each label
const [ columnsWithMeta , setColumnsWithMeta ] = useState < fieldNameMetaStore | undefined > ( undefined ) ;
// Filtered copy of columnsWithMeta that only includes matching results
const [ filteredColumnsWithMeta , setFilteredColumnsWithMeta ] = useState < fieldNameMetaStore | undefined > ( undefined ) ;
2023-11-29 10:57:04 -06:00
const [ searchValue , setSearchValue ] = useState < string > ( '' ) ;
2023-10-26 12:06:41 -05:00
2023-11-22 07:15:29 -06:00
const height = getLogsTableHeight ( ) ;
const panelStateRefId = props ? . panelState ? . refId ;
2023-11-16 10:42:28 -06:00
// The current dataFrame containing the refId of the current query
const [ currentDataFrame , setCurrentDataFrame ] = useState < DataFrame > (
2023-11-22 07:15:29 -06:00
logsFrames . find ( ( f ) = > f . refId === panelStateRefId ) ? ? logsFrames [ 0 ]
2023-11-16 10:42:28 -06:00
) ;
2023-10-26 12:06:41 -05:00
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 ]
) ;
2023-11-29 10:01:28 -06:00
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 ] ) ;
2023-10-26 12:06:41 -05:00
2023-11-22 07:15:29 -06:00
/ * *
* 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 ] ) ;
2023-10-26 12:06:41 -05:00
/ * *
* 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 ( ( ) = > {
2023-11-07 08:00:06 -06:00
// If the data frame is empty, there's nothing to viz, it could mean the user has unselected all columns
2023-11-16 10:42:28 -06:00
if ( ! currentDataFrame . length ) {
2023-11-07 08:00:06 -06:00
return ;
}
2023-11-16 10:42:28 -06:00
const numberOfLogLines = currentDataFrame ? currentDataFrame.length : 0 ;
const logsFrame = parseLogsFrame ( currentDataFrame ) ;
2023-11-07 03:53:10 -06:00
const labels = logsFrame ? . getLogFrameLabelsAsLabels ( ) ;
2023-10-26 12:06:41 -05:00
2023-11-07 08:00:06 -06:00
const otherFields = [ ] ;
if ( logsFrame ) {
otherFields . push ( . . . logsFrame . extraFields . filter ( ( field ) = > ! field ? . config ? . custom ? . hidden ) ) ;
}
2023-10-26 12:06:41 -05:00
if ( logsFrame ? . severityField ) {
otherFields . push ( logsFrame ? . severityField ) ;
}
2023-11-07 08:00:06 -06:00
if ( logsFrame ? . bodyField ) {
otherFields . push ( logsFrame ? . bodyField ) ;
}
if ( logsFrame ? . timeField ) {
otherFields . push ( logsFrame ? . timeField ) ;
}
2023-10-26 12:06:41 -05:00
// Use a map to dedupe labels and count their occurrences in the logs
const labelCardinality = new Map < fieldName , fieldNameMeta > ( ) ;
// 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 ) ;
2023-11-07 08:00:06 -06:00
// 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' ;
}
2023-10-26 12:06:41 -05:00
setColumnsWithMeta ( pendingLabelState ) ;
// The panel state is updated when the user interacts with the multi-select sidebar
2023-11-16 10:42:28 -06:00
} , [ currentDataFrame , getColumnsFromProps ] ) ;
2023-10-26 12:06:41 -05:00
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 ,
2023-11-29 13:50:32 -06:00
datasourceType : props.datasourceType ,
2023-10-26 12:06:41 -05:00
} ;
reportInteraction ( 'grafana_explore_logs_table_column_filter_clicked' , event ) ;
}
}
function searchFilterEvent ( searchResultCount : number ) {
reportInteraction ( 'grafana_explore_logs_table_text_search_result_count' , {
resultCount : searchResultCount ,
2023-11-29 13:50:32 -06:00
datasourceType : props.datasourceType ? ? 'unknown' ,
2023-10-26 12:06:41 -05:00
} ) ;
}
2023-11-29 10:57:04 -06:00
const clearSelection = ( ) = > {
const pendingLabelState = { . . . columnsWithMeta } ;
Object . keys ( pendingLabelState ) . forEach ( ( key ) = > {
pendingLabelState [ key ] . active = ! ! pendingLabelState [ key ] . type ;
} ) ;
setColumnsWithMeta ( pendingLabelState ) ;
} ;
2023-10-26 12:06:41 -05:00
// 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 ) ;
}
2023-11-29 10:01:28 -06:00
const newColumns : Record < number , string > = 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 ? ? '' } ;
2023-10-26 12:06:41 -05:00
const newPanelState : ExploreLogsPanelState = {
. . . props . panelState ,
// URL format requires our array of values be an object, so we convert it using object.assign
2023-11-29 10:01:28 -06:00
columns : Object.keys ( newColumns ) . length ? newColumns : defaultColumns ,
2023-11-16 10:42:28 -06:00
refId : currentDataFrame.refId ,
2023-10-26 12:06:41 -05:00
visualisationType : 'table' ,
2023-11-29 10:01:28 -06:00
labelFieldName : logsFrame?.getLabelFieldName ( ) ? ? undefined ,
2023-10-26 12:06:41 -05:00
} ;
// Update url state
2023-11-29 10:01:28 -06:00
updatePanelState ( newPanelState ) ;
2023-10-26 12:06:41 -05:00
} ;
// 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 < HTMLInputElement > ) = > {
const value = e . currentTarget ? . value ;
2023-11-29 10:57:04 -06:00
setSearchValue ( value ) ;
2023-10-26 12:06:41 -05:00
if ( value ) {
2023-11-29 10:57:04 -06:00
search ( value ) ;
2023-10-26 12:06:41 -05:00
} else {
// If the search input is empty, reset the local search state.
setFilteredColumnsWithMeta ( undefined ) ;
}
} ;
2023-11-16 10:42:28 -06:00
const onFrameSelectorChange = ( value : SelectableValue < string > ) = > {
const matchingDataFrame = logsFrames . find ( ( frame ) = > frame . refId === value . value ) ;
if ( matchingDataFrame ) {
setCurrentDataFrame ( logsFrames . find ( ( frame ) = > frame . refId === value . value ) ? ? logsFrames [ 0 ] ) ;
}
2023-11-29 10:01:28 -06:00
props . updatePanelState ( { refId : value.value , labelFieldName : logsFrame?.getLabelFieldName ( ) ? ? undefined } ) ;
2023-11-16 10:42:28 -06:00
} ;
2023-10-26 12:06:41 -05:00
const sidebarWidth = 220 ;
const totalWidth = props . width ;
const tableWidth = totalWidth - sidebarWidth ;
const styles = getStyles ( props . theme , height , sidebarWidth ) ;
return (
2023-11-16 10:42:28 -06:00
< >
< div >
{ logsFrames . length > 1 && (
< div >
< InlineField
label = "Select query"
htmlFor = "explore_logs_table_frame_selector"
labelWidth = { 22 }
tooltip = "Select a query to visualize in the table."
>
< Select
inputId = { 'explore_logs_table_frame_selector' }
aria - label = { 'Select query by name' }
2023-11-22 07:15:29 -06:00
value = { currentDataFrame . refId }
2023-11-16 10:42:28 -06:00
options = { logsFrames . map ( ( frame ) = > {
return {
label : frame.refId ,
value : frame.refId ,
} ;
} ) }
onChange = { onFrameSelectorChange }
/ >
< / InlineField >
< / div >
) }
< / div >
< div className = { styles . wrapper } >
< section className = { styles . sidebar } >
2023-11-29 10:57:04 -06:00
< LogsColumnSearch value = { searchValue } onChange = { onSearchInputChange } / >
2023-11-16 10:42:28 -06:00
< LogsTableMultiSelect
toggleColumn = { toggleColumn }
filteredColumnsWithMeta = { filteredColumnsWithMeta }
columnsWithMeta = { columnsWithMeta }
2023-11-29 10:57:04 -06:00
clear = { clearSelection }
2023-11-16 10:42:28 -06:00
/ >
< / section >
< LogsTable
onClickFilterLabel = { props . onClickFilterLabel }
onClickFilterOutLabel = { props . onClickFilterOutLabel }
logsSortOrder = { props . logsSortOrder }
range = { props . range }
splitOpen = { props . splitOpen }
timeZone = { props . timeZone }
width = { tableWidth }
dataFrame = { currentDataFrame }
2023-10-26 12:06:41 -05:00
columnsWithMeta = { columnsWithMeta }
2023-11-16 10:42:28 -06:00
height = { height }
2023-10-26 12:06:41 -05:00
/ >
2023-11-16 10:42:28 -06:00
< / div >
< / >
2023-10-26 12:06:41 -05:00
) ;
}
const normalize = ( value : number , total : number ) : number = > {
return Math . ceil ( ( 100 * value ) / total ) ;
} ;
function getStyles ( theme : GrafanaTheme2 , height : number , width : number ) {
return {
wrapper : css ( {
display : 'flex' ,
} ) ,
sidebar : css ( {
height : height ,
fontSize : theme.typography.pxToRem ( 11 ) ,
overflowY : 'hidden' ,
width : width ,
paddingRight : theme.spacing ( 1.5 ) ,
} ) ,
} ;
}
2023-11-22 07:15:29 -06:00
export const getLogsTableHeight = ( ) = > {
2023-11-07 09:22:11 -06:00
// Instead of making the height of the table based on the content (like in the table panel itself), let's try to use the vertical space that is available.
// Since this table is in explore, we can expect the user to be running multiple queries that return disparate numbers of rows and labels in the same session
// Also changing the height of the table between queries can be and cause content to jump, so we'll set a minimum height of 500px, and a max based on the innerHeight
// Ideally the table container should always be able to fit in the users viewport without needing to scroll
return Math . max ( window . innerHeight - 500 , 500 ) ;
2023-10-26 12:06:41 -05:00
} ;