-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Extract LogContextProvider, add LogContextUI
- Loading branch information
Showing
5 changed files
with
416 additions
and
96 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
import { ReactNode } from 'react'; | ||
import { lastValueFrom } from 'rxjs'; | ||
import { QuickwitDataSource } from 'datasource'; | ||
import { catchError } from 'rxjs/operators'; | ||
|
||
import { | ||
CoreApp, | ||
DataFrame, | ||
DataQueryError, | ||
DataQueryRequest, | ||
dateTime, | ||
LogRowModel, | ||
rangeUtil, | ||
} from '@grafana/data'; | ||
|
||
import { ElasticsearchQuery, Logs} from '../types'; | ||
|
||
import { LogContextUI } from 'LogContext/components/LogContextUI'; | ||
|
||
export interface LogRowContextOptions { | ||
direction?: LogRowContextQueryDirection; | ||
limit?: number; | ||
} | ||
export enum LogRowContextQueryDirection { | ||
Backward = 'BACKWARD', | ||
Forward = 'FORWARD', | ||
} | ||
|
||
function createContextTimeRange(rowTimeEpochMs: number, direction: string) { | ||
const offset = 7; | ||
// For log context, we want to request data from 7 subsequent/previous indices | ||
if (direction === LogRowContextQueryDirection.Forward) { | ||
return { | ||
from: dateTime(rowTimeEpochMs).utc(), | ||
to: dateTime(rowTimeEpochMs).add(offset, 'hours').utc(), | ||
}; | ||
} else { | ||
return { | ||
from: dateTime(rowTimeEpochMs).subtract(offset, 'hours').utc(), | ||
to: dateTime(rowTimeEpochMs).utc(), | ||
}; | ||
} | ||
} | ||
|
||
export class LogContextProvider { | ||
datasource: QuickwitDataSource; | ||
contextQuery: string | null; | ||
|
||
constructor(datasource: QuickwitDataSource) { | ||
this.datasource = datasource; | ||
this.contextQuery = null; | ||
} | ||
private makeLogContextDataRequest = ( | ||
row: LogRowModel, | ||
options?: LogRowContextOptions, | ||
origQuery?: ElasticsearchQuery | ||
) => { | ||
const direction = options?.direction || LogRowContextQueryDirection.Backward; | ||
const searchAfter = row.dataFrame.fields.find((f) => f.name === 'sort')?.values.get(row.rowIndex) ?? [row.timeEpochNs] | ||
|
||
const logQuery: Logs = { | ||
type: 'logs', | ||
id: '1', | ||
settings: { | ||
limit: options?.limit ? options?.limit.toString() : '10', | ||
// Sorting of results in the context query | ||
sortDirection: direction === LogRowContextQueryDirection.Backward ? 'desc' : 'asc', | ||
// Used to get the next log lines before/after the current log line using sort field of selected log line | ||
searchAfter: searchAfter, | ||
}, | ||
}; | ||
|
||
const query: ElasticsearchQuery = { | ||
refId: `log-context-${row.dataFrame.refId}-${direction}`, | ||
metrics: [logQuery], | ||
query: this.contextQuery == null ? origQuery?.query : this.contextQuery, | ||
}; | ||
|
||
const timeRange = createContextTimeRange(row.timeEpochMs, direction); | ||
const range = { | ||
from: timeRange.from, | ||
to: timeRange.to, | ||
raw: timeRange, | ||
}; | ||
|
||
const interval = rangeUtil.calculateInterval(range, 1); | ||
|
||
const contextRequest: DataQueryRequest<ElasticsearchQuery> = { | ||
requestId: `log-context-request-${row.dataFrame.refId}-${options?.direction}`, | ||
targets: [query], | ||
interval: interval.interval, | ||
intervalMs: interval.intervalMs, | ||
range, | ||
scopedVars: {}, | ||
timezone: 'UTC', | ||
app: CoreApp.Explore, | ||
startTime: Date.now(), | ||
hideFromInspector: true, | ||
}; | ||
return contextRequest; | ||
}; | ||
|
||
getLogRowContext = async ( | ||
row: LogRowModel, | ||
options?: LogRowContextOptions, | ||
origQuery?: ElasticsearchQuery | ||
): Promise<{ data: DataFrame[] }> => { | ||
const contextRequest = this.makeLogContextDataRequest(row, options, origQuery); | ||
|
||
return lastValueFrom( | ||
this.datasource.query(contextRequest).pipe( | ||
catchError((err) => { | ||
const error: DataQueryError = { | ||
message: 'Error during context query. Please check JS console logs.', | ||
status: err.status, | ||
statusText: err.message, | ||
}; | ||
throw error; | ||
}) | ||
) | ||
); | ||
}; | ||
|
||
getLogRowContextUi( | ||
row: LogRowModel, | ||
runContextQuery?: (() => void), | ||
origQuery?: ElasticsearchQuery | ||
): ReactNode { | ||
return ( LogContextUI({row, runContextQuery, origQuery, updateQuery: query=>{this.contextQuery=query}, datasource:this.datasource})) | ||
} | ||
} |
191 changes: 191 additions & 0 deletions
191
src/LogContext/components/LogContextQueryBuilderSidebar.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,191 @@ | ||
import React, { useEffect, useMemo, useState } from "react"; | ||
// import { Field } from '@grafana/data'; | ||
import { useTheme2, CollapsableSection, Icon } from '@grafana/ui'; | ||
import { LogContextProps } from "./LogContextUI"; | ||
import { css, cx } from "@emotion/css"; | ||
import { LuceneQuery } from "utils/lucene"; | ||
import { LuceneQueryBuilder } from '@/QueryBuilder/lucene'; | ||
|
||
|
||
// TODO : define sensible defaults here | ||
const excludedFields = [ | ||
'_source', | ||
'sort', | ||
'attributes', | ||
'attributes.message', | ||
'body', | ||
'body.message', | ||
'resource_attributes', | ||
'observed_timestamp_nanos', | ||
'timestamp_nanos', | ||
]; | ||
|
||
function isPrimitive(valT: any) { | ||
return ['string', 'number', "boolean", "undefined"].includes(valT) | ||
} | ||
|
||
type FieldContingency = { [value: string]: { | ||
count: number, pinned: boolean, active?: boolean | ||
}}; | ||
type Field = { | ||
name: string, | ||
contingency: FieldContingency | ||
} | ||
|
||
function LogContextFieldSection(field: Field) { | ||
const theme = useTheme2() | ||
const hasActiveFilters = Object.entries(field.contingency).map(([_,entry])=>!!entry.active).reduce((a,b)=>a || b, false); | ||
return( | ||
<span className={css({fontSize:theme.typography.body.fontSize, display:"flex", alignItems: "baseline", gap:"0.5rem", width:"100%"})}> | ||
{hasActiveFilters && <Icon name={"filter"} className={css({ color:theme.colors.primary.text })}/>} | ||
<span>{field.name}</span> | ||
</span> | ||
) | ||
} | ||
|
||
type FieldItemProps = { | ||
label: any, | ||
contingency: { | ||
count: number, | ||
pinned: boolean | ||
}, | ||
active?: boolean, | ||
onClick: () => void | ||
} | ||
|
||
function LogContextFieldItem(props: FieldItemProps){ | ||
const theme = useTheme2() | ||
const lcAttributeItemStyle = css({ | ||
display: "flex", | ||
justifyContent: "space-between", | ||
paddingLeft: "10px", | ||
fontSize: theme.typography.bodySmall.fontSize, | ||
"&[data-active=true]": { | ||
backgroundColor: theme.colors.primary.transparent, | ||
}, | ||
"&:hover": { | ||
backgroundColor: theme.colors.secondary.shade, | ||
} | ||
}); | ||
|
||
const formatLabel = (value: any)=> { | ||
let shouldEmphasize = false; | ||
let label = `${value}`; | ||
|
||
if (value === null || value === '' || value === undefined){ | ||
shouldEmphasize = true; | ||
} | ||
if (value === '') { | ||
label = '<empty string>' | ||
} | ||
return (shouldEmphasize ? <em>{label}</em> : label); | ||
} | ||
|
||
return ( | ||
<a className={lcAttributeItemStyle} onClick={props.onClick} data-active={props.active}> | ||
<span className={css`text-overflow:ellipsis; min-width:0; flex:1 1`}>{ formatLabel(props.label) }</span> | ||
<span className={css`flex-grow:0`}>{props.contingency.pinned && <Icon name={"crosshair"}/>}{props.contingency.count}</span> | ||
</a> | ||
) | ||
} | ||
|
||
const lcSidebarStyle = css` | ||
width: 300px; | ||
min-width: 300px; | ||
flex-shrink: 0; | ||
overflow-y: scroll; | ||
padding-right: 1rem; | ||
` | ||
|
||
type QueryBuilderProps = { | ||
builder: LuceneQueryBuilder, | ||
searchableFields: any[], | ||
updateQuery: (query: LuceneQuery) => void | ||
} | ||
|
||
export function LogContextQueryBuilderSidebar(props: LogContextProps & QueryBuilderProps) { | ||
|
||
const {row, builder, updateQuery, searchableFields} = props; | ||
const [fields, setFields] = useState<Field[]>([]); | ||
|
||
const filteredFields = useMemo(() => { | ||
const searchableFieldsNames = searchableFields.map(f=>f.text); | ||
return row.dataFrame.fields | ||
.filter(f=>searchableFieldsNames.includes(f.name)) | ||
// exclude some low-filterability fields | ||
.filter((f)=> !excludedFields.includes(f.name) && isPrimitive(f.type)) | ||
// sort fields by name | ||
.sort((f1, f2)=> (f1.name>f2.name ? 1 : -1)) | ||
}, [row, searchableFields]); | ||
|
||
useEffect(() => { | ||
const fields = filteredFields | ||
.map((f) => { | ||
const contingency: FieldContingency = {}; | ||
f.values.forEach((value, i) => { | ||
if (!contingency[value]) { | ||
contingency[value] = { | ||
count: 0, | ||
pinned: false, | ||
active: builder.parsedQuery ? !!builder.parsedQuery.findFilter(f.name, `${value}`) : false | ||
} | ||
} | ||
contingency[value].count += 1; | ||
if (i === row.rowIndex) { | ||
contingency[value].pinned = true; | ||
} | ||
}); | ||
return { name: f.name, contingency }; | ||
}) | ||
|
||
setFields(fields); | ||
}, [filteredFields, row.rowIndex, builder.parsedQuery]); | ||
|
||
|
||
const selectQueryFilter = (key: string, value: string): void => { | ||
// Compute mutation to apply to the query and send to parent | ||
// check if that filter is in the query | ||
if (!builder.parsedQuery) { return; } | ||
|
||
const newParsedQuery = ( | ||
builder.parsedQuery.hasFilter(key, value) | ||
? builder.parsedQuery.removeFilter(key, value) | ||
: builder.parsedQuery.addFilter(key, value) | ||
) | ||
|
||
if (newParsedQuery) { | ||
updateQuery(newParsedQuery); | ||
} | ||
} | ||
|
||
const renderFieldSection = (field: Field)=>{ | ||
return ( | ||
<CollapsableSection | ||
label={LogContextFieldSection(field)} | ||
className={css`& > div { flex-grow:1; }` } | ||
isOpen={false} key="log-attribute-field-{field.name}" | ||
contentClassName={cx(css`margin:0; padding:0`)}> | ||
<div className={css`display:flex; flex-direction:column; gap:5px`}> | ||
|
||
{field.contingency && Object.entries(field.contingency) | ||
.sort(([na, ca], [nb, cb])=>(cb.count - ca.count)) | ||
.map(([fieldValue, contingency], i) => ( | ||
<LogContextFieldItem | ||
label={fieldValue} contingency={contingency} key={`field-opt${i}`} | ||
onClick={() => {selectQueryFilter(field.name, fieldValue)}} | ||
active={contingency.active} | ||
/> | ||
)) | ||
} | ||
</div> | ||
</CollapsableSection> | ||
) | ||
} | ||
|
||
return ( | ||
<div className={lcSidebarStyle}> | ||
{fields && fields.map((field) => { | ||
return( renderFieldSection(field) ); | ||
}) } </div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
import React, { useEffect, useState, useCallback, useMemo } from "react"; | ||
import { LogRowModel } from '@grafana/data'; | ||
import { ElasticsearchQuery as DataQuery } from '../../types'; | ||
import { LuceneQueryEditor } from "../../components/LuceneQueryEditor"; | ||
|
||
import { css } from "@emotion/css"; | ||
import { Button } from "@grafana/ui"; | ||
import { useQueryBuilder } from '@/QueryBuilder/lucene'; | ||
import { LogContextQueryBuilderSidebar } from "./LogContextQueryBuilderSidebar"; | ||
import { DatasourceContext } from "components/QueryEditor/ElasticsearchQueryContext"; | ||
import { QuickwitDataSource } from "datasource"; | ||
import { useDatasourceFields } from "datasource.utils"; | ||
|
||
const logContextUiStyle = css` | ||
display: flex; | ||
gap: 1rem; | ||
width: 100%; | ||
height: 200px; | ||
` | ||
|
||
export interface LogContextProps { | ||
row: LogRowModel, | ||
runContextQuery?: (() => void) | ||
origQuery?: DataQuery | ||
} | ||
export interface LogContextUIProps extends LogContextProps { | ||
datasource: QuickwitDataSource, | ||
updateQuery: (query: string) => void | ||
} | ||
|
||
export function LogContextUI(props: LogContextUIProps ){ | ||
const builder = useQueryBuilder(); | ||
const {query, parsedQuery, setQuery, setParsedQuery} = builder; | ||
const [canRunQuery, setCanRunQuery] = useState<boolean>(false); | ||
const { origQuery, updateQuery, runContextQuery } = props; | ||
const {fields, getSuggestions} = useDatasourceFields(props.datasource); | ||
|
||
useEffect(()=>{ | ||
setQuery(origQuery?.query || '') | ||
}, [setQuery, origQuery]) | ||
|
||
useEffect(()=>{ | ||
setCanRunQuery(!parsedQuery.parseError) | ||
}, [parsedQuery, setCanRunQuery]) | ||
|
||
const runQuery = useCallback(()=>{ | ||
if (runContextQuery){ | ||
updateQuery(query); | ||
runContextQuery(); | ||
} | ||
}, [query, runContextQuery, updateQuery]) | ||
|
||
const ActionBar = useMemo(()=>( | ||
<div className={css`display:flex; justify-content:end; flex:0 0; gap:0.5rem;`}> | ||
<Button variant="secondary" onClick={()=>setQuery('')}>Clear</Button> | ||
<Button variant="secondary" onClick={()=>setQuery(origQuery?.query || '')}>Reset</Button> | ||
<Button onClick={runQuery} {...canRunQuery ? {} : {disabled:true, tooltip:"Failed to parse query"}} >Run query</Button> | ||
</div> | ||
), [setQuery, canRunQuery, origQuery, runQuery]) | ||
|
||
return ( | ||
<div className={logContextUiStyle}> | ||
<DatasourceContext.Provider value={props.datasource}> | ||
<LogContextQueryBuilderSidebar {...props} builder={builder} updateQuery={setParsedQuery} searchableFields={fields}/> | ||
<div className={css`width:100%; display:flex; flex-direction:column; gap:0.5rem; min-width:0;`}> | ||
{ActionBar} | ||
<LuceneQueryEditor builder={builder} autocompleter={getSuggestions} onChange={builder.setQuery}/> | ||
</div> | ||
</DatasourceContext.Provider> | ||
</div> | ||
); | ||
} |
Oops, something went wrong.