mirror of https://github.com/grafana/grafana
New Log Details: Create initial component for Log Details (#107466)
* Log Details: fork and refactor as functional * LogDetailsBody: refactor styles * LogDetails: decouple from old panel * LogDetails: extract and centralize styles * LogDetailsRow: refactor as functional * Fix unused * Fix wrong label * LogDetails: create new component * LogLineDetails: process links and add sample sections * LogLineDetails: create and use LogLineDetailsFields * LogLineDetails: group labels by type * LogLineDetails: render all fields * Removed unused components * Fix imports * LogLineDetails: fix label * LogLineDetailsFields: fix stats * LogLinedetailsFields: add base styles * LogLineDetails: store open state * getLabelTypeFromRow: internationalize and add plural support * LogLineDetails: get plural field types * LogLineDetails: sticky header * LogLineDetails: introduce log details header * LogLineDetails: extract into submodules * LogDetails: add more header options and store collapsed state * LogDetails: add scroll for log line * LogLineDetailsHeader: add log line toggle button * LogLineDetailsFieldS: improve sizes * LogLineDetails: add search handler * LogLineDetailsFields: implement search * LogLineDetailsFields: switch to fixed key width * LogLineDetailsFields: refactor fields display * Link: remove tooltip * Fix translations * Revert "Link: remove tooltip" This reverts commit cd927302a7889b9430008ae3b81ace0aed343f5f. * LogLineDetailsFields: switch to css grid * Remap new translations * LogLineDetails: implement disable actions * LogLineDetailsFields: migrate links to css grid * LogLineDetailsFields: migrate stats to css grid * LogLabelStats: make functional * LogLineDetailsHeader: refactor listener * LogLineDetailsFields: decrease column minwidth * Reuse current panel unit tests * Translations * Test search * Update public/app/features/logs/components/panel/LogLineDetails.test.tsx * LogLineDetailsHeader: fix zIndex * Create LogLineDetailsDisplayedFields * Revert "Create LogLineDetailsDisplayedFields" This reverts commit 57d460d966483c3126738994e2705b6578aac120. * LogList: recover unwrapped horizontal scroll * LogLineDetails: apply styles suggestion * LogLineDetailsComponent: fix group option name * LogLineDetailsHeader: tweak styles * LogLineDetailsComponent: remove margin of last collapsablepull/107601/head
parent
de370fb311
commit
41014f29ed
@ -0,0 +1,460 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
|
||||
import { |
||||
Field, |
||||
LogLevel, |
||||
LogRowModel, |
||||
FieldType, |
||||
createDataFrame, |
||||
DataFrameType, |
||||
PluginExtensionPoints, |
||||
toDataFrame, |
||||
LogsSortOrder, |
||||
DataFrame, |
||||
ScopedVars, |
||||
} from '@grafana/data'; |
||||
import { setPluginLinksHook } from '@grafana/runtime'; |
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; |
||||
import { createLogLine } from '../mocks/logRow'; |
||||
|
||||
import { LogLineDetails, Props } from './LogLineDetails'; |
||||
import { LogListContext, LogListContextData } from './LogListContext'; |
||||
import { defaultValue } from './__mocks__/LogListContext'; |
||||
|
||||
jest.mock('@grafana/runtime', () => { |
||||
return { |
||||
...jest.requireActual('@grafana/runtime'), |
||||
usePluginLinks: jest.fn().mockReturnValue({ links: [] }), |
||||
}; |
||||
}); |
||||
jest.mock('./LogListContext'); |
||||
|
||||
const setup = ( |
||||
propOverrides?: Partial<Props>, |
||||
rowOverrides?: Partial<LogRowModel>, |
||||
contextOverrides?: Partial<LogListContextData> |
||||
) => { |
||||
const logs = [createLogLine({ logLevel: LogLevel.error, timeEpochMs: 1546297200000, ...rowOverrides })]; |
||||
|
||||
const props: Props = { |
||||
containerElement: document.createElement('div'), |
||||
logs, |
||||
onResize: jest.fn(), |
||||
...(propOverrides || {}), |
||||
}; |
||||
|
||||
const contextData: LogListContextData = { |
||||
...defaultValue, |
||||
showDetails: logs, |
||||
...contextOverrides, |
||||
}; |
||||
|
||||
return render( |
||||
<LogListContext.Provider value={contextData}> |
||||
<LogLineDetails {...props} /> |
||||
</LogListContext.Provider> |
||||
); |
||||
}; |
||||
|
||||
describe('LogLineDetails', () => { |
||||
describe('when fields are present', () => { |
||||
test('should render the fields and the log line', () => { |
||||
setup(undefined, { labels: { key1: 'label1', key2: 'label2' } }); |
||||
expect(screen.getByText('Log line')).toBeInTheDocument(); |
||||
expect(screen.getByText('Fields')).toBeInTheDocument(); |
||||
}); |
||||
test('fields should be visible by default', () => { |
||||
setup(undefined, { labels: { key1: 'label1', key2: 'label2' } }); |
||||
expect(screen.getByText('key1')).toBeInTheDocument(); |
||||
expect(screen.getByText('label1')).toBeInTheDocument(); |
||||
expect(screen.getByText('key2')).toBeInTheDocument(); |
||||
expect(screen.getByText('label2')).toBeInTheDocument(); |
||||
}); |
||||
test('should show an option to display the log line when displayed fields are used', async () => { |
||||
const onClickShowField = jest.fn(); |
||||
|
||||
setup( |
||||
undefined, |
||||
{ labels: { key1: 'label1' } }, |
||||
{ displayedFields: ['key1'], onClickShowField, onClickHideField: jest.fn() } |
||||
); |
||||
expect(screen.getByText('key1')).toBeInTheDocument(); |
||||
expect(screen.getByLabelText('Show log line')).toBeInTheDocument(); |
||||
|
||||
await userEvent.click(screen.getByLabelText('Show log line')); |
||||
|
||||
expect(onClickShowField).toHaveBeenCalledTimes(1); |
||||
}); |
||||
test('should show an active option to display the log line when displayed fields are used', async () => { |
||||
const onClickHideField = jest.fn(); |
||||
|
||||
setup( |
||||
undefined, |
||||
{ labels: { key1: 'label1' } }, |
||||
{ displayedFields: ['key1', LOG_LINE_BODY_FIELD_NAME], onClickHideField, onClickShowField: jest.fn() } |
||||
); |
||||
expect(screen.getByText('key1')).toBeInTheDocument(); |
||||
expect(screen.getByLabelText('Hide log line')).toBeInTheDocument(); |
||||
|
||||
await userEvent.click(screen.getByLabelText('Hide log line')); |
||||
|
||||
expect(onClickHideField).toHaveBeenCalledTimes(1); |
||||
}); |
||||
test('should not show an option to display the log line when displayed fields are not used', () => { |
||||
setup(undefined, { labels: { key1: 'label1' } }, { displayedFields: [] }); |
||||
expect(screen.getByText('key1')).toBeInTheDocument(); |
||||
expect(screen.queryByLabelText('Show log line')).not.toBeInTheDocument(); |
||||
}); |
||||
test('should render the filter controls when the callbacks are provided', () => { |
||||
setup( |
||||
undefined, |
||||
{ labels: { key1: 'label1' } }, |
||||
{ |
||||
onClickFilterLabel: () => {}, |
||||
onClickFilterOutLabel: () => {}, |
||||
} |
||||
); |
||||
expect(screen.getByLabelText('Filter for value in query A')).toBeInTheDocument(); |
||||
expect(screen.getByLabelText('Filter out value in query A')).toBeInTheDocument(); |
||||
}); |
||||
describe('Toggleable filters', () => { |
||||
test('should pass the log row to Explore filter functions', async () => { |
||||
const onClickFilterLabelMock = jest.fn(); |
||||
const onClickFilterOutLabelMock = jest.fn(); |
||||
const isLabelFilterActiveMock = jest.fn().mockResolvedValue(true); |
||||
const log = createLogLine({ |
||||
logLevel: LogLevel.error, |
||||
timeEpochMs: 1546297200000, |
||||
labels: { key1: 'label1' }, |
||||
}); |
||||
|
||||
setup( |
||||
{ |
||||
logs: [log], |
||||
}, |
||||
undefined, |
||||
{ |
||||
onClickFilterLabel: onClickFilterLabelMock, |
||||
onClickFilterOutLabel: onClickFilterOutLabelMock, |
||||
isLabelFilterActive: isLabelFilterActiveMock, |
||||
showDetails: [log], |
||||
} |
||||
); |
||||
|
||||
expect(isLabelFilterActiveMock).toHaveBeenCalledWith('key1', 'label1', log.dataFrame.refId); |
||||
|
||||
await userEvent.click(screen.getByLabelText('Filter for value in query A')); |
||||
expect(onClickFilterLabelMock).toHaveBeenCalledTimes(1); |
||||
expect(onClickFilterLabelMock).toHaveBeenCalledWith( |
||||
'key1', |
||||
'label1', |
||||
expect.objectContaining({ |
||||
fields: [ |
||||
expect.objectContaining({ values: [0] }), |
||||
expect.objectContaining({ values: ['line1'] }), |
||||
expect.objectContaining({ values: [{ app: 'app01' }] }), |
||||
], |
||||
length: 1, |
||||
}) |
||||
); |
||||
|
||||
await userEvent.click(screen.getByLabelText('Filter out value in query A')); |
||||
expect(onClickFilterOutLabelMock).toHaveBeenCalledTimes(1); |
||||
expect(onClickFilterOutLabelMock).toHaveBeenCalledWith( |
||||
'key1', |
||||
'label1', |
||||
expect.objectContaining({ |
||||
fields: [ |
||||
expect.objectContaining({ values: [0] }), |
||||
expect.objectContaining({ values: ['line1'] }), |
||||
expect.objectContaining({ values: [{ app: 'app01' }] }), |
||||
], |
||||
length: 1, |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
test('should not render filter controls when the callbacks are not provided', () => { |
||||
setup( |
||||
undefined, |
||||
{ labels: { key1: 'label1' } }, |
||||
{ |
||||
onClickFilterLabel: undefined, |
||||
onClickFilterOutLabel: undefined, |
||||
} |
||||
); |
||||
expect(screen.queryByLabelText('Filter for value')).not.toBeInTheDocument(); |
||||
expect(screen.queryByLabelText('Filter out value')).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
describe('when the log has no fields to display', () => { |
||||
test('should render no details available message', () => { |
||||
setup(undefined, { entry: '' }); |
||||
expect(screen.getByText('No fields to display.')).toBeInTheDocument(); |
||||
}); |
||||
test('should not render headings', () => { |
||||
setup(undefined, { entry: '' }); |
||||
expect(screen.queryByText('Fields')).not.toBeInTheDocument(); |
||||
expect(screen.queryByText('Links')).not.toBeInTheDocument(); |
||||
expect(screen.queryByText('Indexed labels')).not.toBeInTheDocument(); |
||||
expect(screen.queryByText('Parsed fields')).not.toBeInTheDocument(); |
||||
expect(screen.queryByText('Structured metadata')).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
test('should render fields from the dataframe with links', () => { |
||||
const entry = 'traceId=1234 msg="some message"'; |
||||
const dataFrame = toDataFrame({ |
||||
fields: [ |
||||
{ name: 'timestamp', config: {}, type: FieldType.time, values: [1] }, |
||||
{ name: 'entry', values: [entry] }, |
||||
// As we have traceId in message already this will shadow it.
|
||||
{ |
||||
name: 'traceId', |
||||
values: ['1234'], |
||||
config: { links: [{ title: 'link title', url: 'localhost:3210/${__value.text}' }] }, |
||||
}, |
||||
{ name: 'userId', values: ['5678'] }, |
||||
], |
||||
}); |
||||
const log = createLogLine( |
||||
{ entry, dataFrame, entryFieldIndex: 0, rowIndex: 0 }, |
||||
{ |
||||
escape: false, |
||||
order: LogsSortOrder.Descending, |
||||
timeZone: 'browser', |
||||
virtualization: undefined, |
||||
wrapLogMessage: true, |
||||
getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame, vars: ScopedVars) => { |
||||
if (field.config && field.config.links) { |
||||
return field.config.links.map((link) => { |
||||
return { |
||||
href: link.url.replace('${__value.text}', field.values[rowIndex]), |
||||
title: link.title, |
||||
target: '_blank', |
||||
origin: field, |
||||
}; |
||||
}); |
||||
} |
||||
return []; |
||||
}, |
||||
} |
||||
); |
||||
|
||||
setup({ logs: [log] }, undefined, { showDetails: [log] }); |
||||
|
||||
expect(screen.getByText('Fields')).toBeInTheDocument(); |
||||
expect(screen.getByText('Links')).toBeInTheDocument(); |
||||
expect(screen.getByText('traceId')).toBeInTheDocument(); |
||||
expect(screen.getByText('link title')).toBeInTheDocument(); |
||||
expect(screen.getByText('1234')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
test('should show the correct log details fields, links and labels for DataFrameType.LogLines frames', () => { |
||||
const entry = 'test'; |
||||
const dataFrame = createDataFrame({ |
||||
fields: [ |
||||
{ name: 'timestamp', config: {}, type: FieldType.time, values: [1] }, |
||||
{ name: 'body', type: FieldType.string, values: [entry] }, |
||||
{ |
||||
name: 'labels', |
||||
type: FieldType.other, |
||||
values: [ |
||||
{ |
||||
label1: 'value1', |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
name: 'shouldNotShowFieldName', |
||||
type: FieldType.string, |
||||
values: ['shouldNotShowFieldValue'], |
||||
}, |
||||
{ |
||||
name: 'shouldShowLinkName', |
||||
type: FieldType.string, |
||||
values: ['shouldShowLinkValue'], |
||||
config: { links: [{ title: 'link', url: 'localhost:3210/${__value.text}' }] }, |
||||
}, |
||||
], |
||||
meta: { |
||||
type: DataFrameType.LogLines, |
||||
}, |
||||
}); |
||||
|
||||
const log = createLogLine( |
||||
{ entry, dataFrame, entryFieldIndex: 0, rowIndex: 0, labels: { label1: 'value1' } }, |
||||
{ |
||||
escape: false, |
||||
order: LogsSortOrder.Descending, |
||||
timeZone: 'browser', |
||||
virtualization: undefined, |
||||
wrapLogMessage: true, |
||||
getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame, vars: ScopedVars) => { |
||||
if (field.config && field.config.links) { |
||||
return field.config.links.map((link) => { |
||||
return { |
||||
href: link.url.replace('${__value.text}', field.values[rowIndex]), |
||||
title: link.title, |
||||
target: '_blank', |
||||
origin: field, |
||||
}; |
||||
}); |
||||
} |
||||
return []; |
||||
}, |
||||
} |
||||
); |
||||
|
||||
setup({ logs: [log] }, undefined, { showDetails: [log] }); |
||||
|
||||
expect(screen.getByText('Log line')).toBeInTheDocument(); |
||||
expect(screen.getByText('Fields')).toBeInTheDocument(); |
||||
expect(screen.getByText('Links')).toBeInTheDocument(); |
||||
|
||||
// Don't show additional fields for DataFrameType.LogLines
|
||||
expect(screen.queryByText('shouldNotShowFieldName')).not.toBeInTheDocument(); |
||||
expect(screen.queryByText('shouldNotShowFieldValue')).not.toBeInTheDocument(); |
||||
|
||||
// Show labels and links
|
||||
expect(screen.getByText('label1')).toBeInTheDocument(); |
||||
expect(screen.getByText('value1')).toBeInTheDocument(); |
||||
expect(screen.getByText('shouldShowLinkName')).toBeInTheDocument(); |
||||
expect(screen.getByText('shouldShowLinkValue')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
test('should load plugin links for logs view resource attributes extension point', () => { |
||||
const usePluginLinksMock = jest.fn().mockReturnValue({ links: [] }); |
||||
setPluginLinksHook(usePluginLinksMock); |
||||
jest.requireMock('@grafana/runtime').usePluginLinks = usePluginLinksMock; |
||||
|
||||
const rowOverrides = { |
||||
datasourceType: 'loki', |
||||
datasourceUid: 'grafanacloud-logs', |
||||
labels: { key1: 'label1', key2: 'label2' }, |
||||
}; |
||||
setup(undefined, rowOverrides); |
||||
|
||||
expect(usePluginLinksMock).toHaveBeenCalledWith({ |
||||
extensionPointId: PluginExtensionPoints.LogsViewResourceAttributes, |
||||
limitPerPlugin: 10, |
||||
context: { |
||||
datasource: { |
||||
type: 'loki', |
||||
uid: 'grafanacloud-logs', |
||||
}, |
||||
attributes: { key1: ['label1'], key2: ['label2'] }, |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
describe('Label types', () => { |
||||
const entry = 'test'; |
||||
const labels = { |
||||
label1: 'value1', |
||||
label2: 'value2', |
||||
label3: 'value3', |
||||
}; |
||||
const dataFrame = createDataFrame({ |
||||
fields: [ |
||||
{ name: 'timestamp', config: {}, type: FieldType.time, values: [1] }, |
||||
{ name: 'body', type: FieldType.string, values: [entry] }, |
||||
{ name: 'id', type: FieldType.string, values: ['1'] }, |
||||
{ |
||||
name: 'labels', |
||||
type: FieldType.other, |
||||
values: [labels], |
||||
}, |
||||
{ |
||||
name: 'labelTypes', |
||||
type: FieldType.other, |
||||
values: [ |
||||
{ |
||||
label1: 'I', |
||||
label2: 'S', |
||||
label3: 'P', |
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
meta: { |
||||
type: DataFrameType.LogLines, |
||||
}, |
||||
}); |
||||
test('should show label types if they are available and supported', () => { |
||||
setup(undefined, { |
||||
entry, |
||||
dataFrame, |
||||
entryFieldIndex: 0, |
||||
rowIndex: 0, |
||||
labels, |
||||
datasourceType: 'loki', |
||||
rowId: '1', |
||||
}); |
||||
|
||||
// Show labels and links
|
||||
expect(screen.getByText('label1')).toBeInTheDocument(); |
||||
expect(screen.getByText('value1')).toBeInTheDocument(); |
||||
expect(screen.getByText('label2')).toBeInTheDocument(); |
||||
expect(screen.getByText('value2')).toBeInTheDocument(); |
||||
expect(screen.getByText('label3')).toBeInTheDocument(); |
||||
expect(screen.getByText('value3')).toBeInTheDocument(); |
||||
expect(screen.getByText('Indexed labels')).toBeInTheDocument(); |
||||
expect(screen.getByText('Parsed fields')).toBeInTheDocument(); |
||||
expect(screen.getByText('Structured metadata')).toBeInTheDocument(); |
||||
}); |
||||
test('should not show label types if they are unavailable or not supported', () => { |
||||
setup( |
||||
{}, |
||||
{ |
||||
entry, |
||||
dataFrame, |
||||
entryFieldIndex: 0, |
||||
rowIndex: 0, |
||||
labels, |
||||
datasourceType: 'other datasource', |
||||
rowId: '1', |
||||
} |
||||
); |
||||
|
||||
// Show labels and links
|
||||
expect(screen.getByText('label1')).toBeInTheDocument(); |
||||
expect(screen.getByText('value1')).toBeInTheDocument(); |
||||
expect(screen.getByText('label2')).toBeInTheDocument(); |
||||
expect(screen.getByText('value2')).toBeInTheDocument(); |
||||
expect(screen.getByText('label3')).toBeInTheDocument(); |
||||
expect(screen.getByText('value3')).toBeInTheDocument(); |
||||
|
||||
expect(screen.getByText('Fields')).toBeInTheDocument(); |
||||
expect(screen.queryByText('Indexed labels')).not.toBeInTheDocument(); |
||||
expect(screen.queryByText('Parsed fields')).not.toBeInTheDocument(); |
||||
expect(screen.queryByText('Structured metadata')).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
test('Should allow to search within fields', async () => { |
||||
setup(undefined, { |
||||
entry, |
||||
dataFrame, |
||||
entryFieldIndex: 0, |
||||
rowIndex: 0, |
||||
labels, |
||||
datasourceType: 'loki', |
||||
rowId: '1', |
||||
}); |
||||
|
||||
expect(screen.getByText('label1')).toBeInTheDocument(); |
||||
expect(screen.getByText('value1')).toBeInTheDocument(); |
||||
expect(screen.getByText('label2')).toBeInTheDocument(); |
||||
expect(screen.getByText('value2')).toBeInTheDocument(); |
||||
expect(screen.getByText('label3')).toBeInTheDocument(); |
||||
expect(screen.getByText('value3')).toBeInTheDocument(); |
||||
|
||||
const input = screen.getByPlaceholderText('Search field names and values'); |
||||
|
||||
await userEvent.type(input, 'something else'); |
||||
|
||||
expect(screen.getAllByText('No results to display.')).toHaveLength(3); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,182 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { camelCase, groupBy } from 'lodash'; |
||||
import { startTransition, useCallback, useMemo, useRef, useState } from 'react'; |
||||
|
||||
import { DataFrameType, GrafanaTheme2, store } from '@grafana/data'; |
||||
import { t, Trans } from '@grafana/i18n'; |
||||
import { ControlledCollapse, useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { getLabelTypeFromRow } from '../../utils'; |
||||
import { useAttributesExtensionLinks } from '../LogDetails'; |
||||
import { createLogLineLinks } from '../logParser'; |
||||
|
||||
import { LabelWithLinks, LogLineDetailsFields, LogLineDetailsLabelFields } from './LogLineDetailsFields'; |
||||
import { LogLineDetailsHeader } from './LogLineDetailsHeader'; |
||||
import { LogListModel } from './processing'; |
||||
|
||||
interface LogLineDetailsComponentProps { |
||||
log: LogListModel; |
||||
logOptionsStorageKey?: string; |
||||
logs: LogListModel[]; |
||||
} |
||||
|
||||
export const LogLineDetailsComponent = ({ log, logOptionsStorageKey, logs }: LogLineDetailsComponentProps) => { |
||||
const [search, setSearch] = useState(''); |
||||
const inputRef = useRef(''); |
||||
const styles = useStyles2(getStyles); |
||||
const extensionLinks = useAttributesExtensionLinks(log); |
||||
const fieldsWithLinks = useMemo(() => { |
||||
const fieldsWithLinks = log.fields.filter((f) => f.links?.length); |
||||
const displayedFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex !== log.entryFieldIndex).sort(); |
||||
const hiddenFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex === log.entryFieldIndex).sort(); |
||||
const fieldsWithLinksFromVariableMap = createLogLineLinks(hiddenFieldsWithLinks); |
||||
return { |
||||
links: displayedFieldsWithLinks, |
||||
linksFromVariableMap: fieldsWithLinksFromVariableMap, |
||||
}; |
||||
}, [log.entryFieldIndex, log.fields]); |
||||
const fieldsWithoutLinks = |
||||
log.dataFrame.meta?.type === DataFrameType.LogLines |
||||
? // for LogLines frames (dataplane) we don't want to show any additional fields besides already extracted labels and links
|
||||
[] |
||||
: // for other frames, do not show the log message unless there is a link attached
|
||||
log.fields.filter((f) => f.links?.length === 0 && f.fieldIndex !== log.entryFieldIndex).sort(); |
||||
const labelsWithLinks: LabelWithLinks[] = useMemo( |
||||
() => |
||||
Object.keys(log.labels) |
||||
.sort() |
||||
.map((label) => ({ |
||||
key: label, |
||||
value: log.labels[label], |
||||
link: extensionLinks?.[label], |
||||
})), |
||||
[extensionLinks, log.labels] |
||||
); |
||||
const groupedLabels = useMemo( |
||||
() => groupBy(labelsWithLinks, (label) => getLabelTypeFromRow(label.key, log, true) ?? ''), |
||||
[labelsWithLinks, log] |
||||
); |
||||
const labelGroups = useMemo(() => Object.keys(groupedLabels), [groupedLabels]); |
||||
|
||||
const logLineOpen = logOptionsStorageKey |
||||
? store.getBool(`${logOptionsStorageKey}.log-details.logLineOpen`, false) |
||||
: false; |
||||
const linksOpen = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.log-details.linksOpen`, true) : true; |
||||
const fieldsOpen = logOptionsStorageKey |
||||
? store.getBool(`${logOptionsStorageKey}.log-details.fieldsOpen`, true) |
||||
: true; |
||||
|
||||
const handleToggle = useCallback( |
||||
(option: string, isOpen: boolean) => { |
||||
console.log(option, isOpen); |
||||
store.set(`${logOptionsStorageKey}.log-details.${option}`, isOpen); |
||||
}, |
||||
[logOptionsStorageKey] |
||||
); |
||||
|
||||
const handleSearch = useCallback((newSearch: string) => { |
||||
inputRef.current = newSearch; |
||||
startTransition(() => { |
||||
setSearch(inputRef.current); |
||||
}); |
||||
}, []); |
||||
|
||||
const noDetails = |
||||
!fieldsWithLinks.links.length && |
||||
!fieldsWithLinks.linksFromVariableMap.length && |
||||
!labelGroups.length && |
||||
!fieldsWithoutLinks.length; |
||||
|
||||
return ( |
||||
<> |
||||
<LogLineDetailsHeader log={log} search={search} onSearch={handleSearch} /> |
||||
<div className={styles.componentWrapper}> |
||||
<ControlledCollapse |
||||
className={styles.collapsable} |
||||
label={t('logs.log-line-details.log-line-section', 'Log line')} |
||||
collapsible |
||||
isOpen={logLineOpen} |
||||
onToggle={(isOpen: boolean) => handleToggle('logLineOpen', isOpen)} |
||||
> |
||||
<div className={styles.logLineWrapper}>{log.raw}</div> |
||||
</ControlledCollapse> |
||||
{fieldsWithLinks.links.length > 0 && ( |
||||
<ControlledCollapse |
||||
className={styles.collapsable} |
||||
label={t('logs.log-line-details.links-section', 'Links')} |
||||
collapsible |
||||
isOpen={linksOpen} |
||||
onToggle={(isOpen: boolean) => handleToggle('linksOpen', isOpen)} |
||||
> |
||||
<LogLineDetailsFields log={log} logs={logs} fields={fieldsWithLinks.links} search={search} /> |
||||
<LogLineDetailsFields |
||||
disableActions |
||||
log={log} |
||||
logs={logs} |
||||
fields={fieldsWithLinks.linksFromVariableMap} |
||||
search={search} |
||||
/> |
||||
</ControlledCollapse> |
||||
)} |
||||
{labelGroups.map((group) => |
||||
group === '' ? ( |
||||
<ControlledCollapse |
||||
className={styles.collapsable} |
||||
key={'fields'} |
||||
label={t('logs.log-line-details.fields-section', 'Fields')} |
||||
collapsible |
||||
isOpen={fieldsOpen} |
||||
onToggle={(isOpen: boolean) => handleToggle('fieldsOpen', isOpen)} |
||||
> |
||||
<LogLineDetailsLabelFields log={log} logs={logs} fields={groupedLabels[group]} search={search} /> |
||||
<LogLineDetailsFields log={log} logs={logs} fields={fieldsWithoutLinks} search={search} /> |
||||
</ControlledCollapse> |
||||
) : ( |
||||
<ControlledCollapse |
||||
className={styles.collapsable} |
||||
key={group} |
||||
label={group} |
||||
collapsible |
||||
isOpen={store.getBool(`${logOptionsStorageKey}.log-details.${groupOptionName(group)}`, true)} |
||||
onToggle={(isOpen: boolean) => handleToggle(groupOptionName(group), isOpen)} |
||||
> |
||||
<LogLineDetailsLabelFields log={log} logs={logs} fields={groupedLabels[group]} search={search} /> |
||||
</ControlledCollapse> |
||||
) |
||||
)} |
||||
{!labelGroups.length && fieldsWithoutLinks.length > 0 && ( |
||||
<ControlledCollapse |
||||
className={styles.collapsable} |
||||
key={'fields'} |
||||
label={t('logs.log-line-details.fields-section', 'Fields')} |
||||
collapsible |
||||
isOpen={fieldsOpen} |
||||
onToggle={(isOpen: boolean) => handleToggle('fieldsOpen', isOpen)} |
||||
> |
||||
<LogLineDetailsFields log={log} logs={logs} fields={fieldsWithoutLinks} search={search} /> |
||||
</ControlledCollapse> |
||||
)} |
||||
{noDetails && <Trans i18nKey="logs.log-line-details.no-details">No fields to display.</Trans>} |
||||
</div> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
function groupOptionName(group: string) { |
||||
return `${camelCase(group)}Open`; |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
collapsable: css({ |
||||
'&:last-of-type': { |
||||
marginBottom: 0, |
||||
}, |
||||
}), |
||||
componentWrapper: css({ |
||||
padding: theme.spacing(0, 1, 1, 1), |
||||
}), |
||||
logLineWrapper: css({ |
||||
maxHeight: '50vh', |
||||
overflow: 'auto', |
||||
}), |
||||
}); |
@ -0,0 +1,522 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { isEqual } from 'lodash'; |
||||
import { useCallback, useEffect, useMemo, useState } from 'react'; |
||||
import * as React from 'react'; |
||||
|
||||
import { CoreApp, Field, fuzzySearch, GrafanaTheme2, IconName, LinkModel, LogLabelStatsModel } from '@grafana/data'; |
||||
import { t } from '@grafana/i18n'; |
||||
import { reportInteraction } from '@grafana/runtime'; |
||||
import { ClipboardButton, DataLinkButton, IconButton, useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { logRowToSingleRowDataFrame } from '../../logsModel'; |
||||
import { calculateLogsLabelStats, calculateStats } from '../../utils'; |
||||
import { LogLabelStats } from '../LogLabelStats'; |
||||
import { FieldDef } from '../logParser'; |
||||
|
||||
import { useLogListContext } from './LogListContext'; |
||||
import { LogListModel } from './processing'; |
||||
|
||||
interface LogLineDetailsFieldsProps { |
||||
disableActions?: boolean; |
||||
fields: FieldDef[]; |
||||
log: LogListModel; |
||||
logs: LogListModel[]; |
||||
search?: string; |
||||
} |
||||
|
||||
export const LogLineDetailsFields = ({ disableActions, fields, log, logs, search }: LogLineDetailsFieldsProps) => { |
||||
if (!fields.length) { |
||||
return null; |
||||
} |
||||
const styles = useStyles2(getFieldsStyles); |
||||
const getLogs = useCallback(() => logs, [logs]); |
||||
const filteredFields = useMemo(() => (search ? filterFields(fields, search) : fields), [fields, search]); |
||||
|
||||
if (filteredFields.length === 0) { |
||||
return t('logs.log-line-details.search.no-results', 'No results to display.'); |
||||
} |
||||
|
||||
return ( |
||||
<div className={disableActions ? styles.fieldsTableNoActions : styles.fieldsTable}> |
||||
{filteredFields.map((field, i) => ( |
||||
<LogLineDetailsField |
||||
key={`${field.keys[0]}=${field.values[0]}-${i}`} |
||||
disableActions={disableActions} |
||||
getLogs={getLogs} |
||||
fieldIndex={field.fieldIndex} |
||||
keys={field.keys} |
||||
links={field.links} |
||||
log={log} |
||||
values={field.values} |
||||
/> |
||||
))} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
interface LinkModelWithIcon extends LinkModel<Field> { |
||||
icon?: IconName; |
||||
} |
||||
|
||||
export interface LabelWithLinks { |
||||
key: string; |
||||
value: string; |
||||
links?: LinkModelWithIcon[]; |
||||
} |
||||
|
||||
interface LogLineDetailsLabelFieldsProps { |
||||
fields: LabelWithLinks[]; |
||||
log: LogListModel; |
||||
logs: LogListModel[]; |
||||
search?: string; |
||||
} |
||||
|
||||
export const LogLineDetailsLabelFields = ({ fields, log, logs, search }: LogLineDetailsLabelFieldsProps) => { |
||||
if (!fields.length) { |
||||
return null; |
||||
} |
||||
const styles = useStyles2(getFieldsStyles); |
||||
const getLogs = useCallback(() => logs, [logs]); |
||||
const filteredFields = useMemo(() => (search ? filterLabels(fields, search) : fields), [fields, search]); |
||||
|
||||
if (filteredFields.length === 0) { |
||||
return t('logs.log-line-details.search.no-results', 'No results to display.'); |
||||
} |
||||
|
||||
return ( |
||||
<div className={styles.fieldsTable}> |
||||
{filteredFields.map((field, i) => ( |
||||
<LogLineDetailsField |
||||
key={`${field.key}=${field.value}-${i}`} |
||||
getLogs={getLogs} |
||||
isLabel |
||||
keys={[field.key]} |
||||
links={field.links} |
||||
log={log} |
||||
values={[field.value]} |
||||
/> |
||||
))} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getFieldsStyles = (theme: GrafanaTheme2) => ({ |
||||
fieldsTable: css({ |
||||
display: 'grid', |
||||
gap: theme.spacing(1), |
||||
gridTemplateColumns: `${theme.spacing(11.5)} minmax(15%, 30%) 1fr`, |
||||
}), |
||||
fieldsTableNoActions: css({ |
||||
display: 'grid', |
||||
gap: theme.spacing(1), |
||||
gridTemplateColumns: `minmax(15%, 30%) 1fr`, |
||||
}), |
||||
}); |
||||
|
||||
interface LogLineDetailsFieldProps { |
||||
keys: string[]; |
||||
values: string[]; |
||||
disableActions?: boolean; |
||||
fieldIndex?: number; |
||||
getLogs(): LogListModel[]; |
||||
isLabel?: boolean; |
||||
links?: LinkModelWithIcon[]; |
||||
log: LogListModel; |
||||
} |
||||
|
||||
export const LogLineDetailsField = ({ |
||||
disableActions = false, |
||||
fieldIndex, |
||||
getLogs, |
||||
isLabel, |
||||
links, |
||||
log, |
||||
keys, |
||||
values, |
||||
}: LogLineDetailsFieldProps) => { |
||||
const [showFieldsStats, setShowFieldStats] = useState(false); |
||||
const [fieldCount, setFieldCount] = useState(0); |
||||
const [fieldStats, setFieldStats] = useState<LogLabelStatsModel[] | null>(null); |
||||
const { |
||||
app, |
||||
closeDetails, |
||||
displayedFields, |
||||
isLabelFilterActive, |
||||
onClickFilterLabel, |
||||
onClickFilterOutLabel, |
||||
onClickShowField, |
||||
onClickHideField, |
||||
onPinLine, |
||||
pinLineButtonTooltipTitle, |
||||
} = useLogListContext(); |
||||
|
||||
const styles = useStyles2(getFieldStyles); |
||||
|
||||
const getStats = useCallback(() => { |
||||
if (isLabel) { |
||||
return calculateLogsLabelStats(getLogs(), keys[0]); |
||||
} |
||||
if (fieldIndex !== undefined) { |
||||
return calculateStats(log.dataFrame.fields[fieldIndex].values); |
||||
} |
||||
return []; |
||||
}, [fieldIndex, getLogs, isLabel, keys, log.dataFrame.fields]); |
||||
|
||||
const updateStats = useCallback(() => { |
||||
const newStats = getStats(); |
||||
const newCount = newStats.reduce((sum, stat) => sum + stat.count, 0); |
||||
if (!isEqual(fieldStats, newStats) || fieldCount !== newCount) { |
||||
setFieldStats(newStats); |
||||
setFieldCount(newCount); |
||||
} |
||||
}, [fieldCount, fieldStats, getStats]); |
||||
|
||||
useEffect(() => { |
||||
if (showFieldsStats) { |
||||
updateStats(); |
||||
} |
||||
}, [showFieldsStats, updateStats]); |
||||
|
||||
const showField = useCallback(() => { |
||||
if (onClickShowField) { |
||||
onClickShowField(keys[0]); |
||||
} |
||||
|
||||
reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', { |
||||
datasourceType: log.datasourceType, |
||||
logRowUid: log.uid, |
||||
type: 'enable', |
||||
}); |
||||
}, [onClickShowField, keys, log.datasourceType, log.uid]); |
||||
|
||||
const hideField = useCallback(() => { |
||||
if (onClickHideField) { |
||||
onClickHideField(keys[0]); |
||||
} |
||||
|
||||
reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', { |
||||
datasourceType: log.datasourceType, |
||||
logRowUid: log.uid, |
||||
type: 'disable', |
||||
}); |
||||
}, [onClickHideField, keys, log.datasourceType, log.uid]); |
||||
|
||||
const filterLabel = useCallback(() => { |
||||
if (onClickFilterLabel) { |
||||
onClickFilterLabel(keys[0], values[0], logRowToSingleRowDataFrame(log) || undefined); |
||||
} |
||||
|
||||
reportInteraction('grafana_explore_logs_log_details_filter_clicked', { |
||||
datasourceType: log.datasourceType, |
||||
filterType: 'include', |
||||
logRowUid: log.uid, |
||||
}); |
||||
}, [onClickFilterLabel, keys, values, log]); |
||||
|
||||
const filterOutLabel = useCallback(() => { |
||||
if (onClickFilterOutLabel) { |
||||
onClickFilterOutLabel(keys[0], values[0], logRowToSingleRowDataFrame(log) || undefined); |
||||
} |
||||
|
||||
reportInteraction('grafana_explore_logs_log_details_filter_clicked', { |
||||
datasourceType: log.datasourceType, |
||||
filterType: 'exclude', |
||||
logRowUid: log.uid, |
||||
}); |
||||
}, [onClickFilterOutLabel, keys, values, log]); |
||||
|
||||
const labelFilterActive = useCallback(async () => { |
||||
if (isLabelFilterActive) { |
||||
return await isLabelFilterActive(keys[0], values[0], log.dataFrame?.refId); |
||||
} |
||||
return false; |
||||
}, [isLabelFilterActive, keys, values, log.dataFrame?.refId]); |
||||
|
||||
const showStats = useCallback(() => { |
||||
setShowFieldStats((showFieldStats: boolean) => !showFieldStats); |
||||
|
||||
reportInteraction('grafana_explore_logs_log_details_stats_clicked', { |
||||
dataSourceType: log.datasourceType, |
||||
fieldType: isLabel ? 'label' : 'detectedField', |
||||
type: showFieldsStats ? 'close' : 'open', |
||||
logRowUid: log.uid, |
||||
app, |
||||
}); |
||||
}, [app, isLabel, log.datasourceType, log.uid, showFieldsStats]); |
||||
|
||||
const refIdTooltip = useMemo( |
||||
() => (app === CoreApp.Explore && log.dataFrame?.refId ? ` in query ${log.dataFrame?.refId}` : ''), |
||||
[app, log.dataFrame?.refId] |
||||
); |
||||
const singleKey = keys.length === 1; |
||||
const singleValue = values.length === 1; |
||||
|
||||
return ( |
||||
<> |
||||
<div className={styles.row}> |
||||
{!disableActions && ( |
||||
<div className={styles.actions}> |
||||
{onClickFilterLabel && ( |
||||
<AsyncIconButton |
||||
name="search-plus" |
||||
onClick={filterLabel} |
||||
// We purposely want to pass a new function on every render to allow the active state to be updated when log details remains open between updates.
|
||||
isActive={labelFilterActive} |
||||
tooltipSuffix={refIdTooltip} |
||||
/> |
||||
)} |
||||
{onClickFilterOutLabel && ( |
||||
<IconButton |
||||
name="search-minus" |
||||
tooltip={ |
||||
app === CoreApp.Explore && log.dataFrame?.refId |
||||
? t('logs.log-line-details.fields.filter-out-query', 'Filter out value in query {{query}}', { |
||||
query: log.dataFrame?.refId, |
||||
}) |
||||
: t('logs.log-line-details.fields.filter-out', 'Filter out value') |
||||
} |
||||
onClick={filterOutLabel} |
||||
/> |
||||
)} |
||||
{singleKey && displayedFields.includes(keys[0]) && ( |
||||
<IconButton |
||||
variant="primary" |
||||
tooltip={t('logs.log-line-details.fields.toggle-field-button.hide-this-field', 'Hide this field')} |
||||
name="eye" |
||||
onClick={hideField} |
||||
/> |
||||
)} |
||||
{singleKey && !displayedFields.includes(keys[0]) && ( |
||||
<IconButton |
||||
tooltip={t( |
||||
'logs.log-line-details.fields.toggle-field-button.field-instead-message', |
||||
'Show this field instead of the message' |
||||
)} |
||||
name="eye" |
||||
onClick={showField} |
||||
/> |
||||
)} |
||||
<IconButton |
||||
variant={showFieldsStats ? 'primary' : 'secondary'} |
||||
name="signal" |
||||
tooltip={t('logs.log-line-details.fields.adhoc-statistics', 'Ad-hoc statistics')} |
||||
className="stats-button" |
||||
disabled={!singleKey} |
||||
onClick={showStats} |
||||
/> |
||||
</div> |
||||
)} |
||||
<div className={styles.label}>{singleKey ? keys[0] : <MultipleValue values={keys} />}</div> |
||||
<div className={styles.value}> |
||||
<div className={styles.valueContainer}> |
||||
{singleValue ? values[0] : <MultipleValue showCopy={true} values={values} />} |
||||
{singleValue && <ClipboardButtonWrapper value={values[0]} />} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{links?.map((link, i) => { |
||||
if (link.onClick && onPinLine) { |
||||
const originalOnClick = link.onClick; |
||||
link.onClick = (e, origin) => { |
||||
// Pin the line
|
||||
onPinLine(log); |
||||
|
||||
// Execute the link onClick function
|
||||
originalOnClick(e, origin); |
||||
|
||||
closeDetails(); |
||||
}; |
||||
} |
||||
return ( |
||||
<div className={styles.row} key={`${link.title}-${i}`}> |
||||
<div className={disableActions ? styles.linkNoActions : styles.link}> |
||||
<DataLinkButton |
||||
buttonProps={{ |
||||
// Show tooltip message if max number of pinned lines has been reached
|
||||
tooltip: |
||||
typeof pinLineButtonTooltipTitle === 'object' && link.onClick |
||||
? pinLineButtonTooltipTitle |
||||
: undefined, |
||||
variant: 'secondary', |
||||
fill: 'outline', |
||||
...(link.icon && { icon: link.icon }), |
||||
}} |
||||
link={link} |
||||
/> |
||||
</div> |
||||
</div> |
||||
); |
||||
})} |
||||
{showFieldsStats && fieldStats && ( |
||||
<div className={styles.row}> |
||||
<div /> |
||||
<div className={disableActions ? undefined : styles.statsColumn}> |
||||
<LogLabelStats |
||||
className={styles.stats} |
||||
stats={fieldStats} |
||||
label={keys[0]} |
||||
value={values[0]} |
||||
rowCount={fieldCount} |
||||
isLabel={isLabel} |
||||
/> |
||||
</div> |
||||
</div> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
const getFieldStyles = (theme: GrafanaTheme2) => ({ |
||||
row: css({ |
||||
display: 'contents', |
||||
}), |
||||
actions: css({ |
||||
whiteSpace: 'nowrap', |
||||
}), |
||||
label: css({ |
||||
overflowWrap: 'break-word', |
||||
wordBreak: 'break-word', |
||||
}), |
||||
value: css({ |
||||
overflowWrap: 'break-word', |
||||
wordBreak: 'break-word', |
||||
button: { |
||||
visibility: 'hidden', |
||||
}, |
||||
'&:hover': { |
||||
button: { |
||||
visibility: 'visible', |
||||
}, |
||||
}, |
||||
}), |
||||
link: css({ |
||||
gridColumn: 'span 3', |
||||
}), |
||||
linkNoActions: css({ |
||||
gridColumn: 'span 2', |
||||
}), |
||||
stats: css({ |
||||
paddingRight: theme.spacing(1), |
||||
wordBreak: 'break-all', |
||||
width: '100%', |
||||
maxWidth: '50vh', |
||||
}), |
||||
statsColumn: css({ |
||||
gridColumn: 'span 2', |
||||
}), |
||||
valueContainer: css({ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
lineHeight: theme.typography.body.lineHeight, |
||||
whiteSpace: 'pre-wrap', |
||||
wordBreak: 'break-all', |
||||
}), |
||||
}); |
||||
|
||||
const ClipboardButtonWrapper = ({ value }: { value: string }) => { |
||||
const styles = useStyles2(getClipboardButtonStyles); |
||||
return ( |
||||
<div className={styles.button}> |
||||
<ClipboardButton |
||||
getText={() => value} |
||||
title={t('logs.log-line-details.fields.copy-value-to-clipboard', 'Copy value to clipboard')} |
||||
fill="text" |
||||
variant="secondary" |
||||
icon="copy" |
||||
size="md" |
||||
/> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getClipboardButtonStyles = (theme: GrafanaTheme2) => ({ |
||||
button: css({ |
||||
'& > button': { |
||||
color: theme.colors.text.secondary, |
||||
padding: 0, |
||||
justifyContent: 'center', |
||||
borderRadius: theme.shape.radius.circle, |
||||
height: theme.spacing(theme.components.height.sm), |
||||
width: theme.spacing(theme.components.height.sm), |
||||
svg: { |
||||
margin: 0, |
||||
}, |
||||
|
||||
'span > div': { |
||||
top: '-5px', |
||||
'& button': { |
||||
color: theme.colors.success.main, |
||||
}, |
||||
}, |
||||
}, |
||||
}), |
||||
}); |
||||
|
||||
const MultipleValue = ({ showCopy, values = [] }: { showCopy?: boolean; values: string[] }) => { |
||||
if (values.every((val) => val === '')) { |
||||
return null; |
||||
} |
||||
return ( |
||||
<table> |
||||
<tbody> |
||||
{values.map((val, i) => { |
||||
return ( |
||||
<tr key={`${val}-${i}`}> |
||||
<td>{val}</td> |
||||
<td>{showCopy && val !== '' && <ClipboardButtonWrapper value={val} />}</td> |
||||
</tr> |
||||
); |
||||
})} |
||||
</tbody> |
||||
</table> |
||||
); |
||||
}; |
||||
|
||||
interface AsyncIconButtonProps extends Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> { |
||||
name: IconName; |
||||
isActive(): Promise<boolean>; |
||||
tooltipSuffix: string; |
||||
} |
||||
|
||||
const AsyncIconButton = ({ isActive, tooltipSuffix, ...rest }: AsyncIconButtonProps) => { |
||||
const [active, setActive] = useState(false); |
||||
const tooltip = active ? 'Remove filter' : 'Filter for value'; |
||||
|
||||
useEffect(() => { |
||||
isActive().then(setActive); |
||||
}, [isActive]); |
||||
|
||||
return <IconButton {...rest} variant={active ? 'primary' : undefined} tooltip={tooltip + tooltipSuffix} />; |
||||
}; |
||||
|
||||
function filterFields(fields: FieldDef[], search: string) { |
||||
const keys = fields.map((field) => field.keys.join(' ')); |
||||
const keysIdx = fuzzySearch(keys, search); |
||||
const values = fields.map((field) => field.values.join(' ')); |
||||
const valuesIdx = fuzzySearch(values, search); |
||||
|
||||
const results = keysIdx.map((index) => fields[index]); |
||||
valuesIdx.forEach((index) => { |
||||
if (!results.includes(fields[index])) { |
||||
results.push(fields[index]); |
||||
} |
||||
}); |
||||
|
||||
return results; |
||||
} |
||||
|
||||
function filterLabels(labels: LabelWithLinks[], search: string) { |
||||
const keys = labels.map((field) => field.key); |
||||
const keysIdx = fuzzySearch(keys, search); |
||||
const values = labels.map((field) => field.value); |
||||
const valuesIdx = fuzzySearch(values, search); |
||||
|
||||
const results = keysIdx.map((index) => labels[index]); |
||||
valuesIdx.forEach((index) => { |
||||
if (!results.includes(labels[index])) { |
||||
results.push(labels[index]); |
||||
} |
||||
}); |
||||
|
||||
return results; |
||||
} |
@ -0,0 +1,216 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { useCallback, useMemo, MouseEvent, useRef, ChangeEvent } from 'react'; |
||||
|
||||
import { colorManipulator, GrafanaTheme2, LogRowModel } from '@grafana/data'; |
||||
import { t } from '@grafana/i18n'; |
||||
import { IconButton, Input, useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { copyText, handleOpenLogsContextClick } from '../../utils'; |
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; |
||||
|
||||
import { useLogIsPinned, useLogListContext } from './LogListContext'; |
||||
import { LogListModel } from './processing'; |
||||
|
||||
interface Props { |
||||
log: LogListModel; |
||||
search: string; |
||||
onSearch(newSearch: string): void; |
||||
} |
||||
|
||||
export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => { |
||||
const { |
||||
closeDetails, |
||||
displayedFields, |
||||
getRowContextQuery, |
||||
logSupportsContext, |
||||
onClickHideField, |
||||
onClickShowField, |
||||
onOpenContext, |
||||
onPermalinkClick, |
||||
onPinLine, |
||||
onUnpinLine, |
||||
} = useLogListContext(); |
||||
const pinned = useLogIsPinned(log); |
||||
const styles = useStyles2(getStyles); |
||||
const containerRef = useRef<HTMLDivElement | null>(null); |
||||
const inputRef = useRef<HTMLInputElement | null>(null); |
||||
|
||||
const copyLogLine = useCallback(() => { |
||||
copyText(log.entry, containerRef); |
||||
}, [log.entry]); |
||||
|
||||
const copyLinkToLogLine = useCallback(() => { |
||||
onPermalinkClick?.(log); |
||||
}, [log, onPermalinkClick]); |
||||
|
||||
const togglePinning = useCallback(() => { |
||||
if (pinned) { |
||||
onUnpinLine?.(log); |
||||
} else { |
||||
onPinLine?.(log); |
||||
} |
||||
}, [log, onPinLine, onUnpinLine, pinned]); |
||||
|
||||
const shouldlogSupportsContext = useMemo( |
||||
() => (logSupportsContext ? logSupportsContext(log) : false), |
||||
[log, logSupportsContext] |
||||
); |
||||
|
||||
const showContext = useCallback( |
||||
async (event: MouseEvent<HTMLElement>) => { |
||||
handleOpenLogsContextClick(event, log, getRowContextQuery, (log: LogRowModel) => onOpenContext?.(log, () => {})); |
||||
}, |
||||
[onOpenContext, getRowContextQuery, log] |
||||
); |
||||
|
||||
const showLogLineToggle = onClickHideField && onClickShowField && displayedFields.length > 0; |
||||
const logLineDisplayed = displayedFields.includes(LOG_LINE_BODY_FIELD_NAME); |
||||
|
||||
const toggleLogLine = useCallback(() => { |
||||
if (logLineDisplayed) { |
||||
onClickHideField?.(LOG_LINE_BODY_FIELD_NAME); |
||||
} else { |
||||
onClickShowField?.(LOG_LINE_BODY_FIELD_NAME); |
||||
} |
||||
}, [logLineDisplayed, onClickHideField, onClickShowField]); |
||||
|
||||
const clearSearch = useMemo( |
||||
() => ( |
||||
<IconButton |
||||
name="times" |
||||
size="sm" |
||||
onClick={() => { |
||||
onSearch(''); |
||||
if (inputRef.current) { |
||||
inputRef.current.value = ''; |
||||
} |
||||
}} |
||||
tooltip={t('logs.log-line-details.clear-search', 'Clear')} |
||||
/> |
||||
), |
||||
[onSearch] |
||||
); |
||||
|
||||
const handleSearch = useCallback( |
||||
(e: ChangeEvent<HTMLInputElement>) => { |
||||
onSearch(e.target.value); |
||||
}, |
||||
[onSearch] |
||||
); |
||||
|
||||
return ( |
||||
<div className={styles.header} ref={containerRef}> |
||||
<Input |
||||
onChange={handleSearch} |
||||
placeholder={t('logs.log-line-details.search-placeholder', 'Search field names and values')} |
||||
ref={inputRef} |
||||
suffix={search !== '' ? clearSearch : undefined} |
||||
/> |
||||
{showLogLineToggle && ( |
||||
<IconButton |
||||
tooltip={ |
||||
logLineDisplayed |
||||
? t('logs.log-line-details.hide-log-line', 'Hide log line') |
||||
: t('logs.log-line-details.show-log-line', 'Show log line') |
||||
} |
||||
tooltipPlacement="top" |
||||
size="md" |
||||
name="eye" |
||||
onClick={toggleLogLine} |
||||
tabIndex={0} |
||||
variant={logLineDisplayed ? 'primary' : undefined} |
||||
/> |
||||
)} |
||||
<IconButton |
||||
tooltip={t('logs.log-line-details.copy-to-clipboard', 'Copy to clipboard')} |
||||
tooltipPlacement="top" |
||||
size="md" |
||||
name="copy" |
||||
onClick={copyLogLine} |
||||
tabIndex={0} |
||||
/> |
||||
{onPermalinkClick && log.rowId !== undefined && log.uid && ( |
||||
<IconButton |
||||
tooltip={t('logs.log-line-details.copy-shortlink', 'Copy shortlink')} |
||||
tooltipPlacement="top" |
||||
size="md" |
||||
name="share-alt" |
||||
onClick={copyLinkToLogLine} |
||||
tabIndex={0} |
||||
/> |
||||
)} |
||||
{pinned && onUnpinLine && ( |
||||
<IconButton |
||||
size="md" |
||||
name="gf-pin" |
||||
onClick={togglePinning} |
||||
tooltip={t('logs.log-line-details.unpin-line', 'Unpin log')} |
||||
tooltipPlacement="top" |
||||
tabIndex={0} |
||||
variant="primary" |
||||
/> |
||||
)} |
||||
{!pinned && onPinLine && ( |
||||
<IconButton |
||||
size="md" |
||||
name="gf-pin" |
||||
onClick={togglePinning} |
||||
tooltip={t('logs.log-line-details.pin-line', 'Pin log')} |
||||
tooltipPlacement="top" |
||||
tabIndex={0} |
||||
/> |
||||
)} |
||||
{shouldlogSupportsContext && ( |
||||
<IconButton |
||||
size="md" |
||||
name="gf-show-context" |
||||
onClick={showContext} |
||||
tooltip={t('logs.log-line-details.show-context', 'Show context')} |
||||
tooltipPlacement="top" |
||||
tabIndex={0} |
||||
/> |
||||
)} |
||||
<IconButton |
||||
name="times" |
||||
aria-label={t('logs.log-line-details.close', 'Close log details')} |
||||
onClick={closeDetails} |
||||
/> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
container: css({ |
||||
overflow: 'auto', |
||||
height: '100%', |
||||
}), |
||||
scrollContainer: css({ |
||||
overflow: 'auto', |
||||
height: '100%', |
||||
}), |
||||
header: css({ |
||||
alignItems: 'center', |
||||
background: theme.colors.background.canvas, |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
gap: theme.spacing(0.75), |
||||
zIndex: theme.zIndex.navbarFixed, |
||||
height: theme.spacing(5.5), |
||||
marginBottom: theme.spacing(1), |
||||
padding: theme.spacing(0.5, 1), |
||||
position: 'sticky', |
||||
top: 0, |
||||
}), |
||||
copyLogButton: css({ |
||||
padding: 0, |
||||
height: theme.spacing(4), |
||||
width: theme.spacing(2.5), |
||||
overflow: 'hidden', |
||||
'&:hover': { |
||||
backgroundColor: colorManipulator.alpha(theme.colors.text.primary, 0.12), |
||||
}, |
||||
}), |
||||
componentWrapper: css({ |
||||
padding: theme.spacing(0, 1, 1, 1), |
||||
}), |
||||
}); |
Loading…
Reference in new issue