mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Fleet] Request diagnostics (#142369)
## Summary Closes https://github.com/elastic/kibana/issues/141074 ### Request diagnostics action Added new action for single agent (Agent details page and Agent list row actions) to request diagnostics. When clicking on the action, an API request is made that creates a `REQUEST_DIAGNOSTICS` type action in `.fleet-actions` index. ### Diagnostics uploads display When the action is submitted, the user is navigated to the new `Agent Details / Diagnostics` tab, which shows the list of pending and completed diagnostics file uploads. The information is coming from the `/action_status` (for action status) as well as the `/uploads` endpoint (for file name and path) By clicking on a diagnostics link, the file should be downloaded in zip. <img width="1060" alt="image" src="https://user-images.githubusercontent.com/90178898/193816708-803c2a22-d421-4af2-9a78-785cdee81136.png"> Failed uploads display: <img width="638" alt="image" src="https://user-images.githubusercontent.com/90178898/194058366-d4874339-9fd1-419e-99e5-f592a6b3bf6d.png"> Expired status was not specified in the design separately, it will be shown like the failed status (with warning icon). ### Mock data (blocker) Currently returning mock data in the `/uploads` API, because of a blocker in Kibana File Service, see [here](https://github.com/elastic/kibana/issues/141074#issuecomment-1267078759). ### Bulk action Added bulk action too: <img width="1759" alt="image" src="https://user-images.githubusercontent.com/90178898/194026861-bf0d5956-de2d-4d2b-895a-c35cf5252a5a.png"> Shows up in agent activity: <img width="594" alt="image" src="https://user-images.githubusercontent.com/90178898/194026960-356a5b40-1203-4182-ad7b-89b1432bf0f6.png"> The Fleet Server / Agent changes are not there yet, though FS delivers the action, and Agents ack it (looks like default behavior for unkown actions as well) ### Confirmation modal Added a confirmation modal when clicking on action button everywhere, except for the `Request diagnostics` button on the Diagnostics page. Open question: - Do we want to display the confirmation window on the Diagnostics page button too? <img width="673" alt="image" src="https://user-images.githubusercontent.com/90178898/194065175-715b158e-0628-4bd9-86db-920c1ec9825e.png"> ### Download Generated file path to download in this format: `/api/fleet/agents/files/{fileId}/{fileName}` Decided not to try to use `files` plugin's API because it doesn't have the Fleet authorization around it. Screen recording demonstrating the download of an agent diagnostics zip file, that I uploaded using the Fleet Server upload API (using [Dan's pr](https://github.com/elastic/fleet-server/pull/1902) locally) https://user-images.githubusercontent.com/90178898/194287842-c7f09c9e-5310-460f-9cae-6fc7fa7750de.mov ### Notification Added toast message to show up when a diagnostics becomes ready, when we are on the Diagnostics tab. https://user-images.githubusercontent.com/90178898/194318170-e7ec66db-8bf8-4535-b07e-682397c2920c.mov ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
545ebb012d
commit
c7cdd00036
32 changed files with 1495 additions and 136 deletions
|
@ -127,6 +127,8 @@ export const AGENT_API_ROUTES = {
|
|||
BULK_UNENROLL_PATTERN: `${API_ROOT}/agents/bulk_unenroll`,
|
||||
REASSIGN_PATTERN: `${API_ROOT}/agents/{agentId}/reassign`,
|
||||
BULK_REASSIGN_PATTERN: `${API_ROOT}/agents/bulk_reassign`,
|
||||
REQUEST_DIAGNOSTICS_PATTERN: `${API_ROOT}/agents/{agentId}/request_diagnostics`,
|
||||
BULK_REQUEST_DIAGNOSTICS_PATTERN: `${API_ROOT}/agents/bulk_request_diagnostics`,
|
||||
AVAILABLE_VERSIONS_PATTERN: `${API_ROOT}/agents/available_versions`,
|
||||
STATUS_PATTERN: `${API_ROOT}/agent_status`,
|
||||
DATA_PATTERN: `${API_ROOT}/agent_status/data`,
|
||||
|
@ -137,6 +139,8 @@ export const AGENT_API_ROUTES = {
|
|||
CURRENT_UPGRADES_PATTERN: `${API_ROOT}/agents/current_upgrades`,
|
||||
ACTION_STATUS_PATTERN: `${API_ROOT}/agents/action_status`,
|
||||
LIST_TAGS_PATTERN: `${API_ROOT}/agents/tags`,
|
||||
LIST_UPLOADS_PATTERN: `${API_ROOT}/agents/{agentId}/uploads`,
|
||||
GET_UPLOAD_FILE_PATTERN: `${API_ROOT}/agents/files/{fileId}/{fileName}`,
|
||||
};
|
||||
|
||||
export const ENROLLMENT_API_KEY_ROUTES = {
|
||||
|
|
|
@ -15,6 +15,7 @@ export const allowedExperimentalValues = Object.freeze({
|
|||
createPackagePolicyMultiPageLayout: true,
|
||||
packageVerification: true,
|
||||
showDevtoolsRequest: true,
|
||||
showRequestDiagnostics: false,
|
||||
});
|
||||
|
||||
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;
|
||||
|
|
|
@ -206,6 +206,16 @@ export const agentRouteService = {
|
|||
AGENT_API_ROUTES.ACTIONS_PATTERN.replace('{agentId}', agentId),
|
||||
getListTagsPath: () => AGENT_API_ROUTES.LIST_TAGS_PATTERN,
|
||||
getAvailableVersionsPath: () => AGENT_API_ROUTES.AVAILABLE_VERSIONS_PATTERN,
|
||||
getRequestDiagnosticsPath: (agentId: string) =>
|
||||
AGENT_API_ROUTES.REQUEST_DIAGNOSTICS_PATTERN.replace('{agentId}', agentId),
|
||||
getBulkRequestDiagnosticsPath: () => AGENT_API_ROUTES.BULK_REQUEST_DIAGNOSTICS_PATTERN,
|
||||
getListAgentUploads: (agentId: string) =>
|
||||
AGENT_API_ROUTES.LIST_UPLOADS_PATTERN.replace('{agentId}', agentId),
|
||||
getAgentFileDownloadLink: (fileId: string, fileName: string) =>
|
||||
AGENT_API_ROUTES.GET_UPLOAD_FILE_PATTERN.replace('{fileId}', fileId).replace(
|
||||
'{fileName}',
|
||||
fileName
|
||||
),
|
||||
};
|
||||
|
||||
export const outputRoutesService = {
|
||||
|
|
|
@ -37,7 +37,8 @@ export type AgentActionType =
|
|||
| 'POLICY_REASSIGN'
|
||||
| 'CANCEL'
|
||||
| 'FORCE_UNENROLL'
|
||||
| 'UPDATE_TAGS';
|
||||
| 'UPDATE_TAGS'
|
||||
| 'REQUEST_DIAGNOSTICS';
|
||||
|
||||
type FleetServerAgentComponentStatusTuple = typeof FleetServerAgentComponentStatuses;
|
||||
export type FleetServerAgentComponentStatus = FleetServerAgentComponentStatusTuple[number];
|
||||
|
@ -142,6 +143,15 @@ export interface ActionStatus {
|
|||
creationTime: string;
|
||||
}
|
||||
|
||||
export interface AgentDiagnostics {
|
||||
id: string;
|
||||
name: string;
|
||||
createTime: string;
|
||||
filePath: string;
|
||||
status: 'READY' | 'AWAITING_UPLOAD' | 'DELETED' | 'IN_PROGRESS';
|
||||
actionId: string;
|
||||
}
|
||||
|
||||
// Generated from FleetServer schema.json
|
||||
export interface FleetServerAgentComponentUnit {
|
||||
id: string;
|
||||
|
|
|
@ -7,7 +7,14 @@
|
|||
|
||||
import type { SearchHit } from '@kbn/es-types';
|
||||
|
||||
import type { Agent, AgentAction, ActionStatus, CurrentUpgrade, NewAgentAction } from '../models';
|
||||
import type {
|
||||
Agent,
|
||||
AgentAction,
|
||||
ActionStatus,
|
||||
CurrentUpgrade,
|
||||
NewAgentAction,
|
||||
AgentDiagnostics,
|
||||
} from '../models';
|
||||
|
||||
import type { ListResult, ListWithKuery } from './common';
|
||||
|
||||
|
@ -38,6 +45,10 @@ export interface GetOneAgentResponse {
|
|||
item: Agent;
|
||||
}
|
||||
|
||||
export interface GetAgentUploadsResponse {
|
||||
items: AgentDiagnostics[];
|
||||
}
|
||||
|
||||
export interface PostNewAgentActionRequest {
|
||||
body: {
|
||||
action: Omit<NewAgentAction, 'agents'>;
|
||||
|
@ -121,6 +132,16 @@ export interface PostBulkAgentReassignRequest {
|
|||
};
|
||||
}
|
||||
|
||||
export type PostRequestDiagnosticsResponse = BulkAgentAction;
|
||||
export type PostBulkRequestDiagnosticsResponse = BulkAgentAction;
|
||||
|
||||
export interface PostRequestBulkDiagnosticsRequest {
|
||||
body: {
|
||||
agents: string[] | string;
|
||||
batchSize?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type PostBulkAgentReassignResponse = BulkAgentAction;
|
||||
|
||||
export type PostBulkUpdateAgentTagsResponse = BulkAgentAction;
|
||||
|
|
|
@ -19,6 +19,8 @@ import {
|
|||
} from '../../components';
|
||||
import { useAgentRefresh } from '../hooks';
|
||||
import { isAgentUpgradeable, policyHasFleetServer } from '../../../../services';
|
||||
import { AgentRequestDiagnosticsModal } from '../../components/agent_request_diagnostics_modal';
|
||||
import { ExperimentalFeaturesService } from '../../../../services';
|
||||
|
||||
export const AgentDetailsActionMenu: React.FunctionComponent<{
|
||||
agent: Agent;
|
||||
|
@ -32,9 +34,11 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{
|
|||
const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(assignFlyoutOpenByDefault);
|
||||
const [isUnenrollModalOpen, setIsUnenrollModalOpen] = useState(false);
|
||||
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
|
||||
const [isRequestDiagnosticsModalOpen, setIsRequestDiagnosticsModalOpen] = useState(false);
|
||||
const isUnenrolling = agent.status === 'unenrolling';
|
||||
|
||||
const hasFleetServer = agentPolicy && policyHasFleetServer(agentPolicy);
|
||||
const { showRequestDiagnostics } = ExperimentalFeaturesService.get();
|
||||
|
||||
const onClose = useMemo(() => {
|
||||
if (onCancelReassign) {
|
||||
|
@ -44,6 +48,70 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{
|
|||
}
|
||||
}, [onCancelReassign, setIsReassignFlyoutOpen]);
|
||||
|
||||
const menuItems = [
|
||||
<EuiContextMenuItem
|
||||
icon="pencil"
|
||||
onClick={() => {
|
||||
setIsReassignFlyoutOpen(true);
|
||||
}}
|
||||
disabled={!agent.active}
|
||||
key="reassignPolicy"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentList.reassignActionText"
|
||||
defaultMessage="Assign to new policy"
|
||||
/>
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
icon="trash"
|
||||
disabled={!hasFleetAllPrivileges || !agent.active}
|
||||
onClick={() => {
|
||||
setIsUnenrollModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{isUnenrolling ? (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentList.forceUnenrollOneButton"
|
||||
defaultMessage="Force unenroll"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentList.unenrollOneButton"
|
||||
defaultMessage="Unenroll agent"
|
||||
/>
|
||||
)}
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
icon="refresh"
|
||||
disabled={!isAgentUpgradeable(agent, kibanaVersion)}
|
||||
onClick={() => {
|
||||
setIsUpgradeModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentList.upgradeOneButton"
|
||||
defaultMessage="Upgrade agent"
|
||||
/>
|
||||
</EuiContextMenuItem>,
|
||||
];
|
||||
|
||||
if (showRequestDiagnostics) {
|
||||
menuItems.push(
|
||||
<EuiContextMenuItem
|
||||
icon="download"
|
||||
disabled={!hasFleetAllPrivileges}
|
||||
onClick={() => {
|
||||
setIsRequestDiagnosticsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentList.diagnosticsOneButton"
|
||||
defaultMessage="Request diagnostics .zip"
|
||||
/>
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isReassignFlyoutOpen && (
|
||||
|
@ -77,6 +145,17 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{
|
|||
/>
|
||||
</EuiPortal>
|
||||
)}
|
||||
{isRequestDiagnosticsModalOpen && (
|
||||
<EuiPortal>
|
||||
<AgentRequestDiagnosticsModal
|
||||
agents={[agent]}
|
||||
agentCount={1}
|
||||
onClose={() => {
|
||||
setIsRequestDiagnosticsModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</EuiPortal>
|
||||
)}
|
||||
<ContextMenuActions
|
||||
button={{
|
||||
props: { iconType: 'arrowDown', iconSide: 'right', color: 'primary' },
|
||||
|
@ -87,52 +166,7 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{
|
|||
/>
|
||||
),
|
||||
}}
|
||||
items={[
|
||||
<EuiContextMenuItem
|
||||
icon="pencil"
|
||||
onClick={() => {
|
||||
setIsReassignFlyoutOpen(true);
|
||||
}}
|
||||
disabled={!agent.active}
|
||||
key="reassignPolicy"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentList.reassignActionText"
|
||||
defaultMessage="Assign to new policy"
|
||||
/>
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
icon="trash"
|
||||
disabled={!hasFleetAllPrivileges || !agent.active}
|
||||
onClick={() => {
|
||||
setIsUnenrollModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{isUnenrolling ? (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentList.forceUnenrollOneButton"
|
||||
defaultMessage="Force unenroll"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentList.unenrollOneButton"
|
||||
defaultMessage="Unenroll agent"
|
||||
/>
|
||||
)}
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
icon="refresh"
|
||||
disabled={!isAgentUpgradeable(agent, kibanaVersion)}
|
||||
onClick={() => {
|
||||
setIsUpgradeModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentList.upgradeOneButton"
|
||||
defaultMessage="Upgrade agent"
|
||||
/>
|
||||
</EuiContextMenuItem>,
|
||||
]}
|
||||
items={menuItems}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,218 @@
|
|||
/*
|
||||
* 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 { EuiTableFieldDataColumnType } from '@elastic/eui';
|
||||
import {
|
||||
EuiBasicTable,
|
||||
EuiButton,
|
||||
EuiCallOut,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIcon,
|
||||
EuiLink,
|
||||
EuiLoadingContent,
|
||||
EuiLoadingSpinner,
|
||||
EuiText,
|
||||
formatDate,
|
||||
} from '@elastic/eui';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import {
|
||||
sendGetAgentUploads,
|
||||
sendPostRequestDiagnostics,
|
||||
useLink,
|
||||
useStartServices,
|
||||
} from '../../../../../hooks';
|
||||
import type { AgentDiagnostics, Agent } from '../../../../../../../../common/types/models';
|
||||
|
||||
const FlexStartEuiFlexItem = styled(EuiFlexItem)`
|
||||
align-self: flex-start;
|
||||
`;
|
||||
|
||||
export interface AgentDiagnosticsProps {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
export const AgentDiagnosticsTab: React.FunctionComponent<AgentDiagnosticsProps> = ({ agent }) => {
|
||||
const { notifications } = useStartServices();
|
||||
const { getAbsolutePath } = useLink();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [diagnosticsEntries, setDiagnosticEntries] = useState<AgentDiagnostics[]>([]);
|
||||
const [prevDiagnosticsEntries, setPrevDiagnosticEntries] = useState<AgentDiagnostics[]>([]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const uploadsResponse = await sendGetAgentUploads(agent.id);
|
||||
const error = uploadsResponse.error;
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
if (!uploadsResponse.data) {
|
||||
throw new Error('No data');
|
||||
}
|
||||
setDiagnosticEntries(uploadsResponse.data.items);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
notifications.toasts.addError(err, {
|
||||
title: i18n.translate(
|
||||
'xpack.fleet.requestDiagnostics.errorLoadingUploadsNotificationTitle',
|
||||
{
|
||||
defaultMessage: 'Error loading diagnostics uploads',
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [agent.id, notifications.toasts]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const interval: ReturnType<typeof setInterval> | null = setInterval(async () => {
|
||||
loadData();
|
||||
}, 10000);
|
||||
|
||||
const cleanup = () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
|
||||
return cleanup;
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
setPrevDiagnosticEntries(diagnosticsEntries);
|
||||
if (prevDiagnosticsEntries.length > 0) {
|
||||
diagnosticsEntries
|
||||
.filter((newEntry) => {
|
||||
const oldEntry = prevDiagnosticsEntries.find((entry) => entry.id === newEntry.id);
|
||||
return newEntry.status === 'READY' && (!oldEntry || oldEntry?.status !== 'READY');
|
||||
})
|
||||
.forEach((entry) => {
|
||||
notifications.toasts.addSuccess(
|
||||
{
|
||||
title: i18n.translate('xpack.fleet.requestDiagnostics.readyNotificationTitle', {
|
||||
defaultMessage: 'Agent diagnostics {name} ready',
|
||||
values: {
|
||||
name: entry.name,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ toastLifeTimeMs: 5000 }
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [prevDiagnosticsEntries, diagnosticsEntries, notifications.toasts]);
|
||||
|
||||
const columns: Array<EuiTableFieldDataColumnType<AgentDiagnostics>> = [
|
||||
{
|
||||
field: 'id',
|
||||
name: 'File',
|
||||
render: (id: string) => {
|
||||
const currentItem = diagnosticsEntries.find((item) => item.id === id);
|
||||
return currentItem?.status === 'READY' ? (
|
||||
<EuiLink href={getAbsolutePath(currentItem?.filePath)} download target="_blank">
|
||||
<EuiIcon type="download" /> {currentItem?.name}
|
||||
</EuiLink>
|
||||
) : currentItem?.status === 'IN_PROGRESS' || currentItem?.status === 'AWAITING_UPLOAD' ? (
|
||||
<EuiText color="subdued">
|
||||
<EuiLoadingSpinner />
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.requestDiagnostics.generatingText"
|
||||
defaultMessage="Generating diagnostics file..."
|
||||
/>
|
||||
</EuiText>
|
||||
) : (
|
||||
<EuiText color="subdued">
|
||||
<EuiIcon type="alert" color="red" />
|
||||
{currentItem?.name}
|
||||
</EuiText>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'id',
|
||||
name: 'Date',
|
||||
dataType: 'date',
|
||||
render: (id: string) => {
|
||||
const currentItem = diagnosticsEntries.find((item) => item.id === id);
|
||||
return (
|
||||
<EuiText color={currentItem?.status === 'READY' ? 'default' : 'subdued'}>
|
||||
{formatDate(currentItem?.createTime, 'll')}
|
||||
</EuiText>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function onSubmit() {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const { error } = await sendPostRequestDiagnostics(agent.id);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
const successMessage = i18n.translate(
|
||||
'xpack.fleet.requestDiagnostics.successSingleNotificationTitle',
|
||||
{
|
||||
defaultMessage: 'Request diagnostics submitted',
|
||||
}
|
||||
);
|
||||
notifications.toasts.addSuccess(successMessage);
|
||||
} catch (error) {
|
||||
setIsSubmitting(false);
|
||||
notifications.toasts.addError(error, {
|
||||
title: i18n.translate('xpack.fleet.requestDiagnostics.fatalErrorNotificationTitle', {
|
||||
defaultMessage:
|
||||
'Error requesting diagnostics {count, plural, one {agent} other {agents}}',
|
||||
values: { count: 1 },
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="l">
|
||||
<EuiFlexItem>
|
||||
<EuiCallOut
|
||||
iconType="alert"
|
||||
color="warning"
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.fleetServerSetup.calloutTitle"
|
||||
defaultMessage="Agent diagnostics"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.requestDiagnostics.calloutText"
|
||||
defaultMessage="Diagnostics files are stored in Elasticsearch, and as such can incur storage costs. Fleet will automatically remove old diagnostics files after 30 days."
|
||||
/>
|
||||
</EuiCallOut>
|
||||
</EuiFlexItem>
|
||||
<FlexStartEuiFlexItem>
|
||||
<EuiButton fill size="m" onClick={onSubmit} disabled={isSubmitting}>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentList.diagnosticsOneButton"
|
||||
defaultMessage="Request diagnostics .zip"
|
||||
/>
|
||||
</EuiButton>
|
||||
</FlexStartEuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
{isLoading ? (
|
||||
<EuiLoadingContent lines={3} />
|
||||
) : (
|
||||
<EuiBasicTable<AgentDiagnostics> items={diagnosticsEntries} columns={columns} />
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -9,3 +9,4 @@ export { AgentLogs } from './agent_logs';
|
|||
export { AgentDetailsActionMenu } from './actions_menu';
|
||||
export { AgentDetailsContent } from './agent_details';
|
||||
export { AgentDashboardLink } from './agent_dashboard_link';
|
||||
export { AgentDiagnosticsTab } from './agent_diagnostics';
|
||||
|
|
|
@ -25,12 +25,15 @@ import {
|
|||
} from '../../../hooks';
|
||||
import { WithHeaderLayout } from '../../../layouts';
|
||||
|
||||
import { ExperimentalFeaturesService } from '../../../services';
|
||||
|
||||
import { AgentRefreshContext } from './hooks';
|
||||
import {
|
||||
AgentLogs,
|
||||
AgentDetailsActionMenu,
|
||||
AgentDetailsContent,
|
||||
AgentDashboardLink,
|
||||
AgentDiagnosticsTab,
|
||||
} from './components';
|
||||
|
||||
export const AgentDetailsPage: React.FunctionComponent = () => {
|
||||
|
@ -65,6 +68,7 @@ export const AgentDetailsPage: React.FunctionComponent = () => {
|
|||
navigateToApp(routeState.onDoneNavigateTo[0], routeState.onDoneNavigateTo[1]);
|
||||
}
|
||||
}, [routeState, navigateToApp]);
|
||||
const { showRequestDiagnostics } = ExperimentalFeaturesService.get();
|
||||
|
||||
const host = agentData?.item?.local_metadata?.host;
|
||||
|
||||
|
@ -134,7 +138,7 @@ export const AgentDetailsPage: React.FunctionComponent = () => {
|
|||
);
|
||||
|
||||
const headerTabs = useMemo(() => {
|
||||
return [
|
||||
const tabs = [
|
||||
{
|
||||
id: 'details',
|
||||
name: i18n.translate('xpack.fleet.agentDetails.subTabs.detailsTab', {
|
||||
|
@ -152,7 +156,18 @@ export const AgentDetailsPage: React.FunctionComponent = () => {
|
|||
isSelected: tabId === 'logs',
|
||||
},
|
||||
];
|
||||
}, [getHref, agentId, tabId]);
|
||||
if (showRequestDiagnostics) {
|
||||
tabs.push({
|
||||
id: 'diagnostics',
|
||||
name: i18n.translate('xpack.fleet.agentDetails.subTabs.diagnosticsTab', {
|
||||
defaultMessage: 'Diagnostics',
|
||||
}),
|
||||
href: getHref('agent_details_diagnostics', { agentId, tabId: 'diagnostics' }),
|
||||
isSelected: tabId === 'diagnostics',
|
||||
});
|
||||
}
|
||||
return tabs;
|
||||
}, [getHref, agentId, tabId, showRequestDiagnostics]);
|
||||
|
||||
return (
|
||||
<AgentRefreshContext.Provider
|
||||
|
@ -222,6 +237,12 @@ const AgentDetailsPageContent: React.FunctionComponent<{
|
|||
return <AgentLogs agent={agent} agentPolicy={agentPolicy} />;
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
path={FLEET_ROUTING_PATHS.agent_details_diagnostics}
|
||||
render={() => {
|
||||
return <AgentDiagnosticsTab agent={agent} />;
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
path={FLEET_ROUTING_PATHS.agent_details}
|
||||
render={() => {
|
||||
|
|
|
@ -254,6 +254,11 @@ const actionNames: {
|
|||
cancelledText: 'update tags',
|
||||
},
|
||||
CANCEL: { inProgressText: 'Cancelling', completedText: 'cancelled', cancelledText: '' },
|
||||
REQUEST_DIAGNOSTICS: {
|
||||
inProgressText: 'Requesting diagnostics for',
|
||||
completedText: 'requested diagnostics',
|
||||
cancelledText: 'request diagnostics',
|
||||
},
|
||||
SETTINGS: {
|
||||
inProgressText: 'Updating settings of',
|
||||
completedText: 'updated settings',
|
||||
|
|
|
@ -25,9 +25,12 @@ import {
|
|||
} from '../../components';
|
||||
import { useLicense } from '../../../../hooks';
|
||||
import { LICENSE_FOR_SCHEDULE_UPGRADE } from '../../../../../../../common/constants';
|
||||
import { ExperimentalFeaturesService } from '../../../../services';
|
||||
|
||||
import { getCommonTags } from '../utils';
|
||||
|
||||
import { AgentRequestDiagnosticsModal } from '../../components/agent_request_diagnostics_modal';
|
||||
|
||||
import type { SelectionMode } from './types';
|
||||
import { TagsAddRemove } from './tags_add_remove';
|
||||
|
||||
|
@ -67,6 +70,8 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
|
|||
const [isUnenrollModalOpen, setIsUnenrollModalOpen] = useState<boolean>(false);
|
||||
const [updateModalState, setUpgradeModalState] = useState({ isOpen: false, isScheduled: false });
|
||||
const [isTagAddVisible, setIsTagAddVisible] = useState<boolean>(false);
|
||||
const [isRequestDiagnosticsModalOpen, setIsRequestDiagnosticsModalOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
// Check if user is working with only inactive agents
|
||||
const atLeastOneActiveAgentSelected =
|
||||
|
@ -77,96 +82,120 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
|
|||
const agentCount = selectionMode === 'manual' ? selectedAgents.length : totalActiveAgents;
|
||||
const agents = selectionMode === 'manual' ? selectedAgents : currentQuery;
|
||||
const [tagsPopoverButton, setTagsPopoverButton] = useState<HTMLElement>();
|
||||
const { showRequestDiagnostics } = ExperimentalFeaturesService.get();
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentBulkActions.addRemoveTags"
|
||||
data-test-subj="agentBulkActionsAddRemoveTags"
|
||||
defaultMessage="Add / remove tags"
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="tag" size="m" />,
|
||||
disabled: !atLeastOneActiveAgentSelected,
|
||||
onClick: (event: any) => {
|
||||
setTagsPopoverButton((event.target as Element).closest('button')!);
|
||||
setIsTagAddVisible(!isTagAddVisible);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentBulkActions.reassignPolicy"
|
||||
data-test-subj="agentBulkActionsReassign"
|
||||
defaultMessage="Assign to new policy"
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="pencil" size="m" />,
|
||||
disabled: !atLeastOneActiveAgentSelected,
|
||||
onClick: () => {
|
||||
closeMenu();
|
||||
setIsReassignFlyoutOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentBulkActions.unenrollAgents"
|
||||
data-test-subj="agentBulkActionsUnenroll"
|
||||
defaultMessage="Unenroll {agentCount, plural, one {# agent} other {# agents}}"
|
||||
values={{
|
||||
agentCount,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="trash" size="m" />,
|
||||
disabled: !atLeastOneActiveAgentSelected,
|
||||
onClick: () => {
|
||||
closeMenu();
|
||||
setIsUnenrollModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentBulkActions.upgradeAgents"
|
||||
data-test-subj="agentBulkActionsUpgrade"
|
||||
defaultMessage="Upgrade {agentCount, plural, one {# agent} other {# agents}}"
|
||||
values={{
|
||||
agentCount,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="refresh" size="m" />,
|
||||
disabled: !atLeastOneActiveAgentSelected,
|
||||
onClick: () => {
|
||||
closeMenu();
|
||||
setUpgradeModalState({ isOpen: true, isScheduled: false });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentBulkActions.scheduleUpgradeAgents"
|
||||
data-test-subj="agentBulkActionsScheduleUpgrade"
|
||||
defaultMessage="Schedule upgrade for {agentCount, plural, one {# agent} other {# agents}}"
|
||||
values={{
|
||||
agentCount,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="timeRefresh" size="m" />,
|
||||
disabled: !atLeastOneActiveAgentSelected || !isLicenceAllowingScheduleUpgrade,
|
||||
onClick: () => {
|
||||
closeMenu();
|
||||
setUpgradeModalState({ isOpen: true, isScheduled: true });
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (showRequestDiagnostics) {
|
||||
menuItems.push({
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentBulkActions.requestDiagnostics"
|
||||
data-test-subj="agentBulkActionsRequestDiagnostics"
|
||||
defaultMessage="Request diagnostics for {agentCount, plural, one {# agent} other {# agents}}"
|
||||
values={{
|
||||
agentCount,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="trash" size="m" />,
|
||||
disabled: !atLeastOneActiveAgentSelected,
|
||||
onClick: () => {
|
||||
closeMenu();
|
||||
setIsRequestDiagnosticsModalOpen(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const panels = [
|
||||
{
|
||||
id: 0,
|
||||
items: [
|
||||
{
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentBulkActions.addRemoveTags"
|
||||
data-test-subj="agentBulkActionsAddRemoveTags"
|
||||
defaultMessage="Add / remove tags"
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="tag" size="m" />,
|
||||
disabled: !atLeastOneActiveAgentSelected,
|
||||
onClick: (event: any) => {
|
||||
setTagsPopoverButton((event.target as Element).closest('button')!);
|
||||
setIsTagAddVisible(!isTagAddVisible);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentBulkActions.reassignPolicy"
|
||||
data-test-subj="agentBulkActionsReassign"
|
||||
defaultMessage="Assign to new policy"
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="pencil" size="m" />,
|
||||
disabled: !atLeastOneActiveAgentSelected,
|
||||
onClick: () => {
|
||||
closeMenu();
|
||||
setIsReassignFlyoutOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentBulkActions.unenrollAgents"
|
||||
data-test-subj="agentBulkActionsUnenroll"
|
||||
defaultMessage="Unenroll {agentCount, plural, one {# agent} other {# agents}}"
|
||||
values={{
|
||||
agentCount,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="trash" size="m" />,
|
||||
disabled: !atLeastOneActiveAgentSelected,
|
||||
onClick: () => {
|
||||
closeMenu();
|
||||
setIsUnenrollModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentBulkActions.upgradeAgents"
|
||||
data-test-subj="agentBulkActionsUpgrade"
|
||||
defaultMessage="Upgrade {agentCount, plural, one {# agent} other {# agents}}"
|
||||
values={{
|
||||
agentCount,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="refresh" size="m" />,
|
||||
disabled: !atLeastOneActiveAgentSelected,
|
||||
onClick: () => {
|
||||
closeMenu();
|
||||
setUpgradeModalState({ isOpen: true, isScheduled: false });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentBulkActions.scheduleUpgradeAgents"
|
||||
data-test-subj="agentBulkActionsScheduleUpgrade"
|
||||
defaultMessage="Schedule upgrade for {agentCount, plural, one {# agent} other {# agents}}"
|
||||
values={{
|
||||
agentCount,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="timeRefresh" size="m" />,
|
||||
disabled: !atLeastOneActiveAgentSelected || !isLicenceAllowingScheduleUpgrade,
|
||||
onClick: () => {
|
||||
closeMenu();
|
||||
setUpgradeModalState({ isOpen: true, isScheduled: true });
|
||||
},
|
||||
},
|
||||
],
|
||||
items: menuItems,
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -228,6 +257,17 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{isRequestDiagnosticsModalOpen && (
|
||||
<EuiPortal>
|
||||
<AgentRequestDiagnosticsModal
|
||||
agents={agents}
|
||||
agentCount={agentCount}
|
||||
onClose={() => {
|
||||
setIsRequestDiagnosticsModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</EuiPortal>
|
||||
)}
|
||||
<EuiFlexGroup gutterSize="m" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPopover
|
||||
|
|
|
@ -20,6 +20,8 @@ import { FleetStatusProvider, ConfigContext, KibanaVersionContext } from '../../
|
|||
|
||||
import { getMockTheme } from '../../../../../../mocks';
|
||||
|
||||
import { ExperimentalFeaturesService } from '../../../../services';
|
||||
|
||||
import { SearchAndFilterBar } from './search_and_filter_bar';
|
||||
|
||||
const mockTheme = getMockTheme({
|
||||
|
@ -49,6 +51,14 @@ const TestComponent = (props: any) => (
|
|||
);
|
||||
|
||||
describe('SearchAndFilterBar', () => {
|
||||
beforeAll(() => {
|
||||
ExperimentalFeaturesService.init({
|
||||
createPackagePolicyMultiPageLayout: true,
|
||||
packageVerification: true,
|
||||
showDevtoolsRequest: false,
|
||||
showRequestDiagnostics: false,
|
||||
});
|
||||
});
|
||||
it('should show no Actions button when no agent is selected', async () => {
|
||||
const selectedAgents: Agent[] = [];
|
||||
const props: any = {
|
||||
|
|
|
@ -13,6 +13,7 @@ import type { Agent, AgentPolicy } from '../../../../types';
|
|||
import { useAuthz, useLink, useKibanaVersion } from '../../../../hooks';
|
||||
import { ContextMenuActions } from '../../../../components';
|
||||
import { isAgentUpgradeable } from '../../../../services';
|
||||
import { ExperimentalFeaturesService } from '../../../../services';
|
||||
|
||||
export const TableRowActions: React.FunctionComponent<{
|
||||
agent: Agent;
|
||||
|
@ -21,6 +22,7 @@ export const TableRowActions: React.FunctionComponent<{
|
|||
onUnenrollClick: () => void;
|
||||
onUpgradeClick: () => void;
|
||||
onAddRemoveTagsClick: (button: HTMLElement) => void;
|
||||
onRequestDiagnosticsClick: () => void;
|
||||
}> = ({
|
||||
agent,
|
||||
agentPolicy,
|
||||
|
@ -28,6 +30,7 @@ export const TableRowActions: React.FunctionComponent<{
|
|||
onUnenrollClick,
|
||||
onUpgradeClick,
|
||||
onAddRemoveTagsClick,
|
||||
onRequestDiagnosticsClick,
|
||||
}) => {
|
||||
const { getHref } = useLink();
|
||||
const hasFleetAllPrivileges = useAuthz().fleet.all;
|
||||
|
@ -35,6 +38,7 @@ export const TableRowActions: React.FunctionComponent<{
|
|||
const isUnenrolling = agent.status === 'unenrolling';
|
||||
const kibanaVersion = useKibanaVersion();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { showRequestDiagnostics } = ExperimentalFeaturesService.get();
|
||||
const menuItems = [
|
||||
<EuiContextMenuItem
|
||||
icon="inspect"
|
||||
|
@ -105,6 +109,23 @@ export const TableRowActions: React.FunctionComponent<{
|
|||
/>
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
|
||||
if (showRequestDiagnostics) {
|
||||
menuItems.push(
|
||||
<EuiContextMenuItem
|
||||
icon="download"
|
||||
disabled={!hasFleetAllPrivileges}
|
||||
onClick={() => {
|
||||
onRequestDiagnosticsClick();
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentList.diagnosticsOneButton"
|
||||
defaultMessage="Request diagnostics .zip"
|
||||
/>
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<ContextMenuActions
|
||||
|
|
|
@ -51,6 +51,8 @@ import {
|
|||
} from '../components';
|
||||
import { useFleetServerUnhealthy } from '../hooks/use_fleet_server_unhealthy';
|
||||
|
||||
import { AgentRequestDiagnosticsModal } from '../components/agent_request_diagnostics_modal';
|
||||
|
||||
import { AgentTableHeader } from './components/table_header';
|
||||
import type { SelectionMode } from './components/types';
|
||||
import { SearchAndFilterBar } from './components/search_and_filter_bar';
|
||||
|
@ -146,6 +148,9 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
|
|||
const [agentToAddRemoveTags, setAgentToAddRemoveTags] = useState<Agent | undefined>(undefined);
|
||||
const [tagsPopoverButton, setTagsPopoverButton] = useState<HTMLElement>();
|
||||
const [showTagsAddRemove, setShowTagsAddRemove] = useState(false);
|
||||
const [agentToRequestDiagnostics, setAgentToRequestDiagnostics] = useState<Agent | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
// Kuery
|
||||
const kuery = useMemo(() => {
|
||||
|
@ -538,6 +543,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
|
|||
setAgentToAddRemoveTags(agent);
|
||||
setShowTagsAddRemove(!showTagsAddRemove);
|
||||
}}
|
||||
onRequestDiagnosticsClick={() => setAgentToRequestDiagnostics(agent)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
@ -612,6 +618,17 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
|
|||
/>
|
||||
</EuiPortal>
|
||||
)}
|
||||
{agentToRequestDiagnostics && (
|
||||
<EuiPortal>
|
||||
<AgentRequestDiagnosticsModal
|
||||
agents={[agentToRequestDiagnostics]}
|
||||
agentCount={1}
|
||||
onClose={() => {
|
||||
setAgentToRequestDiagnostics(undefined);
|
||||
}}
|
||||
/>
|
||||
</EuiPortal>
|
||||
)}
|
||||
{showTagsAddRemove && (
|
||||
<TagsAddRemove
|
||||
agentId={agentToAddRemoveTags?.id!}
|
||||
|
|
|
@ -0,0 +1,127 @@
|
|||
/*
|
||||
* 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, { useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiConfirmModal } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
import type { Agent } from '../../../../types';
|
||||
import {
|
||||
sendPostRequestDiagnostics,
|
||||
sendPostBulkRequestDiagnostics,
|
||||
useStartServices,
|
||||
useLink,
|
||||
} from '../../../../hooks';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
agents: Agent[] | string;
|
||||
agentCount: number;
|
||||
}
|
||||
|
||||
export const AgentRequestDiagnosticsModal: React.FunctionComponent<Props> = ({
|
||||
onClose,
|
||||
agents,
|
||||
agentCount,
|
||||
}) => {
|
||||
const { notifications } = useStartServices();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const isSingleAgent = Array.isArray(agents) && agents.length === 1;
|
||||
const { getPath } = useLink();
|
||||
const history = useHistory();
|
||||
|
||||
async function onSubmit() {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const { error } = isSingleAgent
|
||||
? await sendPostRequestDiagnostics((agents[0] as Agent).id)
|
||||
: await sendPostBulkRequestDiagnostics({
|
||||
agents: typeof agents === 'string' ? agents : agents.map((agent) => agent.id),
|
||||
});
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
const successMessage = i18n.translate(
|
||||
'xpack.fleet.requestDiagnostics.successSingleNotificationTitle',
|
||||
{
|
||||
defaultMessage: 'Request diagnostics submitted',
|
||||
}
|
||||
);
|
||||
notifications.toasts.addSuccess(successMessage);
|
||||
|
||||
if (isSingleAgent) {
|
||||
const path = getPath('agent_details_diagnostics', { agentId: (agents[0] as Agent).id });
|
||||
history.push(path);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setIsSubmitting(false);
|
||||
notifications.toasts.addError(error, {
|
||||
title: i18n.translate('xpack.fleet.requestDiagnostics.fatalErrorNotificationTitle', {
|
||||
defaultMessage:
|
||||
'Error requesting diagnostics {count, plural, one {agent} other {agents}}',
|
||||
values: { count: agentCount },
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiConfirmModal
|
||||
data-test-subj="requestDiagnosticsModal"
|
||||
title={
|
||||
isSingleAgent ? (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.requestDiagnostics.singleTitle"
|
||||
defaultMessage="Request diagnostics"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.requestDiagnostics.multipleTitle"
|
||||
defaultMessage="Request diagnostics for {count} agents"
|
||||
values={{ count: agentCount }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onCancel={onClose}
|
||||
onConfirm={onSubmit}
|
||||
cancelButtonText={
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.requestDiagnostics.cancelButtonLabel"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
}
|
||||
confirmButtonDisabled={isSubmitting}
|
||||
confirmButtonText={
|
||||
isSingleAgent ? (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.requestDiagnostics.confirmSingleButtonLabel"
|
||||
defaultMessage="Request diagnostics"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.requestDiagnostics.confirmMultipleButtonLabel"
|
||||
defaultMessage="Request diagnostics for {count} agents"
|
||||
values={{ count: agentCount }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
buttonColor="primary"
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.requestDiagnostics.description"
|
||||
defaultMessage="Diagnostics files are stored in Elasticsearch, and as such can incur storage costs. Fleet will automatically remove old diagnostics files after 30 days."
|
||||
/>
|
||||
</p>
|
||||
</EuiConfirmModal>
|
||||
);
|
||||
};
|
|
@ -41,6 +41,7 @@ export type DynamicPage =
|
|||
| 'agent_list'
|
||||
| 'agent_details'
|
||||
| 'agent_details_logs'
|
||||
| 'agent_details_diagnostics'
|
||||
| 'settings_edit_outputs'
|
||||
| 'settings_edit_download_sources'
|
||||
| 'settings_edit_fleet_server_hosts';
|
||||
|
@ -61,6 +62,7 @@ export const FLEET_ROUTING_PATHS = {
|
|||
agents: '/agents',
|
||||
agent_details: '/agents/:agentId/:tabId?',
|
||||
agent_details_logs: '/agents/:agentId/logs',
|
||||
agent_details_diagnostics: '/agents/:agentId/diagnostics',
|
||||
policies: '/policies',
|
||||
policies_list: '/policies',
|
||||
policy_details: '/policies/:policyId/:tabId?',
|
||||
|
@ -199,6 +201,7 @@ export const pagePathGetters: {
|
|||
`/agents/${agentId}${tabId ? `/${tabId}` : ''}${logQuery ? `?_q=${logQuery}` : ''}`,
|
||||
],
|
||||
agent_details_logs: ({ agentId }) => [FLEET_BASE_PATH, `/agents/${agentId}/logs`],
|
||||
agent_details_diagnostics: ({ agentId }) => [FLEET_BASE_PATH, `/agents/${agentId}/diagnostics`],
|
||||
enrollment_tokens: () => [FLEET_BASE_PATH, '/enrollment-tokens'],
|
||||
data_streams: () => [FLEET_BASE_PATH, '/data-streams'],
|
||||
settings: () => [FLEET_BASE_PATH, FLEET_ROUTING_PATHS.settings],
|
||||
|
|
|
@ -8,7 +8,11 @@
|
|||
import type {
|
||||
GetActionStatusResponse,
|
||||
GetAgentTagsResponse,
|
||||
GetAgentUploadsResponse,
|
||||
PostBulkRequestDiagnosticsResponse,
|
||||
PostBulkUpdateAgentTagsRequest,
|
||||
PostRequestBulkDiagnosticsRequest,
|
||||
PostRequestDiagnosticsResponse,
|
||||
UpdateAgentRequest,
|
||||
} from '../../../common/types';
|
||||
|
||||
|
@ -171,6 +175,42 @@ export function sendPostAgentUpgrade(
|
|||
});
|
||||
}
|
||||
|
||||
export function sendPostRequestDiagnostics(agentId: string, options?: RequestOptions) {
|
||||
return sendRequest<PostRequestDiagnosticsResponse>({
|
||||
path: agentRouteService.getRequestDiagnosticsPath(agentId),
|
||||
method: 'post',
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function sendPostBulkRequestDiagnostics(
|
||||
body: PostRequestBulkDiagnosticsRequest['body'],
|
||||
options?: RequestOptions
|
||||
) {
|
||||
return sendRequest<PostBulkRequestDiagnosticsResponse>({
|
||||
path: agentRouteService.getBulkRequestDiagnosticsPath(),
|
||||
method: 'post',
|
||||
body,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function sendGetAgentUploads(agentId: string, options?: RequestOptions) {
|
||||
return sendRequest<GetAgentUploadsResponse>({
|
||||
path: agentRouteService.getListAgentUploads(agentId),
|
||||
method: 'get',
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export const useGetAgentUploads = (agentId: string, options?: RequestOptions) => {
|
||||
return useRequest<GetAgentUploadsResponse>({
|
||||
path: agentRouteService.getListAgentUploads(agentId),
|
||||
method: 'get',
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export function sendPostAgentAction(
|
||||
agentId: string,
|
||||
body: PostNewAgentActionRequest['body'],
|
||||
|
|
|
@ -68,6 +68,7 @@ export const createFleetTestRendererMock = (): TestRenderer => {
|
|||
createPackagePolicyMultiPageLayout: true,
|
||||
packageVerification: true,
|
||||
showDevtoolsRequest: false,
|
||||
showRequestDiagnostics: false,
|
||||
});
|
||||
|
||||
const HookWrapper = memo(({ children }) => {
|
||||
|
|
|
@ -28,6 +28,7 @@ import type {
|
|||
GetAgentTagsResponse,
|
||||
GetAvailableVersionsResponse,
|
||||
GetActionStatusResponse,
|
||||
GetAgentUploadsResponse,
|
||||
} from '../../../common/types';
|
||||
import type {
|
||||
GetAgentsRequestSchema,
|
||||
|
@ -41,6 +42,7 @@ import type {
|
|||
PostBulkAgentReassignRequestSchema,
|
||||
PostBulkUpdateAgentTagsRequestSchema,
|
||||
GetActionStatusRequestSchema,
|
||||
GetAgentUploadFileRequestSchema,
|
||||
} from '../../types';
|
||||
import { defaultFleetErrorHandler } from '../../errors';
|
||||
import * as AgentService from '../../services/agents';
|
||||
|
@ -362,3 +364,37 @@ export const getActionStatusHandler: RequestHandler<
|
|||
return defaultFleetErrorHandler({ error, response });
|
||||
}
|
||||
};
|
||||
|
||||
export const getAgentUploadsHandler: RequestHandler<
|
||||
TypeOf<typeof GetOneAgentRequestSchema.params>
|
||||
> = async (context, request, response) => {
|
||||
const coreContext = await context.core;
|
||||
const esClient = coreContext.elasticsearch.client.asInternalUser;
|
||||
try {
|
||||
const body: GetAgentUploadsResponse = {
|
||||
items: await AgentService.getAgentUploads(esClient, request.params.agentId),
|
||||
};
|
||||
|
||||
return response.ok({ body });
|
||||
} catch (error) {
|
||||
return defaultFleetErrorHandler({ error, response });
|
||||
}
|
||||
};
|
||||
|
||||
export const getAgentUploadFileHandler: RequestHandler<
|
||||
TypeOf<typeof GetAgentUploadFileRequestSchema.params>
|
||||
> = async (context, request, response) => {
|
||||
const coreContext = await context.core;
|
||||
const esClient = coreContext.elasticsearch.client.asInternalUser;
|
||||
try {
|
||||
const resp = await AgentService.getAgentUploadFile(
|
||||
esClient,
|
||||
request.params.fileId,
|
||||
request.params.fileName
|
||||
);
|
||||
|
||||
return response.ok(resp);
|
||||
} catch (error) {
|
||||
return defaultFleetErrorHandler({ error, response });
|
||||
}
|
||||
};
|
||||
|
|
|
@ -23,6 +23,10 @@ import {
|
|||
PostBulkAgentUpgradeRequestSchema,
|
||||
PostCancelActionRequestSchema,
|
||||
GetActionStatusRequestSchema,
|
||||
PostRequestDiagnosticsActionRequestSchema,
|
||||
PostBulkRequestDiagnosticsActionRequestSchema,
|
||||
ListAgentUploadsRequestSchema,
|
||||
GetAgentUploadFileRequestSchema,
|
||||
} from '../../types';
|
||||
import * as AgentService from '../../services/agents';
|
||||
import type { FleetConfigType } from '../..';
|
||||
|
@ -43,6 +47,8 @@ import {
|
|||
bulkUpdateAgentTagsHandler,
|
||||
getAvailableVersionsHandler,
|
||||
getActionStatusHandler,
|
||||
getAgentUploadsHandler,
|
||||
getAgentUploadFileHandler,
|
||||
} from './handlers';
|
||||
import {
|
||||
postNewAgentActionHandlerBuilder,
|
||||
|
@ -54,6 +60,10 @@ import {
|
|||
postAgentUpgradeHandler,
|
||||
postBulkAgentsUpgradeHandler,
|
||||
} from './upgrade_handler';
|
||||
import {
|
||||
bulkRequestDiagnosticsHandler,
|
||||
requestDiagnosticsHandler,
|
||||
} from './request_diagnostics_handler';
|
||||
|
||||
export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigType) => {
|
||||
// Get one
|
||||
|
@ -178,6 +188,50 @@ export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigT
|
|||
putAgentsReassignHandler
|
||||
);
|
||||
|
||||
router.post(
|
||||
{
|
||||
path: AGENT_API_ROUTES.REQUEST_DIAGNOSTICS_PATTERN,
|
||||
validate: PostRequestDiagnosticsActionRequestSchema,
|
||||
fleetAuthz: {
|
||||
fleet: { all: true },
|
||||
},
|
||||
},
|
||||
requestDiagnosticsHandler
|
||||
);
|
||||
|
||||
router.post(
|
||||
{
|
||||
path: AGENT_API_ROUTES.BULK_REQUEST_DIAGNOSTICS_PATTERN,
|
||||
validate: PostBulkRequestDiagnosticsActionRequestSchema,
|
||||
fleetAuthz: {
|
||||
fleet: { all: true },
|
||||
},
|
||||
},
|
||||
bulkRequestDiagnosticsHandler
|
||||
);
|
||||
|
||||
router.get(
|
||||
{
|
||||
path: AGENT_API_ROUTES.LIST_UPLOADS_PATTERN,
|
||||
validate: ListAgentUploadsRequestSchema,
|
||||
fleetAuthz: {
|
||||
fleet: { all: true },
|
||||
},
|
||||
},
|
||||
getAgentUploadsHandler
|
||||
);
|
||||
|
||||
router.get(
|
||||
{
|
||||
path: AGENT_API_ROUTES.GET_UPLOAD_FILE_PATTERN,
|
||||
validate: GetAgentUploadFileRequestSchema,
|
||||
fleetAuthz: {
|
||||
fleet: { all: true },
|
||||
},
|
||||
},
|
||||
getAgentUploadFileHandler
|
||||
);
|
||||
|
||||
// Get agent status for policy
|
||||
router.get(
|
||||
{
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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 { RequestHandler } from '@kbn/core/server';
|
||||
import type { TypeOf } from '@kbn/config-schema';
|
||||
|
||||
import * as AgentService from '../../services/agents';
|
||||
import type {
|
||||
PostBulkRequestDiagnosticsActionRequestSchema,
|
||||
PostRequestDiagnosticsActionRequestSchema,
|
||||
} from '../../types';
|
||||
import { defaultFleetErrorHandler } from '../../errors';
|
||||
|
||||
export const requestDiagnosticsHandler: RequestHandler<
|
||||
TypeOf<typeof PostRequestDiagnosticsActionRequestSchema.params>,
|
||||
undefined,
|
||||
undefined
|
||||
> = async (context, request, response) => {
|
||||
const coreContext = await context.core;
|
||||
const esClient = coreContext.elasticsearch.client.asInternalUser;
|
||||
try {
|
||||
const result = await AgentService.requestDiagnostics(esClient, request.params.agentId);
|
||||
|
||||
return response.ok({ body: { actionId: result.actionId } });
|
||||
} catch (error) {
|
||||
return defaultFleetErrorHandler({ error, response });
|
||||
}
|
||||
};
|
||||
|
||||
export const bulkRequestDiagnosticsHandler: RequestHandler<
|
||||
undefined,
|
||||
undefined,
|
||||
TypeOf<typeof PostBulkRequestDiagnosticsActionRequestSchema.body>
|
||||
> = async (context, request, response) => {
|
||||
const coreContext = await context.core;
|
||||
const esClient = coreContext.elasticsearch.client.asInternalUser;
|
||||
const soClient = coreContext.savedObjects.client;
|
||||
const agentOptions = Array.isArray(request.body.agents)
|
||||
? { agentIds: request.body.agents }
|
||||
: { kuery: request.body.agents };
|
||||
try {
|
||||
const result = await AgentService.bulkRequestDiagnostics(esClient, soClient, {
|
||||
...agentOptions,
|
||||
batchSize: request.body.batchSize,
|
||||
});
|
||||
|
||||
return response.ok({ body: { actionId: result.actionId } });
|
||||
} catch (error) {
|
||||
return defaultFleetErrorHandler({ error, response });
|
||||
}
|
||||
};
|
|
@ -20,12 +20,14 @@ import { UpgradeActionRunner } from './upgrade_action_runner';
|
|||
import { UpdateAgentTagsActionRunner } from './update_agent_tags_action_runner';
|
||||
import { UnenrollActionRunner } from './unenroll_action_runner';
|
||||
import type { ActionParams, RetryParams } from './action_runner';
|
||||
import { RequestDiagnosticsActionRunner } from './request_diagnostics_action_runner';
|
||||
|
||||
export enum BulkActionTaskType {
|
||||
REASSIGN_RETRY = 'fleet:reassign_action:retry',
|
||||
UNENROLL_RETRY = 'fleet:unenroll_action:retry',
|
||||
UPGRADE_RETRY = 'fleet:upgrade_action:retry',
|
||||
UPDATE_AGENT_TAGS_RETRY = 'fleet:update_agent_tags:retry',
|
||||
REQUEST_DIAGNOSTICS_RETRY = 'fleet:request_diagnostics:retry',
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -49,6 +51,7 @@ export class BulkActionsResolver {
|
|||
[BulkActionTaskType.REASSIGN_RETRY]: ReassignActionRunner,
|
||||
[BulkActionTaskType.UPDATE_AGENT_TAGS_RETRY]: UpdateAgentTagsActionRunner,
|
||||
[BulkActionTaskType.UPGRADE_RETRY]: UpgradeActionRunner,
|
||||
[BulkActionTaskType.REQUEST_DIAGNOSTICS_RETRY]: RequestDiagnosticsActionRunner,
|
||||
};
|
||||
|
||||
return createRetryTask(
|
||||
|
|
|
@ -15,6 +15,8 @@ export * from './reassign';
|
|||
export * from './setup';
|
||||
export * from './update_agent_tags';
|
||||
export * from './action_status';
|
||||
export * from './request_diagnostics';
|
||||
export { getAgentUploads, getAgentUploadFile } from './uploads';
|
||||
export { AgentServiceImpl } from './agent_service';
|
||||
export type { AgentClient, AgentService } from './agent_service';
|
||||
export { BulkActionsResolver } from './bulk_actions_resolver';
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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 { appContextService } from '../app_context';
|
||||
import { createAppContextStartContractMock } from '../../mocks';
|
||||
|
||||
import { createClientMock } from './action.mock';
|
||||
import { bulkRequestDiagnostics, requestDiagnostics } from './request_diagnostics';
|
||||
|
||||
describe('requestDiagnostics (singular)', () => {
|
||||
it('can request diagnostics for single agent', async () => {
|
||||
const { esClient, agentInRegularDoc } = createClientMock();
|
||||
await requestDiagnostics(esClient, agentInRegularDoc._id);
|
||||
|
||||
expect(esClient.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agents: ['agent-in-regular-policy'],
|
||||
type: 'REQUEST_DIAGNOSTICS',
|
||||
}),
|
||||
index: '.fleet-actions',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestDiagnostics (plural)', () => {
|
||||
beforeEach(async () => {
|
||||
appContextService.start(createAppContextStartContractMock());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
appContextService.stop();
|
||||
});
|
||||
it('can request diagnostics for multiple agents', async () => {
|
||||
const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock();
|
||||
const idsToUnenroll = [agentInRegularDoc._id, agentInRegularDoc2._id];
|
||||
await bulkRequestDiagnostics(esClient, soClient, { agentIds: idsToUnenroll });
|
||||
|
||||
expect(esClient.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agents: ['agent-in-regular-policy', 'agent-in-regular-policy2'],
|
||||
type: 'REQUEST_DIAGNOSTICS',
|
||||
}),
|
||||
index: '.fleet-actions',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* 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 { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server';
|
||||
|
||||
import { SO_SEARCH_LIMIT } from '../../constants';
|
||||
|
||||
import type { GetAgentsOptions } from '.';
|
||||
import { getAgents, getAgentsByKuery } from './crud';
|
||||
import { createAgentAction } from './actions';
|
||||
import { openPointInTime } from './crud';
|
||||
import {
|
||||
RequestDiagnosticsActionRunner,
|
||||
requestDiagnosticsBatch,
|
||||
} from './request_diagnostics_action_runner';
|
||||
|
||||
export async function requestDiagnostics(
|
||||
esClient: ElasticsearchClient,
|
||||
agentId: string
|
||||
): Promise<{ actionId: string }> {
|
||||
const response = await createAgentAction(esClient, {
|
||||
agents: [agentId],
|
||||
created_at: new Date().toISOString(),
|
||||
type: 'REQUEST_DIAGNOSTICS',
|
||||
});
|
||||
return { actionId: response.id };
|
||||
}
|
||||
|
||||
export async function bulkRequestDiagnostics(
|
||||
esClient: ElasticsearchClient,
|
||||
soClient: SavedObjectsClientContract,
|
||||
options: GetAgentsOptions & {
|
||||
batchSize?: number;
|
||||
}
|
||||
): Promise<{ actionId: string }> {
|
||||
if ('agentIds' in options) {
|
||||
const givenAgents = await getAgents(esClient, options);
|
||||
return await requestDiagnosticsBatch(esClient, givenAgents, {});
|
||||
}
|
||||
|
||||
const batchSize = options.batchSize ?? SO_SEARCH_LIMIT;
|
||||
const res = await getAgentsByKuery(esClient, {
|
||||
kuery: options.kuery,
|
||||
showInactive: false,
|
||||
page: 1,
|
||||
perPage: batchSize,
|
||||
});
|
||||
if (res.total <= batchSize) {
|
||||
const givenAgents = await getAgents(esClient, options);
|
||||
return await requestDiagnosticsBatch(esClient, givenAgents, {});
|
||||
} else {
|
||||
return await new RequestDiagnosticsActionRunner(
|
||||
esClient,
|
||||
soClient,
|
||||
{
|
||||
...options,
|
||||
batchSize,
|
||||
total: res.total,
|
||||
},
|
||||
{ pitId: await openPointInTime(esClient) }
|
||||
).runActionAsyncWithRetry();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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 uuid from 'uuid';
|
||||
import type { ElasticsearchClient } from '@kbn/core/server';
|
||||
|
||||
import type { Agent } from '../../types';
|
||||
|
||||
import { ActionRunner } from './action_runner';
|
||||
import { createAgentAction } from './actions';
|
||||
import { BulkActionTaskType } from './bulk_actions_resolver';
|
||||
|
||||
export class RequestDiagnosticsActionRunner extends ActionRunner {
|
||||
protected async processAgents(agents: Agent[]): Promise<{ actionId: string }> {
|
||||
return await requestDiagnosticsBatch(this.esClient, agents, this.actionParams!);
|
||||
}
|
||||
|
||||
protected getTaskType() {
|
||||
return BulkActionTaskType.REQUEST_DIAGNOSTICS_RETRY;
|
||||
}
|
||||
|
||||
protected getActionType() {
|
||||
return 'REQUEST_DIAGNOSTICS';
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestDiagnosticsBatch(
|
||||
esClient: ElasticsearchClient,
|
||||
givenAgents: Agent[],
|
||||
options: {
|
||||
actionId?: string;
|
||||
total?: number;
|
||||
}
|
||||
): Promise<{ actionId: string }> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const actionId = options.actionId ?? uuid();
|
||||
const total = options.total ?? givenAgents.length;
|
||||
|
||||
const agentIds = givenAgents.map((agent) => agent.id);
|
||||
|
||||
await createAgentAction(esClient, {
|
||||
id: actionId,
|
||||
agents: agentIds,
|
||||
created_at: now,
|
||||
type: 'REQUEST_DIAGNOSTICS',
|
||||
total,
|
||||
});
|
||||
|
||||
return {
|
||||
actionId,
|
||||
};
|
||||
}
|
181
x-pack/plugins/fleet/server/services/agents/uploads.ts
Normal file
181
x-pack/plugins/fleet/server/services/agents/uploads.ts
Normal file
|
@ -0,0 +1,181 @@
|
|||
/*
|
||||
* 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 { Readable } from 'stream';
|
||||
|
||||
import moment from 'moment';
|
||||
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
|
||||
import { createEsFileClient } from '@kbn/files-plugin/server';
|
||||
|
||||
import type { ResponseHeaders } from '@kbn/core-http-server';
|
||||
|
||||
import type { AgentDiagnostics } from '../../../common/types/models';
|
||||
import { appContextService } from '../app_context';
|
||||
import {
|
||||
AGENT_ACTIONS_INDEX,
|
||||
agentRouteService,
|
||||
AGENT_ACTIONS_RESULTS_INDEX,
|
||||
} from '../../../common';
|
||||
|
||||
import { SO_SEARCH_LIMIT } from '../../constants';
|
||||
|
||||
const FILE_STORAGE_METADATA_AGENT_INDEX = '.fleet-agent-files';
|
||||
const FILE_STORAGE_DATA_AGENT_INDEX = '.fleet-agent-file-data';
|
||||
|
||||
export async function getAgentUploads(
|
||||
esClient: ElasticsearchClient,
|
||||
agentId: string
|
||||
): Promise<AgentDiagnostics[]> {
|
||||
const getFile = async (fileId: string) => {
|
||||
if (!fileId) return;
|
||||
try {
|
||||
const file = await esClient.get({
|
||||
index: FILE_STORAGE_METADATA_AGENT_INDEX,
|
||||
id: fileId,
|
||||
});
|
||||
return {
|
||||
id: file._id,
|
||||
...(file._source as any)?.file,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404) {
|
||||
appContextService.getLogger().debug(err);
|
||||
return;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const actions = await _getRequestDiagnosticsActions(esClient, agentId);
|
||||
|
||||
const results = [];
|
||||
for (const action of actions) {
|
||||
const file = await getFile(action.fileId);
|
||||
const fileName = file?.name ?? `${moment(action.timestamp!).format('YYYY-MM-DD HH:mm:ss')}.zip`;
|
||||
const filePath = file ? agentRouteService.getAgentFileDownloadLink(file.id, file.name) : '';
|
||||
const result = {
|
||||
actionId: action.actionId,
|
||||
id: file?.id ?? action.actionId,
|
||||
status: file?.Status ?? 'IN_PROGRESS',
|
||||
name: fileName,
|
||||
createTime: action.timestamp!,
|
||||
filePath,
|
||||
};
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function _getRequestDiagnosticsActions(
|
||||
esClient: ElasticsearchClient,
|
||||
agentId: string
|
||||
): Promise<Array<{ actionId: string; timestamp?: string; fileId: string }>> {
|
||||
const agentActionRes = await esClient.search<any>({
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
ignore_unavailable: true,
|
||||
size: SO_SEARCH_LIMIT,
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
term: {
|
||||
type: 'REQUEST_DIAGNOSTICS',
|
||||
},
|
||||
},
|
||||
{
|
||||
term: {
|
||||
agents: agentId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const agentActionIds = agentActionRes.hits.hits.map((hit) => hit._source?.action_id as string);
|
||||
|
||||
if (agentActionIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const actionResults = await esClient.search<any>({
|
||||
index: AGENT_ACTIONS_RESULTS_INDEX,
|
||||
ignore_unavailable: true,
|
||||
size: SO_SEARCH_LIMIT,
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
terms: {
|
||||
action_id: agentActionIds,
|
||||
},
|
||||
},
|
||||
{
|
||||
term: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
return actionResults.hits.hits.map((hit) => ({
|
||||
actionId: hit._source?.action_id as string,
|
||||
timestamp: hit._source?.['@timestamp'],
|
||||
fileId: hit._source?.data?.file_id as string,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404) {
|
||||
// .fleet-actions-results does not yet exist
|
||||
appContextService.getLogger().debug(err);
|
||||
return [];
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAgentUploadFile(
|
||||
esClient: ElasticsearchClient,
|
||||
id: string,
|
||||
fileName: string
|
||||
): Promise<{ body: Readable; headers: ResponseHeaders }> {
|
||||
try {
|
||||
const fileClient = createEsFileClient({
|
||||
blobStorageIndex: FILE_STORAGE_DATA_AGENT_INDEX,
|
||||
metadataIndex: FILE_STORAGE_METADATA_AGENT_INDEX,
|
||||
elasticsearchClient: esClient,
|
||||
logger: appContextService.getLogger(),
|
||||
});
|
||||
|
||||
const file = await fileClient.get({
|
||||
id,
|
||||
});
|
||||
|
||||
return {
|
||||
body: await file.downloadContent(),
|
||||
headers: getDownloadHeadersForFile(fileName),
|
||||
};
|
||||
} catch (error) {
|
||||
appContextService.getLogger().error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDownloadHeadersForFile(fileName: string): ResponseHeaders {
|
||||
return {
|
||||
'content-type': 'application/octet-stream',
|
||||
// Note, this name can be overridden by the client if set via a "download" attribute on the HTML tag.
|
||||
'content-disposition': `attachment; filename="${fileName}"`,
|
||||
'cache-control': 'max-age=31536000, immutable',
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options
|
||||
'x-content-type-options': 'nosniff',
|
||||
};
|
||||
}
|
|
@ -113,6 +113,32 @@ export const PutAgentReassignRequestSchema = {
|
|||
}),
|
||||
};
|
||||
|
||||
export const PostRequestDiagnosticsActionRequestSchema = {
|
||||
params: schema.object({
|
||||
agentId: schema.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
export const PostBulkRequestDiagnosticsActionRequestSchema = {
|
||||
body: schema.object({
|
||||
agents: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]),
|
||||
batchSize: schema.maybe(schema.number()),
|
||||
}),
|
||||
};
|
||||
|
||||
export const ListAgentUploadsRequestSchema = {
|
||||
params: schema.object({
|
||||
agentId: schema.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
export const GetAgentUploadFileRequestSchema = {
|
||||
params: schema.object({
|
||||
fileId: schema.string(),
|
||||
fileName: schema.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
export const PostBulkAgentReassignRequestSchema = {
|
||||
body: schema.object({
|
||||
policy_id: schema.string(),
|
||||
|
|
|
@ -18,5 +18,7 @@ export default function loadTests({ loadTestFile }) {
|
|||
loadTestFile(require.resolve('./update'));
|
||||
loadTestFile(require.resolve('./update_agent_tags'));
|
||||
loadTestFile(require.resolve('./available_versions'));
|
||||
loadTestFile(require.resolve('./request_diagnostics'));
|
||||
loadTestFile(require.resolve('./uploads'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
|
||||
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
|
||||
import { setupFleetAndAgents } from './services';
|
||||
import { skipIfNoDockerRegistry } from '../../helpers';
|
||||
|
||||
export default function (providerContext: FtrProviderContext) {
|
||||
const { getService } = providerContext;
|
||||
const esArchiver = getService('esArchiver');
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('fleet_request_diagnostics', () => {
|
||||
skipIfNoDockerRegistry(providerContext);
|
||||
setupFleetAndAgents(providerContext);
|
||||
beforeEach(async () => {
|
||||
await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server');
|
||||
await esArchiver.load('x-pack/test/functional/es_archives/fleet/agents');
|
||||
await getService('supertest').post(`/api/fleet/setup`).set('kbn-xsrf', 'xxx').send();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await esArchiver.unload('x-pack/test/functional/es_archives/fleet/agents');
|
||||
await esArchiver.load('x-pack/test/functional/es_archives/fleet/empty_fleet_server');
|
||||
});
|
||||
|
||||
async function verifyActionResult(agentCount: number) {
|
||||
const { body } = await supertest
|
||||
.get(`/api/fleet/agents/action_status`)
|
||||
.set('kbn-xsrf', 'xxx');
|
||||
const actionStatus = body.items[0];
|
||||
|
||||
expect(actionStatus.nbAgentsActionCreated).to.eql(agentCount);
|
||||
}
|
||||
|
||||
it('/agents/{agent_id}/request_diagnostics should work', async () => {
|
||||
await supertest
|
||||
.post(`/api/fleet/agents/agent1/request_diagnostics`)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.expect(200);
|
||||
|
||||
verifyActionResult(1);
|
||||
});
|
||||
|
||||
it('/agents/bulk_request_diagnostics should work for multiple agents by id', async () => {
|
||||
await supertest
|
||||
.post(`/api/fleet/agents/bulk_request_diagnostics`)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({
|
||||
agents: ['agent2', 'agent3'],
|
||||
});
|
||||
|
||||
verifyActionResult(2);
|
||||
});
|
||||
|
||||
it('/agents/bulk_request_diagnostics should work for multiple agents by kuery', async () => {
|
||||
await supertest
|
||||
.post(`/api/fleet/agents/bulk_request_diagnostics`)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({
|
||||
agents: '',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
verifyActionResult(4);
|
||||
});
|
||||
|
||||
it('/agents/bulk_request_diagnostics should work for multiple agents by kuery in batches async', async () => {
|
||||
const { body } = await supertest
|
||||
.post(`/api/fleet/agents/bulk_request_diagnostics`)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({
|
||||
agents: '',
|
||||
batchSize: 2,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const actionId = body.actionId;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
let attempts = 0;
|
||||
const intervalId = setInterval(async () => {
|
||||
if (attempts > 2) {
|
||||
clearInterval(intervalId);
|
||||
reject('action timed out');
|
||||
}
|
||||
++attempts;
|
||||
const {
|
||||
body: { items: actionStatuses },
|
||||
} = await supertest.get(`/api/fleet/agents/action_status`).set('kbn-xsrf', 'xxx');
|
||||
|
||||
const action = actionStatuses?.find((a: any) => a.actionId === actionId);
|
||||
if (action && action.nbAgentsActioned === action.nbAgentsActionCreated) {
|
||||
clearInterval(intervalId);
|
||||
resolve({});
|
||||
}
|
||||
}, 1000);
|
||||
}).catch((e) => {
|
||||
throw e;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
129
x-pack/test/fleet_api_integration/apis/agents/uploads.ts
Normal file
129
x-pack/test/fleet_api_integration/apis/agents/uploads.ts
Normal file
|
@ -0,0 +1,129 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common';
|
||||
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
|
||||
import { setupFleetAndAgents } from './services';
|
||||
import { skipIfNoDockerRegistry } from '../../helpers';
|
||||
|
||||
export default function (providerContext: FtrProviderContext) {
|
||||
const { getService } = providerContext;
|
||||
const esArchiver = getService('esArchiver');
|
||||
const supertest = getService('supertest');
|
||||
const esClient = getService('es');
|
||||
|
||||
const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } };
|
||||
|
||||
describe('fleet_uploads', () => {
|
||||
skipIfNoDockerRegistry(providerContext);
|
||||
setupFleetAndAgents(providerContext);
|
||||
|
||||
before(async () => {
|
||||
await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server');
|
||||
await getService('supertest').post(`/api/fleet/setup`).set('kbn-xsrf', 'xxx').send();
|
||||
|
||||
await esClient.create({
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
id: new Date().toISOString(),
|
||||
refresh: true,
|
||||
body: {
|
||||
type: 'REQUEST_DIAGNOSTICS',
|
||||
action_id: 'action1',
|
||||
agents: ['agent1'],
|
||||
'@timestamp': '2022-10-07T11:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await esClient.create(
|
||||
{
|
||||
index: AGENT_ACTIONS_RESULTS_INDEX,
|
||||
id: new Date().toISOString(),
|
||||
refresh: true,
|
||||
body: {
|
||||
action_id: 'action1',
|
||||
agent_id: 'agent1',
|
||||
'@timestamp': '2022-10-07T12:00:00.000Z',
|
||||
data: {
|
||||
file_id: 'file1',
|
||||
},
|
||||
},
|
||||
},
|
||||
ES_INDEX_OPTIONS
|
||||
);
|
||||
|
||||
await esClient.update({
|
||||
index: '.fleet-agent-files',
|
||||
id: 'file1',
|
||||
refresh: true,
|
||||
body: {
|
||||
doc_as_upsert: true,
|
||||
doc: {
|
||||
file: {
|
||||
ChunkSize: 4194304,
|
||||
extension: 'zip',
|
||||
hash: {},
|
||||
mime_type: 'application/zip',
|
||||
mode: '0644',
|
||||
name: 'elastic-agent-diagnostics-2022-10-07T12-00-00Z-00.zip',
|
||||
path: '/agent/elastic-agent-diagnostics-2022-10-07T12-00-00Z-00.zip',
|
||||
size: 24917,
|
||||
Status: 'READY',
|
||||
type: 'file',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
after(async () => {
|
||||
await esArchiver.load('x-pack/test/functional/es_archives/fleet/empty_fleet_server');
|
||||
});
|
||||
|
||||
it('should get agent uploads', async () => {
|
||||
const { body } = await supertest
|
||||
.get(`/api/fleet/agents/agent1/uploads`)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.expect(200);
|
||||
|
||||
expect(body.items[0]).to.eql({
|
||||
actionId: 'action1',
|
||||
createTime: '2022-10-07T12:00:00.000Z',
|
||||
filePath:
|
||||
'/api/fleet/agents/files/file1/elastic-agent-diagnostics-2022-10-07T12-00-00Z-00.zip',
|
||||
id: 'file1',
|
||||
name: 'elastic-agent-diagnostics-2022-10-07T12-00-00Z-00.zip',
|
||||
status: 'READY',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get agent uploaded file', async () => {
|
||||
await esClient.update({
|
||||
index: '.fleet-agent-file-data',
|
||||
id: 'file1.0',
|
||||
refresh: true,
|
||||
body: {
|
||||
doc_as_upsert: true,
|
||||
doc: {
|
||||
last: true,
|
||||
bid: 'file1',
|
||||
data: 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { header } = await supertest
|
||||
.get(`/api/fleet/agents/files/file1/elastic-agent-diagnostics-2022-10-07T12-00-00Z-00.zip`)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.expect(200);
|
||||
|
||||
expect(header['content-type']).to.eql('application/octet-stream');
|
||||
expect(header['content-disposition']).to.eql(
|
||||
'attachment; filename="elastic-agent-diagnostics-2022-10-07T12-00-00Z-00.zip"'
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -114,6 +114,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
'endpoint:user-artifact-packager',
|
||||
'fleet:check-deleted-files-task',
|
||||
'fleet:reassign_action:retry',
|
||||
'fleet:request_diagnostics:retry',
|
||||
'fleet:unenroll_action:retry',
|
||||
'fleet:update_agent_tags:retry',
|
||||
'fleet:upgrade_action:retry',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue