diff --git a/x-pack/plugins/fleet/common/constants/agent.ts b/x-pack/plugins/fleet/common/constants/agent.ts index d05dd66bb096..251db5db2480 100644 --- a/x-pack/plugins/fleet/common/constants/agent.ts +++ b/x-pack/plugins/fleet/common/constants/agent.ts @@ -35,3 +35,15 @@ export const FleetServerAgentComponentStatuses = [ 'STOPPING', 'STOPPED', ] as const; + +export const AgentStatuses = [ + 'offline', + 'error', + 'online', + 'inactive', + 'enrolling', + 'unenrolling', + 'unenrolled', + 'updating', + 'degraded', +] as const; diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 47d9c424ef0e..7edc939ee1bc 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -10,6 +10,7 @@ import type { AGENT_TYPE_PERMANENT, AGENT_TYPE_TEMPORARY, FleetServerAgentComponentStatuses, + AgentStatuses, } from '../../constants'; export type AgentType = @@ -17,16 +18,8 @@ export type AgentType = | typeof AGENT_TYPE_PERMANENT | typeof AGENT_TYPE_TEMPORARY; -export type AgentStatus = - | 'offline' - | 'error' - | 'online' - | 'inactive' - | 'enrolling' - | 'unenrolling' - | 'unenrolled' - | 'updating' - | 'degraded'; +type AgentStatusTuple = typeof AgentStatuses; +export type AgentStatus = AgentStatusTuple[number]; export type SimplifiedAgentStatus = | 'healthy' 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 ae0067a6af21..3eb47af03ee0 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 @@ -33,7 +33,7 @@ export function useFetchAgentsData() { const { displayAgentMetrics } = ExperimentalFeaturesService.get(); const { notifications } = useStartServices(); - // useBreadcrumbs('agent_list'); + const history = useHistory(); const { urlParams, toUrlParams } = useUrlParams(); const defaultKuery: string = (urlParams.kuery as string) || ''; diff --git a/x-pack/plugins/fleet/server/services/agents/crud.test.ts b/x-pack/plugins/fleet/server/services/agents/crud.test.ts index 93dde0737acc..33f30ab1b966 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.test.ts @@ -12,6 +12,7 @@ import { AGENTS_INDEX } from '../../constants'; import { createAppContextStartContractMock } from '../../mocks'; import type { Agent } from '../../types'; import { appContextService } from '../app_context'; +import type { AgentStatus } from '../../../common/types'; import { auditLoggingService } from '../audit_logging'; @@ -57,7 +58,7 @@ describe('Agents CRUD test', () => { appContextService.start(mockContract); }); - function getEsResponse(ids: string[], total: number) { + function getEsResponse(ids: string[], total: number, status: AgentStatus) { return { hits: { total, @@ -65,7 +66,7 @@ describe('Agents CRUD test', () => { _id: id, _source: {}, fields: { - status: ['inactive'], + status: [status], }, })), }, @@ -162,9 +163,11 @@ describe('Agents CRUD test', () => { describe('getAgentsByKuery', () => { it('should return upgradeable on first page', async () => { searchMock - .mockImplementationOnce(() => Promise.resolve(getEsResponse(['1', '2', '3', '4', '5'], 7))) .mockImplementationOnce(() => - Promise.resolve(getEsResponse(['1', '2', '3', '4', '5', 'up', '7'], 7)) + Promise.resolve(getEsResponse(['1', '2', '3', '4', '5'], 7, 'inactive')) + ) + .mockImplementationOnce(() => + Promise.resolve(getEsResponse(['1', '2', '3', '4', '5', 'up', '7'], 7, 'inactive')) ); const result = await getAgentsByKuery(esClientMock, soClientMock, { showUpgradeable: true, @@ -191,9 +194,11 @@ describe('Agents CRUD test', () => { it('should return upgradeable from all pages', async () => { searchMock - .mockImplementationOnce(() => Promise.resolve(getEsResponse(['1', '2', '3', 'up', '5'], 7))) .mockImplementationOnce(() => - Promise.resolve(getEsResponse(['1', '2', '3', 'up', '5', 'up2', '7'], 7)) + Promise.resolve(getEsResponse(['1', '2', '3', 'up', '5'], 7, 'inactive')) + ) + .mockImplementationOnce(() => + Promise.resolve(getEsResponse(['1', '2', '3', 'up', '5', 'up2', '7'], 7, 'inactive')) ); const result = await getAgentsByKuery(esClientMock, soClientMock, { showUpgradeable: true, @@ -227,9 +232,11 @@ describe('Agents CRUD test', () => { it('should return upgradeable on second page', async () => { searchMock - .mockImplementationOnce(() => Promise.resolve(getEsResponse(['up6', '7'], 7))) + .mockImplementationOnce(() => Promise.resolve(getEsResponse(['up6', '7'], 7, 'inactive'))) .mockImplementationOnce(() => - Promise.resolve(getEsResponse(['up1', 'up2', 'up3', 'up4', 'up5', 'up6', '7'], 7)) + Promise.resolve( + getEsResponse(['up1', 'up2', 'up3', 'up4', 'up5', 'up6', '7'], 7, 'inactive') + ) ); const result = await getAgentsByKuery(esClientMock, soClientMock, { showUpgradeable: true, @@ -256,7 +263,7 @@ describe('Agents CRUD test', () => { it('should return upgradeable from one page when total is more than limit', async () => { searchMock.mockImplementationOnce(() => - Promise.resolve(getEsResponse(['1', '2', '3', 'up', '5'], 10001)) + Promise.resolve(getEsResponse(['1', '2', '3', 'up', '5'], 10001, 'inactive')) ); const result = await getAgentsByKuery(esClientMock, soClientMock, { showUpgradeable: true, @@ -281,8 +288,79 @@ describe('Agents CRUD test', () => { }); }); + it('should return correct status summary when showUpgradeable is selected and total is less than limit', async () => { + searchMock.mockImplementationOnce(() => + Promise.resolve(getEsResponse(['1', '2', '3', 'up', '5'], 100, 'updating')) + ); + searchMock.mockImplementationOnce(() => + Promise.resolve(getEsResponse(['1', '2', '3', 'up', '5'], 100, 'updating')) + ); + const result = await getAgentsByKuery(esClientMock, soClientMock, { + showUpgradeable: true, + showInactive: false, + getStatusSummary: true, + page: 1, + perPage: 5, + }); + + expect(result).toEqual( + expect.objectContaining({ + page: 1, + perPage: 5, + statusSummary: { + degraded: 0, + enrolling: 0, + error: 0, + inactive: 0, + offline: 0, + online: 0, + unenrolled: 0, + unenrolling: 0, + updating: 1, + }, + total: 1, + }) + ); + }); + + it('should return correct status summary when showUpgradeable is selected and total is more than limit', async () => { + searchMock.mockImplementationOnce(() => + Promise.resolve(getEsResponse(['1', '2', '3', 'up', '5'], 10001, 'updating')) + ); + searchMock.mockImplementationOnce(() => + Promise.resolve(getEsResponse(['1', '2', '3', 'up', '5'], 10001, 'updating')) + ); + const result = await getAgentsByKuery(esClientMock, soClientMock, { + showUpgradeable: true, + showInactive: false, + getStatusSummary: true, + page: 1, + perPage: 5, + }); + expect(result).toEqual( + expect.objectContaining({ + page: 1, + perPage: 5, + statusSummary: { + degraded: 0, + enrolling: 0, + error: 0, + inactive: 0, + offline: 0, + online: 0, + unenrolled: 0, + unenrolling: 0, + updating: 1, + }, + total: 10001, + }) + ); + }); + it('should return second page', async () => { - searchMock.mockImplementationOnce(() => Promise.resolve(getEsResponse(['6', '7'], 7))); + searchMock.mockImplementationOnce(() => + Promise.resolve(getEsResponse(['6', '7'], 7, 'inactive')) + ); const result = await getAgentsByKuery(esClientMock, soClientMock, { showUpgradeable: false, showInactive: false, @@ -314,7 +392,9 @@ describe('Agents CRUD test', () => { }); it('should pass secondary sort for default sort', async () => { - searchMock.mockImplementationOnce(() => Promise.resolve(getEsResponse(['1', '2'], 2))); + searchMock.mockImplementationOnce(() => + Promise.resolve(getEsResponse(['1', '2'], 2, 'inactive')) + ); await getAgentsByKuery(esClientMock, soClientMock, { showInactive: false, }); @@ -326,7 +406,9 @@ describe('Agents CRUD test', () => { }); it('should not pass secondary sort for non-default sort', async () => { - searchMock.mockImplementationOnce(() => Promise.resolve(getEsResponse(['1', '2'], 2))); + searchMock.mockImplementationOnce(() => + Promise.resolve(getEsResponse(['1', '2'], 2, 'inactive')) + ); await getAgentsByKuery(esClientMock, soClientMock, { showInactive: false, sortField: 'policy_id', diff --git a/x-pack/plugins/fleet/server/services/agents/crud.ts b/x-pack/plugins/fleet/server/services/agents/crud.ts index d8e6e58f059a..5492c52a1639 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.ts @@ -350,9 +350,19 @@ export async function getAgentsByKuery( } if (getStatusSummary) { - res.aggregations?.status.buckets.forEach((bucket) => { - statusSummary[bucket.key] = bucket.doc_count; - }); + if (showUpgradeable) { + // when showUpgradeable is selected, calculate the summary status manually from the upgradeable agents above + // the bucket count doesn't take in account the upgradeable agents + agents.forEach((agent) => { + if (!agent?.status) return; + if (!statusSummary[agent.status]) statusSummary[agent.status] = 0; + statusSummary[agent.status]++; + }); + } else { + res.aggregations?.status.buckets.forEach((bucket) => { + statusSummary[bucket.key] = bucket.doc_count; + }); + } } return { diff --git a/x-pack/test/fleet_api_integration/apis/agents/list.ts b/x-pack/test/fleet_api_integration/apis/agents/list.ts index 91796d5a9b9d..9f4cc1c1abce 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/list.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/list.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { type Agent, FLEET_ELASTIC_AGENT_PACKAGE } from '@kbn/fleet-plugin/common'; +import { type Agent, FLEET_ELASTIC_AGENT_PACKAGE, AGENTS_INDEX } from '@kbn/fleet-plugin/common'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { testUsers } from '../test_users'; @@ -236,5 +236,53 @@ export default function ({ getService }: FtrProviderContext) { updating: 0, }); }); + + it('should return correct status summary if showUpgradeable is provided', async () => { + await es.update({ + id: 'agent1', + refresh: 'wait_for', + index: AGENTS_INDEX, + body: { + doc: { + policy_revision_idx: 1, + last_checkin: new Date().toISOString(), + status: 'online', + local_metadata: { elastic: { agent: { upgradeable: true, version: '0.0.0' } } }, + }, + }, + }); + // 1 agent inactive + await es.update({ + id: 'agent4', + refresh: 'wait_for', + index: AGENTS_INDEX, + body: { + doc: { + policy_id: 'policy-inactivity-timeout', + policy_revision_idx: 1, + last_checkin: new Date(Date.now() - 1000 * 60).toISOString(), // policy timeout 1 min + status: 'online', + local_metadata: { elastic: { agent: { upgradeable: true, version: '0.0.0' } } }, + }, + }, + }); + + const { body: apiResponse } = await supertest + .get('/api/fleet/agents?getStatusSummary=true&perPage=5&showUpgradeable=true') + .set('kbn-xsrf', 'xxxx') + .expect(200); + + expect(apiResponse.statusSummary).to.eql({ + degraded: 0, + enrolling: 0, + error: 0, + inactive: 0, + offline: 0, + online: 2, + unenrolled: 0, + unenrolling: 0, + updating: 0, + }); + }); }); }