From 5f1487607b1ad89b34cd8186b6f8f82875440471 Mon Sep 17 00:00:00 2001 From: bryan Date: Fri, 16 Apr 2021 00:44:19 -0700 Subject: [PATCH] janky infinite loading agents, async searching --- .../osquery/public/agents/agent_grouper.ts | 117 +++++++++++ .../osquery/public/agents/agents_table.tsx | 182 ++++++++---------- x-pack/plugins/osquery/public/agents/types.ts | 3 + .../public/agents/use_agent_policies.ts | 22 ++- .../osquery/public/agents/use_all_agents.ts | 25 ++- .../public/agents/use_osquery_policies.ts | 4 +- 6 files changed, 239 insertions(+), 114 deletions(-) create mode 100644 x-pack/plugins/osquery/public/agents/agent_grouper.ts diff --git a/x-pack/plugins/osquery/public/agents/agent_grouper.ts b/x-pack/plugins/osquery/public/agents/agent_grouper.ts new file mode 100644 index 00000000000000..da32a5621a0023 --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/agent_grouper.ts @@ -0,0 +1,117 @@ +/* + * 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 { Agent } from '../../common/shared_imports'; +import { generateColorPicker } from './helpers'; +import { + ALL_AGENTS_LABEL, + AGENT_PLATFORMS_LABEL, + AGENT_POLICY_LABEL, + AGENT_SELECTION_LABEL, +} from './translations'; +import { AGENT_GROUP_KEY, Group, GroupOption } from './types'; + +const getColor = generateColorPicker(); + +const generateGroup = (label: string, groupType: AGENT_GROUP_KEY) => { + return { + label, + groupType, + color: getColor(groupType), + size: 0, + data: [] as T[], + }; +}; + +export class AgentGrouper { + groupOrder = [ + AGENT_GROUP_KEY.All, + AGENT_GROUP_KEY.Platform, + AGENT_GROUP_KEY.Policy, + AGENT_GROUP_KEY.Agent, + ]; + groups = { + [AGENT_GROUP_KEY.All]: generateGroup(ALL_AGENTS_LABEL, AGENT_GROUP_KEY.All), + [AGENT_GROUP_KEY.Platform]: generateGroup(AGENT_PLATFORMS_LABEL, AGENT_GROUP_KEY.Platform), + [AGENT_GROUP_KEY.Policy]: generateGroup(AGENT_POLICY_LABEL, AGENT_GROUP_KEY.Policy), + [AGENT_GROUP_KEY.Agent]: generateGroup(AGENT_SELECTION_LABEL, AGENT_GROUP_KEY.Agent), + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateGroup(key: AGENT_GROUP_KEY, data: any[], append = false) { + if (!data?.length) { + return; + } + const group = this.groups[key]; + if (append) { + group.data.push(...data); + } else { + group.data = data; + } + group.size = data.length; + } + + setTotalAgents(total: number): void { + this.groups[AGENT_GROUP_KEY.All].size = total; + } + + generateOptions(): GroupOption[] { + const opts: GroupOption[] = []; + for (const key of this.groupOrder) { + const { label, size, groupType, data, color } = this.groups[key]; + if (size === 0) { + continue; + } + + switch (key) { + case AGENT_GROUP_KEY.All: + opts.push({ + label, + options: [ + { + label, + value: { groupType, size }, + color, + }, + ], + }); + break; + case AGENT_GROUP_KEY.Platform: + case AGENT_GROUP_KEY.Policy: + opts.push({ + label, + options: (data as Group[]).map(({ name, id, size: groupSize }) => ({ + label: name, + color: getColor(groupType), + value: { groupType, id, size: groupSize }, + })), + }); + break; + case AGENT_GROUP_KEY.Agent: + opts.push({ + label, + options: (data as Agent[]).map((agent: Agent) => ({ + label: agent.local_metadata.host.hostname, + key: agent.local_metadata.elastic.agent.id, + color, + value: { + groupType, + groups: { + policy: agent.policy_id ?? '', + platform: agent.local_metadata.os.platform, + }, + id: agent.local_metadata.elastic.agent.id, + online: agent.active, + }, + })), + }); + break; + } + } + return opts; + } +} diff --git a/x-pack/plugins/osquery/public/agents/agents_table.tsx b/x-pack/plugins/osquery/public/agents/agents_table.tsx index c7d8caee1a1bb2..07351177e6eafa 100644 --- a/x-pack/plugins/osquery/public/agents/agents_table.tsx +++ b/x-pack/plugins/osquery/public/agents/agents_table.tsx @@ -5,30 +5,25 @@ * 2.0. */ -import React, { useCallback, useEffect, useState } from 'react'; -import { EuiComboBox, EuiComboBoxOptionOption, EuiHealth, EuiHighlight } from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { EuiComboBox, EuiHealth, EuiHighlight } from '@elastic/eui'; +import { useDebounce } from 'react-use'; import { useAllAgents } from './use_all_agents'; import { useAgentGroups } from './use_agent_groups'; import { useOsqueryPolicies } from './use_osquery_policies'; -import { Agent } from '../../common/shared_imports'; -import { - getNumAgentsInGrouping, - generateAgentCheck, - getNumOverlapped, - generateColorPicker, -} from './helpers'; +import { AgentGrouper } from './agent_grouper'; +import { getNumAgentsInGrouping, generateAgentCheck, getNumOverlapped } from './helpers'; -import { - ALL_AGENTS_LABEL, - AGENT_PLATFORMS_LABEL, - AGENT_POLICY_LABEL, - SELECT_AGENT_LABEL, - AGENT_SELECTION_LABEL, - generateSelectedAgentsMessage, -} from './translations'; +import { SELECT_AGENT_LABEL, generateSelectedAgentsMessage } from './translations'; -import { AGENT_GROUP_KEY, SelectedGroups, AgentOptionValue, GroupOptionValue, Group } from './types'; +import { + AGENT_GROUP_KEY, + SelectedGroups, + AgentOptionValue, + GroupOptionValue, + GroupOption, +} from './types'; export interface AgentsSelection { agents: string[]; @@ -42,81 +37,61 @@ interface AgentsTableProps { onChange: (payload: AgentsSelection) => void; } -type GroupOption = EuiComboBoxOptionOption; - -const getColor = generateColorPicker(); - -const generateOptions = (groupType: AGENT_GROUP_KEY, label: string, collection: Group[]) => { - return { - label, - options: collection.map(({ name, id, size }) => ({ - label: name, - color: getColor(groupType), - value: { groupType, id, size }, - })), - }; -} +const perPage = 10; const AgentsTableComponent: React.FC = ({ onChange }) => { + // search related + const [searchValue, setSearchValue] = useState(''); + const [modifyingSearch, setModifyingSearch] = useState(false); + const [page, setPage] = useState(1); + const [debouncedSearchValue, setDebouncedSearchValue] = useState(''); + useDebounce( + () => { + // reset the page, update the real search value, set the typing flag + setPage(1); + setDebouncedSearchValue(searchValue); + setModifyingSearch(false); + }, + 100, + [searchValue] + ); + + // grouping related const osqueryPolicyData = useOsqueryPolicies(); const { loading: groupsLoading, totalCount: totalNumAgents, groups } = useAgentGroups( osqueryPolicyData ); - const { agents } = useAllAgents(osqueryPolicyData); - const [loading, setLoading] = useState(true); + const grouper = useMemo(() => new AgentGrouper(), []); + const { agentsLoading, agents } = useAllAgents(osqueryPolicyData, debouncedSearchValue, { + perPage, + page, + }); + + // option related const [options, setOptions] = useState([]); + const [lastLabel, setLastLabel] = useState(''); const [selectedOptions, setSelectedOptions] = useState([]); const [numAgentsSelected, setNumAgentsSelected] = useState(0); useEffect(() => { - const allAgentsLabel = ALL_AGENTS_LABEL; - const opts: GroupOption[] = [ - { - label: allAgentsLabel, - options: [ - { - label: allAgentsLabel, - value: { groupType: AGENT_GROUP_KEY.All, size: totalNumAgents }, - color: getColor(AGENT_GROUP_KEY.All), - }, - ], - }, - ]; - - if (groups.platforms.length > 0) { - const groupType = AGENT_GROUP_KEY.Platform; - opts.push(generateOptions(groupType, AGENT_PLATFORMS_LABEL, groups.platforms)) - } - - if (groups.policies.length > 0) { - const groupType = AGENT_GROUP_KEY.Policy; - opts.push(generateOptions(groupType, AGENT_POLICY_LABEL, groups.policies)) - } - - if (agents && agents.length > 0) { - const groupType = AGENT_GROUP_KEY.Agent; - opts.push({ - label: AGENT_SELECTION_LABEL, - options: (agents as Agent[]).map((agent: Agent) => ({ - label: agent.local_metadata.host.hostname, - key: agent.local_metadata.elastic.agent.id, - color: getColor(groupType), - value: { - groupType, - groups: { policy: agent.policy_id ?? '', platform: agent.local_metadata.os.platform }, - id: agent.local_metadata.elastic.agent.id, - online: agent.active, - }, - })), - }); + // update the groups when groups or agents have changed + grouper.setTotalAgents(totalNumAgents); + grouper.updateGroup(AGENT_GROUP_KEY.Platform, groups.platforms); + grouper.updateGroup(AGENT_GROUP_KEY.Policy, groups.policies); + grouper.updateGroup(AGENT_GROUP_KEY.Agent, agents, page > 1); + const newOptions = grouper.generateOptions(); + setOptions(newOptions); + if (newOptions.length) { + const lastGroup = newOptions[newOptions.length - 1].options; + if (lastGroup?.length) { + setLastLabel(lastGroup[lastGroup.length - 1].label); + } } - setLoading(false); - setOptions(opts); - }, [groups.platforms, groups.policies, totalNumAgents, groupsLoading, agents]); + }, [groups.platforms, groups.policies, totalNumAgents, groupsLoading, agents, page, grouper]); const onSelection = useCallback( (selection: GroupOption[]) => { - // TODO?: optimize this by making it incremental + // TODO?: optimize this by making the selection computation incremental const newAgentSelection: AgentsSelection = { agents: [], allAgentsSelected: false, @@ -189,36 +164,49 @@ const AgentsTableComponent: React.FC = ({ onChange }) => { [groups, onChange, totalNumAgents] ); - const renderOption = useCallback((option, searchValue, contentClassName) => { - const { label, value, key } = option; - return value?.groupType === AGENT_GROUP_KEY.Agent ? ( - + const renderOption = useCallback( + (option, searchVal, contentClassName) => { + const { label, value, key } = option; + if (label === lastLabel) { + setPage((p) => p + 1); + } + return value?.groupType === AGENT_GROUP_KEY.Agent ? ( + + + {label} +   + ({key}) + + + ) : ( - {label} + [{value?.size}]   - ({key}) + {label} +   + {value?.id && label !== value?.id && ({value?.id})} - - ) : ( - - [{value?.size}] -   - {label} -   - {value?.id && label !== value?.id && (({value?.id}))} - - ); + ); + }, + [lastLabel] + ); + + const onSearchChange = useCallback((v: string) => { + // set the typing flag and update the search value + setModifyingSearch(true); + setSearchValue(v); }, []); + return (
-

{SELECT_AGENT_LABEL}

{numAgentsSelected > 0 ? {generateSelectedAgentsMessage(numAgentsSelected)} : ''}   ; + interface BaseGroupOption { id?: string; groupType: AGENT_GROUP_KEY; diff --git a/x-pack/plugins/osquery/public/agents/use_agent_policies.ts b/x-pack/plugins/osquery/public/agents/use_agent_policies.ts index 056be8baafba9c..3045423ccbe2d2 100644 --- a/x-pack/plugins/osquery/public/agents/use_agent_policies.ts +++ b/x-pack/plugins/osquery/public/agents/use_agent_policies.ts @@ -7,7 +7,11 @@ import { useQueries, UseQueryResult } from 'react-query'; import { useKibana } from '../common/lib/kibana'; -import { AgentPolicy, agentPolicyRouteService, GetOneAgentPolicyResponse } from '../../../fleet/common'; +import { + AgentPolicy, + agentPolicyRouteService, + GetOneAgentPolicyResponse, +} from '../../../fleet/common'; export const useAgentPolicies = (policyIds: string[] = []) => { const { http } = useKibana().services; @@ -16,19 +20,19 @@ export const useAgentPolicies = (policyIds: string[] = []) => { policyIds.map((policyId) => ({ queryKey: ['agentPolicy', policyId], queryFn: () => http.get(agentPolicyRouteService.getInfoPath(policyId)), - options: {enabled: policyIds.length > 0} - })), + enabled: policyIds.length > 0, + })) ) as Array>; - const agentPoliciesLoading = agentResponse.some(p => p.isLoading) - const agentPolicies = agentResponse.map(p => p.data?.item) + const agentPoliciesLoading = agentResponse.some((p) => p.isLoading); + const agentPolicies = agentResponse.map((p) => p.data?.item); const agentPolicyById = agentPolicies.reduce((acc, p) => { if (!p) { - return acc + return acc; } - acc[p.id] = p - return acc - }, {} as {[key: string]: AgentPolicy}) + acc[p.id] = p; + return acc; + }, {} as { [key: string]: AgentPolicy }); return { agentPoliciesLoading, agentPolicies, agentPolicyById }; }; diff --git a/x-pack/plugins/osquery/public/agents/use_all_agents.ts b/x-pack/plugins/osquery/public/agents/use_all_agents.ts index 607f9ae0076929..54128e331e9c92 100644 --- a/x-pack/plugins/osquery/public/agents/use_all_agents.ts +++ b/x-pack/plugins/osquery/public/agents/use_all_agents.ts @@ -14,16 +14,31 @@ interface UseAllAgents { osqueryPoliciesLoading: boolean; } -export const useAllAgents = ({ osqueryPolicies, osqueryPoliciesLoading }: UseAllAgents) => { - // TODO: properly fetch these in an async manner +interface RequestOptions { + perPage: number; + page: number; +} + +// TODO: break out the paginated vs all cases into separate hooks +export const useAllAgents = ( + { osqueryPolicies, osqueryPoliciesLoading }: UseAllAgents, + searchValue = '', + opts: RequestOptions = { perPage: 9000, page: 1 } +) => { + const { perPage, page } = opts; const { http } = useKibana().services; const { isLoading: agentsLoading, data: agentData } = useQuery( - ['agents', osqueryPolicies], + ['agents', osqueryPolicies, searchValue, page, perPage], async () => { + let kuery = `(${osqueryPolicies.map((p) => `policy_id:${p}`).join(' or ')})`; + if (searchValue) { + kuery += ` and local_metadata.host.hostname:*${searchValue}*`; + } return await http.get('/api/fleet/agents', { query: { - kuery: osqueryPolicies.map((p) => `policy_id:${p}`).join(' or '), - perPage: 9000, + kuery, + perPage, + page, }, }); }, diff --git a/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts b/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts index d9f51033a14f4a..f786e9167d2f83 100644 --- a/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts +++ b/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts @@ -15,13 +15,11 @@ export const useOsqueryPolicies = () => { const { isLoading: osqueryPoliciesLoading, data: osqueryPolicies } = useQuery( ['osqueryPolicies'], async () => { - const d = await http.get('/api/fleet/package_policies', { + return await http.get('/api/fleet/package_policies', { query: { kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:osquery_manager`, }, }); - console.log('init', d) - return d }, { select: (data) => data.items.map((p: { policy_id: string }) => p.policy_id) } );