[Log Explorer] Implement Flyout content header (#169832)

## 📓 Summary

Closes #169501 

🛑 ~**Merge blocked by:** https://github.com/elastic/kibana/pull/169634~

This work implements the first frame for a detailed log flyout.
It adds highlight on the log level, timestamp and message details for a
log.
This first layer of customization will work as a base for all the
upcoming enhancements on the flyout detail.


a1c2997c-5fef-4899-836f-ff810de3f148

---------

Co-authored-by: Marco Antonio Ghiani <marcoantonio.ghiani@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Achyut Jhunjhunwala <achyut.jhunjhunwala@elastic.co>
This commit is contained in:
Marco Antonio Ghiani 2023-11-03 09:05:11 +01:00 committed by GitHub
parent 014df2d697
commit 9b6edea13b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 585 additions and 20 deletions

View file

@ -5,7 +5,6 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { DataTableRecord } from '@kbn/discover-utils/types';
import type { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import React, { type ComponentType } from 'react';

View file

@ -26,6 +26,8 @@ export type {
DiscoverCustomization,
DiscoverCustomizationService,
FlyoutCustomization,
FlyoutContentActions,
FlyoutContentProps,
SearchBarCustomization,
UnifiedHistogramCustomization,
TopNavCustomization,

View file

@ -15,6 +15,7 @@
"data",
"dataViews",
"discover",
"fieldFormats",
"fleet",
"kibanaReact",
"kibanaUtils",

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { LogLevel } from './sub_components/log_level';
import { Timestamp } from './sub_components/timestamp';
import { FlyoutProps, LogDocument } from './types';
import { getDocDetailRenderFlags, useDocDetail } from './use_doc_detail';
import { Message } from './sub_components/message';
export function FlyoutDetail({ dataView, doc }: Pick<FlyoutProps, 'dataView' | 'doc' | 'actions'>) {
const parsedDoc = useDocDetail(doc as LogDocument, { dataView });
const { hasTimestamp, hasLogLevel, hasMessage, hasBadges, hasFlyoutHeader } =
getDocDetailRenderFlags(parsedDoc);
return hasFlyoutHeader ? (
<EuiFlexGroup direction="column" gutterSize="m" data-test-subj="logExplorerFlyoutDetail">
<EuiFlexItem grow={false}>
{hasBadges && (
<EuiFlexGroup responsive={false} gutterSize="m">
{hasLogLevel && (
<EuiFlexItem grow={false}>
<LogLevel level={parsedDoc['log.level']} />
</EuiFlexItem>
)}
{hasTimestamp && (
<EuiFlexItem grow={false}>
<Timestamp timestamp={parsedDoc['@timestamp']} />
</EuiFlexItem>
)}
</EuiFlexGroup>
)}
</EuiFlexItem>
{hasMessage && (
<EuiFlexItem grow={false}>
<Message message={parsedDoc.message} />
</EuiFlexItem>
)}
</EuiFlexGroup>
) : null;
}

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './flyout_detail';
export * from './types';

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiBadge, type EuiBadgeProps } from '@elastic/eui';
import { FlyoutDoc } from '../types';
const LEVEL_DICT: Record<string, EuiBadgeProps['color']> = {
error: 'danger',
warn: 'warning',
info: 'primary',
default: 'default',
};
interface LogLevelProps {
level: FlyoutDoc['log.level'];
}
export function LogLevel({ level }: LogLevelProps) {
if (!level) return null;
const levelColor = LEVEL_DICT[level] ?? LEVEL_DICT.default;
return (
<EuiBadge color={levelColor} data-test-subj="logExplorerFlyoutLogLevel">
{level}
</EuiBadge>
);
}

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCodeBlock, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { FlyoutDoc } from '../types';
import { flyoutMessageLabel } from '../translations';
interface MessageProps {
message: FlyoutDoc['message'];
}
export function Message({ message }: MessageProps) {
if (!message) return null;
return (
<EuiFlexGroup direction="column" gutterSize="xs" data-test-subj="logExplorerFlyoutLogMessage">
<EuiFlexItem>
<EuiText color="subdued" size="xs">
{flyoutMessageLabel}
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiCodeBlock overflowHeight={100} paddingSize="m" isCopyable language="txt" fontSize="m">
{message}
</EuiCodeBlock>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiBadge } from '@elastic/eui';
import { FlyoutDoc } from '../types';
interface TimestampProps {
timestamp: FlyoutDoc['@timestamp'];
}
export function Timestamp({ timestamp }: TimestampProps) {
if (!timestamp) return null;
return (
<EuiBadge color="hollow" data-test-subj="logExplorerFlyoutLogTimestamp">
{timestamp}
</EuiBadge>
);
}

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const flyoutMessageLabel = i18n.translate('xpack.logExplorer.flyoutDetail.label.message', {
defaultMessage: 'Message',
});

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { EuiIconType } from '@elastic/eui/src/components/icon/icon';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { FlyoutContentProps } from '@kbn/discover-plugin/public';
import type { DataTableRecord } from '@kbn/discover-utils/types';
export interface FlyoutProps extends FlyoutContentProps {
dataView: DataView;
}
export interface LogDocument extends DataTableRecord {
flattened: {
'@timestamp': string;
'log.level'?: string;
message?: string;
};
}
export interface FlyoutDoc {
'@timestamp': string;
'log.level'?: string;
message?: string;
}
export interface FlyoutHighlightField {
label: string;
value: string;
iconType?: EuiIconType;
}

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { formatFieldValue } from '@kbn/discover-utils';
import { LOG_LEVEL_FIELD, MESSAGE_FIELD, TIMESTAMP_FIELD } from '../../../common/constants';
import { useKibanaContextForPlugin } from '../../utils/use_kibana';
import { FlyoutDoc, FlyoutProps, LogDocument } from './types';
export function useDocDetail(
doc: LogDocument,
{ dataView }: Pick<FlyoutProps, 'dataView'>
): FlyoutDoc {
const { services } = useKibanaContextForPlugin();
const formatField = <F extends keyof LogDocument['flattened']>(
field: F
): LogDocument['flattened'][F] => {
return (
doc.flattened[field] &&
formatFieldValue(
doc.flattened[field],
doc.raw,
services.fieldFormats,
dataView,
dataView.fields.getByName(field)
)
);
};
const level = formatField(LOG_LEVEL_FIELD)?.toLowerCase();
const timestamp = formatField(TIMESTAMP_FIELD);
const message = formatField(MESSAGE_FIELD);
return {
[LOG_LEVEL_FIELD]: level,
[TIMESTAMP_FIELD]: timestamp,
[MESSAGE_FIELD]: message,
};
}
export const getDocDetailRenderFlags = (doc: FlyoutDoc) => {
const hasTimestamp = Boolean(doc['@timestamp']);
const hasLogLevel = Boolean(doc['log.level']);
const hasMessage = Boolean(doc.message);
const hasBadges = hasTimestamp || hasLogLevel;
const hasFlyoutHeader = hasBadges || hasMessage;
return {
hasTimestamp,
hasLogLevel,
hasMessage,
hasBadges,
hasFlyoutHeader,
};
};

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FlyoutDetail } from '../components/flyout_detail/flyout_detail';
import { FlyoutProps } from '../components/flyout_detail';
export const CustomFlyoutContent = ({
actions,
dataView,
doc,
renderDefaultContent,
}: FlyoutProps) => {
return (
<EuiFlexGroup direction="column">
{/* Apply custom Log Explorer detail */}
<EuiFlexItem>
<FlyoutDetail actions={actions} dataView={dataView} doc={doc} />
</EuiFlexItem>
{/* Restore default content */}
<EuiFlexItem>{renderDefaultContent()}</EuiFlexItem>
</EuiFlexGroup>
);
};
// eslint-disable-next-line import/no-default-export
export default CustomFlyoutContent;

View file

@ -8,14 +8,16 @@ import type { CoreStart } from '@kbn/core/public';
import { CustomizationCallback, DiscoverStateContainer } from '@kbn/discover-plugin/public';
import React from 'react';
import { type BehaviorSubject, combineLatest, from, map, Subscription } from 'rxjs';
import useObservable from 'react-use/lib/useObservable';
import { dynamic } from '../utils/dynamic';
import { LogExplorerProfileStateService } from '../state_machines/log_explorer_profile';
import { LogExplorerStateContainer } from '../components/log_explorer';
import { LogExplorerStartDeps } from '../types';
import { useKibanaContextForPluginProvider } from '../utils/use_kibana';
const LazyCustomDatasetSelector = dynamic(() => import('./custom_dataset_selector'));
const LazyCustomDatasetFilters = dynamic(() => import('./custom_dataset_filters'));
const LazyCustomDatasetSelector = dynamic(() => import('./custom_dataset_selector'));
const LazyCustomFlyoutContent = dynamic(() => import('./custom_flyout_content'));
export interface CreateLogExplorerProfileCustomizationsDeps {
core: CoreStart;
@ -115,6 +117,20 @@ export const createLogExplorerProfileCustomizations =
viewSurroundingDocument: { disabled: true },
},
},
Content: (props) => {
const KibanaContextProviderForPlugin = useKibanaContextForPluginProvider(core, plugins);
const internalState = useObservable(
stateContainer.internalState.state$,
stateContainer.internalState.get()
);
return (
<KibanaContextProviderForPlugin>
<LazyCustomFlyoutContent {...props} dataView={internalState.dataView} />
</KibanaContextProviderForPlugin>
);
},
});
return () => {

View file

@ -9,6 +9,7 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { DiscoverSetup, DiscoverStart } from '@kbn/discover-plugin/public';
import { SharePluginSetup } from '@kbn/share-plugin/public';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { LogExplorerLocators } from '../common/locators';
import type { LogExplorerProps } from './components/log_explorer';
@ -28,4 +29,5 @@ export interface LogExplorerStartDeps {
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
discover: DiscoverStart;
fieldFormats: FieldFormatsStart;
}

View file

@ -24,7 +24,8 @@
"@kbn/unified-data-table",
"@kbn/core-ui-settings-browser",
"@kbn/discover-utils",
"@kbn/deeplinks-observability"
"@kbn/deeplinks-observability",
"@kbn/field-formats-plugin"
],
"exclude": ["target/**/*"]
}

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../../ftr_provider_context';
const DATASET_NAME = 'flyout';
const NAMESPACE = 'default';
const DATA_STREAM_NAME = `logs-${DATASET_NAME}-${NAMESPACE}`;
const NOW = Date.now();
const sharedDoc = {
logFilepath: '/flyout.log',
serviceName: DATASET_NAME,
datasetName: DATASET_NAME,
namespace: NAMESPACE,
};
const docs = [
{
...sharedDoc,
time: NOW + 1000,
message: 'full document',
logLevel: 'info',
},
{
...sharedDoc,
time: NOW,
},
];
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const dataGrid = getService('dataGrid');
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['observabilityLogExplorer']);
describe('Flyout content customization', () => {
let cleanupDataStreamSetup: () => Promise<void>;
before('initialize tests', async () => {
cleanupDataStreamSetup = await PageObjects.observabilityLogExplorer.setupDataStream(
DATASET_NAME,
NAMESPACE
);
await PageObjects.observabilityLogExplorer.ingestLogEntries(DATA_STREAM_NAME, docs);
});
beforeEach(async () => {
await PageObjects.observabilityLogExplorer.navigateTo({
from: new Date(NOW - 60_000).toISOString(),
to: new Date(NOW + 60_000).toISOString(),
});
});
after('clean up archives', async () => {
if (cleanupDataStreamSetup) {
cleanupDataStreamSetup();
}
});
it('should mount the flyout customization content', async () => {
await dataGrid.clickRowToggle();
await testSubjects.existOrFail('logExplorerFlyoutDetail');
});
it('should display a timestamp badge', async () => {
await dataGrid.clickRowToggle();
await testSubjects.existOrFail('logExplorerFlyoutLogTimestamp');
});
it('should display a log level badge when available', async () => {
await dataGrid.clickRowToggle();
await testSubjects.existOrFail('logExplorerFlyoutLogLevel');
await dataGrid.closeFlyout();
await dataGrid.clickRowToggle({ rowIndex: 1 });
await testSubjects.missingOrFail('logExplorerFlyoutLogLevel');
});
it('should display a message code block when available', async () => {
await dataGrid.clickRowToggle();
await testSubjects.existOrFail('logExplorerFlyoutLogMessage');
await dataGrid.closeFlyout();
await dataGrid.clickRowToggle({ rowIndex: 1 });
await testSubjects.missingOrFail('logExplorerFlyoutLogMessage');
});
});
}

