[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:
Julia Bardi 2022-11-10 09:24:22 +01:00 committed by GitHub
parent 545ebb012d
commit c7cdd00036
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1495 additions and 136 deletions

View file

@ -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 = {

View file

@ -15,6 +15,7 @@ export const allowedExperimentalValues = Object.freeze({
createPackagePolicyMultiPageLayout: true,
packageVerification: true,
showDevtoolsRequest: true,
showRequestDiagnostics: false,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -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 = {

View file

@ -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;

View file

@ -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;

View file

@ -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}
/>
</>
);

View file

@ -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" /> &nbsp; {currentItem?.name}
</EuiLink>
) : currentItem?.status === 'IN_PROGRESS' || currentItem?.status === 'AWAITING_UPLOAD' ? (
<EuiText color="subdued">
<EuiLoadingSpinner /> &nbsp;
<FormattedMessage
id="xpack.fleet.requestDiagnostics.generatingText"
defaultMessage="Generating diagnostics file..."
/>
</EuiText>
) : (
<EuiText color="subdued">
<EuiIcon type="alert" color="red" /> &nbsp;
{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>
);
};

View file

@ -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';

View file

@ -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={() => {

View file

@ -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',

View file

@ -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

View file

@ -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 = {

View file

@ -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

View file

@ -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!}

View file

@ -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>
);
};

View file

@ -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],

View file

@ -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'],

View file

@ -68,6 +68,7 @@ export const createFleetTestRendererMock = (): TestRenderer => {
createPackagePolicyMultiPageLayout: true,
packageVerification: true,
showDevtoolsRequest: false,
showRequestDiagnostics: false,
});
const HookWrapper = memo(({ children }) => {

View file

@ -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 });
}
};

View file

@ -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(
{

View file

@ -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 });
}
};

View file

@ -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(

View file

@ -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';

View file

@ -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',
})
);
});
});

View file

@ -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();
}
}

View file

@ -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,
};
}

View 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',
};
}

View file

@ -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(),

View file

@ -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'));
});
}

View file

@ -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;
});
});
});
}

View 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"'
);
});
});
}

View file

@ -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',