[Infrastructure UI] Integrated the logs tab to the Hosts View (#152995)

closes [#957](https://github.com/elastic/obs-infraobs-team/issues/957)
closes [#958](https://github.com/elastic/obs-infraobs-team/issues/958)
closes [#959](https://github.com/elastic/obs-infraobs-team/issues/959)

## Summary

This PR is for integrating the logs tab in the Hosts view and adding a
search bar to allow further filtering the logs, it also enables
navigating to the Logs stream view using an Open in logs link.

## Demo


https://user-images.githubusercontent.com/11225826/226317269-c35a292f-095d-4c15-94ea-13fc75f07102.mov

## Next Steps

1. Implementing Telemetry
2. Functional Tests

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Carlos Crespo <carloshenrique.leonelcrespo@elastic.co>
This commit is contained in:
mohamedhamed-ahmed 2023-03-27 11:22:07 +01:00 committed by GitHub
parent d06d2a3bc9
commit cd96ab465b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 330 additions and 57 deletions

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { HttpHandler } from '@kbn/core/public';
import { ToastInput } from '@kbn/core/public';
@ -83,7 +83,7 @@ export function useHTTPRequest<Response>(
};
}, [abortable]);
const [request, makeRequest] = useTrackedPromise<any, Response>(
const [request, makeRequest, resetRequestState] = useTrackedPromise<any, Response>(
{
cancelPreviousOn: 'resolution',
createPromise: () => {
@ -117,17 +117,13 @@ export function useHTTPRequest<Response>(
[pathname, body, method, fetch, toast, onError]
);
const loading = useMemo(() => {
if (request.state === 'resolved' && response === null) {
return true;
}
return request.state === 'pending';
}, [request.state, response]);
const loading = request.state === 'uninitialized' || request.state === 'pending';
return {
response,
error,
loading,
makeRequest,
resetRequestState,
};
}

View file

@ -37,7 +37,8 @@ export const updateContextInUrl =
positionStateKey,
positionStateInUrlRT.encode({
position: context.latestPosition ? pickTimeKey(context.latestPosition) : null,
})
}),
{ replace: true }
);
};

View file

@ -88,7 +88,8 @@ export const updateContextInUrl =
filters: context.filters,
timeRange: context.timeRange,
refreshInterval: context.refreshInterval,
})
}),
{ replace: true }
);
};

View file

