[Asset management] Text updates (#98192)

* updated scheduled query activation toggle text and interval header in query group

* added id validation for schedule queries

* fixed up agent resolution to ignore inactive agents, and properly pull all agents

* nixed unused file

* more validation for query fields

* added status table to the results data tab, added more validation

* updated wording

* added error notifications for failed queries

* pr feedback and cleanup

* fix up last hook

* use the pluralize macro, removed rbac tags

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Bryan Clement 2021-04-28 19:54:09 -07:00 committed by GitHub
parent f690c60517
commit b94f712f8c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 537 additions and 168 deletions

View file

@ -12,6 +12,16 @@ export type Name = t.TypeOf<typeof name>;
export const nameOrUndefined = t.union([name, t.undefined]);
export type NameOrUndefined = t.TypeOf<typeof nameOrUndefined>;
export const agentSelection = t.type({
agents: t.array(t.string),
allAgentsSelected: t.boolean,
platformsSelected: t.array(t.string),
policiesSelected: t.array(t.string),
});
export type AgentSelection = t.TypeOf<typeof agentSelection>;
export const agentSelectionOrUndefined = t.union([agentSelection, t.undefined]);
export type AgentSelectionOrUndefined = t.TypeOf<typeof agentSelectionOrUndefined>;
export const description = t.string;
export type Description = t.TypeOf<typeof description>;
export const descriptionOrUndefined = t.union([description, t.undefined]);

View file

@ -0,0 +1,17 @@
/*
* 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 * as t from 'io-ts';
import { query, agentSelection } from '../../common/schemas';
export const createActionRequestBodySchema = t.type({
agentSelection,
query,
});
export type CreateActionRequestBodySchema = t.OutputOf<typeof createActionRequestBodySchema>;

View file

@ -0,0 +1,8 @@
/*
* 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 * from './create_action_request_body_schema';

View file

@ -8,6 +8,7 @@
import { flatten, reverse, uniqBy } from 'lodash/fp';
import { useQuery } from 'react-query';
import { i18n } from '@kbn/i18n';
import { createFilter } from '../common/helpers';
import { useKibana } from '../common/lib/kibana';
import {
@ -32,7 +33,7 @@ export interface ResultsArgs {
totalCount: number;
}
interface UseActionResults {
export interface UseActionResults {
actionId: string;
activePage: number;
agentIds?: string[];
@ -55,7 +56,10 @@ export const useActionResults = ({
skip = false,
isLive = false,
}: UseActionResults) => {
const { data } = useKibana().services;
const {
data,
notifications: { toasts },
} = useKibana().services;
return useQuery(
['actionResults', { actionId }],
@ -120,6 +124,12 @@ export const useActionResults = ({
refetchInterval: isLive ? 1000 : false,
keepPreviousData: true,
enabled: !skip && !!agentIds?.length,
onError: (error: Error) =>
toasts.addError(error, {
title: i18n.translate('xpack.osquery.action_results.fetchError', {
defaultMessage: 'Error while fetching action results',
}),
}),
}
);
};

View file

@ -7,6 +7,7 @@
import { useQuery } from 'react-query';
import { i18n } from '@kbn/i18n';
import { createFilter } from '../common/helpers';
import { useKibana } from '../common/lib/kibana';
import {
@ -32,7 +33,10 @@ interface UseActionDetails {
}
export const useActionDetails = ({ actionId, filterQuery, skip = false }: UseActionDetails) => {
const { data } = useKibana().services;
const {
data,
notifications: { toasts },
} = useKibana().services;
return useQuery(
['actionDetails', { actionId, filterQuery }],
@ -57,6 +61,12 @@ export const useActionDetails = ({ actionId, filterQuery, skip = false }: UseAct
},
{
enabled: !skip,
onError: (error: Error) =>
toasts.addError(error, {
title: i18n.translate('xpack.osquery.action_details.fetchError', {
defaultMessage: 'Error while fetching action details',
}),
}),
}
);
};

View file

@ -7,6 +7,7 @@
import { useQuery } from 'react-query';
import { i18n } from '@kbn/i18n';
import { createFilter } from '../common/helpers';
import { useKibana } from '../common/lib/kibana';
import {
@ -47,7 +48,10 @@ export const useAllActions = ({
filterQuery,
skip = false,
}: UseAllActions) => {
const { data } = useKibana().services;
const {
data,
notifications: { toasts },
} = useKibana().services;
return useQuery(
['actions', { activePage, direction, limit, sortField }],
@ -78,6 +82,12 @@ export const useAllActions = ({
{
keepPreviousData: true,
enabled: !skip,
onError: (error: Error) =>
toasts.addError(error, {
title: i18n.translate('xpack.osquery.all_actions.fetchError', {
defaultMessage: 'Error while fetching actions',
}),
}),
}
);
};

View file

@ -7,6 +7,7 @@
import { useQuery } from 'react-query';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../common/lib/kibana';
import {
agentPolicyRouteService,
@ -15,7 +16,10 @@ import {
} from '../../../fleet/common';
export const useAgentPolicies = () => {
const { http } = useKibana().services;
const {
http,
notifications: { toasts },
} = useKibana().services;
return useQuery<GetAgentPoliciesResponse, unknown, GetAgentPoliciesResponseItem[]>(
['agentPolicies'],
@ -30,6 +34,12 @@ export const useAgentPolicies = () => {
placeholderData: [],
keepPreviousData: true,
select: (response) => response.items,
onError: (error) =>
toasts.addError(error as Error, {
title: i18n.translate('xpack.osquery.agent_policies.fetchError', {
defaultMessage: 'Error while fetching agent policies',
}),
}),
}
);
};

View file

@ -7,6 +7,7 @@
import { useQuery } from 'react-query';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../common/lib/kibana';
import { agentPolicyRouteService } from '../../../fleet/common';
@ -16,7 +17,10 @@ interface UseAgentPolicy {
}
export const useAgentPolicy = ({ policyId, skip }: UseAgentPolicy) => {
const { http } = useKibana().services;
const {
http,
notifications: { toasts },
} = useKibana().services;
return useQuery(
['agentPolicy', { policyId }],
@ -25,6 +29,12 @@ export const useAgentPolicy = ({ policyId, skip }: UseAgentPolicy) => {
enabled: !skip,
keepPreviousData: true,
select: (response) => response.item,
onError: (error: Error) =>
toasts.addError(error, {
title: i18n.translate('xpack.osquery.agent_policy_details.fetchError', {
defaultMessage: 'Error while fetching agent policy details',
}),
}),
}
);
};

View file

@ -6,6 +6,7 @@
*/
import { useState } from 'react';
import { useQuery } from 'react-query';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../common/lib/kibana';
import { useAgentPolicies } from './use_agent_policies';
@ -24,7 +25,10 @@ interface UseAgentGroups {
}
export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseAgentGroups) => {
const { data } = useKibana().services;
const {
data,
notifications: { toasts },
} = useKibana().services;
const { agentPoliciesLoading, agentPolicyById } = useAgentPolicies(osqueryPolicies);
const [platforms, setPlatforms] = useState<Group[]>([]);
@ -96,6 +100,12 @@ export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseA
},
{
enabled: !osqueryPoliciesLoading && !agentPoliciesLoading,
onError: (error) =>
toasts.addError(error as Error, {
title: i18n.translate('xpack.osquery.agent_groups.fetchError', {
defaultMessage: 'Error while fetching agent groups',
}),
}),
}
);

