diff --git a/x-pack/plugins/fleet/common/experimental_features.ts b/x-pack/plugins/fleet/common/experimental_features.ts index 522af87e56015..730bcb393e987 100644 --- a/x-pack/plugins/fleet/common/experimental_features.ts +++ b/x-pack/plugins/fleet/common/experimental_features.ts @@ -28,6 +28,7 @@ const _allowedExperimentalValues = { useSpaceAwareness: false, enableReusableIntegrationPolicies: true, asyncDeployPolicies: true, + enableExportCSV: false, }; /** diff --git a/x-pack/plugins/fleet/common/index.ts b/x-pack/plugins/fleet/common/index.ts index 5b88793b3e6f2..dc1efbe67d353 100644 --- a/x-pack/plugins/fleet/common/index.ts +++ b/x-pack/plugins/fleet/common/index.ts @@ -77,6 +77,8 @@ export { INVALID_NAMESPACE_CHARACTERS, getFileMetadataIndexName, getFileDataIndexName, + removeSOAttributes, + getSortConfig, } from './services'; export type { FleetAuthz } from './authz'; diff --git a/x-pack/plugins/fleet/common/services/agent_utils.test.ts b/x-pack/plugins/fleet/common/services/agent_utils.test.ts new file mode 100644 index 0000000000000..dc045188651ba --- /dev/null +++ b/x-pack/plugins/fleet/common/services/agent_utils.test.ts @@ -0,0 +1,29 @@ +/* + * 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 { getSortConfig, removeSOAttributes } from './agent_utils'; + +describe('Agent utils', () => { + it('should get sort config', () => { + const sortConfig = getSortConfig('agent.id', 'asc'); + expect(sortConfig).toEqual([{ 'agent.id': { order: 'asc' } }]); + }); + + it('should get default sort config', () => { + const sortConfig = getSortConfig('enrolled_at', 'desc'); + expect(sortConfig).toEqual([ + { enrolled_at: { order: 'desc' } }, + { 'local_metadata.host.hostname.keyword': { order: 'asc' } }, + ]); + }); + + it('should remove SO attributes', () => { + const kuery = 'attributes.test AND fleet-agents.test'; + const result = removeSOAttributes(kuery); + expect(result).toEqual('test AND test'); + }); +}); diff --git a/x-pack/plugins/fleet/common/services/agent_utils.ts b/x-pack/plugins/fleet/common/services/agent_utils.ts new file mode 100644 index 0000000000000..c0e935543fc39 --- /dev/null +++ b/x-pack/plugins/fleet/common/services/agent_utils.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function removeSOAttributes(kuery: string): string { + return kuery.replace(/attributes\./g, '').replace(/fleet-agents\./g, ''); +} + +export function getSortConfig( + sortField: string, + sortOrder: 'asc' | 'desc' +): Array> { + const isDefaultSort = sortField === 'enrolled_at' && sortOrder === 'desc'; + // if using default sorting (enrolled_at), adding a secondary sort on hostname, so that the results are not changing randomly in case many agents were enrolled at the same time + const secondarySort: Array> = isDefaultSort + ? [{ 'local_metadata.host.hostname.keyword': { order: 'asc' } }] + : []; + return [{ [sortField]: { order: sortOrder } }, ...secondarySort]; +} diff --git a/x-pack/plugins/fleet/common/services/index.ts b/x-pack/plugins/fleet/common/services/index.ts index 099e2487438e1..4443878617796 100644 --- a/x-pack/plugins/fleet/common/services/index.ts +++ b/x-pack/plugins/fleet/common/services/index.ts @@ -90,3 +90,5 @@ export { getFleetServerVersionMessage, isAgentVersionLessThanFleetServer, } from './check_fleet_server_versions'; + +export { removeSOAttributes, getSortConfig } from './agent_utils'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.test.tsx index f9ec74f7ce123..791b719859a5e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.test.tsx @@ -28,6 +28,12 @@ const mockedUseLicence = useLicense as jest.MockedFunction; jest.mock('../../components/agent_reassign_policy_modal'); +jest.mock('../hooks/export_csv', () => ({ + useExportCSV: jest.fn().mockReturnValue({ + generateReportingJobCSV: jest.fn(), + }), +})); + const defaultProps = { nAgentsInTable: 10, totalManagedAgentIds: [], diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx index c5fd1c2caec81..ea020417a2a2b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx @@ -31,6 +31,8 @@ import { getCommonTags } from '../utils'; import { AgentRequestDiagnosticsModal } from '../../components/agent_request_diagnostics_modal'; +import { useExportCSV } from '../hooks/export_csv'; + import type { SelectionMode } from './types'; import { TagsAddRemove } from './tags_add_remove'; @@ -44,6 +46,8 @@ export interface Props { refreshAgents: (args?: { refreshTags?: boolean }) => void; allTags: string[]; agentPolicies: AgentPolicy[]; + sortField?: string; + sortOrder?: 'asc' | 'desc'; } export const AgentBulkActions: React.FunctionComponent = ({ @@ -56,6 +60,8 @@ export const AgentBulkActions: React.FunctionComponent = ({ refreshAgents, allTags, agentPolicies, + sortField, + sortOrder, }) => { const licenseService = useLicense(); const isLicenceAllowingScheduleUpgrade = licenseService.hasAtLeast(LICENSE_FOR_SCHEDULE_UPGRADE); @@ -96,7 +102,9 @@ export const AgentBulkActions: React.FunctionComponent = ({ : nAgentsInTable - totalManagedAgentIds?.length; const [tagsPopoverButton, setTagsPopoverButton] = useState(); - const { diagnosticFileUploadEnabled } = ExperimentalFeaturesService.get(); + const { diagnosticFileUploadEnabled, enableExportCSV } = ExperimentalFeaturesService.get(); + + const { generateReportingJobCSV } = useExportCSV(enableExportCSV); const menuItems = [ { @@ -217,6 +225,30 @@ export const AgentBulkActions: React.FunctionComponent = ({ setIsUnenrollModalOpen(true); }, }, + ...(enableExportCSV + ? [ + { + name: ( + + ), + icon: , + onClick: () => { + closeMenu(); + generateReportingJobCSV(agents, { + field: sortField, + direction: sortOrder, + }); + }, + }, + ] + : []), ]; const panels = [ diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx index 718684606f9d7..7375ecdf27bfc 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx @@ -60,6 +60,8 @@ export interface SearchAndFilterBarProps { onClickAgentActivity: () => void; showAgentActivityTour: { isOpen: boolean }; latestAgentActionErrors: number; + sortField?: string; + sortOrder?: 'asc' | 'desc'; } export const SearchAndFilterBar: React.FunctionComponent = ({ @@ -89,6 +91,8 @@ export const SearchAndFilterBar: React.FunctionComponent { const authz = useAuthz(); @@ -219,6 +223,8 @@ export const SearchAndFilterBar: React.FunctionComponent ) : null} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/export_csv.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/export_csv.test.tsx new file mode 100644 index 0000000000000..b2fdf7a1023f6 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/export_csv.test.tsx @@ -0,0 +1,187 @@ +/* + * 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 { RenderHookResult } from '@testing-library/react-hooks'; +import { act } from '@testing-library/react-hooks'; + +import { createFleetTestRendererMock } from '../../../../../../mock'; + +import type { Agent } from '../../../../../../../common'; + +import { useExportCSV } from './export_csv'; + +jest.mock('../../../../../../hooks', () => ({ + useGetAgentStatusRuntimeFieldQuery: jest.fn().mockReturnValue({ + data: 'emit("offline")', + isLoading: false, + }), + useKibanaVersion: jest.fn().mockReturnValue('9.0.0'), + useStartServices: jest.fn().mockReturnValue({ + notifications: { + toasts: { + addSuccess: jest.fn(), + addError: jest.fn(), + }, + }, + http: {}, + uiSettings: {}, + }), +})); + +const mockGetDecoratedJobParams = jest.fn().mockImplementation((params) => params); +const mockCreateReportingShareJob = jest.fn().mockResolvedValue({}); + +jest.mock('@kbn/reporting-public', () => ({ + ReportingAPIClient: jest.fn().mockImplementation(() => ({ + getDecoratedJobParams: mockGetDecoratedJobParams, + createReportingShareJob: mockCreateReportingShareJob, + })), +})); + +describe('export_csv', () => { + let result: RenderHookResult; + + function render() { + const renderer = createFleetTestRendererMock(); + return renderer.renderHook(() => useExportCSV(true)); + } + + beforeEach(() => { + jest.clearAllMocks(); + act(() => { + result = render(); + }); + }); + + it('should generate reporting job for export csv with agent ids', () => { + const agents = [{ id: 'agent1' }, { id: 'agent2' }] as Agent[]; + const sortOptions = { + field: 'agent.id', + direction: 'asc', + }; + + act(() => { + result.result.current.generateReportingJobCSV(agents, sortOptions); + }); + + expect(mockGetDecoratedJobParams.mock.calls[0][0].columns.length).toEqual(6); + expect(mockGetDecoratedJobParams.mock.calls[0][0].searchSource).toEqual( + expect.objectContaining({ + filter: expect.objectContaining({ + query: { + bool: { + minimum_should_match: 1, + should: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'agent.id': 'agent1', + }, + }, + ], + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'agent.id': 'agent2', + }, + }, + ], + }, + }, + ], + }, + }, + }), + index: expect.objectContaining({ + runtimeFieldMap: { + status: { + script: { + source: 'emit("offline")', + }, + type: 'keyword', + }, + }, + }), + sort: [ + { + 'agent.id': { + order: 'asc', + }, + }, + ], + }) + ); + expect(mockCreateReportingShareJob).toHaveBeenCalled(); + }); + + it('should generate reporting job for export csv with agents query', () => { + const agents = 'policy_id:1 AND status:online'; + + act(() => { + result.result.current.generateReportingJobCSV(agents, undefined); + }); + + expect(mockGetDecoratedJobParams.mock.calls[0][0].columns.length).toEqual(6); + expect(mockGetDecoratedJobParams.mock.calls[0][0].searchSource).toEqual( + expect.objectContaining({ + filter: expect.objectContaining({ + query: { + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + policy_id: '1', + }, + }, + ], + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + status: 'online', + }, + }, + ], + }, + }, + ], + }, + }, + }), + sort: [ + { + enrolled_at: { + order: 'desc', + }, + }, + { + 'local_metadata.host.hostname.keyword': { + order: 'asc', + }, + }, + ], + }) + ); + expect(mockCreateReportingShareJob).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/export_csv.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/export_csv.tsx new file mode 100644 index 0000000000000..e7a806ff11f02 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/export_csv.tsx @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; +import type { EsQuerySortValue, SearchSourceFields } from '@kbn/data-plugin/common'; +import { DataView, SortDirection } from '@kbn/data-plugin/common'; +import { ReportingAPIClient } from '@kbn/reporting-public'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { FieldFormatsStartCommon } from '@kbn/field-formats-plugin/common'; + +import { + useGetAgentStatusRuntimeFieldQuery, + useKibanaVersion, + useStartServices, +} from '../../../../../../hooks'; +import type { Agent } from '../../../../../../../common'; +import { getSortConfig, removeSOAttributes } from '../../../../../../../common'; + +import { getSortFieldForAPI } from './use_fetch_agents_data'; + +export function useExportCSV(enableExportCSV?: boolean) { + const startServices = useStartServices(); + const { notifications, http, uiSettings } = startServices; + const kibanaVersion = useKibanaVersion(); + const { data: runtimeFieldsResponse } = useGetAgentStatusRuntimeFieldQuery({ + enabled: enableExportCSV, + }); + const runtimeFields = runtimeFieldsResponse ? runtimeFieldsResponse : 'emit("")'; + + const getJobParams = ( + agents: Agent[] | string, + sortOptions?: { field?: string; direction?: string } + ) => { + // TODO pass columns from Agent list UI + // TODO set readable column names + const columns = [ + { field: 'agent.id' }, + { field: 'status' }, + { field: 'local_metadata.host.hostname' }, + { field: 'policy_id' }, // policy name would need to be enriched + { field: 'last_checkin' }, + { field: 'local_metadata.elastic.agent.version' }, + ]; + + const index = new DataView({ + spec: { + title: '.fleet-agents', + allowHidden: true, + runtimeFieldMap: { + status: { + type: 'keyword', + script: { + source: runtimeFields, + }, + }, + }, + }, + fieldFormats: {} as FieldFormatsStartCommon, + }); + + let query: string; + if (Array.isArray(agents)) { + query = `agent.id:(${agents.map((agent) => agent.id).join(' OR ')})`; + } else { + query = agents; + } + + const sortField = getSortFieldForAPI(sortOptions?.field ?? 'enrolled_at'); + const sortOrder = (sortOptions?.direction as SortDirection) ?? SortDirection.desc; + + const sort = getSortConfig(sortField, sortOrder) as EsQuerySortValue[]; + + const searchSource: SearchSourceFields = { + type: 'search', + query: { + query: '', + language: 'kuery', + }, + filter: { + meta: { + index: 'fleet-agents', + params: {}, + }, + query: toElasticsearchQuery(fromKueryExpression(removeSOAttributes(query))), + }, + fields: columns, + index, + sort, + }; + + return { + title: 'Agent List', + objectType: 'search', + columns: columns.map((column) => column.field), + searchSource, + }; + }; + + const apiClient = new ReportingAPIClient(http, uiSettings, kibanaVersion); + + // copied and adapted logic from here: https://github.com/elastic/kibana/blob/2846a162de7e56d2107eeb2e33e006a3310a4ae1/packages/kbn-reporting/public/share/share_context_menu/register_csv_modal_reporting.tsx#L86 + const generateReportingJobCSV = ( + agents: Agent[] | string, + sortOptions?: { field?: string; direction?: string } + ) => { + const decoratedJobParams = apiClient.getDecoratedJobParams(getJobParams(agents, sortOptions)); + return apiClient + .createReportingShareJob('csv_searchsource', decoratedJobParams) + .then(() => { + notifications.toasts.addSuccess({ + title: i18n.translate( + 'xpack.fleet.modalContent.successfullyQueuedReportNotificationTitle', + { defaultMessage: 'Queued report for CSV' } + ), + text: toMountPoint( + + + + ), + }} + />, + startServices + ), + 'data-test-subj': 'queueReportSuccess', + }); + }) + .catch((error) => { + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.modalContent.notification.reportingErrorTitle', { + defaultMessage: 'Unable to create report', + }), + toastMessage: ( + // eslint-disable-next-line react/no-danger + + ) as unknown as string, + }); + }); + }; + + return { + generateReportingJobCSV, + }; +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_fetch_agents_data.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_fetch_agents_data.tsx index e27eb43bfad10..ec87383456e80 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_fetch_agents_data.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_fetch_agents_data.tsx @@ -85,6 +85,16 @@ function useFullAgentPolicyFetcher() { ); } +const VERSION_FIELD = 'local_metadata.elastic.agent.version'; +const HOSTNAME_FIELD = 'local_metadata.host.hostname'; + +export const getSortFieldForAPI = (field: string): string => { + if ([VERSION_FIELD, HOSTNAME_FIELD].includes(field)) { + return `${field}.keyword`; + } + return field; +}; + export function useFetchAgentsData() { const fullAgentPolicyFecher = useFullAgentPolicyFetcher(); const { displayAgentMetrics } = ExperimentalFeaturesService.get(); @@ -106,9 +116,6 @@ export function useFetchAgentsData() { const [sortField, setSortField] = useState('enrolled_at'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); - const VERSION_FIELD = 'local_metadata.elastic.agent.version'; - const HOSTNAME_FIELD = 'local_metadata.host.hostname'; - // Policies state for filtering const [selectedAgentPolicies, setSelectedAgentPolicies] = useState([]); @@ -177,13 +184,6 @@ export function useFetchAgentsData() { const [latestAgentActionErrors, setLatestAgentActionErrors] = useState([]); - const getSortFieldForAPI = (field: keyof Agent): string => { - if ([VERSION_FIELD, HOSTNAME_FIELD].includes(field as string)) { - return `${field}.keyword`; - } - return field; - }; - const isLoadingVar = useRef(false); // Request to fetch agents and agent status diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index a4171a8d5197a..47cc796d300c4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -429,6 +429,8 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { onClickAgentActivity={onClickAgentActivity} showAgentActivityTour={showAgentActivityTour} latestAgentActionErrors={latestAgentActionErrors.length} + sortField={sortField} + sortOrder={sortOrder} /> {/* Agent total, bulk actions and status bar */} diff --git a/x-pack/plugins/fleet/public/hooks/use_request/agents.ts b/x-pack/plugins/fleet/public/hooks/use_request/agents.ts index 236c470cd15b7..98e71732c34c8 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/agents.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/agents.ts @@ -53,7 +53,7 @@ import type { PostRetrieveAgentsByActionsResponse, } from '../../types'; -import { useRequest, sendRequest } from './use_request'; +import { useRequest, sendRequest, sendRequestForRq } from './use_request'; import type { UseRequestConfig } from './use_request'; type RequestOptions = Pick, 'pollIntervalMs'>; @@ -350,3 +350,17 @@ export function sendGetAgentsAvailableVersions() { version: API_VERSIONS.public.v1, }); } + +export function sendGetAgentStatusRuntimeField() { + return sendRequestForRq({ + method: 'get', + path: '/internal/fleet/agents/status_runtime_field', + version: API_VERSIONS.internal.v1, + }); +} + +export function useGetAgentStatusRuntimeFieldQuery(options: Partial<{ enabled: boolean }> = {}) { + return useQuery(['status_runtime_field'], () => sendGetAgentStatusRuntimeField(), { + enabled: options.enabled, + }); +} diff --git a/x-pack/plugins/fleet/server/routes/agent/handlers.ts b/x-pack/plugins/fleet/server/routes/agent/handlers.ts index 76d0fefe90fde..f7f56b76503b4 100644 --- a/x-pack/plugins/fleet/server/routes/agent/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/handlers.ts @@ -9,6 +9,8 @@ import { omit, uniq } from 'lodash'; import { type RequestHandler, SavedObjectsErrorHelpers } from '@kbn/core/server'; import type { TypeOf } from '@kbn/config-schema'; +import type { Script } from '@elastic/elasticsearch/lib/api/types'; + import type { GetAgentsResponse, GetOneAgentResponse, @@ -44,6 +46,7 @@ import { fetchAndAssignAgentMetrics } from '../../services/agents/agent_metrics' import { getAgentStatusForAgentPolicy } from '../../services/agents'; import { isAgentInNamespace } from '../../services/spaces/agent_namespaces'; import { getCurrentNamespace } from '../../services/spaces/get_current_namespace'; +import { buildAgentStatusRuntimeField } from '../../services/agents/build_status_runtime_field'; async function verifyNamespace(agent: Agent, namespace?: string) { if (!(await isAgentInNamespace(agent, namespace))) { @@ -353,6 +356,20 @@ function isStringArray(arr: unknown | string[]): arr is string[] { return Array.isArray(arr) && arr.every((p) => typeof p === 'string'); } +export const getAgentStatusRuntimeFieldHandler: RequestHandler = async ( + context, + request, + response +) => { + try { + const runtimeFields = await buildAgentStatusRuntimeField(); + + return response.ok({ body: (runtimeFields.status.script as Script)!.source! }); + } catch (error) { + return defaultFleetErrorHandler({ error, response }); + } +}; + export const getAvailableVersionsHandler: RequestHandler = async (context, request, response) => { try { const availableVersions = await AgentService.getAvailableVersions(); diff --git a/x-pack/plugins/fleet/server/routes/agent/index.ts b/x-pack/plugins/fleet/server/routes/agent/index.ts index cc1550fe24689..82893b6590e30 100644 --- a/x-pack/plugins/fleet/server/routes/agent/index.ts +++ b/x-pack/plugins/fleet/server/routes/agent/index.ts @@ -8,7 +8,7 @@ import { schema } from '@kbn/config-schema'; import type { FleetAuthz } from '../../../common'; import { API_VERSIONS } from '../../../common/constants'; - +import { parseExperimentalConfigValue } from '../../../common/experimental_features'; import { getRouteRequiredAuthz, type FleetAuthzRouter } from '../../services/security'; import { AGENT_API_ROUTES } from '../../constants'; @@ -77,6 +77,7 @@ import { deleteAgentUploadFileHandler, postAgentReassignHandler, postRetrieveAgentsByActionsHandler, + getAgentStatusRuntimeFieldHandler, } from './handlers'; import { postNewAgentActionHandlerBuilder, @@ -807,4 +808,35 @@ export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigT }, getAvailableVersionsHandler ); + + const experimentalFeatures = parseExperimentalConfigValue(config.enableExperimental); + + // route used by export CSV feature on the UI to generate report + if (experimentalFeatures.enableExportCSV) { + router.versioned + .get({ + path: '/internal/fleet/agents/status_runtime_field', + access: 'internal', + fleetAuthz: { + fleet: { readAgents: true }, + }, + }) + .addVersion( + { + version: API_VERSIONS.internal.v1, + validate: { + request: {}, + response: { + 200: { + body: () => schema.string(), + }, + 400: { + body: genericErrorResponse, + }, + }, + }, + }, + getAgentStatusRuntimeFieldHandler + ); + } }; diff --git a/x-pack/plugins/fleet/server/services/agents/build_status_runtime_field.ts b/x-pack/plugins/fleet/server/services/agents/build_status_runtime_field.ts index 2c9f2a0c32b8d..12314cb30ac68 100644 --- a/x-pack/plugins/fleet/server/services/agents/build_status_runtime_field.ts +++ b/x-pack/plugins/fleet/server/services/agents/build_status_runtime_field.ts @@ -168,7 +168,7 @@ export function _buildStatusRuntimeField(opts: { // pathPrefix is used by the endpoint team currently to run // agent queries against the endpoint metadata index export async function buildAgentStatusRuntimeField( - soClient: SavedObjectsClientContract, // Deprecated, it's now using an internal client + soClient?: SavedObjectsClientContract, // Deprecated, it's now using an internal client pathPrefix?: string ) { const config = appContextService.getConfig(); diff --git a/x-pack/plugins/fleet/server/services/agents/crud.ts b/x-pack/plugins/fleet/server/services/agents/crud.ts index f7682ce0e7726..e6a264c394397 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.ts @@ -18,6 +18,7 @@ import type { AgentSOAttributes, Agent, ListWithKuery } from '../../types'; import { appContextService, agentPolicyService } from '..'; import type { AgentStatus, FleetServerAgent } from '../../../common/types'; import { SO_SEARCH_LIMIT } from '../../../common/constants'; +import { getSortConfig } from '../../../common'; import { isAgentUpgradeAvailable } from '../../../common/services'; import { AGENTS_INDEX } from '../../constants'; import { @@ -254,11 +255,7 @@ export async function getAgentsByKuery( const runtimeFields = await buildAgentStatusRuntimeField(soClient); - const isDefaultSort = sortField === 'enrolled_at' && sortOrder === 'desc'; - // if using default sorting (enrolled_at), adding a secondary sort on hostname, so that the results are not changing randomly in case many agents were enrolled at the same time - const secondarySort: estypes.Sort = isDefaultSort - ? [{ 'local_metadata.host.hostname.keyword': { order: 'asc' } }] - : []; + const sort = getSortConfig(sortField, sortOrder); const statusSummary: Record = { online: 0, @@ -302,7 +299,7 @@ export async function getAgentsByKuery( rest_total_hits_as_int: true, runtime_mappings: runtimeFields, fields: Object.keys(runtimeFields), - sort: [{ [sortField]: { order: sortOrder } }, ...secondarySort], + sort, query: kueryNode ? toElasticsearchQuery(kueryNode) : undefined, ...(pitId ? { diff --git a/x-pack/plugins/fleet/tsconfig.json b/x-pack/plugins/fleet/tsconfig.json index 7ae2402aa6cb6..6ebdaffde2a31 100644 --- a/x-pack/plugins/fleet/tsconfig.json +++ b/x-pack/plugins/fleet/tsconfig.json @@ -116,5 +116,7 @@ "@kbn/server-http-tools", "@kbn/avc-banner", "@kbn/zod", + "@kbn/reporting-public", + "@kbn/field-formats-plugin", ] }