@ -0,0 +1,8 @@
/*
* 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 './logs_tab_content';

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 React from 'react';
import { stringify } from 'querystring';
import { encode } from '@kbn/rison';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { EuiButtonEmpty } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useKibanaContextForPlugin } from '../../../../../../hooks/use_kibana';
interface LogsLinkToStreamProps {
startTimestamp: number;
endTimestamp: number;
query: string;
}
export const LogsLinkToStream = ({
startTimestamp,
endTimestamp,
query,
}: LogsLinkToStreamProps) => {
const { services } = useKibanaContextForPlugin();
const { http } = services;
const queryString = new URLSearchParams(
stringify({
logPosition: encode({
start: new Date(startTimestamp),
end: new Date(endTimestamp),
streamLive: false,
}),
logFilter: encode({
kind: 'kuery',
expression: query,
}),
})
);
const viewInLogsUrl = http.basePath.prepend(`/app/logs/stream?${queryString}`);
return (
<RedirectAppLinks coreStart={services}>
<EuiButtonEmpty
href={viewInLogsUrl}
data-test-subj="hostsView-logs-link-to-stream-button"
iconType="popout"
flush="both"
>
<FormattedMessage
id="xpack.infra.hostsViewPage.tabs.logs.openInLogsUiLinkText"
defaultMessage="Open in Logs"
/>
</EuiButtonEmpty>
</RedirectAppLinks>
);
};

View file

@ -0,0 +1,40 @@
/*
* 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, { useCallback, useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import { i18n } from '@kbn/i18n';
import { EuiFieldSearch } from '@elastic/eui';
import { useLogsSearchUrlState } from '../../../hooks/use_logs_search_url_state';
const debounceIntervalInMs = 1000;
export const LogsSearchBar = () => {
const [filterQuery, setFilterQuery] = useLogsSearchUrlState();
const [searchText, setSearchText] = useState(filterQuery.query);
const onQueryChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(e.target.value);
}, []);
useDebounce(() => setFilterQuery({ ...filterQuery, query: searchText }), debounceIntervalInMs, [
searchText,
]);
return (
<EuiFieldSearch
data-test-subj="hostsView-logs-text-field-search"
fullWidth
isClearable
placeholder={i18n.translate('xpack.infra.hostsViewPage.tabs.logs.textFieldPlaceholder', {
defaultMessage: 'Search for log entries...',
})}
onChange={onQueryChange}
value={searchText}
/>
);
};

View file

@ -0,0 +1,92 @@
/*
* 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, { useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { InfraLoadingPanel } from '../../../../../../components/loading';
import { SnapshotNode } from '../../../../../../../common/http_api';
import { LogStream } from '../../../../../../components/log_stream';
import { useHostsViewContext } from '../../../hooks/use_hosts_view';
import { useUnifiedSearchContext } from '../../../hooks/use_unified_search';
import { useLogsSearchUrlState } from '../../../hooks/use_logs_search_url_state';
import { LogsLinkToStream } from './logs_link_to_stream';
import { LogsSearchBar } from './logs_search_bar';
import { createHostsFilter } from '../../../utils';
export const LogsTabContent = () => {
const [filterQuery] = useLogsSearchUrlState();
const { getDateRangeAsTimestamp } = useUnifiedSearchContext();
const { from, to } = useMemo(() => getDateRangeAsTimestamp(), [getDateRangeAsTimestamp]);
const { hostNodes, loading } = useHostsViewContext();
const hostsFilterQuery = useMemo(() => createHostsFilter(hostNodes), [hostNodes]);
const logsLinkToStreamQuery = useMemo(() => {
const hostsFilterQueryParam = createHostsFilterQueryParam(hostNodes);
if (filterQuery.query && hostsFilterQueryParam) {
return `${filterQuery.query} and ${hostsFilterQueryParam}`;
}
return filterQuery.query || hostsFilterQueryParam;
}, [filterQuery.query, hostNodes]);
if (loading) {
return (
<EuiFlexGroup style={{ height: 300 }} direction="column" alignItems="stretch">
<EuiFlexItem grow>
<InfraLoadingPanel
width="100%"
height="100%"
text={
<FormattedMessage
id="xpack.infra.hostsViewPage.tabs.logs.loadingEntriesLabel"
defaultMessage="Loading entries"
/>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<EuiFlexGroup direction="column" gutterSize="m" data-test-subj="hostsView-logs">
<EuiFlexGroup gutterSize="m" alignItems="center" responsive={false}>
<EuiFlexItem>
<LogsSearchBar />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LogsLinkToStream startTimestamp={from} endTimestamp={to} query={logsLinkToStreamQuery} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem>
<LogStream
height={500}
logView={{ type: 'log-view-reference', logViewId: 'default' }}
startTimestamp={from}
endTimestamp={to}
filters={[hostsFilterQuery]}
query={filterQuery}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
const createHostsFilterQueryParam = (hostNodes: SnapshotNode[]): string => {
if (!hostNodes.length) {
return '';
}
const joinedHosts = hostNodes.map((p) => p.name).join(' or ');
const hostsQueryParam = `host.name:(${joinedHosts})`;
return hostsQueryParam;
};

View file

@ -14,6 +14,7 @@ import { AlertsTabContent } from './alerts';
import { AlertsTabBadge } from './alerts_tab_badge';
import { TabIds, useTabId } from '../../hooks/use_tab_id';
import { LogsTabContent } from './logs';
const tabs = [
{
@ -23,6 +24,13 @@ const tabs = [
}),
'data-test-subj': 'hostsView-tabs-metrics',
},
{
id: TabIds.LOGS,
name: i18n.translate('xpack.infra.hostsViewPage.tabs.logs.title', {
defaultMessage: 'Logs',
}),
'data-test-subj': 'hostsView-tabs-logs',
},
{
id: TabIds.ALERTS,
name: i18n.translate('xpack.infra.hostsViewPage.tabs.alerts.title', {
@ -63,6 +71,11 @@ export const Tabs = () => {
<MetricsGrid />
</div>
)}
{renderedTabsSet.current.has(TabIds.LOGS) && (
<div hidden={selectedTabId !== TabIds.LOGS}>
<LogsTabContent />
</div>
)}
{renderedTabsSet.current.has(TabIds.ALERTS) && (
<div hidden={selectedTabId !== TabIds.ALERTS}>
<AlertsTabContent />

View file

@ -15,6 +15,7 @@ import { HostsState } from './use_unified_search_url_state';
import { useHostsViewContext } from './use_hosts_view';
import { AlertStatus } from '../types';
import { ALERT_STATUS_QUERY } from '../constants';
import { createHostsFilter } from '../utils';
export interface AlertsEsQuery {
bool: BoolQuery;
@ -80,12 +81,3 @@ const createDateFilter = (date: HostsState['dateRange']) =>
const createAlertStatusFilter = (status: AlertStatus = 'all'): Filter | null =>
ALERT_STATUS_QUERY[status] ? { query: ALERT_STATUS_QUERY[status], meta: {} } : null;
const createHostsFilter = (hosts: SnapshotNode[]): Filter => ({
query: {
terms: {
'host.name': hosts.map((p) => p.name),
},
},
meta: {},
});

View file

@ -0,0 +1,40 @@
/*
* 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 * as rt from 'io-ts';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import { useUrlState } from '../../../../utils/use_url_state';
const DEFAULT_QUERY = {
language: 'kuery',
query: '',
};
type LogsUrlStateUpdater = (newState: LogsUrlState) => void;
const LogsQueryStateRT = rt.type({
language: rt.string,
query: rt.any,
});
const encodeUrlState = LogsQueryStateRT.encode;
const decodeUrlState = (defaultValue: LogsUrlState) => (value: unknown) => {
return pipe(LogsQueryStateRT.decode(value), fold(constant(defaultValue), identity));
};
export type LogsUrlState = rt.TypeOf<typeof LogsQueryStateRT>;
export const useLogsSearchUrlState = (): [LogsUrlState, LogsUrlStateUpdater] => {
return useUrlState<LogsUrlState>({
defaultState: DEFAULT_QUERY,
decodeUrlState: decodeUrlState(DEFAULT_QUERY),
encodeUrlState,
urlStateKey: 'logsQuery',
});
};

View file

@ -22,10 +22,11 @@ export const useTabId = (initialValue: TabId = TabIds.METRICS): [TabId, TabIdUpd
});
};
const TabIdRT = rt.union([rt.literal('alerts'), rt.literal('metrics')]);
const TabIdRT = rt.union([rt.literal('alerts'), rt.literal('logs'), rt.literal('metrics')]);
export enum TabIds {
ALERTS = 'alerts',
LOGS = 'logs',
METRICS = 'metrics',
}

View file

@ -0,0 +1,20 @@
/*
* 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 { Filter } from '@kbn/es-query';
import { SnapshotNode } from '../../../../common/http_api';
export const createHostsFilter = (hostNodes: SnapshotNode[]): Filter => {
return {
query: {
terms: {
'host.name': hostNodes.map((p) => p.name),
},
},
meta: {},
};
};

View file

@ -68,7 +68,7 @@ export function useSnapshot(
dropPartialBuckets,
};
const { error, loading, response, makeRequest } = useHTTPRequest(
const { error, loading, response, makeRequest, resetRequestState } = useHTTPRequest(
'/api/metrics/snapshot',
'POST',
JSON.stringify(payload),
@ -79,12 +79,13 @@ export function useSnapshot(
);
useEffect(() => {
(async () => {
if (sendRequestImmediately) {
await makeRequest();
}
})();
}, [makeRequest, sendRequestImmediately, requestTs]);
if (sendRequestImmediately) {
makeRequest();
}
return () => {
resetRequestState();
};
}, [makeRequest, sendRequestImmediately, resetRequestState, requestTs]);
return {
error: (error && error.message) || null,

View file

@ -112,6 +112,12 @@ export const useTrackedPromise = <Arguments extends any[], Result>(
state: 'uninitialized',
});
const reset = useCallback(() => {
setPromiseState({
state: 'uninitialized',
});
}, []);
const execute = useMemo(
() =>
(...args: Arguments) => {
@ -149,17 +155,6 @@ export const useTrackedPromise = <Arguments extends any[], Result>(
},
promise: newCancelablePromise.then(
(value) => {
setPromiseState((previousPromiseState) =>
previousPromiseState.state === 'pending' &&
previousPromiseState.promise === newCancelablePromise
? {
state: 'resolved',
promise: newPendingPromise.promise,
value,
}
: previousPromiseState
);
if (['settlement', 'resolution'].includes(cancelPreviousOn)) {
cancelPreviousPendingPromises();
}
@ -173,10 +168,38 @@ export const useTrackedPromise = <Arguments extends any[], Result>(
onResolve(value);
}
setPromiseState((previousPromiseState) =>
previousPromiseState.state === 'pending' &&
previousPromiseState.promise === newCancelablePromise
? {
state: 'resolved',
promise: newPendingPromise.promise,
value,
}
: previousPromiseState
);
return value;
},
(value) => {
if (!(value instanceof SilentCanceledPromiseError)) {
if (['settlement', 'rejection'].includes(cancelPreviousOn)) {
cancelPreviousPendingPromises();
}
// remove itself from the list of pending promises
pendingPromises.current = pendingPromises.current.filter(
(pendingPromise) => pendingPromise.promise !== newPendingPromise.promise
);
if (shouldTriggerOrThrow()) {
if (onReject) {
onReject(value);
} else {
throw value;
}
}
setPromiseState((previousPromiseState) =>
previousPromiseState.state === 'pending' &&
previousPromiseState.promise === newCancelablePromise
@ -188,23 +211,6 @@ export const useTrackedPromise = <Arguments extends any[], Result>(
: previousPromiseState
);
}
if (['settlement', 'rejection'].includes(cancelPreviousOn)) {
cancelPreviousPendingPromises();
}
// remove itself from the list of pending promises
pendingPromises.current = pendingPromises.current.filter(
(pendingPromise) => pendingPromise.promise !== newPendingPromise.promise
);
if (shouldTriggerOrThrow()) {
if (onReject) {
onReject(value);
} else {
throw value;
}
}
}
),
};
@ -233,7 +239,7 @@ export const useTrackedPromise = <Arguments extends any[], Result>(
[]
);
return [promiseState, execute] as [typeof promiseState, typeof execute];
return [promiseState, execute, reset] as [typeof promiseState, typeof execute, typeof reset];
};
export interface UninitializedPromiseState {

View file

@ -56,7 +56,9 @@
"@kbn/shared-ux-router",
"@kbn/alerts-as-data-utils",
"@kbn/cases-plugin",
"@kbn/shared-ux-prompt-not-found"
"@kbn/shared-ux-prompt-not-found",
"@kbn/shared-ux-router",
"@kbn/shared-ux-link-redirect-app"
],
"exclude": ["target/**/*"]
}