View file

@ -7,17 +7,27 @@
import { mapKeys } from 'lodash';
import { useQueries, UseQueryResult } from 'react-query';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../common/lib/kibana';
import { agentPolicyRouteService, GetOneAgentPolicyResponse } from '../../../fleet/common';
export const useAgentPolicies = (policyIds: string[] = []) => {
const { http } = useKibana().services;
const {
http,
notifications: { toasts },
} = useKibana().services;
const agentResponse = useQueries(
policyIds.map((policyId) => ({
queryKey: ['agentPolicy', policyId],
queryFn: () => http.get(agentPolicyRouteService.getInfoPath(policyId)),
enabled: policyIds.length > 0,
onError: (error) =>
toasts.addError(error as Error, {
title: i18n.translate('xpack.osquery.action_policy_details.fetchError', {
defaultMessage: 'Error while fetching policy details',
}),
}),
}))
) as Array<UseQueryResult<GetOneAgentPolicyResponse>>;

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { useQuery } from 'react-query';
import { GetAgentStatusResponse, agentRouteService } from '../../../fleet/common';
@ -16,7 +17,10 @@ interface UseAgentStatus {
}
export const useAgentStatus = ({ policyId, skip }: UseAgentStatus) => {
const { http } = useKibana().services;
const {
http,
notifications: { toasts },
} = useKibana().services;
return useQuery<GetAgentStatusResponse, unknown, GetAgentStatusResponse['results']>(
['agentStatus', policyId],
@ -34,6 +38,12 @@ export const useAgentStatus = ({ policyId, skip }: UseAgentStatus) => {
{
enabled: !skip,
select: (response) => response.results,
onError: (error) =>
toasts.addError(error as Error, {
title: i18n.translate('xpack.osquery.agent_status.fetchError', {
defaultMessage: 'Error while fetching agent status',
}),
}),
}
);
};

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { useQuery } from 'react-query';
import { GetAgentsResponse, agentRouteService } from '../../../fleet/common';
@ -27,7 +28,10 @@ export const useAllAgents = (
opts: RequestOptions = { perPage: 9000 }
) => {
const { perPage } = opts;
const { http } = useKibana().services;
const {
http,
notifications: { toasts },
} = useKibana().services;
const { isLoading: agentsLoading, data: agentData } = useQuery<GetAgentsResponse>(
['agents', osqueryPolicies, searchValue, perPage],
() => {
@ -52,6 +56,12 @@ export const useAllAgents = (
},
{
enabled: !osqueryPoliciesLoading,
onError: (error) =>
toasts.addError(error as Error, {
title: i18n.translate('xpack.osquery.agents.fetchError', {
defaultMessage: 'Error while fetching agents',
}),
}),
}
);

View file

@ -5,15 +5,21 @@
* 2.0.
*/
import { uniq } from 'lodash';
import { useQuery } from 'react-query';
import { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../common/lib/kibana';
import { packagePolicyRouteService, PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../fleet/common';
import { OSQUERY_INTEGRATION_NAME } from '../../common';
export const useOsqueryPolicies = () => {
const { http } = useKibana().services;
const {
http,
notifications: { toasts },
} = useKibana().services;
const { isLoading: osqueryPoliciesLoading, data: osqueryPolicies } = useQuery(
const { isLoading: osqueryPoliciesLoading, data: osqueryPolicies = [] } = useQuery(
['osqueryPolicies'],
() =>
http.get(packagePolicyRouteService.getListPath(), {
@ -21,8 +27,19 @@ export const useOsqueryPolicies = () => {
kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${OSQUERY_INTEGRATION_NAME}`,
},
}),
{ select: (data) => data.items.map((p: { policy_id: string }) => p.policy_id) }
{
select: (response) =>
uniq<string>(response.items.map((p: { policy_id: string }) => p.policy_id)),
onError: (error: Error) =>
toasts.addError(error, {
title: i18n.translate('xpack.osquery.osquery_policies.fetchError', {
defaultMessage: 'Error while fetching osquery policies',
}),
}),
}
);
return { osqueryPoliciesLoading, osqueryPolicies };
return useMemo(() => ({ osqueryPoliciesLoading, osqueryPolicies }), [
osqueryPoliciesLoading,
osqueryPolicies,
]);
};

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { find } from 'lodash/fp';
import { useQuery } from 'react-query';
@ -13,7 +14,10 @@ import { OSQUERY_INTEGRATION_NAME } from '../../../common';
import { useKibana } from '../lib/kibana';
export const useOsqueryIntegration = () => {
const { http } = useKibana().services;
const {
http,
notifications: { toasts },
} = useKibana().services;
return useQuery(
'integrations',
@ -26,6 +30,12 @@ export const useOsqueryIntegration = () => {
{
select: ({ response }: GetPackagesResponse) =>
find(['name', OSQUERY_INTEGRATION_NAME], response),
onError: (error: Error) =>
toasts.addError(error, {
title: i18n.translate('xpack.osquery.osquery_integration.fetchError', {
defaultMessage: 'Error while fetching osquery integration',
}),
}),
}
);
};

View file

@ -0,0 +1,17 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { ValidationFunc, fieldValidators } from '../shared_imports';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const queryFieldValidation: ValidationFunc<any, string, string> = fieldValidators.emptyField(
i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.emptyQueryError', {
defaultMessage: 'Query is a required field',
})
);

View file

@ -22,7 +22,7 @@ const QueryAgentResultsComponent = () => {
{data?.actionDetails._source?.data?.query}
</EuiCodeBlock>
<EuiSpacer />
<ResultsTable actionId={actionId} agentId={agentId} />
<ResultsTable actionId={actionId} selectedAgent={agentId} />
</>
);
};

View file

@ -12,14 +12,18 @@ import { FormattedMessage } from '@kbn/i18n/react';
import React, { useMemo } from 'react';
import { useMutation } from 'react-query';
import { UseField, Form, FormData, useForm, useFormData } from '../../shared_imports';
import { UseField, Form, FormData, useForm, useFormData, FIELD_TYPES } from '../../shared_imports';
import { AgentsTableField } from './agents_table_field';
import { LiveQueryQueryField } from './live_query_query_field';
import { useKibana } from '../../common/lib/kibana';
import { ResultTabs } from '../../queries/edit/tabs';
import { queryFieldValidation } from '../../common/validations';
import { fieldValidators } from '../../shared_imports';
const FORM_ID = 'liveQueryForm';
export const MAX_QUERY_LENGTH = 2000;
interface LiveQueryFormProps {
defaultValue?: Partial<FormData> | undefined;
onSubmit?: (payload: Record<string, string>) => Promise<void>;
@ -50,9 +54,27 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
}
);
const formSchema = {
query: {
type: FIELD_TYPES.TEXT,
validations: [
{
validator: fieldValidators.maxLengthField({
length: MAX_QUERY_LENGTH,
message: i18n.translate('xpack.osquery.liveQuery.queryForm.largeQueryError', {
defaultMessage: 'Query is too large (max {maxLength} characters)',
values: { maxLength: MAX_QUERY_LENGTH },
}),
}),
},
{ validator: queryFieldValidation },
],
},
};
const { form } = useForm({
id: FORM_ID,
// schema: formSchema,
schema: formSchema,
onSubmit: (payload) => {
return mutateAsync(payload);
},
@ -60,10 +82,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
stripEmptyFields: false,
},
defaultValue: defaultValue ?? {
query: {
id: null,
query: '',
},
query: '',
},
});
@ -85,16 +104,16 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
[agentSelection]
);
const queryValueProvided = useMemo(() => !!query?.query?.length, [query]);
const queryValueProvided = useMemo(() => !!query?.length, [query]);
const queryStatus = useMemo(() => {
if (!agentSelected) return 'disabled';
if (isError) return 'danger';
if (isError || !form.getFields().query.isValid) return 'danger';
if (isLoading) return 'loading';
if (isSuccess) return 'complete';
return 'incomplete';
}, [agentSelected, isError, isLoading, isSuccess]);
}, [agentSelected, isError, isLoading, isSuccess, form]);
const resultsStatus = useMemo(() => (queryStatus === 'complete' ? 'incomplete' : 'disabled'), [
queryStatus,

View file

@ -5,86 +5,32 @@
* 2.0.
*/
// import { find } from 'lodash/fp';
// import { EuiCodeBlock, EuiSuperSelect, EuiText, EuiSpacer } from '@elastic/eui';
import React, { useCallback } from 'react';
// import { useQuery } from 'react-query';
import { EuiFormRow } from '@elastic/eui';
import { FieldHook } from '../../shared_imports';
// import { useKibana } from '../../common/lib/kibana';
import { OsqueryEditor } from '../../editor';
interface LiveQueryQueryFieldProps {
disabled?: boolean;
field: FieldHook<{
id: string | null;
query: string;
}>;
field: FieldHook<string>;
}
const LiveQueryQueryFieldComponent: React.FC<LiveQueryQueryFieldProps> = ({ disabled, field }) => {
// const { http } = useKibana().services;
// const { data } = useQuery('savedQueryList', () =>
// http.get('/internal/osquery/saved_query', {
// query: {
// pageIndex: 0,
// pageSize: 100,
// sortField: 'updated_at',
// sortDirection: 'desc',
// },
// })
// );
// const queryOptions =
// // @ts-expect-error update types
// data?.saved_objects.map((savedQuery) => ({
// value: savedQuery,
// inputDisplay: savedQuery.attributes.name,
// dropdownDisplay: (
// <>
// <strong>{savedQuery.attributes.name}</strong>
// <EuiText size="s" color="subdued">
// <p className="euiTextColor--subdued">{savedQuery.attributes.description}</p>
// </EuiText>
// <EuiCodeBlock language="sql" fontSize="s" paddingSize="s">
// {savedQuery.attributes.query}
// </EuiCodeBlock>
// </>
// ),
// })) ?? [];
const { value, setValue } = field;
// const handleSavedQueryChange = useCallback(
// (newValue) => {
// setValue({
// id: newValue.id,
// query: newValue.attributes.query,
// });
// },
// [setValue]
// );
const { value, setValue, errors } = field;
const error = errors[0]?.message;
const handleEditorChange = useCallback(
(newValue) => {
setValue({
id: null,
query: newValue,
});
setValue(newValue);
},
[setValue]
);
return (
<>
{/* <EuiSuperSelect
valueOfSelected={find(['id', value.id], data?.saved_objects)}
options={queryOptions}
onChange={handleSavedQueryChange}
/>
<EuiSpacer /> */}
<OsqueryEditor defaultValue={value.query} disabled={disabled} onChange={handleEditorChange} />
</>
<EuiFormRow isInvalid={typeof error === 'string'} error={error} fullWidth>
<OsqueryEditor defaultValue={value} disabled={disabled} onChange={handleEditorChange} />
</EuiFormRow>
);
};

View file

@ -36,7 +36,7 @@ const ResultTabsComponent: React.FC<ResultTabsProps> = ({ actionId, agentIds, is
content: (
<>
<EuiSpacer />
<ResultsTable actionId={actionId} isLive={isLive} />
<ResultsTable actionId={actionId} agentIds={agentIds} isLive={isLive} />
</>
),
},

View file

@ -31,15 +31,16 @@ const OsquerySchemaLink = React.memo(() => (
OsquerySchemaLink.displayName = 'OsquerySchemaLink';
const CodeEditorFieldComponent: React.FC<CodeEditorFieldProps> = ({ field }) => {
const { value, label, labelAppend, helpText, setValue } = field;
const { value, label, labelAppend, helpText, setValue, errors } = field;
const error = errors[0]?.message;
return (
<EuiFormRow
label={label}
labelAppend={!isEmpty(labelAppend) ? labelAppend : <OsquerySchemaLink />}
helpText={helpText}
// isInvalid={typeof error === 'string'}
// error={error}
isInvalid={typeof error === 'string'}
error={error}
fullWidth
>
<OsqueryEditor defaultValue={value} onChange={setValue} />

View file

@ -12,6 +12,10 @@ import {
EuiDataGridProps,
EuiDataGridColumn,
EuiLink,
EuiTextColor,
EuiBasicTable,
EuiBasicTableColumn,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react';
@ -20,16 +24,89 @@ import { pagePathGetters } from '../../../fleet/public';
import { useAllResults } from './use_all_results';
import { Direction, ResultEdges } from '../../common/search_strategy';
import { useKibana } from '../common/lib/kibana';
import { useActionResults } from '../action_results/use_action_results';
import { generateEmptyDataMessage } from './translations';
const DataContext = createContext<ResultEdges>([]);
interface ResultsTableComponentProps {
actionId: string;
agentId?: string;
selectedAgent?: string;
agentIds?: string[];
isLive?: boolean;
}
const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({ actionId, isLive }) => {
interface SummaryTableValue {
total: number | string;
pending: number | string;
responded: number;
failed: number;
}
const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({
actionId,
agentIds,
isLive,
}) => {
const {
// @ts-expect-error update types
data: { aggregations },
} = useActionResults({
actionId,
activePage: 0,
agentIds,
limit: 0,
direction: Direction.asc,
sortField: '@timestamp',
isLive,
});
const notRespondedCount = useMemo(() => {
if (!agentIds || !aggregations.totalResponded) {
return '-';
}
return agentIds.length - aggregations.totalResponded;
}, [aggregations.totalResponded, agentIds]);
const summaryColumns: Array<EuiBasicTableColumn<SummaryTableValue>> = useMemo(
() => [
{
field: 'total',
name: 'Agents queried',
},
{
field: 'responded',
name: 'Successful',
},
{
field: 'pending',
name: 'Not yet responded',
},
{
field: 'failed',
name: 'Failed',
// eslint-disable-next-line react/display-name
render: (failed: number) => (
<EuiTextColor color={failed ? 'danger' : 'default'}>{failed}</EuiTextColor>
),
},
],
[]
);
const summaryItems = useMemo(
() => [
{
total: agentIds?.length ?? '-',
pending: notRespondedCount,
responded: aggregations.totalResponded,
failed: aggregations.failed,
},
],
[aggregations, agentIds, notRespondedCount]
);
const { getUrlForApp } = useKibana().services.application;
const getFleetAppUrl = useCallback(
@ -115,30 +192,41 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({ actionId,
const newColumns = keys(allResultsData?.edges[0]?.fields)
.sort()
.reduce((acc, fieldName) => {
if (fieldName === 'agent.name') {
acc.push({
id: fieldName,
displayAsText: i18n.translate('xpack.osquery.liveQueryResults.table.agentColumnTitle', {
defaultMessage: 'agent',
}),
defaultSortDirection: Direction.asc,
});
.reduce(
(acc, fieldName) => {
const { data, seen } = acc;
if (fieldName === 'agent.name') {
data.push({
id: fieldName,
displayAsText: i18n.translate(
'xpack.osquery.liveQueryResults.table.agentColumnTitle',
{
defaultMessage: 'agent',
}
),
defaultSortDirection: Direction.asc,
});
return acc;
}
if (fieldName.startsWith('osquery.')) {
const displayAsText = fieldName.split('.')[1];
if (!seen.has(displayAsText)) {
data.push({
id: fieldName,
displayAsText,
defaultSortDirection: Direction.asc,
});
seen.add(displayAsText);
}
return acc;
}
return acc;
}
if (fieldName.startsWith('osquery.')) {
acc.push({
id: fieldName,
displayAsText: fieldName.split('.')[1],
defaultSortDirection: Direction.asc,
});
return acc;
}
return acc;
}, [] as EuiDataGridColumn[]);
},
{ data: [], seen: new Set<string>() } as { data: EuiDataGridColumn[]; seen: Set<string> }
).data;
if (!isEqual(columns, newColumns)) {
setColumns(newColumns);
@ -149,16 +237,24 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({ actionId,
return (
// @ts-expect-error update types
<DataContext.Provider value={allResultsData?.edges}>
<EuiDataGrid
aria-label="Osquery results"
columns={columns}
columnVisibility={columnVisibility}
rowCount={allResultsData?.totalCount ?? 0}
renderCellValue={renderCellValue}
sorting={tableSorting}
pagination={tablePagination}
height="500px"
/>
<EuiBasicTable items={summaryItems} rowHeader="total" columns={summaryColumns} />
<EuiSpacer />
{columns.length > 0 ? (
<EuiDataGrid
aria-label="Osquery results"
columns={columns}
columnVisibility={columnVisibility}
rowCount={allResultsData?.totalCount ?? 0}
renderCellValue={renderCellValue}
sorting={tableSorting}
pagination={tablePagination}
height="500px"
/>
) : (
<div className={'eui-textCenter'}>
{generateEmptyDataMessage(aggregations.totalResponded)}
</div>
)}
</DataContext.Provider>
);
};

View file

@ -7,6 +7,14 @@
import { i18n } from '@kbn/i18n';
export const generateEmptyDataMessage = (agentsResponded: number): string => {
return i18n.translate('xpack.osquery.results.multipleAgentsResponded', {
defaultMessage:
'{agentsResponded, plural, one {# agent has} other {# agents have}} responded, but no osquery data has been reported.',
values: { agentsResponded },
});
};
export const ERROR_ALL_RESULTS = i18n.translate('xpack.osquery.results.errorSearchDescription', {
defaultMessage: `An error has occurred on all results search`,
});

View file

@ -7,6 +7,7 @@
import { useQuery } from 'react-query';
import { i18n } from '@kbn/i18n';
import { createFilter } from '../common/helpers';
import { useKibana } from '../common/lib/kibana';
import {
@ -51,7 +52,10 @@ export const useAllResults = ({
skip = false,
isLive = false,
}: UseAllResults) => {
const { data } = useKibana().services;
const {
data,
notifications: { toasts },
} = useKibana().services;
return useQuery(
['allActionResults', { actionId, activePage, direction, limit, sortField }],
@ -82,6 +86,12 @@ export const useAllResults = ({
{
refetchInterval: isLive ? 1000 : false,
enabled: !skip,
onError: (error: Error) =>
toasts.addError(error, {
title: i18n.translate('xpack.osquery.results.fetchError', {
defaultMessage: 'Error while fetching results',
}),
}),
}
);
};

View file

@ -23,6 +23,7 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { CodeEditorField } from '../../queries/form/code_editor_field';
import { idFieldValidations, intervalFieldValidation, queryFieldValidation } from './validations';
import { Form, useForm, FormData, getUseField, Field, FIELD_TYPES } from '../../shared_imports';
const FORM_ID = 'addQueryFlyoutForm';
@ -50,12 +51,14 @@ const AddQueryFlyoutComponent: React.FC<AddQueryFlyoutProps> = ({ onSave, onClos
label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.idFieldLabel', {
defaultMessage: 'ID',
}),
validations: idFieldValidations.map((validator) => ({ validator })),
},
query: {
type: FIELD_TYPES.TEXT,
label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.queryFieldLabel', {
defaultMessage: 'Query',
}),
validations: [{ validator: queryFieldValidation }],
},
interval: {
type: FIELD_TYPES.NUMBER,
@ -65,6 +68,7 @@ const AddQueryFlyoutComponent: React.FC<AddQueryFlyoutProps> = ({ onSave, onClos
defaultMessage: 'Interval (s)',
}
),
validations: [{ validator: intervalFieldValidation }],
},
},
});

View file

@ -74,7 +74,7 @@ const ConfirmDeployAgentPolicyModalComponent: React.FC<ConfirmDeployAgentPolicyM
<EuiSpacer size="l" />
<FormattedMessage
id="xpack.osquery.agentPolicy.confirmModalDescription"
defaultMessage="This action can not be undone. Are you sure you wish to continue?"
defaultMessage="Are you sure you wish to continue?"
/>
</EuiConfirmModal>
);

View file

@ -25,6 +25,7 @@ import { i18n } from '@kbn/i18n';
import { PackagePolicyInputStream } from '../../../../fleet/common';
import { CodeEditorField } from '../../queries/form/code_editor_field';
import { Form, useForm, getUseField, Field, FIELD_TYPES } from '../../shared_imports';
import { idFieldValidations, intervalFieldValidation, queryFieldValidation } from './validations';
const FORM_ID = 'editQueryFlyoutForm';
@ -64,12 +65,14 @@ export const EditQueryFlyout: React.FC<EditQueryFlyoutProps> = ({
label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.idFieldLabel', {
defaultMessage: 'ID',
}),
validations: idFieldValidations.map((validator) => ({ validator })),
},
query: {
type: FIELD_TYPES.TEXT,
label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.queryFieldLabel', {
defaultMessage: 'Query',
}),
validations: [{ validator: queryFieldValidation }],
},
interval: {
type: FIELD_TYPES.NUMBER,
@ -79,6 +82,7 @@ export const EditQueryFlyout: React.FC<EditQueryFlyoutProps> = ({
defaultMessage: 'Interval (s)',
}
),
validations: [{ validator: intervalFieldValidation }],
},
},
});

View file

@ -0,0 +1,12 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const INVALID_ID_ERROR = i18n.translate('xpack.osquery.agents.failSearchDescription', {
defaultMessage: `Failed to fetch agents`,
});

View file

@ -0,0 +1,48 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { ValidationFunc, fieldValidators } from '../../shared_imports';
export { queryFieldValidation } from '../../common/validations';
const idPattern = /^[a-zA-Z0-9-_]+$/;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const idSchemaValidation: ValidationFunc<any, string, string> = ({ value }) => {
const valueIsValid = idPattern.test(value);
if (!valueIsValid) {
return {
message: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.invalidIdError', {
defaultMessage: 'Characters must be alphanumeric, _, or -',
}),
};
}
};
export const idFieldValidations = [
fieldValidators.emptyField(
i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.emptyIdError', {
defaultMessage: 'ID is required',
})
),
idSchemaValidation,
];
export const intervalFieldValidation: ValidationFunc<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any,
string,
number
> = fieldValidators.numberGreaterThanField({
than: 0,
message: i18n.translate(
'xpack.osquery.scheduledQueryGroup.queryFlyoutForm.invalidIntervalField',
{
defaultMessage: 'A positive interval value is required',
}
),
});

View file

@ -148,7 +148,7 @@ const ScheduledQueryGroupQueriesTableComponent: React.FC<ScheduledQueryGroupQuer
{
field: 'vars.interval.value',
name: i18n.translate('xpack.osquery.scheduledQueryGroup.queriesTable.intervalColumnTitle', {
defaultMessage: 'Interval',
defaultMessage: 'Interval (s)',
}),
width: '100px',
},

View file

@ -26,6 +26,7 @@ export {
ValidationFunc,
VALIDATION_TYPES,
} from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib';
export {
Field,
ComboBoxField,

View file

@ -5,7 +5,10 @@
* 2.0.
*/
import type { ElasticsearchClient } from 'src/core/server';
import { uniq } from 'lodash';
import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server';
import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../fleet/common';
import { OSQUERY_INTEGRATION_NAME } from '../../common';
import { OsqueryAppContext } from './osquery_app_context_services';
export interface AgentSelection {
@ -15,45 +18,82 @@ export interface AgentSelection {
policiesSelected: string[];
}
const PER_PAGE = 9000;
const aggregateResults = async (
generator: (page: number, perPage: number) => Promise<{ results: string[]; total: number }>
) => {
const { results, total } = await generator(1, PER_PAGE);
const totalPages = Math.ceil(total / PER_PAGE);
let currPage = 2;
while (currPage <= totalPages) {
const { results: additionalResults } = await generator(currPage++, PER_PAGE);
results.push(...additionalResults);
}
return uniq<string>(results);
};
export const parseAgentSelection = async (
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
context: OsqueryAppContext,
agentSelection: AgentSelection
) => {
let selectedAgents: string[] = [];
const selectedAgents: Set<string> = new Set();
const addAgent = selectedAgents.add.bind(selectedAgents);
const { allAgentsSelected, platformsSelected, policiesSelected, agents } = agentSelection;
const agentService = context.service.getAgentService();
if (agentService) {
if (allAgentsSelected) {
// TODO: actually fetch all the agents
const { agents: fetchedAgents } = await agentService.listAgents(esClient, {
perPage: 9000,
showInactive: true,
const packagePolicyService = context.service.getPackagePolicyService();
const kueryFragments = ['active:true'];
if (agentService && packagePolicyService) {
const osqueryPolicies = await aggregateResults(async (page, perPage) => {
const { items, total } = await packagePolicyService.list(soClient, {
kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${OSQUERY_INTEGRATION_NAME}`,
perPage,
page,
});
selectedAgents.push(...fetchedAgents.map((a) => a.id));
} else {
if (platformsSelected.length > 0 || policiesSelected.length > 0) {
const kueryFragments = [];
if (platformsSelected.length) {
kueryFragments.push(
...platformsSelected.map((platform) => `local_metadata.os.platform:${platform}`)
);
}
if (policiesSelected.length) {
kueryFragments.push(...policiesSelected.map((policy) => `policy_id:${policy}`));
}
const kuery = kueryFragments.join(' or ');
// TODO: actually fetch all the agents
const { agents: fetchedAgents } = await agentService.listAgents(esClient, {
return { results: items.map((it) => it.policy_id), total };
});
kueryFragments.push(`policy_id:(${uniq(osqueryPolicies).join(',')})`);
if (allAgentsSelected) {
const kuery = kueryFragments.join(' and ');
const fetchedAgents = await aggregateResults(async (page, perPage) => {
const res = await agentService.listAgents(esClient, {
perPage,
page,
kuery,
perPage: 9000,
showInactive: true,
});
selectedAgents.push(...fetchedAgents.map((a) => a.id));
return { results: res.agents.map((agent) => agent.id), total: res.total };
});
fetchedAgents.forEach(addAgent);
} else {
if (platformsSelected.length > 0 || policiesSelected.length > 0) {
const groupFragments = [];
if (platformsSelected.length) {
groupFragments.push(`local_metadata.os.platform:(${platformsSelected.join(',')})`);
}
if (policiesSelected.length) {
groupFragments.push(`policy_id:(${policiesSelected.join(',')})`);
}
kueryFragments.push(`(${groupFragments.join(' or ')})`);
const kuery = kueryFragments.join(' and ');
const fetchedAgents = await aggregateResults(async (page, perPage) => {
const res = await agentService.listAgents(esClient, {
perPage,
page,
kuery,
showInactive: true,
});
return { results: res.agents.map((agent) => agent.id), total: res.total };
});
fetchedAgents.forEach(addAgent);
}
selectedAgents.push(...agents);
selectedAgents = Array.from(new Set(selectedAgents));
}
}
return selectedAgents;
agents.forEach(addAgent);
return Array.from(selectedAgents);
};

View file

@ -7,29 +7,42 @@
import uuid from 'uuid';
import moment from 'moment';
import { schema } from '@kbn/config-schema';
import { IRouter } from '../../../../../../src/core/server';
import { OsqueryAppContext } from '../../lib/osquery_app_context_services';
import { parseAgentSelection, AgentSelection } from '../../lib/parse_agent_groups';
import { buildRouteValidation } from '../../utils/build_validation/route_validation';
import {
createActionRequestBodySchema,
CreateActionRequestBodySchema,
} from '../../../common/schemas/routes/action/create_action_request_body_schema';
export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => {
router.post(
{
path: '/internal/osquery/action',
validate: {
params: schema.object({}, { unknowns: 'allow' }),
body: schema.object({}, { unknowns: 'allow' }),
},
options: {
tags: ['access:osquery', 'access:osquery_write'],
body: buildRouteValidation<
typeof createActionRequestBodySchema,
CreateActionRequestBodySchema
>(createActionRequestBodySchema),
},
},
async (context, request, response) => {
const esClient = context.core.elasticsearch.client.asCurrentUser;
const soClient = context.core.savedObjects.client;
const { agentSelection } = request.body as { agentSelection: AgentSelection };
const selectedAgents = await parseAgentSelection(esClient, osqueryContext, agentSelection);
const selectedAgents = await parseAgentSelection(
esClient,
soClient,
osqueryContext,
agentSelection
);
if (!selectedAgents.length) {
throw new Error('No agents found for selection, aborting.');
}
const action = {
action_id: uuid.v4(),
@ -39,10 +52,8 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon
input_type: 'osquery',
agents: selectedAgents,
data: {
// @ts-expect-error update validation
id: request.body.query.id ?? uuid.v4(),
// @ts-expect-error update validation
query: request.body.query.query,
id: uuid.v4(),
query: request.body.query,
},
};
const actionResponse = await esClient.index<{}, {}>({