Skip to content

Commit

Permalink
Extract LogContextProvider, add LogContextUI
Browse files Browse the repository at this point in the history
  • Loading branch information
ddelemeny committed Feb 5, 2024
1 parent 0817b52 commit 7183f8c
Show file tree
Hide file tree
Showing 5 changed files with 416 additions and 96 deletions.
131 changes: 131 additions & 0 deletions src/LogContext/LogContextProvider.ts
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 src/LogContext/components/LogContextQueryBuilderSidebar.tsx
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>
);
}
72 changes: 72 additions & 0 deletions src/LogContext/components/LogContextUI.tsx
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>
);
}
Loading

0 comments on commit 7183f8c

Please sign in to comment.