[Fleet] Spacetime: Add fleet server health check to debug page (#146594)

## Summary
Closes https://github.com/elastic/ingest-dev/issues/1422
Closes https://github.com/elastic/kibana/issues/143644
Fixes https://github.com/elastic/kibana/issues/141635

### Description
Spacetime project:
- Fixing UI layout 
- Adding a new endpoint to check the connection to fleet-server. The
endpoint executes `curl -s -k <hostname>/api/status` and can be called
from dev tools as:
```
POST kbn:/api/fleet/health_check
{
  "host": $hostname
}
```
Where `$hostname` is the host configured in fleet server hosts settings
section.
- Adding a Fleet Server health check to the debug page
`app/fleet/_debug`. The host can be selected via a dropdown.
- Moving debug page outside of setup to allow debugging when fleet
couldn't initialise. I added a warning on top of the page in this
specific case - https://github.com/elastic/kibana/issues/143644
- Added some more saved objects and indices that weren't added the first
time round to allow further debugging.

### Repro steps:
To try that the page loads even when setup didn't work, I changed some
values in the code. Comment out [this
code](https://github.com/elastic/kibana/blob/main/x-pack/plugins/fleet/public/applications/fleet/app.tsx#L163-L165)
and replace it with `setInitializationError({ name: 'error', message:
'unable to initialize' });`, then set `false` in
[here](b155134d66/x-pack/plugins/fleet/public/applications/fleet/app.tsx (L179))
and the fleet UI should show a setup error, however now the debug page
should be visible.

### Screenshots
<img width="2076" alt="Screenshot 2022-11-30 at 15 25 51"
src="https://user-images.githubusercontent.com/16084106/204824818-d620aabf-83b1-4acd-9f38-6f271d17a38a.png">
<img width="1403" alt="Screenshot 2022-11-30 at 15 26 36"
src="https://user-images.githubusercontent.com/16084106/204824851-04b36d5e-e466-4f0c-9eed-b8b492f128b9.png">
<img width="2063" alt="Screenshot 2022-11-30 at 15 27 09"
src="https://user-images.githubusercontent.com/16084106/204824909-a26a8df1-38ba-4553-984f-fce13a3abf8d.png">
<img width="2110" alt="Screenshot 2022-12-01 at 17 36 57"
src="https://user-images.githubusercontent.com/16084106/205110349-a682a894-767e-47f9-beb9-7f9c39bece72.png">


### Checklist

- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Cristina Amico 2022-12-05 17:52:16 +01:00 committed by GitHub
parent b5ba42bf1a
commit 035ebc4106
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 424 additions and 106 deletions

View file

@ -259,6 +259,7 @@ export const settingsRoutesService = {
export const appRoutesService = {
getCheckPermissionsPath: (fleetServerSetup?: boolean) => APP_API_ROUTES.CHECK_PERMISSIONS_PATTERN,
getRegenerateServiceTokenPath: () => APP_API_ROUTES.GENERATE_SERVICE_TOKEN_PATTERN,
postHealthCheckPath: () => APP_API_ROUTES.HEALTH_CHECK_PATTERN,
};
export const enrollmentAPIKeyRouteService = {

View file

@ -0,0 +1,18 @@
/*
* 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 interface PostHealthCheckRequest {
body: {
host: string;
};
}
export interface PostHealthCheckResponse {
name: string;
host: string;
status: string;
}

View file

@ -18,3 +18,4 @@ export * from './fleet_setup';
export * from './output';
export * from './package_policy';
export * from './settings';
export * from './health_check';

View file

@ -146,6 +146,7 @@ export const WithPermissionsAndSetup: React.FC = memo(({ children }) => {
const [initializationError, setInitializationError] = useState<Error | null>(null);
const isAddIntegrationsPath = !!useRouteMatch(FLEET_ROUTING_PATHS.add_integration_to_policy);
const isDebugPath = !!useRouteMatch(FLEET_ROUTING_PATHS.debug);
useEffect(() => {
(async () => {
@ -193,6 +194,10 @@ export const WithPermissionsAndSetup: React.FC = memo(({ children }) => {
</ErrorLayout>
);
}
// Debug page moved outside of initialization to allow debugging when setup failed
if (isDebugPath) {
return <DebugPage setupError={initializationError} isInitialized={isInitialized} />;
}
if (!isInitialized || initializationError) {
return (
@ -328,10 +333,6 @@ export const AppRoutes = memo(
<SettingsApp />
</Route>
<Route path={FLEET_ROUTING_PATHS.debug}>
<DebugPage />
</Route>
{/* TODO: Move this route to the Integrations app */}
<Route path={FLEET_ROUTING_PATHS.add_integration_to_policy}>
<CreatePackagePolicyPage />

View file

@ -43,6 +43,8 @@ export const FleetIndexDebugger = () => {
const indices = [
{ label: '.fleet-agents', value: '.fleet-agents' },
{ label: '.fleet-actions', value: '.fleet-actions' },
{ label: '.fleet-servers', value: '.fleet-servers' },
{ label: '.fleet-enrollment-api-keys', value: '.fleet-enrollment-api-keys' },
];
const [index, setIndex] = useState<string | undefined>();

View file

@ -0,0 +1,167 @@
/*
* 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, { useEffect, useState, useMemo } from 'react';
import {
EuiSpacer,
EuiText,
EuiSuperSelect,
EuiFlexGroup,
EuiFlexItem,
EuiCallOut,
EuiHealth,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useQuery } from '@tanstack/react-query';
import { sendPostHealthCheck, useGetFleetServerHosts } from '../../../hooks';
import type { FleetServerHost } from '../../../types';
const POLLING_INTERVAL_S = 10; // 10 sec
const POLLING_INTERVAL_MS = POLLING_INTERVAL_S * 1000;
export const HealthCheckPanel: React.FunctionComponent = () => {
const [selectedFleetServerHost, setSelectedFleetServerHost] = useState<
FleetServerHost | undefined
>();
const { data } = useGetFleetServerHosts();
const fleetServerHosts = useMemo(() => data?.items ?? [], [data?.items]);
useEffect(() => {
const defaultHost = fleetServerHosts.find((item) => item.is_default === true);
if (defaultHost) {
setSelectedFleetServerHost(defaultHost);
}
}, [fleetServerHosts]);
const hostName = useMemo(
() => selectedFleetServerHost?.host_urls[0] || '',
[selectedFleetServerHost?.host_urls]
);
const [healthData, setHealthData] = useState<any>();
const { data: healthCheckResponse } = useQuery(
['fleetServerHealth', hostName],
() => sendPostHealthCheck({ host: hostName }),
{ refetchInterval: POLLING_INTERVAL_MS }
);
useEffect(() => {
setHealthData(healthCheckResponse);
}, [healthCheckResponse]);
const fleetServerHostsOptions = useMemo(
() => [
...fleetServerHosts.map((fleetServerHost) => {
return {
inputDisplay: `${fleetServerHost.name} (${fleetServerHost.host_urls[0]})`,
value: fleetServerHost.id,
};
}),
],
[fleetServerHosts]
);
const healthStatus = (statusValue: string) => {
if (!statusValue) return null;
let color;
switch (statusValue) {
case 'HEALTHY':
color = 'success';
break;
case 'UNHEALTHY':
color = 'warning';
break;
case 'OFFLINE':
color = 'subdued';
break;
default:
color = 'subdued';
}
return <EuiHealth color={color}>{statusValue}</EuiHealth>;
};
return (
<>
<EuiText grow={false}>
<p>
<FormattedMessage
id="xpack.fleet.debug.healthCheckPanel.description"
defaultMessage="Select the host used to enroll Fleet Server. The connection is refreshed every {interval}s."
values={{
interval: POLLING_INTERVAL_S,
}}
/>
</p>
</EuiText>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="center">
<EuiFlexItem
grow={false}
css={`
min-width: 600px;
`}
>
<EuiSuperSelect
fullWidth
data-test-subj="fleetDebug.fleetServerHostsSelect"
prepend={
<EuiText size="relative" color={''}>
<FormattedMessage
id="xpack.fleet.debug.healthCheckPanel.fleetServerHostsLabel"
defaultMessage="Fleet Server Hosts"
/>
</EuiText>
}
onChange={(fleetServerHostId) => {
setHealthData(undefined);
setSelectedFleetServerHost(
fleetServerHosts.find((fleetServerHost) => fleetServerHost.id === fleetServerHostId)
);
}}
valueOfSelected={selectedFleetServerHost?.id}
options={fleetServerHostsOptions}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{healthData?.data?.status && hostName === healthData?.data?.host ? (
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<FormattedMessage
id="xpack.fleet.debug.healthCheckPanel.status"
defaultMessage="Status:"
/>
</EuiFlexItem>
<EuiFlexItem>{healthStatus(healthData?.data?.status)}</EuiFlexItem>
</EuiFlexGroup>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
{healthData?.error && (
<>
<EuiSpacer size="m" />
<EuiCallOut title="Error" color="danger">
{healthData?.error?.message ?? (
<FormattedMessage
id="xpack.fleet.debug.healthCheckPanel.fetchError"
defaultMessage="Message: {errorMessage}"
values={{
errorMessage: healthData?.error?.message,
}}
/>
)}
</EuiCallOut>
</>
)}
</>
);
};

View file

@ -11,3 +11,4 @@ export * from './saved_object_debugger';
export * from './preconfiguration_debugger';
export * from './fleet_index_debugger';
export * from './orphaned_integration_policy_debugger';
export * from './health_check_panel';

View file

@ -129,7 +129,7 @@ export const PreconfigurationDebugger: React.FunctionComponent = () => {
<p>
<FormattedMessage
id="xpack.fleet.debug.preconfigurationDebugger.description"
defaultMessage="This tool can be used to reset preconfigured policies that are managed via {codeKibanaYml}. This includes Fleet's default policies that may existin cloud environments."
defaultMessage="This tool can be used to reset preconfigured policies that are managed via {codeKibanaYml}. This includes Fleet's default policies that may exist in cloud environments."
values={{ codeKibanaYml: <EuiCode>kibana.yml</EuiCode> }}
/>
</p>

View file

@ -22,6 +22,15 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { sendRequest } from '../../../hooks';
import {
OUTPUT_SAVED_OBJECT_TYPE,
AGENT_POLICY_SAVED_OBJECT_TYPE,
PACKAGE_POLICY_SAVED_OBJECT_TYPE,
PACKAGES_SAVED_OBJECT_TYPE,
DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE,
FLEET_SERVER_HOST_SAVED_OBJECT_TYPE,
} from '../../../../../../common/constants';
import { CodeBlock } from './code_block';
import { SavedObjectNamesCombo } from './saved_object_names_combo';
@ -61,29 +70,41 @@ const fetchSavedObjects = async (type?: string, name?: string) => {
export const SavedObjectDebugger: React.FunctionComponent = () => {
const types = [
{
value: 'ingest-agent-policies',
value: `${AGENT_POLICY_SAVED_OBJECT_TYPE}`,
text: i18n.translate('xpack.fleet.debug.savedObjectDebugger.agentPolicyLabel', {
defaultMessage: 'Agent policy',
}),
},
{
value: 'ingest-package-policies',
value: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}`,
text: i18n.translate('xpack.fleet.debug.savedObjectDebugger.packagePolicyLabel', {
defaultMessage: 'Integration policy',
}),
},
{
value: 'ingest-outputs',
value: `${OUTPUT_SAVED_OBJECT_TYPE}`,
text: i18n.translate('xpack.fleet.debug.savedObjectDebugger.outputLabel', {
defaultMessage: 'Output',
}),
},
{
value: 'epm-packages',
value: `${PACKAGES_SAVED_OBJECT_TYPE}`,
text: i18n.translate('xpack.fleet.debug.savedObjectDebugger.packageLabel', {
defaultMessage: 'Packages',
}),
},
{
value: `${DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE}`,
text: i18n.translate('xpack.fleet.debug.savedObjectDebugger.downloadSourceLabel', {
defaultMessage: 'Download Sources',
}),
},
{
value: `${FLEET_SERVER_HOST_SAVED_OBJECT_TYPE}`,
text: i18n.translate('xpack.fleet.debug.savedObjectDebugger.fleetServerHostLabel', {
defaultMessage: 'Fleet Server Hosts',
}),
},
];
const [type, setType] = useState(types[0].value);
@ -153,7 +174,7 @@ export const SavedObjectDebugger: React.FunctionComponent = () => {
</EuiFlexItem>
</EuiFlexGroup>
{(status === 'error' || namesStatus === 'error') && (
{savedObjectResult && (status === 'error' || namesStatus === 'error') && (
<>
<EuiSpacer size="m" />
<EuiCallOut title="Error" color="danger">

View file

@ -14,6 +14,7 @@ import {
EuiPage,
EuiPageBody,
EuiPageHeader,
EuiPageSection,
EuiSpacer,
EuiText,
EuiTitle,
@ -24,6 +25,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { RequestError } from '../../hooks';
import { useLink, useStartServices } from '../../hooks';
import {
@ -33,6 +35,7 @@ import {
FleetIndexDebugger,
SavedObjectDebugger,
OrphanedIntegrationPolicyDebugger,
HealthCheckPanel,
} from './components';
// TODO: Evaluate moving this react-query initialization up to the main Fleet app
@ -40,6 +43,13 @@ import {
export const queryClient = new QueryClient();
const panels = [
{
title: i18n.translate('xpack.fleet.debug.HealthCheckStatus.title', {
defaultMessage: 'Health Check Status',
}),
id: 'healthCheckStatus',
component: <HealthCheckPanel />,
},
{
title: i18n.translate('xpack.fleet.debug.agentPolicyDebugger.title', {
defaultMessage: 'Agent Policy Debugger',
@ -84,7 +94,10 @@ const panels = [
},
];
export const DebugPage: React.FunctionComponent = () => {
export const DebugPage: React.FunctionComponent<{
isInitialized: boolean;
setupError: RequestError | null;
}> = ({ isInitialized, setupError }) => {
const { chrome } = useStartServices();
const { getHref } = useLink();
@ -92,93 +105,113 @@ export const DebugPage: React.FunctionComponent = () => {
return (
<QueryClientProvider client={queryClient}>
<EuiPage>
<EuiPage paddingSize="xl">
<EuiPageBody panelled>
<EuiPageHeader
pageTitle={i18n.translate('xpack.fleet.debug.pageTitle', {
defaultMessage: 'Fleet Debugging Dashboard',
})}
iconType="wrench"
/>
<EuiCallOut color="danger" iconType="alert" title="Danger zone">
<EuiText grow={false}>
<FormattedMessage
id="xpack.fleet.debug.dangerZone.description"
defaultMessage="This page provides an interface for directly managing Fleet's underlying data and diagnosing issues. Be aware that these debugging tools can be {strongDestructive} in nature and can result in {strongLossOfData}. Please proceed with caution."
values={{
strongDestructive: (
<strong>
<FormattedMessage
id="xpack.fleet.debug.dangerZone.destructive"
defaultMessage="destructive"
/>
</strong>
),
strongLossOfData: (
<strong>
<FormattedMessage
id="xpack.fleet.debug.dangerZone.lossOfData"
defaultMessage="loss of data"
/>
</strong>
),
}}
/>
</EuiText>
</EuiCallOut>
<EuiSpacer size="xl" />
{panels.map(({ title, id, component }) => (
<>
<EuiAccordion
id={id}
initialIsOpen
buttonContent={
<EuiTitle size="l">
<h2>{title}</h2>
</EuiTitle>
}
>
<EuiSpacer size="m" />
{component}
</EuiAccordion>
<EuiHorizontalRule />
</>
))}
<EuiTitle size="l">
<h2>
<FormattedMessage
id="xpack.fleet.debug.usefulLinks.title"
defaultMessage="Useful links"
/>
</h2>
</EuiTitle>
<EuiPageSection>
<EuiPageHeader
pageTitle={i18n.translate('xpack.fleet.debug.pageTitle', {
defaultMessage: 'Fleet Debugging Dashboard',
})}
iconType="wrench"
/>
<EuiSpacer size="m" />
<EuiCallOut color="danger" iconType="alert" title="Danger zone">
<EuiText grow={false}>
<FormattedMessage
id="xpack.fleet.debug.dangerZone.description"
defaultMessage="This page provides an interface for directly managing Fleet's underlying data and diagnosing issues. Be aware that these debugging tools can be {strongDestructive} in nature and can result in {strongLossOfData}. Please proceed with caution."
values={{
strongDestructive: (
<strong>
<FormattedMessage
id="xpack.fleet.debug.dangerZone.destructive"
defaultMessage="destructive"
/>
</strong>
),
strongLossOfData: (
<strong>
<FormattedMessage
id="xpack.fleet.debug.dangerZone.lossOfData"
defaultMessage="loss of data"
/>
</strong>
),
}}
/>
</EuiText>
</EuiCallOut>
{!isInitialized && setupError?.message && (
<>
<EuiSpacer size="s" />
<EuiCallOut color="danger" iconType="alert" title="Setup error">
<EuiText grow={false}>
<FormattedMessage
id="xpack.fleet.debug.initializationError.description"
defaultMessage="{message}. You can use this page to debug the error."
values={{
message: setupError?.message,
}}
/>
</EuiText>
</EuiCallOut>
</>
)}
</EuiPageSection>
<EuiSpacer size="m" />
<EuiPageSection>
{panels.map(({ title, id, component }) => (
<>
<EuiAccordion
id={id}
initialIsOpen
buttonContent={
<EuiTitle size="l">
<h2>{title}</h2>
</EuiTitle>
}
>
<EuiSpacer size="m" />
{component}
</EuiAccordion>
<EuiListGroup
listItems={[
{
label: i18n.translate('xpack.fleet.debug.usefulLinks.viewAgents', {
defaultMessage: 'View Agents in Fleet UI',
}),
href: getHref('agent_list'),
iconType: 'agentApp',
target: '_blank',
},
{
label: i18n.translate('xpack.fleet.debug.usefulLinks.troubleshootingGuide', {
defaultMessage: 'Troubleshooting Guide',
}),
href: 'https://www.elastic.co/guide/en/fleet/current/fleet-troubleshooting.html',
iconType: 'popout',
target: '_blank',
},
]}
/>
<EuiHorizontalRule />
</>
))}
<EuiTitle size="l">
<h2>
<FormattedMessage
id="xpack.fleet.debug.usefulLinks.title"
defaultMessage="Useful links"
/>
</h2>
</EuiTitle>
<EuiSpacer size="m" />
<EuiListGroup
listItems={[
{
label: i18n.translate('xpack.fleet.debug.usefulLinks.viewAgents', {
defaultMessage: 'View Agents in Fleet UI',
}),
href: getHref('agent_list'),
iconType: 'agentApp',
target: '_blank',
},
{
label: i18n.translate('xpack.fleet.debug.usefulLinks.troubleshootingGuide', {
defaultMessage: 'Troubleshooting Guide',
}),
href: 'https://www.elastic.co/guide/en/fleet/current/fleet-troubleshooting.html',
iconType: 'popout',
target: '_blank',
},
]}
/>
</EuiPageSection>
</EuiPageBody>
</EuiPage>
<ReactQueryDevtools initialIsOpen={false} />

View file

@ -0,0 +1,19 @@
/*
* 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 { PostHealthCheckRequest, PostHealthCheckResponse } from '../../types';
import { appRoutesService } from '../../services';
import { sendRequest } from './use_request';
export function sendPostHealthCheck(body: PostHealthCheckRequest['body']) {
return sendRequest<PostHealthCheckResponse>({
method: 'post',
path: appRoutesService.postHealthCheckPath(),
body,
});
}

View file

@ -20,3 +20,4 @@ export * from './ingest_pipelines';
export * from './download_source';
export * from './fleet_server_hosts';
export * from './fleet_proxies';
export * from './health_check';

View file

@ -129,6 +129,8 @@ export type {
PostDownloadSourceRequest,
PutDownloadSourceRequest,
GetAvailableVersionsResponse,
PostHealthCheckRequest,
PostHealthCheckResponse,
} from '../../common/types';
export {
entries,

View file

@ -4,31 +4,67 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import https from 'https';
import type { TypeOf } from '@kbn/config-schema';
import fetch from 'node-fetch';
import { APP_API_ROUTES } from '../../constants';
import type { FleetRequestHandler } from '../../types';
import type { FleetAuthzRouter } from '../security';
import { defaultFleetErrorHandler } from '../../errors';
import { PostHealthCheckRequestSchema } from '../../types';
export const registerRoutes = (router: FleetAuthzRouter) => {
router.get(
// get fleet server health check by host
router.post(
{
path: APP_API_ROUTES.HEALTH_CHECK_PATTERN,
validate: {},
validate: PostHealthCheckRequestSchema,
fleetAuthz: {
fleet: { all: true },
},
},
getHealthCheckHandler
postHealthCheckHandler
);
};
export const getHealthCheckHandler: FleetRequestHandler<undefined, undefined, undefined> = async (
context,
request,
response
) => {
return response.ok({
body: 'Fleet Health Check Report:\nFleet Server: HEALTHY',
headers: { 'content-type': 'text/plain' },
});
export const postHealthCheckHandler: FleetRequestHandler<
undefined,
undefined,
TypeOf<typeof PostHealthCheckRequestSchema.body>
> = async (context, request, response) => {
try {
const abortController = new AbortController();
const { host } = request.body;
// Sometimes when the host is not online, the request hangs
// Setting a timeout to abort the request after 5s
setTimeout(() => {
abortController.abort();
}, 5000);
const res = await fetch(`${host}/api/status`, {
headers: {
accept: '*/*',
},
method: 'GET',
agent: new https.Agent({
rejectUnauthorized: false,
}),
signal: abortController.signal,
});
const bodyRes = await res.json();
const body = { ...bodyRes, host };
return response.ok({ body });
} catch (error) {
// when the request is aborted, return offline status
if (error.name === 'AbortError') {
return response.ok({
body: { name: 'fleet-server', status: `OFFLINE`, host: request.body.host },
});
}
return defaultFleetErrorHandler({ error, response });
}
};

View file

@ -0,0 +1,14 @@
/*
* 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 { schema } from '@kbn/config-schema';
export const PostHealthCheckRequestSchema = {
body: schema.object({
host: schema.uri({ scheme: ['http', 'https'] }),
}),
};

View file

@ -20,3 +20,4 @@ export * from './setup';
export * from './check_permissions';
export * from './download_sources';
export * from './tags';
export * from './health_check';