mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
d06d2a3bc9
commit
cd96ab465b
15 changed files with 330 additions and 57 deletions
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -37,7 +37,8 @@ export const updateContextInUrl =
|
|||
positionStateKey,
|
||||
positionStateInUrlRT.encode({
|
||||
position: context.latestPosition ? pickTimeKey(context.latestPosition) : null,
|
||||
})
|
||||
}),
|
||||
{ replace: true }
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -88,7 +88,8 @@ export const updateContextInUrl =
|
|||
filters: context.filters,
|
||||
timeRange: context.timeRange,
|
||||
refreshInterval: context.refreshInterval,
|
||||
})
|
||||
}),
|
||||
{ replace: true }
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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 />
|
||||
|
|
|
@ -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: {},
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
};
|
|
@ -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',
|
||||
}
|
||||
|
||||
|
|
20
x-pack/plugins/infra/public/pages/metrics/hosts/utils.ts
Normal file
20
x-pack/plugins/infra/public/pages/metrics/hosts/utils.ts
Normal 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: {},
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue