[Asset Management] Agent picker follow up (#97357)

This commit is contained in:
Bryan Clement 2021-04-17 03:42:49 -07:00 committed by GitHub
parent e321f57f64
commit e0da8b2e96
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 344 additions and 158 deletions

View file

@ -0,0 +1,118 @@
/*
* 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 = <T = Group>(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>(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 !== id ? `${name} (${id})` : name,
key: id,
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} (${agent.local_metadata.elastic.agent.id})`,
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;
}
}

View file

@ -5,179 +5,98 @@
* 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 { AgentGrouper } from './agent_grouper';
import {
getNumAgentsInGrouping,
generateAgentCheck,
getNumOverlapped,
generateColorPicker,
generateAgentSelection,
} from './helpers';
import { SELECT_AGENT_LABEL, generateSelectedAgentsMessage } from './translations';
import {
ALL_AGENTS_LABEL,
AGENT_PLATFORMS_LABEL,
AGENT_POLICY_LABEL,
SELECT_AGENT_LABEL,
AGENT_SELECTION_LABEL,
generateSelectedAgentsMessage,
} from './translations';
import { AGENT_GROUP_KEY, SelectedGroups, AgentOptionValue, GroupOptionValue } from './types';
export interface AgentsSelection {
agents: string[];
allAgentsSelected: boolean;
platformsSelected: string[];
policiesSelected: string[];
}
AGENT_GROUP_KEY,
SelectedGroups,
AgentOptionValue,
GroupOption,
AgentSelection,
} from './types';
interface AgentsTableProps {
agentSelection: AgentsSelection;
onChange: (payload: AgentsSelection) => void;
agentSelection: AgentSelection;
onChange: (payload: AgentSelection) => void;
}
type GroupOption = EuiComboBoxOptionOption<AgentOptionValue | GroupOptionValue>;
const getColor = generateColorPicker();
const perPage = 10;
const DEBOUNCE_DELAY = 100; // ms
const AgentsTableComponent: React.FC<AgentsTableProps> = ({ onChange }) => {
// search related
const [searchValue, setSearchValue] = useState<string>('');
const [modifyingSearch, setModifyingSearch] = useState<boolean>(false);
const [debouncedSearchValue, setDebouncedSearchValue] = useState<string>('');
useDebounce(
() => {
// update the real search value, set the typing flag
setDebouncedSearchValue(searchValue);
setModifyingSearch(false);
},
DEBOUNCE_DELAY,
[searchValue]
);
// grouping related
const osqueryPolicyData = useOsqueryPolicies();
const { loading: groupsLoading, totalCount: totalNumAgents, groups } = useAgentGroups(
osqueryPolicyData
);
const { agents } = useAllAgents(osqueryPolicyData);
const [loading, setLoading] = useState<boolean>(true);
const grouper = useMemo(() => new AgentGrouper(), []);
const { agentsLoading, agents } = useAllAgents(osqueryPolicyData, debouncedSearchValue, {
perPage,
});
// option related
const [options, setOptions] = useState<GroupOption[]>([]);
const [selectedOptions, setSelectedOptions] = useState<GroupOption[]>([]);
const [numAgentsSelected, setNumAgentsSelected] = useState<number>(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({
label: AGENT_PLATFORMS_LABEL,
options: groups.platforms.map(({ name, size }) => ({
label: name,
color: getColor(groupType),
value: { groupType, size },
})),
});
}
if (groups.policies.length > 0) {
const groupType = AGENT_GROUP_KEY.Policy;
opts.push({
label: AGENT_POLICY_LABEL,
options: groups.policies.map(({ name, size }) => ({
label: name,
color: getColor(groupType),
value: { groupType, size },
})),
});
}
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,
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,
},
})),
});
}
setLoading(false);
setOptions(opts);
}, [groups.platforms, groups.policies, totalNumAgents, groupsLoading, agents]);
// 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);
const newOptions = grouper.generateOptions();
setOptions(newOptions);
}, [groups.platforms, groups.policies, totalNumAgents, groupsLoading, agents, grouper]);
const onSelection = useCallback(
(selection: GroupOption[]) => {
// TODO?: optimize this by making it incremental
const newAgentSelection: AgentsSelection = {
agents: [],
allAgentsSelected: false,
platformsSelected: [],
policiesSelected: [],
};
// parse through the selections to be able to determine how many are actually selected
const selectedAgents = [];
const selectedGroups: SelectedGroups = {
policy: {},
platform: {},
};
// TODO: clean this up, make it less awkward
for (const opt of selection) {
const groupType = opt.value?.groupType;
let value;
switch (groupType) {
case AGENT_GROUP_KEY.All:
newAgentSelection.allAgentsSelected = true;
break;
case AGENT_GROUP_KEY.Platform:
value = opt.value as GroupOptionValue;
if (!newAgentSelection.allAgentsSelected) {
// we don't need to calculate diffs when all agents are selected
selectedGroups.platform[opt.label] = value.size;
}
newAgentSelection.platformsSelected.push(opt.label);
break;
case AGENT_GROUP_KEY.Policy:
value = opt.value as GroupOptionValue;
if (!newAgentSelection.allAgentsSelected) {
// we don't need to calculate diffs when all agents are selected
selectedGroups.policy[opt.label] = value.size ?? 0;
}
newAgentSelection.policiesSelected.push(opt.label);
break;
case AGENT_GROUP_KEY.Agent:
value = opt.value as AgentOptionValue;
if (!newAgentSelection.allAgentsSelected) {
// we don't need to count how many agents are selected if they are all selected
selectedAgents.push(opt.value);
}
// TODO: fix this casting by updating the opt type to be a union
newAgentSelection.agents.push(value.id as string);
break;
default:
// this should never happen!
// eslint-disable-next-line no-console
console.error(`unknown group type ${groupType}`);
}
}
// TODO?: optimize this by making the selection computation incremental
const {
newAgentSelection,
selectedAgents,
selectedGroups,
}: {
newAgentSelection: AgentSelection;
selectedAgents: AgentOptionValue[];
selectedGroups: SelectedGroups;
} = generateAgentSelection(selection);
if (newAgentSelection.allAgentsSelected) {
setNumAgentsSelected(totalNumAgents);
} else {
const checkAgent = generateAgentCheck(selectedGroups);
setNumAgentsSelected(
// filter out all the agents counted by selected policies and platforms
selectedAgents.filter((a) => checkAgent(a as AgentOptionValue)).length +
selectedAgents.filter(checkAgent).length +
// add the number of agents added via policy and platform groups
getNumAgentsInGrouping(selectedGroups) -
// subtract the number of agents double counted by policy/platform selections
@ -190,32 +109,40 @@ const AgentsTableComponent: React.FC<AgentsTableProps> = ({ onChange }) => {
[groups, onChange, totalNumAgents]
);
const renderOption = useCallback((option, searchValue, contentClassName) => {
const renderOption = useCallback((option, searchVal, contentClassName) => {
const { label, value } = option;
return value?.groupType === AGENT_GROUP_KEY.Agent ? (
<EuiHealth color={value?.online ? 'success' : 'danger'}>
<span className={contentClassName}>
<EuiHighlight search={searchValue}>{label}</EuiHighlight>
<EuiHighlight search={searchVal}>{label}</EuiHighlight>
</span>
</EuiHealth>
) : (
<span className={contentClassName}>
<EuiHighlight search={searchValue}>{label}</EuiHighlight>
<span>[{value?.size ?? 0}]</span>
&nbsp;
<span>({value?.size})</span>
<EuiHighlight search={searchVal}>{label}</EuiHighlight>
</span>
);
}, []);
const onSearchChange = useCallback((v: string) => {
// set the typing flag and update the search value
setModifyingSearch(v !== '');
setSearchValue(v);
}, []);
return (
<div>
<h2>{SELECT_AGENT_LABEL}</h2>
{numAgentsSelected > 0 ? <span>{generateSelectedAgentsMessage(numAgentsSelected)}</span> : ''}
&nbsp;
<EuiComboBox
placeholder="Select or create options"
isLoading={loading}
placeholder={SELECT_AGENT_LABEL}
isLoading={modifyingSearch || groupsLoading || agentsLoading}
options={options}
isClearable={true}
fullWidth={true}
onSearchChange={onSearchChange}
selectedOptions={selectedOptions}
onChange={onSelection}
renderOption={renderOption}

View file

@ -33,6 +33,7 @@ describe('processAggregations', () => {
const { platforms, policies, overlap } = processAggregations(input);
expect(platforms).toEqual([
{
id: 'darwin',
name: 'darwin',
size: 200,
},
@ -59,10 +60,12 @@ describe('processAggregations', () => {
expect(platforms).toEqual([]);
expect(policies).toEqual([
{
id: '8cd01a60-8a74-11eb-86cb-c58693443a4f',
name: '8cd01a60-8a74-11eb-86cb-c58693443a4f',
size: 100,
},
{
id: '8cd06880-8a74-11eb-86cb-c58693443a4f',
name: '8cd06880-8a74-11eb-86cb-c58693443a4f',
size: 100,
},
@ -107,16 +110,19 @@ describe('processAggregations', () => {
const { platforms, policies, overlap } = processAggregations(input);
expect(platforms).toEqual([
{
id: 'darwin',
name: 'darwin',
size: 200,
},
]);
expect(policies).toEqual([
{
id: '8cd01a60-8a74-11eb-86cb-c58693443a4f',
name: '8cd01a60-8a74-11eb-86cb-c58693443a4f',
size: 100,
},
{
id: '8cd06880-8a74-11eb-86cb-c58693443a4f',
name: '8cd06880-8a74-11eb-86cb-c58693443a4f',
size: 100,
},

View file

@ -20,6 +20,9 @@ import {
Group,
AgentOptionValue,
AggregationDataPoint,
AgentSelection,
GroupOptionValue,
GroupOption,
} from './types';
export type InspectResponse = Inspect & { response: string[] };
@ -43,11 +46,12 @@ export const processAggregations = (aggs: Record<string, Aggregate>) => {
const platformTerms = aggs.platforms as TermsAggregate<AggregationDataPoint>;
const policyTerms = aggs.policies as TermsAggregate<AggregationDataPoint>;
const policies = policyTerms?.buckets.map((o) => ({ name: o.key, size: o.doc_count })) ?? [];
const policies =
policyTerms?.buckets.map((o) => ({ name: o.key, id: o.key, size: o.doc_count })) ?? [];
if (platformTerms?.buckets) {
for (const { key, doc_count: size, policies: platformPolicies } of platformTerms.buckets) {
platforms.push({ name: key, size });
platforms.push({ name: key, id: key, size });
if (platformPolicies?.buckets && policies.length > 0) {
overlap[key] = platformPolicies.buckets.reduce((acc: { [key: string]: number }, pol) => {
acc[pol.key] = pol.doc_count;
@ -96,6 +100,63 @@ export const generateAgentCheck = (selectedGroups: SelectedGroups) => {
};
};
export const generateAgentSelection = (selection: GroupOption[]) => {
const newAgentSelection: AgentSelection = {
agents: [],
allAgentsSelected: false,
platformsSelected: [],
policiesSelected: [],
};
// parse through the selections to be able to determine how many are actually selected
const selectedAgents: AgentOptionValue[] = [];
const selectedGroups: SelectedGroups = {
policy: {},
platform: {},
};
// TODO: clean this up, make it less awkward
for (const opt of selection) {
const groupType = opt.value?.groupType;
let value;
switch (groupType) {
case AGENT_GROUP_KEY.All:
newAgentSelection.allAgentsSelected = true;
break;
case AGENT_GROUP_KEY.Platform:
value = opt.value as GroupOptionValue;
if (!newAgentSelection.allAgentsSelected) {
// we don't need to calculate diffs when all agents are selected
selectedGroups.platform[opt.value?.id ?? opt.label] = value.size;
}
newAgentSelection.platformsSelected.push(opt.label);
break;
case AGENT_GROUP_KEY.Policy:
value = opt.value as GroupOptionValue;
if (!newAgentSelection.allAgentsSelected) {
// we don't need to calculate diffs when all agents are selected
selectedGroups.policy[opt.value?.id ?? opt.label] = value.size;
}
newAgentSelection.policiesSelected.push(opt.label);
break;
case AGENT_GROUP_KEY.Agent:
value = opt.value as AgentOptionValue;
if (!newAgentSelection.allAgentsSelected) {
// we don't need to count how many agents are selected if they are all selected
selectedAgents.push(value);
}
if (value?.id) {
newAgentSelection.agents.push(value.id);
}
break;
default:
// this should never happen!
// eslint-disable-next-line no-console
console.error(`unknown group type ${groupType}`);
}
}
return { newAgentSelection, selectedGroups, selectedAgents };
};
export const generateTablePaginationOptions = (
activePage: number,
limit: number,

View file

@ -40,7 +40,7 @@ export const AGENT_SELECTION_LABEL = i18n.translate('xpack.osquery.agents.select
});
export const SELECT_AGENT_LABEL = i18n.translate('xpack.osquery.agents.selectAgentLabel', {
defaultMessage: `Select Agents`,
defaultMessage: `Select agents or groups`,
});
export const ERROR_ALL_AGENTS = i18n.translate('xpack.osquery.agents.errorSearchDescription', {

View file

@ -6,6 +6,7 @@
*/
import { TermsAggregate } from '@elastic/elasticsearch/api/types';
import { EuiComboBoxOptionOption } from '@elastic/eui';
interface BaseDataPoint {
key: string;
@ -17,6 +18,7 @@ export type AggregationDataPoint = BaseDataPoint & {
};
export interface Group {
id: string;
name: string;
size: number;
}
@ -28,14 +30,23 @@ export interface SelectedGroups {
[groupType: string]: { [groupName: string]: number };
}
export type GroupOption = EuiComboBoxOptionOption<AgentOptionValue | GroupOptionValue>;
export interface AgentSelection {
agents: string[];
allAgentsSelected: boolean;
platformsSelected: string[];
policiesSelected: string[];
}
interface BaseGroupOption {
id?: string;
groupType: AGENT_GROUP_KEY;
}
export type AgentOptionValue = BaseGroupOption & {
groups: { [groupType: string]: string };
online: boolean;
id: string;
};
export type GroupOptionValue = BaseGroupOption & {

View file

@ -7,6 +7,7 @@
import { useState } from 'react';
import { useQuery } from 'react-query';
import { useKibana } from '../common/lib/kibana';
import { useAgentPolicies } from './use_agent_policies';
import {
OsqueryQueries,
@ -25,6 +26,7 @@ interface UseAgentGroups {
export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseAgentGroups) => {
const { data } = useKibana().services;
const { agentPoliciesLoading, agentPolicyById } = useAgentPolicies(osqueryPolicies);
const [platforms, setPlatforms] = useState<Group[]>([]);
const [policies, setPolicies] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
@ -78,14 +80,22 @@ export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseA
setPlatforms(newPlatforms);
setOverlap(newOverlap);
setPolicies(newPolicies);
setPolicies(
newPolicies.map((p) => {
const name = agentPolicyById[p.id]?.name ?? p.name;
return {
...p,
name,
};
})
);
}
setLoading(false);
setTotalCount(responseData.totalCount);
},
{
enabled: !osqueryPoliciesLoading,
enabled: !osqueryPoliciesLoading && !agentPoliciesLoading,
}
);

View file

@ -0,0 +1,38 @@
/*
* 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 { useQueries, UseQueryResult } from 'react-query';
import { useKibana } from '../common/lib/kibana';
import {
AgentPolicy,
agentPolicyRouteService,
GetOneAgentPolicyResponse,
} from '../../../fleet/common';
export const useAgentPolicies = (policyIds: string[] = []) => {
const { http } = useKibana().services;
const agentResponse = useQueries(
policyIds.map((policyId) => ({
queryKey: ['agentPolicy', policyId],
queryFn: () => http.get(agentPolicyRouteService.getInfoPath(policyId)),
enabled: policyIds.length > 0,
}))
) as Array<UseQueryResult<GetOneAgentPolicyResponse>>;
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;
}
acc[p.id] = p;
return acc;
}, {} as { [key: string]: AgentPolicy });
return { agentPoliciesLoading, agentPolicies, agentPolicyById };
};

View file

@ -14,16 +14,30 @@ 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 }
) => {
const { perPage } = opts;
const { http } = useKibana().services;
const { isLoading: agentsLoading, data: agentData } = useQuery(
['agents', osqueryPolicies],
['agents', osqueryPolicies, searchValue, perPage],
async () => {
let kuery = `(${osqueryPolicies.map((p) => `policy_id:${p}`).join(' or ')})`;
if (searchValue) {
kuery += ` and (local_metadata.host.hostname:/${searchValue}/ or local_metadata.elastic.agent.id:/${searchValue}/)`;
}
return await http.get('/api/fleet/agents', {
query: {
kuery: osqueryPolicies.map((p) => `policy_id:${p}`).join(' or '),
perPage: 9000,
kuery,
perPage,
},
});
},

View file

@ -7,10 +7,11 @@
import React, { useCallback } from 'react';
import { FieldHook } from '../../shared_imports';
import { AgentsTable, AgentsSelection } from '../../agents/agents_table';
import { AgentsTable } from '../../agents/agents_table';
import { AgentSelection } from '../../agents/types';
interface AgentsTableFieldProps {
field: FieldHook<AgentsSelection>;
field: FieldHook<AgentSelection>;
}
const AgentsTableFieldComponent: React.FC<AgentsTableFieldProps> = ({ field }) => {