View file

@ -14,6 +14,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./dataset_selection_state'));
loadTestFile(require.resolve('./dataset_selector'));
loadTestFile(require.resolve('./filter_controls'));
loadTestFile(require.resolve('./flyout'));
loadTestFile(require.resolve('./header_menu'));
});
}

View file

@ -109,7 +109,10 @@ export function ObservabilityLogExplorerPageObject({
getService,
}: FtrProviderContext) {
const PageObjects = getPageObjects(['common']);
const dataGrid = getService('dataGrid');
const es = getService('es');
const log = getService('log');
const queryBar = getService('queryBar');
const supertest = getService('supertest');
const testSubjects = getService('testSubjects');
const toasts = getService('toasts');
@ -119,6 +122,8 @@ export function ObservabilityLogExplorerPageObject({
'search'
> & {
search?: Record<string, string>;
from?: string;
to?: string;
};
return {
@ -167,6 +172,27 @@ export function ObservabilityLogExplorerPageObject({
};
},
async setupDataStream(datasetName: string, namespace: string = 'default') {
const dataStream = `logs-${datasetName}-${namespace}`;
log.info(`===== Setup initial data stream "${dataStream}". =====`);
await es.indices.createDataStream({ name: dataStream });
return async () => {
log.info(`===== Removing data stream "${dataStream}". =====`);
await es.indices.deleteDataStream({
name: dataStream,
});
};
},
ingestLogEntries(dataStream: string, docs: MockLogDoc[] = []) {
log.info(`===== Ingesting ${docs.length} docs for "${dataStream}" data stream. =====`);
return es.bulk({
body: docs.flatMap((doc) => [{ create: { _index: dataStream } }, createLogDoc(doc)]),
refresh: 'wait_for',
});
},
async setupAdditionalIntegrations() {
log.info(`===== Setup additional integration packages. =====`);
log.info(`===== Install ${additionalPackages.length} mock integration packages. =====`);
@ -183,11 +209,11 @@ export function ObservabilityLogExplorerPageObject({
},
async navigateTo(options: NavigateToAppOptions = {}) {
const { search = {}, ...extraOptions } = options;
const { search = {}, from = FROM, to = TO, ...extraOptions } = options;
const composedSearch = querystring.stringify({
...search,
_g: rison.encode({
time: { from: FROM, to: TO },
time: { from, to },
}),
});
@ -262,6 +288,11 @@ export function ObservabilityLogExplorerPageObject({
return testSubjects.find('unmanagedDatasets');
},
async getFlyoutDetail(rowIndex: number = 0) {
await dataGrid.clickRowToggle({ rowIndex });
return testSubjects.find('logExplorerFlyoutDetail');
},
async getIntegrations() {
const menu = await this.getIntegrationsContextMenu();
@ -359,24 +390,65 @@ export function ObservabilityLogExplorerPageObject({
},
// Query Bar
getQueryBar() {
return testSubjects.find('queryInput');
},
async getQueryBarValue() {
const queryBar = await testSubjects.find('queryInput');
return queryBar.getAttribute('value');
},
async typeInQueryBar(query: string) {
const queryBar = await this.getQueryBar();
await queryBar.clearValueWithKeyboard();
return queryBar.type(query);
getQueryBarValue() {
return queryBar.getQueryString();
},
async submitQuery(query: string) {
await this.typeInQueryBar(query);
await testSubjects.click('querySubmitButton');
await queryBar.setQuery(query);
await queryBar.clickQuerySubmitButton();
},
};
}
interface MockLogDoc {
time: number;
logFilepath: string;
serviceName?: string;
namespace: string;
datasetName: string;
message?: string;
logLevel?: string;
[key: string]: unknown;
}
export function createLogDoc({
time,
logFilepath,
serviceName,
namespace,
datasetName,
message,
logLevel,
...extraFields
}: MockLogDoc) {
return {
input: {
type: 'log',
},
'@timestamp': new Date(time).toISOString(),
log: {
file: {
path: logFilepath,
},
},
...(serviceName
? {
service: {
name: serviceName,
},
}
: {}),
data_stream: {
namespace,
type: 'logs',
dataset: datasetName,
},
message,
event: {
dataset: datasetName,
},
...(logLevel && { 'log.level': logLevel }),
...extraFields,
};
}

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../../../ftr_provider_context';
const DATASET_NAME = 'flyout';
const NAMESPACE = 'default';
const DATA_STREAM_NAME = `logs-${DATASET_NAME}-${NAMESPACE}`;
const NOW = Date.now();
const sharedDoc = {
logFilepath: '/flyout.log',
serviceName: DATASET_NAME,
datasetName: DATASET_NAME,
namespace: NAMESPACE,
};
const docs = [
{
...sharedDoc,
time: NOW + 1000,
message: 'full document',
logLevel: 'info',
},
{
...sharedDoc,
time: NOW,
},
];
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const dataGrid = getService('dataGrid');
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['observabilityLogExplorer', 'svlCommonPage']);
describe('Flyout content customization', () => {
let cleanupDataStreamSetup: () => Promise<void>;
before('initialize tests', async () => {
cleanupDataStreamSetup = await PageObjects.observabilityLogExplorer.setupDataStream(
DATASET_NAME,
NAMESPACE
);
await PageObjects.observabilityLogExplorer.ingestLogEntries(DATA_STREAM_NAME, docs);
await PageObjects.svlCommonPage.login();
});
beforeEach(async () => {
await PageObjects.observabilityLogExplorer.navigateTo({
from: new Date(NOW - 60_000).toISOString(),
to: new Date(NOW + 60_000).toISOString(),
});
});
after('clean up archives', async () => {
await PageObjects.svlCommonPage.forceLogout();
if (cleanupDataStreamSetup) {
cleanupDataStreamSetup();
}
});
it('should mount the flyout customization content', async () => {
await dataGrid.clickRowToggle();
await testSubjects.existOrFail('logExplorerFlyoutDetail');
});
it('should display a timestamp badge', async () => {
await dataGrid.clickRowToggle();
await testSubjects.existOrFail('logExplorerFlyoutLogTimestamp');
});
it('should display a log level badge when available', async () => {
await dataGrid.clickRowToggle();
await testSubjects.existOrFail('logExplorerFlyoutLogLevel');
await dataGrid.closeFlyout();
await dataGrid.clickRowToggle({ rowIndex: 1 });
await testSubjects.missingOrFail('logExplorerFlyoutLogLevel');
});
it('should display a message code block when available', async () => {
await dataGrid.clickRowToggle();
await testSubjects.existOrFail('logExplorerFlyoutLogMessage');
await dataGrid.closeFlyout();
await dataGrid.clickRowToggle({ rowIndex: 1 });
await testSubjects.missingOrFail('logExplorerFlyoutLogMessage');
});
});
}

View file

@ -14,6 +14,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./dataset_selection_state'));
loadTestFile(require.resolve('./dataset_selector'));
loadTestFile(require.resolve('./filter_controls'));
loadTestFile(require.resolve('./flyout'));
loadTestFile(require.resolve('./header_menu'));
});
}