[Osquery] Fix scheduled query status (#106600) (#110160)

Co-authored-by: Patryk Kopyciński <patryk.kopycinski@elastic.co>
This commit is contained in:
Kibana Machine 2021-08-25 21:10:05 -04:00 committed by GitHub
parent bdd059e150
commit b82c629c6c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1323 additions and 532 deletions

View file

@ -349,7 +349,7 @@
"react-moment-proptypes": "^1.7.0",
"react-monaco-editor": "^0.41.2",
"react-popper-tooltip": "^2.10.1",
"react-query": "^3.18.1",
"react-query": "^3.21.0",
"react-redux": "^7.2.0",
"react-resizable": "^1.7.5",
"react-resize-detector": "^4.2.0",

View file

@ -7,12 +7,19 @@
import { EuiLink } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import { PLUGIN_ID } from '../../../fleet/common';
import { pagePathGetters } from '../../../fleet/public';
import { useKibana, isModifiedEvent, isLeftClickEvent } from '../common/lib/kibana';
import { useAgentPolicy } from './use_agent_policy';
const StyledEuiLink = styled(EuiLink)`
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
interface AgentsPolicyLinkProps {
policyId: string;
}
@ -46,10 +53,9 @@ const AgentsPolicyLinkComponent: React.FC<AgentsPolicyLinkProps> = ({ policyId }
);
return (
// eslint-disable-next-line @elastic/eui/href-or-on-click
<EuiLink href={href} onClick={handleClick}>
<StyledEuiLink href={href} onClick={handleClick}>
{data?.name ?? policyId}
</EuiLink>
</StyledEuiLink>
);
};

View file

@ -36,6 +36,10 @@ export const useAgentPolicy = ({ policyId, skip, silent }: UseAgentPolicy) => {
defaultMessage: 'Error while fetching agent policy details',
}),
}),
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
}
);
};

View file

@ -0,0 +1,58 @@
/*
* 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 { map } from 'lodash';
import { i18n } from '@kbn/i18n';
import { useQuery } from 'react-query';
import { AGENT_SAVED_OBJECT_TYPE, Agent } from '../../../fleet/common';
import { useErrorToast } from '../common/hooks/use_error_toast';
import { useKibana } from '../common/lib/kibana';
interface UseAgentPolicyAgentIdsProps {
agentPolicyId: string | undefined;
silent?: boolean;
skip?: boolean;
}
export const useAgentPolicyAgentIds = ({
agentPolicyId,
silent,
skip,
}: UseAgentPolicyAgentIdsProps) => {
const { http } = useKibana().services;
const setErrorToast = useErrorToast();
return useQuery<{ agents: Agent[] }, unknown, string[]>(
['agentPolicyAgentIds', agentPolicyId],
() => {
const kuery = `${AGENT_SAVED_OBJECT_TYPE}.policy_id:${agentPolicyId}`;
return http.get(`/internal/osquery/fleet_wrapper/agents`, {
query: {
kuery,
perPage: 10000,
},
});
},
{
select: (data) => map(data?.agents, 'id') || ([] as string[]),
enabled: !skip || !agentPolicyId,
onSuccess: () => setErrorToast(),
onError: (error) =>
!silent &&
setErrorToast(error as Error, {
title: i18n.translate('xpack.osquery.agents.fetchError', {
defaultMessage: 'Error while fetching agents',
}),
}),
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
}
);
};

View file

@ -22,5 +22,9 @@ export const useOsqueryIntegrationStatus = () => {
defaultMessage: 'Error while fetching osquery integration',
}),
}),
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
});
};

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import React, { useCallback, useRef } from 'react';
import React, { useEffect, useState } from 'react';
import { EuiCodeEditor } from '@elastic/eui';
import useDebounce from 'react-use/lib/useDebounce';
import 'brace/theme/tomorrow';
import './osquery_mode.ts';
@ -26,22 +27,19 @@ interface OsqueryEditorProps {
}
const OsqueryEditorComponent: React.FC<OsqueryEditorProps> = ({ defaultValue, onChange }) => {
const editorValue = useRef(defaultValue ?? '');
const [editorValue, setEditorValue] = useState(defaultValue ?? '');
const handleChange = useCallback((newValue: string) => {
editorValue.current = newValue;
}, []);
useDebounce(() => onChange(editorValue.replaceAll('\n', ' ').replaceAll(' ', ' ')), 500, [
editorValue,
]);
const onBlur = useCallback(() => {
onChange(editorValue.current.replaceAll('\n', ' ').replaceAll(' ', ' '));
}, [onChange]);
useEffect(() => setEditorValue(defaultValue), [defaultValue]);
return (
<EuiCodeEditor
onBlur={onBlur}
value={defaultValue}
value={editorValue}
mode="osquery"
onChange={handleChange}
onChange={setEditorValue}
theme="tomorrow"
name="osquery_editor"
setOptions={EDITOR_SET_OPTIONS}

View file

@ -16,7 +16,7 @@ import {
import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation } from 'react-query';
import deepMerge from 'deepmerge';
@ -114,7 +114,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
),
});
const { submit } = form;
const { setFieldValue, submit } = form;
const actionId = useMemo(() => data?.actions[0].action_id, [data?.actions]);
const agentIds = useMemo(() => data?.actions[0].agents, [data?.actions]);
@ -253,6 +253,15 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
[queryFieldStepContent, resultsStepContent]
);
useEffect(() => {
if (defaultValue?.agentSelection) {
setFieldValue('agentSelection', defaultValue?.agentSelection);
}
if (defaultValue?.query) {
setFieldValue('query', defaultValue?.query);
}
}, [defaultValue, setFieldValue]);
return (
<>
<Form form={form}>{singleAgentMode ? singleAgentForm : <EuiSteps steps={formSteps} />}</Form>

View file

@ -31,7 +31,7 @@ import {
ViewResultsInDiscoverAction,
ViewResultsInLensAction,
ViewResultsActionButtonType,
} from '../scheduled_query_groups/scheduled_query_group_queries_table';
} from '../scheduled_query_groups/scheduled_query_group_queries_status_table';
import { useActionResultsPrivileges } from '../action_results/use_action_privileges';
import { OSQUERY_INTEGRATION_NAME } from '../../common';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import moment from 'moment';
import moment from 'moment-timezone';
import {
EuiInMemoryTable,
EuiButton,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { startCase } from 'lodash';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useMemo } from 'react';
@ -26,7 +27,7 @@ const AddScheduledQueryGroupPageComponent = () => {
return {
name: osqueryIntegration.name,
title: osqueryIntegration.title,
title: osqueryIntegration.title ?? startCase(osqueryIntegration.name),
version: osqueryIntegration.version,
};
}, [osqueryIntegration]);

View file

@ -24,10 +24,11 @@ import styled from 'styled-components';
import { useKibana, useRouterNavigate } from '../../../common/lib/kibana';
import { WithHeaderLayout } from '../../../components/layouts';
import { useScheduledQueryGroup } from '../../../scheduled_query_groups/use_scheduled_query_group';
import { ScheduledQueryGroupQueriesTable } from '../../../scheduled_query_groups/scheduled_query_group_queries_table';
import { ScheduledQueryGroupQueriesStatusTable } from '../../../scheduled_query_groups/scheduled_query_group_queries_status_table';
import { useBreadcrumbs } from '../../../common/hooks/use_breadcrumbs';
import { AgentsPolicyLink } from '../../../agent_policies/agents_policy_link';
import { BetaBadge, BetaBadgeRowWrapper } from '../../../components/beta_badge';
import { useAgentPolicyAgentIds } from '../../../agents/use_agent_policy_agent_ids';
const Divider = styled.div`
width: 0;
@ -44,6 +45,10 @@ const ScheduledQueryGroupDetailsPageComponent = () => {
);
const { data } = useScheduledQueryGroup({ scheduledQueryGroupId });
const { data: agentIds } = useAgentPolicyAgentIds({
agentPolicyId: data?.policy_id,
skip: !data,
});
useBreadcrumbs('scheduled_query_group_details', { scheduledQueryGroupName: data?.name ?? '' });
@ -131,7 +136,13 @@ const ScheduledQueryGroupDetailsPageComponent = () => {
return (
<WithHeaderLayout leftColumn={LeftColumn} rightColumn={RightColumn} rightColumnGrow={false}>
{data && <ScheduledQueryGroupQueriesTable data={data.inputs[0].streams} />}
{data && (
<ScheduledQueryGroupQueriesStatusTable
agentIds={agentIds}
scheduledQueryGroupName={data.name}
data={data.inputs[0].streams}
/>
)}
</WithHeaderLayout>
);
};

View file

@ -244,7 +244,7 @@ const ScheduledQueryGroupFormComponent: React.FC<ScheduledQueryGroupFormProps> =
),
});
const { submit } = form;
const { setFieldValue, submit } = form;
const policyIdEuiFieldProps = useMemo(
() => ({ isDisabled: !!defaultValue, options: agentPolicyOptions }),
@ -276,6 +276,10 @@ const ScheduledQueryGroupFormComponent: React.FC<ScheduledQueryGroupFormProps> =
};
}, [agentPoliciesById, policyId]);
const handleNameChange = useCallback((newName: string) => setFieldValue('name', newName), [
setFieldValue,
]);
const handleSaveClick = useCallback(() => {
if (currentPolicy.agentCount) {
setShowConfirmationModal(true);
@ -343,6 +347,7 @@ const ScheduledQueryGroupFormComponent: React.FC<ScheduledQueryGroupFormProps> =
component={QueriesField}
scheduledQueryGroupId={defaultValue?.id ?? null}
integrationPackageVersion={integrationPackageVersion}
handleNameChange={handleNameChange}
/>
<CommonUseField path="enabled" component={GhostFormField} />

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { mapKeys, kebabCase } from 'lodash';
import { kebabCase } from 'lodash';
import { EuiLink, EuiFormRow, EuiFilePicker, EuiSpacer } from '@elastic/eui';
import React, { useCallback, useState, useRef } from 'react';
import { i18n } from '@kbn/i18n';
@ -25,7 +25,7 @@ const ExamplePackLink = React.memo(() => (
ExamplePackLink.displayName = 'ExamplePackLink';
interface OsqueryPackUploaderProps {
onChange: (payload: Record<string, unknown>) => void;
onChange: (payload: Record<string, unknown>, packName: string) => void;
}
const OsqueryPackUploaderComponent: React.FC<OsqueryPackUploaderProps> = ({ onChange }) => {
@ -61,12 +61,7 @@ const OsqueryPackUploaderComponent: React.FC<OsqueryPackUploaderProps> = ({ onCh
return;
}
const queriesJSON = mapKeys(
parsedContent?.queries,
(value, key) => `pack_${packName.current}_${key}`
);
onChange(queriesJSON);
onChange(parsedContent?.queries, packName.current);
// @ts-expect-error update types
filePickerRef.current?.removeFiles(new Event('fake'));
};

View file

@ -10,6 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiSpacer } from '@elastic/eui';
import { produce } from 'immer';
import React, { useCallback, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { satisfies } from 'semver';
import {
OsqueryManagerPackagePolicyInputStream,
@ -23,6 +24,7 @@ import { OsqueryPackUploader } from './pack_uploader';
import { getSupportedPlatforms } from '../queries/platforms/helpers';
interface QueriesFieldProps {
handleNameChange: (name: string) => void;
field: FieldHook<OsqueryManagerPackagePolicyInput[]>;
integrationPackageVersion?: string | undefined;
scheduledQueryGroupId: string;
@ -82,6 +84,7 @@ const getNewStream = (payload: GetNewStreamProps) =>
const QueriesFieldComponent: React.FC<QueriesFieldProps> = ({
field,
handleNameChange,
integrationPackageVersion,
scheduledQueryGroupId,
}) => {
@ -208,13 +211,18 @@ const QueriesFieldComponent: React.FC<QueriesFieldProps> = ({
}, [setValue, tableSelectedItems]);
const handlePackUpload = useCallback(
(newQueries) => {
(newQueries, packName) => {
/* Osquery scheduled packs are supported since osquery_manager@0.5.0 */
const isOsqueryPackSupported = integrationPackageVersion
? satisfies(integrationPackageVersion, '>=0.5.0')
: false;
setValue(
produce((draft) => {
forEach(newQueries, (newQuery, newQueryId) => {
draft[0].streams.push(
getNewStream({
id: newQueryId,
id: isOsqueryPackSupported ? newQueryId : `pack_${packName}_${newQueryId}`,
interval: newQuery.interval,
query: newQuery.query,
version: newQuery.version,
@ -227,8 +235,12 @@ const QueriesFieldComponent: React.FC<QueriesFieldProps> = ({
return draft;
})
);
if (isOsqueryPackSupported) {
handleNameChange(packName);
}
},
[scheduledQueryGroupId, setValue]
[handleNameChange, integrationPackageVersion, scheduledQueryGroupId, setValue]
);
const tableData = useMemo(() => (field.value.length ? field.value[0].streams : []), [
@ -277,7 +289,6 @@ const QueriesFieldComponent: React.FC<QueriesFieldProps> = ({
<EuiSpacer />
{field.value && field.value[0].streams?.length ? (
<ScheduledQueryGroupQueriesTable
editMode={true}
data={tableData}
onEditClick={handleEditClick}
onDeleteClick={handleDeleteClick}

View file

@ -6,7 +6,7 @@
*/
import { produce } from 'immer';
import { find, sortBy, isArray, map } from 'lodash';
import { find, orderBy, sortedUniqBy, isArray, map } from 'lodash';
import React, {
forwardRef,
useCallback,
@ -78,7 +78,10 @@ const typeMap = {
const StyledFieldIcon = styled(FieldIcon)`
width: 32px;
padding: 0 4px;
> svg {
padding: 0 6px !important;
}
`;
const StyledFieldSpan = styled.span`
@ -88,7 +91,15 @@ const StyledFieldSpan = styled.span`
// align the icon to the inputs
const StyledButtonWrapper = styled.div`
margin-top: 32px;
margin-top: 30px;
`;
const ECSFieldColumn = styled(EuiFlexGroup)`
max-width: 100%;
`;
const ECSFieldWrapper = styled(EuiFlexItem)`
max-width: calc(100% - 66px);
`;
const singleSelection = { asPlainText: true };
@ -163,7 +174,7 @@ export const ECSComboboxField: React.FC<ECSComboboxFieldProps> = ({
size="l"
type={
// @ts-expect-error update types
selectedOptions[0]?.value?.type === 'keyword' ? 'string' : selectedOptions[0]?.value?.type
typeMap[selectedOptions[0]?.value?.type] ?? selectedOptions[0]?.value?.type
}
/>
),
@ -220,7 +231,7 @@ export const OsqueryColumnField: React.FC<OsqueryColumnFieldProps> = ({
idAria,
...rest
}) => {
const { setErrors, setValue } = field;
const { setValue } = field;
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const describedByIds = useMemo(() => (idAria ? [idAria] : []), [idAria]);
const [selectedOptions, setSelected] = useState<
@ -250,25 +261,6 @@ export const OsqueryColumnField: React.FC<OsqueryColumnFieldProps> = ({
[]
);
const onCreateOsqueryOption = useCallback(
(searchValue = []) => {
const normalizedSearchValue = searchValue.trim().toLowerCase();
if (!normalizedSearchValue) {
return;
}
const newOption = {
label: searchValue,
};
// Select the option.
setSelected([newOption]);
setValue(newOption.label);
},
[setValue, setSelected]
);
const handleChange = useCallback(
(newSelectedOptions) => {
setSelected(newSelectedOptions);
@ -285,7 +277,7 @@ export const OsqueryColumnField: React.FC<OsqueryColumnFieldProps> = ({
return selectedOption ? [selectedOption] : [{ label: field.value }];
});
}, [euiFieldProps?.options, setSelected, field.value, setErrors]);
}, [euiFieldProps?.options, setSelected, field.value]);
return (
<EuiFormRow
@ -302,7 +294,6 @@ export const OsqueryColumnField: React.FC<OsqueryColumnFieldProps> = ({
singleSelection={singleSelection}
selectedOptions={selectedOptions}
onChange={handleChange}
onCreateOption={onCreateOsqueryOption}
renderOption={renderOsqueryOption}
rowHeight={32}
isClearable
@ -513,7 +504,7 @@ export const ECSMappingEditorForm = forwardRef<ECSMappingEditorFormRef, ECSMappi
return (
<Form form={form}>
<EuiFlexGroup alignItems="flexStart">
<EuiFlexGroup alignItems="flexStart" gutterSize="s">
<EuiFlexItem>
<CommonUseField
path="value.field"
@ -526,15 +517,15 @@ export const ECSMappingEditorForm = forwardRef<ECSMappingEditorFormRef, ECSMappi
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup alignItems="flexStart">
<ECSFieldColumn alignItems="flexStart" gutterSize="s" wrap>
<EuiFlexItem grow={false}>
<StyledButtonWrapper>
<EuiIcon type="arrowRight" />
</StyledButtonWrapper>
</EuiFlexItem>
<EuiFlexItem>
<ECSFieldWrapper>
<CommonUseField path="key" component={ECSComboboxField} />
</EuiFlexItem>
</ECSFieldWrapper>
<EuiFlexItem grow={false}>
<StyledButtonWrapper>
{defaultValue ? (
@ -564,7 +555,7 @@ export const ECSMappingEditorForm = forwardRef<ECSMappingEditorFormRef, ECSMappi
)}
</StyledButtonWrapper>
</EuiFlexItem>
</EuiFlexGroup>
</ECSFieldColumn>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
@ -634,6 +625,11 @@ export const ECSMappingEditorField = ({ field, query, fieldRef }: ECSMappingEdit
return currentValue;
}
const tablesOrderMap = ast?.from?.reduce((acc, table, index) => {
acc[table.as ?? table.table] = index;
return acc;
}, {});
const astOsqueryTables: Record<string, OsqueryColumn[]> = ast?.from?.reduce((acc, table) => {
const osqueryTable = find(osquerySchema, ['name', table.table]);
@ -665,6 +661,7 @@ export const ECSMappingEditorField = ({ field, query, fieldRef }: ECSMappingEdit
name: osqueryColumn.name,
description: osqueryColumn.description,
table: tableName,
tableOrder: tablesOrderMap[tableName],
suggestion_label: osqueryColumn.name,
},
}));
@ -678,13 +675,14 @@ export const ECSMappingEditorField = ({ field, query, fieldRef }: ECSMappingEdit
isArray(ast?.columns) &&
ast?.columns
?.map((column) => {
if (column.expr.column === '*') {
if (column.expr.column === '*' && astOsqueryTables[column.expr.table]) {
return astOsqueryTables[column.expr.table].map((osqueryColumn) => ({
label: osqueryColumn.name,
value: {
name: osqueryColumn.name,
description: osqueryColumn.description,
table: column.expr.table,
tableOrder: tablesOrderMap[column.expr.table],
suggestion_label: `${osqueryColumn.name}`,
},
}));
@ -706,6 +704,7 @@ export const ECSMappingEditorField = ({ field, query, fieldRef }: ECSMappingEdit
name: osqueryColumn.name,
description: osqueryColumn.description,
table: column.expr.table,
tableOrder: tablesOrderMap[column.expr.table],
suggestion_label: `${label}`,
},
},
@ -717,8 +716,12 @@ export const ECSMappingEditorField = ({ field, query, fieldRef }: ECSMappingEdit
})
.flat();
// @ts-expect-error update types
return sortBy(suggestions, 'value.suggestion_label');
// Remove column duplicates by keeping the column from the table that appears last in the query
return sortedUniqBy(
// @ts-expect-error update types
orderBy(suggestions, ['value.suggestion_label', 'value.tableOrder'], ['asc', 'desc']),
'label'
);
});
}, [query]);
@ -766,6 +769,10 @@ export const ECSMappingEditorField = ({ field, query, fieldRef }: ECSMappingEdit
return draft;
})
);
if (formRefs.current[key]) {
delete formRefs.current[key];
}
}
},
[setValue]

View file

@ -13,7 +13,6 @@ import {
EuiFlyoutBody,
EuiFlyoutHeader,
EuiFlyoutFooter,
EuiPortal,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
@ -117,138 +116,145 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
[isFieldSupported, setFieldValue, reset]
);
/* Avoids accidental closing of the flyout when the user clicks outside of the flyout */
const maskProps = useMemo(() => ({ onClick: () => ({}) }), []);
return (
<EuiPortal>
<EuiFlyout size="m" ownFocus onClose={onClose} aria-labelledby="flyoutTitle">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 id="flyoutTitle">
{isEditMode ? (
<FormattedMessage
id="xpack.osquery.scheduleQueryGroup.queryFlyoutForm.editFormTitle"
defaultMessage="Edit query"
/>
) : (
<FormattedMessage
id="xpack.osquery.scheduleQueryGroup.queryFlyoutForm.addFormTitle"
defaultMessage="Attach next query"
/>
)}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<Form form={form}>
{!isEditMode ? (
<>
<SavedQueriesDropdown onChange={handleSetQueryValue} />
<EuiSpacer />
</>
) : null}
<CommonUseField path="id" />
<EuiSpacer />
<CommonUseField path="query" component={CodeEditorField} />
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem>
<CommonUseField
path="interval"
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{ append: 's' }}
/>
<EuiSpacer />
<CommonUseField
path="version"
labelAppend={
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.osquery.scheduledQueryGroup.queryFlyoutForm.versionFieldOptionalLabel"
defaultMessage="(optional)"
/>
</EuiText>
</EuiFlexItem>
}
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{
isDisabled: !isFieldSupported,
noSuggestions: false,
singleSelection: { asPlainText: true },
placeholder: i18n.translate(
'xpack.osquery.scheduledQueryGroup.queriesTable.osqueryVersionAllLabel',
{
defaultMessage: 'ALL',
}
),
options: ALL_OSQUERY_VERSIONS_OPTIONS,
onCreateOption: undefined,
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<CommonUseField
path="platform"
component={PlatformCheckBoxGroupField}
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{ disabled: !isFieldSupported }}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem>
<CommonUseField
path="ecs_mapping"
component={ECSMappingEditorField}
query={query}
fieldRef={ecsFieldRef}
/>
</EuiFlexItem>
</EuiFlexGroup>
</Form>
{!isFieldSupported ? (
<EuiCallOut
size="s"
title={
<FormattedMessage
id="xpack.osquery.scheduleQueryGroup.queryFlyoutForm.unsupportedPlatformAndVersionFieldsCalloutTitle"
defaultMessage="Platform and version fields are available from {version}"
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
values={{ version: `osquery_manager@0.3.0` }}
/>
}
iconType="pin"
>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>
<ManageIntegrationLink />
</EuiFlexItem>
</EuiFlexGroup>
</EuiCallOut>
<EuiFlyout
size="m"
onClose={onClose}
aria-labelledby="flyoutTitle"
outsideClickCloses={false}
maskProps={maskProps}
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 id="flyoutTitle">
{isEditMode ? (
<FormattedMessage
id="xpack.osquery.scheduleQueryGroup.queryFlyoutForm.editFormTitle"
defaultMessage="Edit query"
/>
) : (
<FormattedMessage
id="xpack.osquery.scheduleQueryGroup.queryFlyoutForm.addFormTitle"
defaultMessage="Attach next query"
/>
)}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<Form form={form}>
{!isEditMode ? (
<>
<SavedQueriesDropdown onChange={handleSetQueryValue} />
<EuiSpacer />
</>
) : null}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={onClose} flush="left">
<FormattedMessage
id="xpack.osquery.scheduledQueryGroup.queryFlyoutForm.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
<CommonUseField path="id" />
<EuiSpacer />
<CommonUseField path="query" component={CodeEditorField} />
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem>
<CommonUseField
path="interval"
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{ append: 's' }}
/>
<EuiSpacer />
<CommonUseField
path="version"
labelAppend={
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.osquery.scheduledQueryGroup.queryFlyoutForm.versionFieldOptionalLabel"
defaultMessage="(optional)"
/>
</EuiText>
</EuiFlexItem>
}
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{
isDisabled: !isFieldSupported,
noSuggestions: false,
singleSelection: { asPlainText: true },
placeholder: i18n.translate(
'xpack.osquery.scheduledQueryGroup.queriesTable.osqueryVersionAllLabel',
{
defaultMessage: 'ALL',
}
),
options: ALL_OSQUERY_VERSIONS_OPTIONS,
onCreateOption: undefined,
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={submit} fill>
<FormattedMessage
id="xpack.osquery.scheduledQueryGroup.queryFlyoutForm.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
<EuiFlexItem>
<CommonUseField
path="platform"
component={PlatformCheckBoxGroupField}
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{ disabled: !isFieldSupported }}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</EuiPortal>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem>
<CommonUseField
path="ecs_mapping"
component={ECSMappingEditorField}
query={query}
fieldRef={ecsFieldRef}
/>
</EuiFlexItem>
</EuiFlexGroup>
</Form>
{!isFieldSupported ? (
<EuiCallOut
size="s"
title={
<FormattedMessage
id="xpack.osquery.scheduleQueryGroup.queryFlyoutForm.unsupportedPlatformAndVersionFieldsCalloutTitle"
defaultMessage="Platform and version fields are available from {version}"
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
values={{ version: `osquery_manager@0.3.0` }}
/>
}
iconType="pin"
>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>
<ManageIntegrationLink />
</EuiFlexItem>
</EuiFlexGroup>
</EuiCallOut>
) : null}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={onClose} flush="left">
<FormattedMessage
id="xpack.osquery.scheduledQueryGroup.queryFlyoutForm.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={submit} fill>
<FormattedMessage
id="xpack.osquery.scheduledQueryGroup.queryFlyoutForm.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -63,7 +63,23 @@ export const useScheduledQueryGroupQueryForm = ({
options: {
stripEmptyFields: false,
},
defaultValue,
defaultValue: defaultValue || {
id: {
type: 'text',
value: '',
},
query: {
type: 'text',
value: '',
},
interval: {
type: 'integer',
value: '3600',
},
ecs_mapping: {
value: {},
},
},
// @ts-expect-error update types
serializer: (payload) =>
produce(payload, (draft) => {

View file

@ -0,0 +1,146 @@
/*
* 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 { EuiInMemoryTable, EuiCodeBlock, EuiToolTip, EuiButtonIcon } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { encode } from 'rison-node';
import { stringify } from 'querystring';
import { useKibana, isModifiedEvent, isLeftClickEvent } from '../common/lib/kibana';
import { AgentIdToName } from '../agents/agent_id_to_name';
import { useScheduledQueryGroupQueryErrors } from './use_scheduled_query_group_query_errors';
const VIEW_IN_LOGS = i18n.translate(
'xpack.osquery.scheduledQueryGroup.queriesTable.viewLogsErrorsActionAriaLabel',
{
defaultMessage: 'View in Logs',
}
);
interface ViewErrorsInLogsActionProps {
actionId: string;
agentId: string;
timestamp?: string;
}
const ViewErrorsInLogsActionComponent: React.FC<ViewErrorsInLogsActionProps> = ({
actionId,
agentId,
timestamp,
}) => {
const navigateToApp = useKibana().services.application.navigateToApp;
const handleClick = useCallback(
(event) => {
const openInNewTab = !(!isModifiedEvent(event) && isLeftClickEvent(event));
event.preventDefault();
const queryString = stringify({
logPosition: encode({
end: timestamp,
streamLive: false,
}),
logFilter: encode({
expression: `elastic_agent.id:${agentId} and (data_stream.dataset:elastic_agent or data_stream.dataset:elastic_agent.osquerybeat) and "${actionId}"`,
kind: 'kuery',
}),
});
navigateToApp('logs', {
path: `stream?${queryString}`,
openInNewTab,
});
},
[actionId, agentId, navigateToApp, timestamp]
);
return (
<EuiToolTip content={VIEW_IN_LOGS}>
<EuiButtonIcon iconType="search" onClick={handleClick} aria-label={VIEW_IN_LOGS} />
</EuiToolTip>
);
};
export const ViewErrorsInLogsAction = React.memo(ViewErrorsInLogsActionComponent);
interface ScheduledQueryErrorsTableProps {
actionId: string;
agentIds?: string[];
interval: number;
}
const renderErrorMessage = (error: string) => (
<EuiCodeBlock fontSize="s" paddingSize="none" transparentBackground>
{error}
</EuiCodeBlock>
);
const ScheduledQueryErrorsTableComponent: React.FC<ScheduledQueryErrorsTableProps> = ({
actionId,
agentIds,
interval,
}) => {
const { data: lastErrorsData } = useScheduledQueryGroupQueryErrors({
actionId,
agentIds,
interval,
});
const renderAgentIdColumn = useCallback((agentId) => <AgentIdToName agentId={agentId} />, []);
const renderLogsErrorsAction = useCallback(
(item) => (
<ViewErrorsInLogsAction
actionId={actionId}
agentId={item?.fields['elastic_agent.id'][0]}
timestamp={item?.fields['event.ingested'][0]}
/>
),
[actionId]
);
const columns = useMemo(
() => [
{
field: 'fields.@timestamp',
name: '@timestamp',
width: '220px',
},
{
field: 'fields["elastic_agent.id"][0]',
name: i18n.translate('xpack.osquery.scheduledQueryErrorsTable.agentIdColumnTitle', {
defaultMessage: 'Agent Id',
}),
truncateText: true,
render: renderAgentIdColumn,
width: '15%',
},
{
field: 'fields.message[0]',
name: i18n.translate('xpack.osquery.scheduledQueryErrorsTable.errorColumnTitle', {
defaultMessage: 'Error',
}),
render: renderErrorMessage,
},
{
width: '50px',
actions: [
{
render: renderLogsErrorsAction,
},
],
},
],
[renderAgentIdColumn, renderLogsErrorsAction]
);
// @ts-expect-error update types
return <EuiInMemoryTable items={lastErrorsData?.hits} columns={columns} pagination={true} />;
};
export const ScheduledQueryErrorsTable = React.memo(ScheduledQueryErrorsTableComponent);

View file

@ -0,0 +1,644 @@
/*
* 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 { get } from 'lodash/fp';
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import {
EuiBasicTable,
EuiButtonEmpty,
EuiCodeBlock,
EuiButtonIcon,
EuiToolTip,
EuiLoadingSpinner,
EuiFlexGroup,
EuiFlexItem,
EuiNotificationBadge,
EuiSpacer,
EuiPanel,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import moment from 'moment-timezone';
import {
TypedLensByValueInput,
PersistedIndexPatternLayer,
PieVisualizationState,
} from '../../../lens/public';
import { FilterStateStore } from '../../../../../src/plugins/data/common';
import { useKibana, isModifiedEvent, isLeftClickEvent } from '../common/lib/kibana';
import { OsqueryManagerPackagePolicyInputStream } from '../../common/types';
import { ScheduledQueryErrorsTable } from './scheduled_query_errors_table';
import { useScheduledQueryGroupQueryLastResults } from './use_scheduled_query_group_query_last_results';
import { useScheduledQueryGroupQueryErrors } from './use_scheduled_query_group_query_errors';
const VIEW_IN_DISCOVER = i18n.translate(
'xpack.osquery.scheduledQueryGroup.queriesTable.viewDiscoverResultsActionAriaLabel',
{
defaultMessage: 'View in Discover',
}
);
const VIEW_IN_LENS = i18n.translate(
'xpack.osquery.scheduledQueryGroup.queriesTable.viewLensResultsActionAriaLabel',
{
defaultMessage: 'View in Lens',
}
);
export enum ViewResultsActionButtonType {
icon = 'icon',
button = 'button',
}
interface ViewResultsInDiscoverActionProps {
actionId: string;
agentIds?: string[];
buttonType: ViewResultsActionButtonType;
endDate?: string;
startDate?: string;
}
function getLensAttributes(
actionId: string,
agentIds?: string[]
): TypedLensByValueInput['attributes'] {
const dataLayer: PersistedIndexPatternLayer = {
columnOrder: ['8690befd-fd69-4246-af4a-dd485d2a3b38', 'ed999e9d-204c-465b-897f-fe1a125b39ed'],
columns: {
'8690befd-fd69-4246-af4a-dd485d2a3b38': {
sourceField: 'type',
isBucketed: true,
dataType: 'string',
scale: 'ordinal',
operationType: 'terms',
label: 'Top values of type',
params: {
otherBucket: true,
size: 5,
missingBucket: false,
orderBy: {
columnId: 'ed999e9d-204c-465b-897f-fe1a125b39ed',
type: 'column',
},
orderDirection: 'desc',
},
},
'ed999e9d-204c-465b-897f-fe1a125b39ed': {
sourceField: 'Records',
isBucketed: false,
dataType: 'number',
scale: 'ratio',
operationType: 'count',
label: 'Count of records',
},
},
incompleteColumns: {},
};
const xyConfig: PieVisualizationState = {
shape: 'pie',
layers: [
{
layerType: 'data',
legendDisplay: 'default',
nestedLegend: false,
layerId: 'layer1',
metric: 'ed999e9d-204c-465b-897f-fe1a125b39ed',
numberDisplay: 'percent',
groups: ['8690befd-fd69-4246-af4a-dd485d2a3b38'],
categoryDisplay: 'default',
},
],
};
const agentIdsQuery = {
bool: {
minimum_should_match: 1,
should: agentIds?.map((agentId) => ({ match_phrase: { 'agent.id': agentId } })),
},
};
return {
visualizationType: 'lnsPie',
title: `Action ${actionId} results`,
references: [
{
id: 'logs-*',
name: 'indexpattern-datasource-current-indexpattern',
type: 'index-pattern',
},
{
id: 'logs-*',
name: 'indexpattern-datasource-layer-layer1',
type: 'index-pattern',
},
{
name: 'filter-index-pattern-0',
id: 'logs-*',
type: 'index-pattern',
},
],
state: {
datasourceStates: {
indexpattern: {
layers: {
layer1: dataLayer,
},
},
},
filters: [
{
$state: { store: FilterStateStore.APP_STATE },
meta: {
indexRefName: 'filter-index-pattern-0',
negate: false,
alias: null,
disabled: false,
params: {
query: actionId,
},
type: 'phrase',
key: 'action_id',
},
query: {
match_phrase: {
action_id: actionId,
},
},
},
...(agentIdsQuery
? [
{
$state: { store: FilterStateStore.APP_STATE },
meta: {
alias: 'agent IDs',
disabled: false,
indexRefName: 'filter-index-pattern-0',
key: 'query',
negate: false,
type: 'custom',
value: JSON.stringify(agentIdsQuery),
},
query: agentIdsQuery,
},
]
: []),
],
query: { language: 'kuery', query: '' },
visualization: xyConfig,
},
};
}
const ViewResultsInLensActionComponent: React.FC<ViewResultsInDiscoverActionProps> = ({
actionId,
agentIds,
buttonType,
endDate,
startDate,
}) => {
const lensService = useKibana().services.lens;
const handleClick = useCallback(
(event) => {
const openInNewTab = !(!isModifiedEvent(event) && isLeftClickEvent(event));
event.preventDefault();
lensService?.navigateToPrefilledEditor(
{
id: '',
timeRange: {
from: startDate ?? 'now-1d',
to: endDate ?? 'now',
mode: startDate || endDate ? 'absolute' : 'relative',
},
attributes: getLensAttributes(actionId, agentIds),
},
{
openInNewTab,
}
);
},
[actionId, agentIds, endDate, lensService, startDate]
);
if (buttonType === ViewResultsActionButtonType.button) {
return (
<EuiButtonEmpty
size="xs"
iconType="lensApp"
onClick={handleClick}
disabled={!lensService?.canUseEditor()}
>
{VIEW_IN_LENS}
</EuiButtonEmpty>
);
}
return (
<EuiToolTip content={VIEW_IN_LENS}>
<EuiButtonIcon
iconType="lensApp"
disabled={!lensService?.canUseEditor()}
onClick={handleClick}
aria-label={VIEW_IN_LENS}
/>
</EuiToolTip>
);
};
export const ViewResultsInLensAction = React.memo(ViewResultsInLensActionComponent);
const ViewResultsInDiscoverActionComponent: React.FC<ViewResultsInDiscoverActionProps> = ({
actionId,
agentIds,
buttonType,
endDate,
startDate,
}) => {
const urlGenerator = useKibana().services.discover?.urlGenerator;
const [discoverUrl, setDiscoverUrl] = useState<string>('');
useEffect(() => {
const getDiscoverUrl = async () => {
if (!urlGenerator?.createUrl) return;
const agentIdsQuery = agentIds?.length
? {
bool: {
minimum_should_match: 1,
should: agentIds.map((agentId) => ({ match_phrase: { 'agent.id': agentId } })),
},
}
: null;
const newUrl = await urlGenerator.createUrl({
indexPatternId: 'logs-*',
filters: [
{
meta: {
index: 'logs-*',
alias: null,
negate: false,
disabled: false,
type: 'phrase',
key: 'action_id',
params: { query: actionId },
},
query: { match_phrase: { action_id: actionId } },
$state: { store: FilterStateStore.APP_STATE },
},
...(agentIdsQuery
? [
{
$state: { store: FilterStateStore.APP_STATE },
meta: {
alias: 'agent IDs',
disabled: false,
index: 'logs-*',
key: 'query',
negate: false,
type: 'custom',
value: JSON.stringify(agentIdsQuery),
},
query: agentIdsQuery,
},
]
: []),
],
refreshInterval: {
pause: true,
value: 0,
},
timeRange:
startDate && endDate
? {
to: endDate,
from: startDate,
mode: 'absolute',
}
: {
to: 'now',
from: 'now-1d',
mode: 'relative',
},
});
setDiscoverUrl(newUrl);
};
getDiscoverUrl();
}, [actionId, agentIds, endDate, startDate, urlGenerator]);
if (buttonType === ViewResultsActionButtonType.button) {
return (
<EuiButtonEmpty size="xs" iconType="discoverApp" href={discoverUrl}>
{VIEW_IN_DISCOVER}
</EuiButtonEmpty>
);
}
return (
<EuiToolTip content={VIEW_IN_DISCOVER}>
<EuiButtonIcon iconType="discoverApp" href={discoverUrl} aria-label={VIEW_IN_DISCOVER} />
</EuiToolTip>
);
};
export const ViewResultsInDiscoverAction = React.memo(ViewResultsInDiscoverActionComponent);
interface ScheduledQueryExpandedContentProps {
actionId: string;
agentIds?: string[];
interval: number;
}
const ScheduledQueryExpandedContent = React.memo<ScheduledQueryExpandedContentProps>(
({ actionId, agentIds, interval }) => (
<EuiFlexGroup direction="column" gutterSize="xl">
<EuiFlexItem>
<EuiSpacer size="m" />
<EuiPanel paddingSize="s" hasBorder hasShadow={false}>
<ScheduledQueryErrorsTable actionId={actionId} agentIds={agentIds} interval={interval} />
</EuiPanel>
<EuiSpacer size="m" />
</EuiFlexItem>
</EuiFlexGroup>
)
);
ScheduledQueryExpandedContent.displayName = 'ScheduledQueryExpandedContent';
interface ScheduledQueryLastResultsProps {
actionId: string;
agentIds: string[];
queryId: string;
interval: number;
toggleErrors: (payload: { queryId: string; interval: number }) => void;
expanded: boolean;
}
const ScheduledQueryLastResults: React.FC<ScheduledQueryLastResultsProps> = ({
actionId,
agentIds,
queryId,
interval,
toggleErrors,
expanded,
}) => {
const { data: lastResultsData, isFetched } = useScheduledQueryGroupQueryLastResults({
actionId,
agentIds,
interval,
});
const { data: errorsData, isFetched: errorsFetched } = useScheduledQueryGroupQueryErrors({
actionId,
agentIds,
interval,
});
const handleErrorsToggle = useCallback(() => toggleErrors({ queryId, interval }), [
queryId,
interval,
toggleErrors,
]);
if (!isFetched || !errorsFetched) {
return <EuiLoadingSpinner />;
}
if (!lastResultsData) {
return <>{'-'}</>;
}
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={4}>
{lastResultsData.first_event_ingested_time?.value ? (
<EuiToolTip content={lastResultsData.first_event_ingested_time?.value}>
<>{moment(lastResultsData.first_event_ingested_time?.value).fromNow()}</>
</EuiToolTip>
) : (
'-'
)}
</EuiFlexItem>
<EuiFlexItem grow={4}>
<EuiFlexGroup gutterSize="s" alignItems="center" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiNotificationBadge color="subdued">
{lastResultsData?.doc_count ?? 0}
</EuiNotificationBadge>
</EuiFlexItem>
<EuiFlexItem grow={false}>{'Documents'}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={4}>
<EuiFlexGroup gutterSize="s" alignItems="center" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiNotificationBadge color="subdued">
{lastResultsData?.unique_agents?.value ?? 0}
</EuiNotificationBadge>
</EuiFlexItem>
<EuiFlexItem grow={false}>{'Agents'}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={5}>
<EuiFlexGroup gutterSize="s" alignItems="center" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiNotificationBadge color={errorsData?.total ? 'accent' : 'subdued'}>
{errorsData?.total ?? 0}
</EuiNotificationBadge>
</EuiFlexItem>
<EuiFlexItem grow={false}>{'Errors'}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
isDisabled={!errorsData?.total}
onClick={handleErrorsToggle}
iconType={expanded ? 'arrowUp' : 'arrowDown'}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
};
const getPackActionId = (actionId: string, packName: string) => `pack_${packName}_${actionId}`;
interface ScheduledQueryGroupQueriesStatusTableProps {
agentIds?: string[];
data: OsqueryManagerPackagePolicyInputStream[];
scheduledQueryGroupName: string;
}
const ScheduledQueryGroupQueriesStatusTableComponent: React.FC<ScheduledQueryGroupQueriesStatusTableProps> = ({
agentIds,
data,
scheduledQueryGroupName,
}) => {
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<
Record<string, ReturnType<typeof ScheduledQueryExpandedContent>>
>({});
const renderQueryColumn = useCallback(
(query: string) => (
<EuiCodeBlock language="sql" fontSize="s" paddingSize="none" transparentBackground>
{query}
</EuiCodeBlock>
),
[]
);
const toggleErrors = useCallback(
({ queryId, interval }: { queryId: string; interval: number }) => {
const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap };
if (itemIdToExpandedRowMapValues[queryId]) {
delete itemIdToExpandedRowMapValues[queryId];
} else {
itemIdToExpandedRowMapValues[queryId] = (
<ScheduledQueryExpandedContent
actionId={getPackActionId(queryId, scheduledQueryGroupName)}
agentIds={agentIds}
interval={interval}
/>
);
}
setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues);
},
[agentIds, itemIdToExpandedRowMap, scheduledQueryGroupName]
);
const renderLastResultsColumn = useCallback(
(item) => (
<ScheduledQueryLastResults
// @ts-expect-error update types
agentIds={agentIds}
queryId={item.vars.id.value}
actionId={getPackActionId(item.vars.id.value, scheduledQueryGroupName)}
interval={item.vars?.interval.value}
toggleErrors={toggleErrors}
expanded={!!itemIdToExpandedRowMap[item.vars.id.value]}
/>
),
[agentIds, itemIdToExpandedRowMap, scheduledQueryGroupName, toggleErrors]
);
const renderDiscoverResultsAction = useCallback(
(item) => (
<ViewResultsInDiscoverAction
actionId={getPackActionId(item.vars?.id.value, scheduledQueryGroupName)}
agentIds={agentIds}
buttonType={ViewResultsActionButtonType.icon}
/>
),
[agentIds, scheduledQueryGroupName]
);
const renderLensResultsAction = useCallback(
(item) => (
<ViewResultsInLensAction
actionId={item.vars?.id.value}
agentIds={agentIds}
buttonType={ViewResultsActionButtonType.icon}
/>
),
[agentIds]
);
const getItemId = useCallback(
(item: OsqueryManagerPackagePolicyInputStream) => get('vars.id.value', item),
[]
);
const columns = useMemo(
() => [
{
field: 'vars.id.value',
name: i18n.translate('xpack.osquery.scheduledQueryGroup.queriesTable.idColumnTitle', {
defaultMessage: 'ID',
}),
width: '15%',
},
{
field: 'vars.interval.value',
name: i18n.translate('xpack.osquery.scheduledQueryGroup.queriesTable.intervalColumnTitle', {
defaultMessage: 'Interval (s)',
}),
width: '80px',
},
{
field: 'vars.query.value',
name: i18n.translate('xpack.osquery.scheduledQueryGroup.queriesTable.queryColumnTitle', {
defaultMessage: 'Query',
}),
render: renderQueryColumn,
width: '20%',
},
{
name: i18n.translate(
'xpack.osquery.scheduledQueryGroup.queriesTable.lastResultsColumnTitle',
{
defaultMessage: 'Last results',
}
),
render: renderLastResultsColumn,
},
{
name: i18n.translate(
'xpack.osquery.scheduledQueryGroup.queriesTable.viewResultsColumnTitle',
{
defaultMessage: 'View results',
}
),
width: '90px',
actions: [
{
render: renderDiscoverResultsAction,
},
{
render: renderLensResultsAction,
},
],
},
],
[
renderQueryColumn,
renderLastResultsColumn,
renderDiscoverResultsAction,
renderLensResultsAction,
]
);
const sorting = useMemo(
() => ({
sort: {
field: 'vars.id.value' as keyof OsqueryManagerPackagePolicyInputStream,
direction: 'asc' as const,
},
}),
[]
);
return (
<EuiBasicTable<OsqueryManagerPackagePolicyInputStream>
items={data}
itemId={getItemId}
columns={columns}
sorting={sorting}
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
isExpandable
/>
);
};
export const ScheduledQueryGroupQueriesStatusTable = React.memo(
ScheduledQueryGroupQueriesStatusTableComponent
);

View file

@ -6,288 +6,15 @@
*/
import { get } from 'lodash/fp';
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import {
EuiBasicTable,
EuiButtonEmpty,
EuiCodeBlock,
EuiButtonIcon,
EuiToolTip,
} from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { EuiBasicTable, EuiCodeBlock, EuiButtonIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import {
TypedLensByValueInput,
PersistedIndexPatternLayer,
PieVisualizationState,
} from '../../../lens/public';
import { FilterStateStore } from '../../../../../src/plugins/data/common';
import { useKibana, isModifiedEvent, isLeftClickEvent } from '../common/lib/kibana';
import { PlatformIcons } from './queries/platforms';
import { OsqueryManagerPackagePolicyInputStream } from '../../common/types';
const VIEW_IN_DISCOVER = i18n.translate(
'xpack.osquery.scheduledQueryGroup.queriesTable.viewDiscoverResultsActionAriaLabel',
{
defaultMessage: 'View in Discover',
}
);
const VIEW_IN_LENS = i18n.translate(
'xpack.osquery.scheduledQueryGroup.queriesTable.viewLensResultsActionAriaLabel',
{
defaultMessage: 'View in Lens',
}
);
export enum ViewResultsActionButtonType {
icon = 'icon',
button = 'button',
}
interface ViewResultsInDiscoverActionProps {
actionId: string;
buttonType: ViewResultsActionButtonType;
endDate?: string;
startDate?: string;
}
function getLensAttributes(actionId: string): TypedLensByValueInput['attributes'] {
const dataLayer: PersistedIndexPatternLayer = {
columnOrder: ['8690befd-fd69-4246-af4a-dd485d2a3b38', 'ed999e9d-204c-465b-897f-fe1a125b39ed'],
columns: {
'8690befd-fd69-4246-af4a-dd485d2a3b38': {
sourceField: 'type',
isBucketed: true,
dataType: 'string',
scale: 'ordinal',
operationType: 'terms',
label: 'Top values of type',
params: {
otherBucket: true,
size: 5,
missingBucket: false,
orderBy: {
columnId: 'ed999e9d-204c-465b-897f-fe1a125b39ed',
type: 'column',
},
orderDirection: 'desc',
},
},
'ed999e9d-204c-465b-897f-fe1a125b39ed': {
sourceField: 'Records',
isBucketed: false,
dataType: 'number',
scale: 'ratio',
operationType: 'count',
label: 'Count of records',
},
},
incompleteColumns: {},
};
const xyConfig: PieVisualizationState = {
shape: 'pie',
layers: [
{
legendDisplay: 'default',
nestedLegend: false,
layerId: 'layer1',
layerType: 'data',
metric: 'ed999e9d-204c-465b-897f-fe1a125b39ed',
numberDisplay: 'percent',
groups: ['8690befd-fd69-4246-af4a-dd485d2a3b38'],
categoryDisplay: 'default',
},
],
};
return {
visualizationType: 'lnsPie',
title: `Action ${actionId} results`,
references: [
{
id: 'logs-*',
name: 'indexpattern-datasource-current-indexpattern',
type: 'index-pattern',
},
{
id: 'logs-*',
name: 'indexpattern-datasource-layer-layer1',
type: 'index-pattern',
},
{
name: 'filter-index-pattern-0',
id: 'logs-*',
type: 'index-pattern',
},
],
state: {
datasourceStates: {
indexpattern: {
layers: {
layer1: dataLayer,
},
},
},
filters: [
{
$state: { store: FilterStateStore.APP_STATE },
meta: {
indexRefName: 'filter-index-pattern-0',
negate: false,
alias: null,
disabled: false,
params: {
query: actionId,
},
type: 'phrase',
key: 'action_id',
},
query: {
match_phrase: {
action_id: actionId,
},
},
},
],
query: { language: 'kuery', query: '' },
visualization: xyConfig,
},
};
}
const ViewResultsInLensActionComponent: React.FC<ViewResultsInDiscoverActionProps> = ({
actionId,
buttonType,
endDate,
startDate,
}) => {
const lensService = useKibana().services.lens;
const handleClick = useCallback(
(event) => {
const openInNewTab = !(!isModifiedEvent(event) && isLeftClickEvent(event));
event.preventDefault();
lensService?.navigateToPrefilledEditor(
{
id: '',
timeRange: {
from: startDate ?? 'now-1d',
to: endDate ?? 'now',
mode: startDate || endDate ? 'absolute' : 'relative',
},
attributes: getLensAttributes(actionId),
},
{
openInNewTab,
}
);
},
[actionId, endDate, lensService, startDate]
);
if (buttonType === ViewResultsActionButtonType.button) {
return (
<EuiButtonEmpty
size="xs"
iconType="lensApp"
onClick={handleClick}
disabled={!lensService?.canUseEditor()}
>
{VIEW_IN_LENS}
</EuiButtonEmpty>
);
}
return (
<EuiToolTip content={VIEW_IN_LENS}>
<EuiButtonIcon
iconType="lensApp"
disabled={!lensService?.canUseEditor()}
onClick={handleClick}
aria-label={VIEW_IN_LENS}
/>
</EuiToolTip>
);
};
export const ViewResultsInLensAction = React.memo(ViewResultsInLensActionComponent);
const ViewResultsInDiscoverActionComponent: React.FC<ViewResultsInDiscoverActionProps> = ({
actionId,
buttonType,
endDate,
startDate,
}) => {
const urlGenerator = useKibana().services.discover?.urlGenerator;
const [discoverUrl, setDiscoverUrl] = useState<string>('');
useEffect(() => {
const getDiscoverUrl = async () => {
if (!urlGenerator?.createUrl) return;
const newUrl = await urlGenerator.createUrl({
indexPatternId: 'logs-*',
filters: [
{
meta: {
index: 'logs-*',
alias: null,
negate: false,
disabled: false,
type: 'phrase',
key: 'action_id',
params: { query: actionId },
},
query: { match_phrase: { action_id: actionId } },
$state: { store: FilterStateStore.APP_STATE },
},
],
refreshInterval: {
pause: true,
value: 0,
},
timeRange:
startDate && endDate
? {
to: endDate,
from: startDate,
mode: 'absolute',
}
: {
to: 'now',
from: 'now-1d',
mode: 'relative',
},
});
setDiscoverUrl(newUrl);
};
getDiscoverUrl();
}, [actionId, endDate, startDate, urlGenerator]);
if (buttonType === ViewResultsActionButtonType.button) {
return (
<EuiButtonEmpty size="xs" iconType="discoverApp" href={discoverUrl}>
{VIEW_IN_DISCOVER}
</EuiButtonEmpty>
);
}
return (
<EuiToolTip content={VIEW_IN_DISCOVER}>
<EuiButtonIcon iconType="discoverApp" href={discoverUrl} aria-label={VIEW_IN_DISCOVER} />
</EuiToolTip>
);
};
export const ViewResultsInDiscoverAction = React.memo(ViewResultsInDiscoverActionComponent);
interface ScheduledQueryGroupQueriesTableProps {
data: OsqueryManagerPackagePolicyInputStream[];
editMode?: boolean;
onDeleteClick?: (item: OsqueryManagerPackagePolicyInputStream) => void;
onEditClick?: (item: OsqueryManagerPackagePolicyInputStream) => void;
selectedItems?: OsqueryManagerPackagePolicyInputStream[];
@ -296,7 +23,6 @@ interface ScheduledQueryGroupQueriesTableProps {
const ScheduledQueryGroupQueriesTableComponent: React.FC<ScheduledQueryGroupQueriesTableProps> = ({
data,
editMode = false,
onDeleteClick,
onEditClick,
selectedItems,
@ -370,26 +96,6 @@ const ScheduledQueryGroupQueriesTableComponent: React.FC<ScheduledQueryGroupQuer
[]
);
const renderDiscoverResultsAction = useCallback(
(item) => (
<ViewResultsInDiscoverAction
actionId={item.vars?.id.value}
buttonType={ViewResultsActionButtonType.icon}
/>
),
[]
);
const renderLensResultsAction = useCallback(
(item) => (
<ViewResultsInLensAction
actionId={item.vars?.id.value}
buttonType={ViewResultsActionButtonType.icon}
/>
),
[]
);
const columns = useMemo(
() => [
{
@ -428,42 +134,23 @@ const ScheduledQueryGroupQueriesTableComponent: React.FC<ScheduledQueryGroupQuer
render: renderVersionColumn,
},
{
name: editMode
? i18n.translate('xpack.osquery.scheduledQueryGroup.queriesTable.actionsColumnTitle', {
defaultMessage: 'Actions',
})
: i18n.translate(
'xpack.osquery.scheduledQueryGroup.queriesTable.viewResultsColumnTitle',
{
defaultMessage: 'View results',
}
),
name: i18n.translate('xpack.osquery.scheduledQueryGroup.queriesTable.actionsColumnTitle', {
defaultMessage: 'Actions',
}),
width: '120px',
actions: editMode
? [
{
render: renderEditAction,
},
{
render: renderDeleteAction,
},
]
: [
{
render: renderDiscoverResultsAction,
},
{
render: renderLensResultsAction,
},
],
actions: [
{
render: renderEditAction,
},
{
render: renderDeleteAction,
},
],
},
],
[
editMode,
renderDeleteAction,
renderDiscoverResultsAction,
renderEditAction,
renderLensResultsAction,
renderPlatformColumn,
renderQueryColumn,
renderVersionColumn,
@ -499,8 +186,8 @@ const ScheduledQueryGroupQueriesTableComponent: React.FC<ScheduledQueryGroupQuer
itemId={itemId}
columns={columns}
sorting={sorting}
selection={editMode ? selection : undefined}
isSelectable={editMode}
selection={selection}
isSelectable
/>
);
};

View file

@ -6,7 +6,7 @@
*/
import { EuiInMemoryTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui';
import moment from 'moment';
import moment from 'moment-timezone';
import React, { useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';

View file

@ -33,6 +33,10 @@ export const useScheduledQueryGroup = ({
keepPreviousData: true,
enabled: !skip || !scheduledQueryGroupId,
select: (response) => response.item,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
}
);
};

View file

@ -0,0 +1,91 @@
/*
* 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 { useQuery } from 'react-query';
import { SortDirection } from '../../../../../src/plugins/data/common';
import { useKibana } from '../common/lib/kibana';
interface UseScheduledQueryGroupQueryErrorsProps {
actionId: string;
agentIds?: string[];
interval: number;
skip?: boolean;
}
export const useScheduledQueryGroupQueryErrors = ({
actionId,
agentIds,
interval,
skip = false,
}: UseScheduledQueryGroupQueryErrorsProps) => {
const data = useKibana().services.data;
return useQuery(
['scheduledQueryErrors', { actionId, interval }],
async () => {
const indexPattern = await data.indexPatterns.find('logs-*');
const searchSource = await data.search.searchSource.create({
index: indexPattern[0],
fields: ['*'],
sort: [
{
'@timestamp': SortDirection.desc,
},
],
query: {
// @ts-expect-error update types
bool: {
should: agentIds?.map((agentId) => ({
match_phrase: {
'elastic_agent.id': agentId,
},
})),
minimum_should_match: 1,
filter: [
{
match_phrase: {
message: 'Error',
},
},
{
match_phrase: {
'data_stream.dataset': 'elastic_agent.osquerybeat',
},
},
{
match_phrase: {
message: actionId,
},
},
{
range: {
'@timestamp': {
gte: `now-${interval * 2}s`,
lte: 'now',
},
},
},
],
},
},
size: 1000,
});
return searchSource.fetch$().toPromise();
},
{
keepPreviousData: true,
enabled: !!(!skip && actionId && interval && agentIds?.length),
select: (response) => response.rawResponse.hits ?? [],
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
}
);
};

View file

@ -0,0 +1,88 @@
/*
* 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 { useQuery } from 'react-query';
import { useKibana } from '../common/lib/kibana';
interface UseScheduledQueryGroupQueryLastResultsProps {
actionId: string;
agentIds?: string[];
interval: number;
skip?: boolean;
}
export const useScheduledQueryGroupQueryLastResults = ({
actionId,
agentIds,
interval,
skip = false,
}: UseScheduledQueryGroupQueryLastResultsProps) => {
const data = useKibana().services.data;
return useQuery(
['scheduledQueryLastResults', { actionId }],
async () => {
const indexPattern = await data.indexPatterns.find('logs-*');
const searchSource = await data.search.searchSource.create({
index: indexPattern[0],
size: 0,
aggs: {
runs: {
terms: {
field: 'response_id',
order: { first_event_ingested_time: 'desc' },
size: 1,
},
aggs: {
first_event_ingested_time: { min: { field: '@timestamp' } },
unique_agents: { cardinality: { field: 'agent.id' } },
},
},
},
query: {
// @ts-expect-error update types
bool: {
should: agentIds?.map((agentId) => ({
match_phrase: {
'agent.id': agentId,
},
})),
minimum_should_match: 1,
filter: [
{
match_phrase: {
action_id: actionId,
},
},
{
range: {
'@timestamp': {
gte: `now-${interval * 2}s`,
lte: 'now',
},
},
},
],
},
},
});
return searchSource.fetch$().toPromise();
},
{
keepPreviousData: true,
enabled: !!(!skip && actionId && interval && agentIds?.length),
// @ts-expect-error update types
select: (response) => response.rawResponse.aggregations?.runs?.buckets[0] ?? [],
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
}
);
};

View file

@ -6,7 +6,7 @@
*/
import uuid from 'uuid';
import moment from 'moment';
import moment from 'moment-timezone';
import { PLUGIN_ID } from '../../../common';
import { IRouter } from '../../../../../../src/core/server';

View file

@ -23291,10 +23291,10 @@ react-popper@^2.2.4:
react-fast-compare "^3.0.1"
warning "^4.0.2"
react-query@^3.18.1:
version "3.18.1"
resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.18.1.tgz#893b5475a7b4add099e007105317446f7a2cd310"
integrity sha512-17lv3pQxU9n+cB5acUv0/cxNTjo9q8G+RsedC6Ax4V9D8xEM7Q5xf9xAbCPdEhDrrnzPjTls9fQEABKRSi7OJA==
react-query@^3.21.0:
version "3.21.0"
resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.21.0.tgz#2e099a7906c38eeeb750e8b9b12121a21fa8d9ef"
integrity sha512-5rY5J8OD9f4EdkytjSsdCO+pqbJWKwSIMETfh/UyxqyjLURHE0IhlB+IPNPrzzu/dzK0rRxi5p0IkcCdSfizDQ==
dependencies:
"@babel/runtime" "^7.5.5"
broadcast-channel "^3.4.1"