mirror of https://github.com/grafana/grafana
Logs Panel: Column selection for experimental table visualization in explore (#76983)
* New experimental table customization for logs in explore * Logs Panel: Explore url sync for table visualization (#76980) * Sync explore URL state with logs panel state in explorepull/77235/head
parent
c122ffc72b
commit
05376a950c
@ -0,0 +1,23 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data/src'; |
||||
import { Field, Input, useTheme2 } from '@grafana/ui/src'; |
||||
|
||||
function getStyles(theme: GrafanaTheme2) { |
||||
return { |
||||
searchWrap: css({ |
||||
padding: theme.spacing(0.4), |
||||
}), |
||||
}; |
||||
} |
||||
|
||||
export function LogsColumnSearch(props: { onChange: (e: React.FormEvent<HTMLInputElement>) => void }) { |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme); |
||||
return ( |
||||
<Field className={styles.searchWrap}> |
||||
<Input type={'text'} placeholder={'Search fields by name'} onChange={props.onChange} /> |
||||
</Field> |
||||
); |
||||
} |
@ -0,0 +1,53 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data/src'; |
||||
import { useTheme2 } from '@grafana/ui/src'; |
||||
|
||||
import { LogsTableNavColumn } from './LogsTableNavColumn'; |
||||
import { fieldNameMeta } from './LogsTableWrap'; |
||||
|
||||
function getStyles(theme: GrafanaTheme2) { |
||||
return { |
||||
sidebarWrap: css({ |
||||
overflowY: 'scroll', |
||||
height: 'calc(100% - 50px)', |
||||
}), |
||||
columnHeader: css({ |
||||
fontSize: theme.typography.h6.fontSize, |
||||
background: theme.colors.background.secondary, |
||||
position: 'sticky', |
||||
top: 0, |
||||
left: 0, |
||||
paddingTop: theme.spacing(0.75), |
||||
paddingRight: theme.spacing(0.75), |
||||
paddingBottom: theme.spacing(0.75), |
||||
paddingLeft: theme.spacing(1.5), |
||||
zIndex: 3, |
||||
marginBottom: theme.spacing(2), |
||||
}), |
||||
}; |
||||
} |
||||
|
||||
export const LogsTableMultiSelect = (props: { |
||||
toggleColumn: (columnName: string) => void; |
||||
filteredColumnsWithMeta: Record<string, fieldNameMeta> | undefined; |
||||
columnsWithMeta: Record<string, fieldNameMeta>; |
||||
}) => { |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme); |
||||
|
||||
return ( |
||||
<div className={styles.sidebarWrap}> |
||||
{/* Sidebar columns */} |
||||
<> |
||||
<div className={styles.columnHeader}>Fields</div> |
||||
<LogsTableNavColumn |
||||
toggleColumn={props.toggleColumn} |
||||
labels={props.filteredColumnsWithMeta ?? props.columnsWithMeta} |
||||
valueFilter={(value) => !!value} |
||||
/> |
||||
</> |
||||
</div> |
||||
); |
||||
}; |
@ -0,0 +1,64 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data/src'; |
||||
import { Checkbox, useTheme2 } from '@grafana/ui/src'; |
||||
|
||||
import { fieldNameMeta } from './LogsTableWrap'; |
||||
|
||||
function getStyles(theme: GrafanaTheme2) { |
||||
return { |
||||
labelCount: css({ |
||||
marginLeft: theme.spacing(0.5), |
||||
marginRight: theme.spacing(0.5), |
||||
}), |
||||
wrap: css({ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
marginTop: theme.spacing(1), |
||||
marginBottom: theme.spacing(1), |
||||
justifyContent: 'space-between', |
||||
}), |
||||
checkbox: css({}), |
||||
columnWrapper: css({ |
||||
marginBottom: theme.spacing(1.5), |
||||
// need some space or the outline of the checkbox is cut off
|
||||
paddingLeft: theme.spacing(0.5), |
||||
}), |
||||
empty: css({ |
||||
marginBottom: theme.spacing(2), |
||||
marginLeft: theme.spacing(1.75), |
||||
fontSize: theme.typography.fontSize, |
||||
}), |
||||
}; |
||||
} |
||||
|
||||
export const LogsTableNavColumn = (props: { |
||||
labels: Record<string, fieldNameMeta>; |
||||
valueFilter: (value: number) => boolean; |
||||
toggleColumn: (columnName: string) => void; |
||||
}): JSX.Element => { |
||||
const { labels, valueFilter, toggleColumn } = props; |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme); |
||||
const labelKeys = Object.keys(labels).filter((labelName) => valueFilter(labels[labelName].percentOfLinesWithLabel)); |
||||
if (labelKeys.length) { |
||||
return ( |
||||
<div className={styles.columnWrapper}> |
||||
{labelKeys.map((labelName) => ( |
||||
<div className={styles.wrap} key={labelName}> |
||||
<Checkbox |
||||
className={styles.checkbox} |
||||
label={labelName} |
||||
onChange={() => toggleColumn(labelName)} |
||||
checked={labels[labelName]?.active ?? false} |
||||
/> |
||||
<span className={styles.labelCount}>({labels[labelName]?.percentOfLinesWithLabel}%)</span> |
||||
</div> |
||||
))} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return <div className={styles.empty}>No fields</div>; |
||||
}; |
@ -0,0 +1,165 @@ |
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; |
||||
import React, { ComponentProps } from 'react'; |
||||
|
||||
import { |
||||
createTheme, |
||||
ExploreLogsPanelState, |
||||
LogsSortOrder, |
||||
standardTransformersRegistry, |
||||
toUtc, |
||||
} from '@grafana/data/src'; |
||||
import { organizeFieldsTransformer } from '@grafana/data/src/transformations/transformers/organize'; |
||||
import { config } from '@grafana/runtime'; |
||||
|
||||
import { extractFieldsTransformer } from '../../transformers/extractFields/extractFields'; |
||||
|
||||
import { LogsTableWrap } from './LogsTableWrap'; |
||||
import { getMockLokiFrame, getMockLokiFrameDataPlane } from './utils/testMocks.test'; |
||||
|
||||
const getComponent = (partialProps?: Partial<ComponentProps<typeof LogsTableWrap>>) => { |
||||
return ( |
||||
<LogsTableWrap |
||||
range={{ |
||||
from: toUtc('2019-01-01 10:00:00'), |
||||
to: toUtc('2019-01-01 16:00:00'), |
||||
raw: { from: 'now-1h', to: 'now' }, |
||||
}} |
||||
onClickFilterOutLabel={() => undefined} |
||||
onClickFilterLabel={() => undefined} |
||||
updatePanelState={() => undefined} |
||||
panelState={undefined} |
||||
logsSortOrder={LogsSortOrder.Descending} |
||||
splitOpen={() => undefined} |
||||
timeZone={'utc'} |
||||
width={50} |
||||
logsFrames={[getMockLokiFrame()]} |
||||
theme={createTheme()} |
||||
{...partialProps} |
||||
/> |
||||
); |
||||
}; |
||||
const setup = (partialProps?: Partial<ComponentProps<typeof LogsTableWrap>>) => { |
||||
return render(getComponent(partialProps)); |
||||
}; |
||||
|
||||
describe('LogsTableWrap', () => { |
||||
beforeAll(() => { |
||||
const transformers = [extractFieldsTransformer, organizeFieldsTransformer]; |
||||
standardTransformersRegistry.setInit(() => { |
||||
return transformers.map((t) => { |
||||
return { |
||||
id: t.id, |
||||
aliasIds: t.aliasIds, |
||||
name: t.name, |
||||
transformation: t, |
||||
description: t.description, |
||||
editor: () => null, |
||||
}; |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
it('should render 4 table rows', async () => { |
||||
setup(); |
||||
|
||||
await waitFor(() => { |
||||
const rows = screen.getAllByRole('row'); |
||||
// tableFrame has 3 rows + 1 header row
|
||||
expect(rows.length).toBe(4); |
||||
}); |
||||
}); |
||||
|
||||
it('should render 4 table rows (dataplane)', async () => { |
||||
config.featureToggles.lokiLogsDataplane = true; |
||||
setup({ logsFrames: [getMockLokiFrameDataPlane()] }); |
||||
|
||||
await waitFor(() => { |
||||
const rows = screen.getAllByRole('row'); |
||||
// tableFrame has 3 rows + 1 header row
|
||||
expect(rows.length).toBe(4); |
||||
}); |
||||
}); |
||||
|
||||
it('updatePanelState should be called when a column is selected', async () => { |
||||
const updatePanelState = jest.fn() as (panelState: Partial<ExploreLogsPanelState>) => void; |
||||
setup({ |
||||
panelState: { |
||||
visualisationType: 'table', |
||||
columns: undefined, |
||||
}, |
||||
updatePanelState: updatePanelState, |
||||
}); |
||||
|
||||
expect.assertions(3); |
||||
|
||||
const checkboxLabel = screen.getByLabelText('app'); |
||||
expect(checkboxLabel).toBeInTheDocument(); |
||||
|
||||
// Add a new column
|
||||
await waitFor(() => { |
||||
checkboxLabel.click(); |
||||
expect(updatePanelState).toBeCalledWith({ |
||||
visualisationType: 'table', |
||||
columns: { 0: 'app' }, |
||||
}); |
||||
}); |
||||
|
||||
// Remove the same column
|
||||
await waitFor(() => { |
||||
checkboxLabel.click(); |
||||
expect(updatePanelState).toBeCalledWith({ |
||||
visualisationType: 'table', |
||||
columns: {}, |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
it('search input should search matching columns', async () => { |
||||
config.featureToggles.lokiLogsDataplane = false; |
||||
const updatePanelState = jest.fn() as (panelState: Partial<ExploreLogsPanelState>) => void; |
||||
setup({ |
||||
panelState: { |
||||
visualisationType: 'table', |
||||
columns: undefined, |
||||
}, |
||||
updatePanelState: updatePanelState, |
||||
}); |
||||
|
||||
await waitFor(() => { |
||||
expect(screen.getByLabelText('app')).toBeInTheDocument(); |
||||
expect(screen.getByLabelText('cluster')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search fields by name'); |
||||
fireEvent.change(searchInput, { target: { value: 'app' } }); |
||||
|
||||
expect(screen.getByLabelText('app')).toBeInTheDocument(); |
||||
await waitFor(() => { |
||||
expect(screen.queryByLabelText('cluster')).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
it('search input should search matching columns (dataplane)', async () => { |
||||
config.featureToggles.lokiLogsDataplane = true; |
||||
|
||||
const updatePanelState = jest.fn() as (panelState: Partial<ExploreLogsPanelState>) => void; |
||||
setup({ |
||||
panelState: {}, |
||||
updatePanelState: updatePanelState, |
||||
logsFrames: [getMockLokiFrameDataPlane()], |
||||
}); |
||||
|
||||
await waitFor(() => { |
||||
expect(screen.getByLabelText('app')).toBeInTheDocument(); |
||||
expect(screen.getByLabelText('cluster')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search fields by name'); |
||||
fireEvent.change(searchInput, { target: { value: 'app' } }); |
||||
|
||||
expect(screen.getByLabelText('app')).toBeInTheDocument(); |
||||
await waitFor(() => { |
||||
expect(screen.queryByLabelText('cluster')).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,339 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { debounce } from 'lodash'; |
||||
import React, { useState, useEffect, useCallback } from 'react'; |
||||
|
||||
import { |
||||
DataFrame, |
||||
ExploreLogsPanelState, |
||||
GrafanaTheme2, |
||||
Labels, |
||||
LogsSortOrder, |
||||
SplitOpen, |
||||
TimeRange, |
||||
} from '@grafana/data'; |
||||
import { reportInteraction } from '@grafana/runtime/src'; |
||||
import { 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<ExploreLogsPanelState>) => void; |
||||
onClickFilterLabel?: (key: string, value: string, refId?: string) => void; |
||||
onClickFilterOutLabel?: (key: string, value: string, refId?: string) => void; |
||||
} |
||||
|
||||
export type fieldNameMeta = { percentOfLinesWithLabel: number; active: boolean | undefined }; |
||||
type fieldName = string; |
||||
type fieldNameMetaStore = Record<fieldName, fieldNameMeta>; |
||||
|
||||
export function LogsTableWrap(props: Props) { |
||||
const { logsFrames } = props; |
||||
// 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); |
||||
|
||||
const [height, setHeight] = useState<number>(600); |
||||
|
||||
const dataFrame = 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] |
||||
); |
||||
|
||||
/** |
||||
* 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(() => { |
||||
const numberOfLogLines = dataFrame ? dataFrame.length : 0; |
||||
const logsFrame = parseLogsFrame(dataFrame); |
||||
const labels = logsFrame?.getAttributesAsLabels(); |
||||
|
||||
const otherFields = logsFrame ? logsFrame.extraFields.filter((field) => !field?.config?.custom?.hidden) : []; |
||||
if (logsFrame?.severityField) { |
||||
otherFields.push(logsFrame?.severityField); |
||||
} |
||||
|
||||
// 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); |
||||
|
||||
setColumnsWithMeta(pendingLabelState); |
||||
|
||||
// The panel state is updated when the user interacts with the multi-select sidebar
|
||||
}, [dataFrame, getColumnsFromProps]); |
||||
|
||||
// As the number of rows change, so too must the height of the table
|
||||
useEffect(() => { |
||||
setHeight(getTableHeight(dataFrame.length, false)); |
||||
}, [dataFrame.length]); |
||||
|
||||
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, |
||||
}; |
||||
|
||||
reportInteraction('grafana_explore_logs_table_column_filter_clicked', event); |
||||
} |
||||
} |
||||
|
||||
function searchFilterEvent(searchResultCount: number) { |
||||
reportInteraction('grafana_explore_logs_table_text_search_result_count', { |
||||
resultCount: searchResultCount, |
||||
}); |
||||
} |
||||
|
||||
// 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 newPanelState: ExploreLogsPanelState = { |
||||
...props.panelState, |
||||
// URL format requires our array of values be an object, so we convert it using object.assign
|
||||
columns: Object.assign( |
||||
{}, |
||||
// Get the keys of the object as an array
|
||||
Object.keys(pendingLabelState) |
||||
// Only include active filters
|
||||
.filter((key) => pendingLabelState[key]?.active) |
||||
), |
||||
visualisationType: 'table', |
||||
}; |
||||
|
||||
// Update url state
|
||||
props.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); |
||||
}; |
||||
|
||||
// Debounce fuzzy search
|
||||
const debouncedSearch = debounce(search, 500); |
||||
|
||||
// onChange handler for search input
|
||||
const onSearchInputChange = (e: React.FormEvent<HTMLInputElement>) => { |
||||
const value = e.currentTarget?.value; |
||||
if (value) { |
||||
debouncedSearch(value); |
||||
} else { |
||||
// If the search input is empty, reset the local search state.
|
||||
setFilteredColumnsWithMeta(undefined); |
||||
} |
||||
}; |
||||
|
||||
const sidebarWidth = 220; |
||||
const totalWidth = props.width; |
||||
const tableWidth = totalWidth - sidebarWidth; |
||||
const styles = getStyles(props.theme, height, sidebarWidth); |
||||
|
||||
return ( |
||||
<div className={styles.wrapper}> |
||||
<section className={styles.sidebar}> |
||||
<LogsColumnSearch onChange={onSearchInputChange} /> |
||||
<LogsTableMultiSelect |
||||
toggleColumn={toggleColumn} |
||||
filteredColumnsWithMeta={filteredColumnsWithMeta} |
||||
columnsWithMeta={columnsWithMeta} |
||||
/> |
||||
</section> |
||||
<LogsTable |
||||
onClickFilterLabel={props.onClickFilterLabel} |
||||
onClickFilterOutLabel={props.onClickFilterOutLabel} |
||||
logsSortOrder={props.logsSortOrder} |
||||
range={props.range} |
||||
splitOpen={props.splitOpen} |
||||
timeZone={props.timeZone} |
||||
width={tableWidth} |
||||
logsFrames={logsFrames} |
||||
columnsWithMeta={columnsWithMeta} |
||||
height={height} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
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), |
||||
}), |
||||
|
||||
labelCount: css({}), |
||||
checkbox: css({}), |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* from public/app/features/explore/Table/TableContainer.tsx |
||||
*/ |
||||
const getTableHeight = (rowCount: number, hasSubFrames: boolean) => { |
||||
if (rowCount === 0) { |
||||
return 200; |
||||
} |
||||
// 600px is pretty small for taller monitors, using the innerHeight minus an arbitrary 500px so the table can be viewed in its entirety without needing to scroll outside the panel to see the top and the bottom
|
||||
const max = Math.max(window.innerHeight - 500, 600); |
||||
const min = Math.max(rowCount * 36, hasSubFrames ? 300 : 0) + 40 + 46; |
||||
// tries to estimate table height, with a min of 300 and a max of 600
|
||||
// if there are multiple tables, there is no min
|
||||
return Math.min(max, min); |
||||
}; |
@ -0,0 +1,162 @@ |
||||
import { DataFrame, Field, FieldType } from '@grafana/data/src'; |
||||
|
||||
import { DataFrameType } from '../../../../../../packages/grafana-data'; |
||||
|
||||
export const getMockLokiFrame = (override?: Partial<DataFrame>) => { |
||||
const testDataFrame: DataFrame = { |
||||
meta: { |
||||
custom: { |
||||
frameType: 'LabeledTimeValues', |
||||
}, |
||||
}, |
||||
fields: [ |
||||
{ |
||||
config: {}, |
||||
name: 'labels', |
||||
type: FieldType.other, |
||||
typeInfo: { |
||||
frame: 'json.RawMessage', |
||||
}, |
||||
values: [ |
||||
{ app: 'grafana', cluster: 'dev-us-central-0', container: 'hg-plugins' }, |
||||
{ app: 'grafana', cluster: 'dev-us-central-1', container: 'hg-plugins' }, |
||||
{ app: 'grafana', cluster: 'dev-us-central-2', container: 'hg-plugins' }, |
||||
], |
||||
} as Field, |
||||
{ |
||||
config: {}, |
||||
name: 'Time', |
||||
type: FieldType.time, |
||||
values: ['2019-01-01 10:00:00', '2019-01-01 11:00:00', '2019-01-01 12:00:00'], |
||||
}, |
||||
{ |
||||
config: {}, |
||||
name: 'Line', |
||||
type: FieldType.string, |
||||
values: ['log message 1', 'log message 2', 'log message 3'], |
||||
}, |
||||
{ |
||||
config: {}, |
||||
name: 'tsNs', |
||||
type: FieldType.string, |
||||
values: ['1697561006608165746', '1697560998869868000', '1697561010006578474'], |
||||
}, |
||||
{ |
||||
config: {}, |
||||
name: 'id', |
||||
type: FieldType.string, |
||||
values: ['1697561006608165746_b4cc4b72', '1697560998869868000_eeb96c0f', '1697561010006578474_ad5e2e5a'], |
||||
}, |
||||
], |
||||
length: 3, |
||||
}; |
||||
return { ...testDataFrame, ...override }; |
||||
}; |
||||
export const getMockLokiFrameDataPlane = (override?: Partial<DataFrame>): DataFrame => { |
||||
const testDataFrame: DataFrame = { |
||||
meta: { |
||||
type: DataFrameType.LogLines, |
||||
}, |
||||
fields: [ |
||||
{ |
||||
config: {}, |
||||
name: 'attributes', |
||||
type: FieldType.other, |
||||
values: [ |
||||
{ app: 'grafana', cluster: 'dev-us-central-0', container: 'hg-plugins' }, |
||||
{ app: 'grafana', cluster: 'dev-us-central-1', container: 'hg-plugins' }, |
||||
{ app: 'grafana', cluster: 'dev-us-central-2', container: 'hg-plugins' }, |
||||
], |
||||
}, |
||||
{ |
||||
config: {}, |
||||
name: 'timestamp', |
||||
type: FieldType.time, |
||||
values: ['2019-01-01 10:00:00', '2019-01-01 11:00:00', '2019-01-01 12:00:00'], |
||||
}, |
||||
{ |
||||
config: {}, |
||||
name: 'body', |
||||
type: FieldType.string, |
||||
values: ['log message 1', 'log message 2', 'log message 3'], |
||||
}, |
||||
{ |
||||
config: {}, |
||||
name: 'tsNs', |
||||
type: FieldType.string, |
||||
values: ['1697561006608165746', '1697560998869868000', '1697561010006578474'], |
||||
}, |
||||
{ |
||||
config: {}, |
||||
name: 'id', |
||||
type: FieldType.string, |
||||
values: ['1697561006608165746_b4cc4b72', '1697560998869868000_eeb96c0f', '1697561010006578474_ad5e2e5a'], |
||||
}, |
||||
{ |
||||
config: { |
||||
links: [ |
||||
{ |
||||
url: 'http://example.com', |
||||
title: 'foo', |
||||
}, |
||||
], |
||||
}, |
||||
name: 'traceID', |
||||
type: FieldType.string, |
||||
values: ['trace1', 'trace2', 'trace3'], |
||||
}, |
||||
], |
||||
length: 3, |
||||
}; |
||||
return { ...testDataFrame, ...override }; |
||||
}; |
||||
|
||||
export const getMockElasticFrame = (override?: Partial<DataFrame>, timestamp = 1697732037084) => { |
||||
const testDataFrame: DataFrame = { |
||||
meta: {}, |
||||
fields: [ |
||||
{ |
||||
name: '@timestamp', |
||||
type: FieldType.time, |
||||
values: [timestamp, timestamp + 1000, timestamp + 2000], |
||||
config: {}, |
||||
}, |
||||
{ |
||||
name: 'line', |
||||
type: FieldType.string, |
||||
values: ['log message 1', 'log message 2', 'log message 3'], |
||||
config: {}, |
||||
}, |
||||
{ |
||||
name: 'counter', |
||||
type: FieldType.string, |
||||
values: ['1', '2', '3'], |
||||
config: {}, |
||||
}, |
||||
{ |
||||
name: 'level', |
||||
type: FieldType.string, |
||||
values: ['info', 'info', 'info'], |
||||
config: {}, |
||||
}, |
||||
{ |
||||
name: 'id', |
||||
type: FieldType.string, |
||||
values: ['1', '2', '3'], |
||||
config: {}, |
||||
}, |
||||
], |
||||
length: 3, |
||||
}; |
||||
return { ...testDataFrame, ...override }; |
||||
}; |
||||
|
||||
it('should return a frame', () => { |
||||
expect( |
||||
getMockLokiFrame({ |
||||
name: 'test', |
||||
}) |
||||
).toMatchObject({ |
||||
name: 'test', |
||||
}); |
||||
}); |
@ -0,0 +1,45 @@ |
||||
import uFuzzy from '@leeoniya/ufuzzy'; |
||||
import { debounce as debounceLodash } from 'lodash'; |
||||
|
||||
const uf = new uFuzzy({ |
||||
intraMode: 1, |
||||
intraIns: 1, |
||||
intraSub: 1, |
||||
intraTrn: 1, |
||||
intraDel: 1, |
||||
}); |
||||
|
||||
export function fuzzySearch(haystack: string[], query: string, dispatcher: (data: string[][]) => void) { |
||||
const [idxs, info, order] = uf.search(haystack, query, false, 1e5); |
||||
|
||||
let haystackOrder: string[] = []; |
||||
let matchesSet: Set<string> = new Set(); |
||||
if (idxs && order) { |
||||
/** |
||||
* get the fuzzy matches for hilighting |
||||
* @param part |
||||
* @param matched |
||||
*/ |
||||
const mark = (part: string, matched: boolean) => { |
||||
if (matched) { |
||||
matchesSet.add(part); |
||||
} |
||||
}; |
||||
|
||||
// Iterate to create the order of needles(queries) and the matches
|
||||
for (let i = 0; i < order.length; i++) { |
||||
let infoIdx = order[i]; |
||||
|
||||
/** Evaluate the match, get the matches for highlighting */ |
||||
uFuzzy.highlight(haystack[info.idx[infoIdx]], info.ranges[infoIdx], mark); |
||||
/** Get the order */ |
||||
haystackOrder.push(haystack[info.idx[infoIdx]]); |
||||
} |
||||
|
||||
dispatcher([haystackOrder, [...matchesSet]]); |
||||
} else if (!query) { |
||||
dispatcher([[], []]); |
||||
} |
||||
} |
||||
|
||||
export const debouncedFuzzySearch = debounceLodash(fuzzySearch, 300); |
Loading…
Reference in new issue