[Osquery] Add Saved queries (#100965)

This commit is contained in:
Patryk Kopyciński 2021-06-29 04:19:02 +03:00 committed by GitHub
parent c05588f077
commit ccf42c0b80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 1678 additions and 551 deletions

View file

@ -7,10 +7,10 @@
import * as t from 'io-ts';
export const name = t.string;
export type Name = t.TypeOf<typeof name>;
export const nameOrUndefined = t.union([name, t.undefined]);
export type NameOrUndefined = t.TypeOf<typeof nameOrUndefined>;
export const id = t.string;
export type Id = t.TypeOf<typeof id>;
export const idOrUndefined = t.union([id, t.undefined]);
export type IdOrUndefined = t.TypeOf<typeof idOrUndefined>;
export const agentSelection = t.type({
agents: t.array(t.string),
@ -18,6 +18,7 @@ export const agentSelection = t.type({
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>;
@ -36,3 +37,13 @@ export const query = t.string;
export type Query = t.TypeOf<typeof query>;
export const queryOrUndefined = t.union([query, t.undefined]);
export type QueryOrUndefined = t.TypeOf<typeof queryOrUndefined>;
export const version = t.string;
export type Version = t.TypeOf<typeof query>;
export const versionOrUndefined = t.union([version, t.undefined]);
export type VersionOrUndefined = t.TypeOf<typeof versionOrUndefined>;
export const interval = t.string;
export type Interval = t.TypeOf<typeof query>;
export const intervalOrUndefined = t.union([interval, t.undefined]);
export type IntervalOrUndefined = t.TypeOf<typeof intervalOrUndefined>;

View file

@ -7,14 +7,24 @@
import * as t from 'io-ts';
import { name, description, Description, platform, query } from '../../common/schemas';
import {
id,
description,
Description,
platform,
query,
version,
interval,
} from '../../common/schemas';
import { RequiredKeepUndefined } from '../../../types';
export const createSavedQueryRequestSchema = t.type({
name,
id,
description,
platform,
query,
version,
interval,
});
export type CreateSavedQueryRequestSchema = t.OutputOf<typeof createSavedQueryRequestSchema>;

View file

@ -26,7 +26,8 @@
"features",
"fleet",
"navigation",
"triggersActionsUi"
"triggersActionsUi",
"security"
],
"server": true,
"ui": true,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { isArray } from 'lodash';
import { i18n } from '@kbn/i18n';
import { EuiBasicTable, EuiButtonIcon, EuiCodeBlock, formatDate } from '@elastic/eui';
import React, { useState, useCallback, useMemo } from 'react';
@ -54,6 +55,8 @@ const ActionsTableComponent = () => {
const renderAgentsColumn = useCallback((_, item) => <>{item.fields.agents?.length ?? 0}</>, []);
const renderCreatedByColumn = useCallback((userId) => (isArray(userId) ? userId[0] : '-'), []);
const renderTimestampColumn = useCallback(
(_, item) => <>{formatDate(item.fields['@timestamp'][0])}</>,
[]
@ -90,6 +93,14 @@ const ActionsTableComponent = () => {
width: '200px',
render: renderTimestampColumn,
},
{
field: 'fields.user_id',
name: i18n.translate('xpack.osquery.liveQueryActions.table.createdByColumnTitle', {
defaultMessage: 'Run by',
}),
width: '200px',
render: renderCreatedByColumn,
},
{
name: i18n.translate('xpack.osquery.liveQueryActions.table.viewDetailsColumnTitle', {
defaultMessage: 'View details',
@ -101,7 +112,13 @@ const ActionsTableComponent = () => {
],
},
],
[renderActionsColumn, renderAgentsColumn, renderQueryColumn, renderTimestampColumn]
[
renderActionsColumn,
renderAgentsColumn,
renderCreatedByColumn,
renderQueryColumn,
renderTimestampColumn,
]
);
const pagination = useMemo(

View file

@ -67,6 +67,40 @@ const breadcrumbGetters: {
text: liveQueryId,
},
],
saved_queries: () => [
BASE_BREADCRUMB,
{
text: i18n.translate('xpack.osquery.breadcrumbs.savedQueriesPageTitle', {
defaultMessage: 'Saved queries',
}),
},
],
saved_query_new: () => [
BASE_BREADCRUMB,
{
href: pagePathGetters.saved_queries(),
text: i18n.translate('xpack.osquery.breadcrumbs.savedQueriesPageTitle', {
defaultMessage: 'Saved queries',
}),
},
{
text: i18n.translate('xpack.osquery.breadcrumbs.newSavedQueryPageTitle', {
defaultMessage: 'New',
}),
},
],
saved_query_edit: ({ savedQueryName }) => [
BASE_BREADCRUMB,
{
href: pagePathGetters.saved_queries(),
text: i18n.translate('xpack.osquery.breadcrumbs.savedQueriesPageTitle', {
defaultMessage: 'Saved queries',
}),
},
{
text: savedQueryName,
},
],
scheduled_query_groups: () => [
BASE_BREADCRUMB,
{

View file

@ -11,12 +11,15 @@ export type StaticPage =
| 'live_queries'
| 'live_query_new'
| 'scheduled_query_groups'
| 'scheduled_query_group_add';
| 'scheduled_query_group_add'
| 'saved_queries'
| 'saved_query_new';
export type DynamicPage =
| 'live_query_details'
| 'scheduled_query_group_details'
| 'scheduled_query_group_edit';
| 'scheduled_query_group_edit'
| 'saved_query_edit';
export type Page = StaticPage | DynamicPage;
@ -50,6 +53,9 @@ export const pagePathGetters: {
live_queries: () => '/live_queries',
live_query_new: () => '/live_queries/new',
live_query_details: ({ liveQueryId }) => `/live_queries/${liveQueryId}`,
saved_queries: () => '/saved_queries',
saved_query_new: () => '/saved_queries/new',
saved_query_edit: ({ savedQueryId }) => `/saved_queries/${savedQueryId}`,
scheduled_query_groups: () => '/scheduled_query_groups',
scheduled_query_group_add: () => '/scheduled_query_groups/add',
scheduled_query_group_details: ({ scheduledQueryGroupId }) =>

View file

@ -50,6 +50,15 @@ const OsqueryAppComponent = () => {
defaultMessage="Scheduled query groups"
/>
</EuiTab>
<EuiTab
isSelected={section === 'saved_queries'}
{...useRouterNavigate('saved_queries')}
>
<FormattedMessage
id="xpack.osquery.appNavigation.savedQueriesLinkText"
defaultMessage="Saved queries"
/>
</EuiTab>
</EuiTabs>
</EuiFlexItem>
<EuiFlexItem grow={false}>

View file

@ -0,0 +1,20 @@
/*
* 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 { EuiText, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
export const OsquerySchemaLink = React.memo(() => (
<EuiText size="xs">
<EuiLink href="https://osquery.io/schema/4.8.0" target="_blank">
<FormattedMessage id="xpack.osquery.osquerySchemaLinkLabel" defaultMessage="Osquery schema" />
</EuiLink>
</EuiText>
));
OsquerySchemaLink.displayName = 'OsquerySchemaLink';

View file

@ -40,7 +40,7 @@ const OsqueryEditorComponent: React.FC<OsqueryEditorProps> = ({
name="osquery_editor"
setOptions={EDITOR_SET_OPTIONS}
editorProps={EDITOR_PROPS}
height="200px"
height="150px"
width="100%"
/>
);

File diff suppressed because one or more lines are too long

View file

@ -20,7 +20,7 @@ let osqueryTables: TablesJSON | null = null;
export const getOsqueryTables = () => {
if (!osqueryTables) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
osqueryTables = normalizeTables(require('./osquery_schema/v4.7.0.json'));
osqueryTables = normalizeTables(require('./osquery_schema/v4.8.0.json'));
}
return osqueryTables;
};

View file

@ -207,7 +207,8 @@ export const OsqueryManagedPolicyCreateImportExtension = React.memo<
integrationPolicyId={policy?.id}
agentPolicyId={policy?.policy_id}
/>
<EuiSpacer />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
{editMode && scheduledQueryGroupTableData.inputs[0].streams.length ? (
<EuiFlexGroup>

View file

@ -5,20 +5,28 @@
* 2.0.
*/
import { EuiButton, EuiSteps, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import {
EuiButton,
EuiButtonEmpty,
EuiSteps,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useMemo } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { useMutation } from 'react-query';
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 { ResultTabs } from '../../routes/saved_queries/edit/tabs';
import { queryFieldValidation } from '../../common/validations';
import { fieldValidators } from '../../shared_imports';
import { SavedQueryFlyout } from '../../saved_queries';
import { useErrorToast } from '../../common/hooks/use_error_toast';
const FORM_ID = 'liveQueryForm';
@ -27,19 +35,17 @@ export const MAX_QUERY_LENGTH = 2000;
interface LiveQueryFormProps {
defaultValue?: Partial<FormData> | undefined;
onSubmit?: (payload: Record<string, string>) => Promise<void>;
onSuccess?: () => void;
}
const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
defaultValue,
// onSubmit,
onSuccess,
}) => {
const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({ defaultValue, onSuccess }) => {
const { http } = useKibana().services;
const [showSavedQueryFlyout, setShowSavedQueryFlyout] = useState(false);
const setErrorToast = useErrorToast();
const handleShowSaveQueryFlout = useCallback(() => setShowSavedQueryFlyout(true), []);
const handleCloseSaveQueryFlout = useCallback(() => setShowSavedQueryFlyout(false), []);
const {
data,
isLoading,
@ -139,6 +145,8 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
[queryStatus]
);
const flyoutFormDefaultValue = useMemo(() => ({ query }), [query]);
const formSteps: EuiContainedStepProps[] = useMemo(
() => [
{
@ -161,6 +169,17 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
/>
<EuiSpacer />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
disabled={!agentSelected || !queryValueProvided || resultsStatus === 'disabled'}
onClick={handleShowSaveQueryFlout}
>
<FormattedMessage
id="xpack.osquery.liveQueryForm.form.saveForLaterButtonLabel"
defaultMessage="Save for later"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton disabled={!agentSelected || !queryValueProvided} onClick={submit}>
<FormattedMessage
@ -193,6 +212,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
actionId,
agentIds,
agentSelected,
handleShowSaveQueryFlout,
queryComponentProps,
queryStatus,
queryValueProvided,
@ -203,9 +223,17 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
);
return (
<Form form={form}>
<EuiSteps steps={formSteps} />
</Form>
<>
<Form form={form}>
<EuiSteps steps={formSteps} />
</Form>
{showSavedQueryFlyout ? (
<SavedQueryFlyout
onClose={handleCloseSaveQueryFlout}
defaultValue={flyoutFormDefaultValue}
/>
) : null}
</>
);
};

View file

@ -5,11 +5,13 @@
* 2.0.
*/
import { EuiFormRow, EuiSpacer } from '@elastic/eui';
import React, { useCallback } from 'react';
import { EuiFormRow } from '@elastic/eui';
import { OsquerySchemaLink } from '../../components/osquery_schema_link';
import { FieldHook } from '../../shared_imports';
import { OsqueryEditor } from '../../editor';
import { SavedQueriesDropdown } from '../../saved_queries/saved_queries_dropdown';
interface LiveQueryQueryFieldProps {
disabled?: boolean;
@ -20,6 +22,13 @@ const LiveQueryQueryFieldComponent: React.FC<LiveQueryQueryFieldProps> = ({ disa
const { value, setValue, errors } = field;
const error = errors[0]?.message;
const handleSavedQueryChange = useCallback(
(savedQuery) => {
setValue(savedQuery.query);
},
[setValue]
);
const handleEditorChange = useCallback(
(newValue) => {
setValue(newValue);
@ -29,7 +38,13 @@ const LiveQueryQueryFieldComponent: React.FC<LiveQueryQueryFieldProps> = ({ disa
return (
<EuiFormRow isInvalid={typeof error === 'string'} error={error} fullWidth>
<OsqueryEditor defaultValue={value} disabled={disabled} onChange={handleEditorChange} />
<>
<SavedQueriesDropdown disabled={disabled} onChange={handleSavedQueryChange} />
<EuiSpacer />
<EuiFormRow fullWidth labelAppend={<OsquerySchemaLink />}>
<OsqueryEditor defaultValue={value} disabled={disabled} onChange={handleEditorChange} />
</EuiFormRow>
</>
</EuiFormRow>
);
};

View file

@ -8,7 +8,7 @@
import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui';
import React from 'react';
import { SavedQueryForm } from '../../queries/form';
import { SavedQueryForm } from '../../saved_queries/form';
// @ts-expect-error update types
const AddNewPackQueryFlyoutComponent = ({ handleClose, handleSubmit }) => (
@ -19,7 +19,10 @@ const AddNewPackQueryFlyoutComponent = ({ handleClose, handleSubmit }) => (
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<SavedQueryForm handleSubmit={handleSubmit} />
{
// @ts-expect-error update types
<SavedQueryForm handleSubmit={handleSubmit} />
}
</EuiFlyoutBody>
</EuiFlyout>
);

View file

@ -1,53 +0,0 @@
/*
* 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 { isEmpty } from 'lodash/fp';
import React from 'react';
import { useMutation, useQuery } from 'react-query';
import { SavedQueryForm } from '../form';
import { useKibana } from '../../common/lib/kibana';
interface EditSavedQueryPageProps {
onSuccess: () => void;
savedQueryId: string;
}
const EditSavedQueryPageComponent: React.FC<EditSavedQueryPageProps> = ({
onSuccess,
savedQueryId,
}) => {
const { http } = useKibana().services;
const { isLoading, data: savedQueryDetails } = useQuery(['savedQuery', { savedQueryId }], () =>
http.get(`/internal/osquery/saved_query/${savedQueryId}`)
);
const updateSavedQueryMutation = useMutation(
(payload) =>
http.put(`/internal/osquery/saved_query/${savedQueryId}`, { body: JSON.stringify(payload) }),
{ onSuccess }
);
if (isLoading) {
return <>{'Loading...'}</>;
}
return (
<>
{!isEmpty(savedQueryDetails) && (
<SavedQueryForm
defaultValue={savedQueryDetails}
// @ts-expect-error update types
handleSubmit={updateSavedQueryMutation.mutate}
type="edit"
/>
)}
</>
);
};
export const EditSavedQueryPage = React.memo(EditSavedQueryPageComponent);

View file

@ -1,72 +0,0 @@
/*
* 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 { EuiButton, EuiSpacer } from '@elastic/eui';
import React from 'react';
import { Field, getUseField, useForm, UseField, Form } from '../../shared_imports';
import { CodeEditorField } from './code_editor_field';
import { formSchema } from './schema';
export const CommonUseField = getUseField({ component: Field });
const SAVED_QUERY_FORM_ID = 'savedQueryForm';
interface SavedQueryFormProps {
defaultValue?: unknown;
handleSubmit: () => Promise<void>;
type?: string;
}
const SavedQueryFormComponent: React.FC<SavedQueryFormProps> = ({
defaultValue,
handleSubmit,
type,
}) => {
const { form } = useForm({
// @ts-expect-error update types
id: defaultValue ? SAVED_QUERY_FORM_ID + defaultValue.id : SAVED_QUERY_FORM_ID,
schema: formSchema,
onSubmit: handleSubmit,
options: {
stripEmptyFields: false,
},
// @ts-expect-error update types
defaultValue,
});
const { submit } = form;
return (
<Form form={form}>
<CommonUseField path="name" />
<EuiSpacer />
<CommonUseField path="description" />
<EuiSpacer />
<CommonUseField
path="platform"
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{
options: [
{ value: 'darwin', text: 'macOS' },
{ value: 'freebsd', text: 'FreeBSD' },
{ value: 'linux', text: 'Linux' },
{ value: 'posix', text: 'Posix' },
{ value: 'windows', text: 'Windows' },
{ value: 'all', text: 'All' },
],
}}
/>
<EuiSpacer />
<UseField path="query" component={CodeEditorField} />
<EuiSpacer />
<EuiButton onClick={submit}>{type === 'edit' ? 'Update' : 'Save'}</EuiButton>
</Form>
);
};
export const SavedQueryForm = React.memo(SavedQueryFormComponent);

View file

@ -1,30 +0,0 @@
/*
* 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 { FIELD_TYPES, FormSchema } from '../../shared_imports';
export const formSchema: FormSchema = {
name: {
type: FIELD_TYPES.TEXT,
label: 'Query name',
},
description: {
type: FIELD_TYPES.TEXTAREA,
label: 'Description',
validations: [],
},
platform: {
type: FIELD_TYPES.SELECT,
label: 'Platform',
defaultValue: 'all',
},
query: {
label: 'Query',
type: FIELD_TYPES.TEXTAREA,
validations: [],
},
};

View file

@ -1,36 +0,0 @@
/*
* 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 React, { useCallback, useState } from 'react';
import { QueriesPage } from './queries';
import { NewSavedQueryPage } from './new';
import { EditSavedQueryPage } from './edit';
const QueriesComponent = () => {
const [showNewSavedQueryForm, setShowNewSavedQueryForm] = useState(false);
const [editSavedQueryId, setEditSavedQueryId] = useState<string | null>(null);
const goBack = useCallback(() => {
setShowNewSavedQueryForm(false);
setEditSavedQueryId(null);
}, []);
const handleNewQueryClick = useCallback(() => setShowNewSavedQueryForm(true), []);
if (showNewSavedQueryForm) {
return <NewSavedQueryPage onSuccess={goBack} />;
}
if (editSavedQueryId?.length) {
return <EditSavedQueryPage onSuccess={goBack} savedQueryId={editSavedQueryId} />;
}
return <QueriesPage onNewClick={handleNewQueryClick} onEditClick={setEditSavedQueryId} />;
};
export const Queries = React.memo(QueriesComponent);

View file

@ -1,32 +0,0 @@
/*
* 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 React from 'react';
import { useMutation } from 'react-query';
import { useKibana } from '../../common/lib/kibana';
import { SavedQueryForm } from '../form';
interface NewSavedQueryPageProps {
onSuccess: () => void;
}
const NewSavedQueryPageComponent: React.FC<NewSavedQueryPageProps> = ({ onSuccess }) => {
const { http } = useKibana().services;
const createSavedQueryMutation = useMutation(
(payload) => http.post(`/internal/osquery/saved_query`, { body: JSON.stringify(payload) }),
{
onSuccess,
}
);
// @ts-expect-error update types
return <SavedQueryForm handleSubmit={createSavedQueryMutation.mutate} />;
};
export const NewSavedQueryPage = React.memo(NewSavedQueryPageComponent);

View file

@ -1,244 +0,0 @@
/*
* 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/fp';
import {
EuiBasicTable,
EuiButton,
EuiButtonIcon,
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
RIGHT_ALIGNMENT,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { useQuery, useQueryClient, useMutation } from 'react-query';
import { useHistory } from 'react-router-dom';
import qs from 'query-string';
import { useKibana } from '../../common/lib/kibana';
interface QueriesPageProps {
onEditClick: (savedQueryId: string) => void;
onNewClick: () => void;
}
const QueriesPageComponent: React.FC<QueriesPageProps> = ({ onEditClick, onNewClick }) => {
const { push } = useHistory();
const queryClient = useQueryClient();
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const [sortField, setSortField] = useState('updated_at');
const [sortDirection, setSortDirection] = useState('desc');
const [selectedItems, setSelectedItems] = useState([]);
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<Record<string, unknown>>({});
const { http } = useKibana().services;
const deleteSavedQueriesMutation = useMutation(
(payload) => http.delete(`/internal/osquery/saved_query`, { body: JSON.stringify(payload) }),
{
onSuccess: () => queryClient.invalidateQueries('savedQueryList'),
}
);
const { data = {} } = useQuery(
['savedQueryList', { pageIndex, pageSize, sortField, sortDirection }],
() =>
http.get('/internal/osquery/saved_query', {
query: {
pageIndex,
pageSize,
sortField,
sortDirection,
},
}),
{
keepPreviousData: true,
// Refetch the data every 10 seconds
refetchInterval: 5000,
}
);
const { total = 0, saved_objects: savedQueries } = data;
const toggleDetails = useCallback(
(item) => () => {
const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap };
if (itemIdToExpandedRowMapValues[item.id]) {
delete itemIdToExpandedRowMapValues[item.id];
} else {
itemIdToExpandedRowMapValues[item.id] = (
<EuiCodeBlock language="sql" fontSize="m" paddingSize="m">
{item.attributes.query}
</EuiCodeBlock>
);
}
setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues);
},
[itemIdToExpandedRowMap]
);
const renderExtendedItemToggle = useCallback(
(item) => (
<EuiButtonIcon
onClick={toggleDetails(item)}
aria-label={itemIdToExpandedRowMap[item.id] ? 'Collapse' : 'Expand'}
iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'}
/>
),
[itemIdToExpandedRowMap, toggleDetails]
);
const handleEditClick = useCallback((item) => onEditClick(item.id), [onEditClick]);
const handlePlayClick = useCallback(
(item) =>
push({
search: qs.stringify({
tab: 'live_query',
}),
state: {
query: {
id: item.id,
query: item.attributes.query,
},
},
}),
[push]
);
const columns = useMemo(
() => [
{
field: 'attributes.name',
name: 'Query name',
sortable: true,
truncateText: true,
},
{
field: 'attributes.description',
name: 'Description',
sortable: true,
truncateText: true,
},
{
field: 'updated_at',
name: 'Last updated at',
sortable: true,
truncateText: true,
},
{
name: 'Actions',
actions: [
{
name: 'Live query',
description: 'Run live query',
type: 'icon',
icon: 'play',
onClick: handlePlayClick,
},
{
name: 'Edit',
description: 'Edit or run this query',
type: 'icon',
icon: 'documentEdit',
onClick: handleEditClick,
},
],
},
{
align: RIGHT_ALIGNMENT,
width: '40px',
isExpander: true,
render: renderExtendedItemToggle,
},
],
[handleEditClick, handlePlayClick, renderExtendedItemToggle]
);
const onTableChange = useCallback(({ page = {}, sort = {} }) => {
setPageIndex(page.index);
setPageSize(page.size);
setSortField(sort.field);
setSortDirection(sort.direction);
}, []);
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
totalItemCount: total,
pageSizeOptions: [3, 5, 8],
}),
[total, pageIndex, pageSize]
);
const sorting = useMemo(
() => ({
sort: {
field: sortField,
direction: sortDirection,
},
}),
[sortDirection, sortField]
);
const selection = useMemo(
() => ({
selectable: () => true,
onSelectionChange: setSelectedItems,
initialSelected: [],
}),
[]
);
const handleDeleteClick = useCallback(() => {
const selectedItemsIds = map<string>('id', selectedItems);
// @ts-expect-error update types
deleteSavedQueriesMutation.mutate({ savedQueryIds: selectedItemsIds });
}, [deleteSavedQueriesMutation, selectedItems]);
return (
<div>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
{!selectedItems.length ? (
<EuiButton fill onClick={onNewClick}>
{'New query'}
</EuiButton>
) : (
<EuiButton color="danger" iconType="trash" onClick={handleDeleteClick}>
{`Delete ${selectedItems.length} Queries`}
</EuiButton>
)}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{savedQueries && (
<EuiBasicTable
items={savedQueries}
itemId="id"
// @ts-expect-error update types
columns={columns}
pagination={pagination}
// @ts-expect-error update types
sorting={sorting}
isSelectable={true}
selection={selection}
onChange={onTableChange}
// @ts-expect-error update types
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
rowHeader="id"
/>
)}
</div>
);
};
export const QueriesPage = React.memo(QueriesPageComponent);

View file

@ -11,12 +11,16 @@ import { Switch, Redirect, Route } from 'react-router-dom';
import { useBreadcrumbs } from '../common/hooks/use_breadcrumbs';
import { LiveQueries } from './live_queries';
import { ScheduledQueryGroups } from './scheduled_query_groups';
import { SavedQueries } from './saved_queries';
const OsqueryAppRoutesComponent = () => {
useBreadcrumbs('base');
return (
<Switch>
<Route path={`/saved_queries`}>
<SavedQueries />
</Route>
<Route path={`/scheduled_query_groups`}>
<ScheduledQueryGroups />
</Route>

View file

@ -27,7 +27,7 @@ import { useRouterNavigate } from '../../../common/lib/kibana';
import { WithHeaderLayout } from '../../../components/layouts';
import { useActionResults } from '../../../action_results/use_action_results';
import { useActionDetails } from '../../../actions/use_action_details';
import { ResultTabs } from '../../../queries/edit/tabs';
import { ResultTabs } from '../../saved_queries/edit/tabs';
import { useBreadcrumbs } from '../../../common/hooks/use_breadcrumbs';
import { BetaBadge, BetaBadgeRowWrapper } from '../../../components/beta_badge';

View file

@ -0,0 +1,81 @@
/*
* 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 {
EuiBottomBar,
EuiButtonEmpty,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
} from '@elastic/eui';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { useRouterNavigate } from '../../../common/lib/kibana';
import { Form } from '../../../shared_imports';
import { SavedQueryForm } from '../../../saved_queries/form';
import { useSavedQueryForm } from '../../../saved_queries/form/use_saved_query_form';
interface EditSavedQueryFormProps {
defaultValue?: unknown;
handleSubmit: () => Promise<void>;
}
const EditSavedQueryFormComponent: React.FC<EditSavedQueryFormProps> = ({
defaultValue,
handleSubmit,
}) => {
const savedQueryListProps = useRouterNavigate('saved_queries');
const { form } = useSavedQueryForm({
defaultValue,
handleSubmit,
});
return (
<Form form={form}>
<SavedQueryForm />
<EuiBottomBar>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="m">
<EuiFlexItem grow={false}>
<EuiButtonEmpty color="ghost" {...savedQueryListProps}>
<FormattedMessage
id="xpack.osquery.editSavedQuery.form.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
// isLoading={isLoading}
color="primary"
fill
size="m"
iconType="save"
onClick={form.submit}
>
<FormattedMessage
id="xpack.osquery.editSavedQuery.form.updateQueryButtonLabel"
defaultMessage="Update query"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiBottomBar>
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
</Form>
);
};
export const EditSavedQueryForm = React.memo(EditSavedQueryFormComponent);

View file

@ -0,0 +1,124 @@
/*
* 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 {
EuiButtonEmpty,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiConfirmModal,
} from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { useParams } from 'react-router-dom';
import { useRouterNavigate } from '../../../common/lib/kibana';
import { WithHeaderLayout } from '../../../components/layouts';
import { useBreadcrumbs } from '../../../common/hooks/use_breadcrumbs';
import { BetaBadge, BetaBadgeRowWrapper } from '../../../components/beta_badge';
import { EditSavedQueryForm } from './form';
import { useDeleteSavedQuery, useUpdateSavedQuery, useSavedQuery } from '../../../saved_queries';
const EditSavedQueryPageComponent = () => {
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const { savedQueryId } = useParams<{ savedQueryId: string }>();
const savedQueryListProps = useRouterNavigate('saved_queries');
const { isLoading, data: savedQueryDetails } = useSavedQuery({ savedQueryId });
const updateSavedQueryMutation = useUpdateSavedQuery({ savedQueryId });
const deleteSavedQueryMutation = useDeleteSavedQuery({ savedQueryId });
useBreadcrumbs('saved_query_edit', { savedQueryId: savedQueryDetails?.attributes?.id ?? '' });
const handleCloseDeleteConfirmationModal = useCallback(() => {
setIsDeleteModalVisible(false);
}, []);
const handleDeleteClick = useCallback(() => {
setIsDeleteModalVisible(true);
}, []);
const handleDeleteConfirmClick = useCallback(() => {
deleteSavedQueryMutation.mutateAsync().then(() => {
handleCloseDeleteConfirmationModal();
});
}, [deleteSavedQueryMutation, handleCloseDeleteConfirmationModal]);
const LeftColumn = useMemo(
() => (
<EuiFlexGroup alignItems="flexStart" direction="column" gutterSize="m">
<EuiFlexItem>
<EuiButtonEmpty iconType="arrowLeft" {...savedQueryListProps} flush="left" size="xs">
<FormattedMessage
id="xpack.osquery.editSavedQuery.viewSavedQueriesListTitle"
defaultMessage="View all saved queries"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<BetaBadgeRowWrapper>
<h1>
<FormattedMessage
id="xpack.osquery.editSavedQuery.pageTitle"
defaultMessage='Edit "{savedQueryId}"'
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
values={{
savedQueryId: savedQueryDetails?.attributes?.id ?? '',
}}
/>
</h1>
<BetaBadge />
</BetaBadgeRowWrapper>
</EuiFlexItem>
</EuiFlexGroup>
),
[savedQueryDetails?.attributes?.id, savedQueryListProps]
);
const RightColumn = useMemo(
() => (
<EuiButton color="danger" onClick={handleDeleteClick} iconType="trash">
<FormattedMessage
id="xpack.osquery.editSavedQuery.deleteSavedQueryButtonLabel"
defaultMessage="Delete query"
/>
</EuiButton>
),
[handleDeleteClick]
);
if (isLoading) return null;
return (
<WithHeaderLayout leftColumn={LeftColumn} rightColumn={RightColumn} rightColumnGrow={false}>
{!isLoading && !isEmpty(savedQueryDetails) && (
<EditSavedQueryForm
defaultValue={savedQueryDetails?.attributes}
// @ts-expect-error update types
handleSubmit={updateSavedQueryMutation.mutateAsync}
/>
)}
{isDeleteModalVisible ? (
<EuiConfirmModal
title={`Are you sure you want to delete this query?`}
onCancel={handleCloseDeleteConfirmationModal}
onConfirm={handleDeleteConfirmClick}
cancelButtonText="No, don't do it"
confirmButtonText="Yes, do it"
buttonColor="danger"
defaultFocusedButton="confirm"
>
<p>You&rsquo;re about to delete this query.</p>
<p>Are you sure you want to do this?</p>
</EuiConfirmModal>
) : null}
</WithHeaderLayout>
);
};
export const EditSavedQueryPage = React.memo(EditSavedQueryPageComponent);

View file

@ -0,0 +1,78 @@
/*
* 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 { EuiTabbedContent, EuiSpacer } from '@elastic/eui';
import React, { useMemo } from 'react';
import { ResultsTable } from '../../../results/results_table';
import { ActionResultsSummary } from '../../../action_results/action_results_summary';
interface ResultTabsProps {
actionId: string;
agentIds?: string[];
expirationDate: Date;
isLive?: boolean;
startDate?: string;
endDate?: string;
}
const ResultTabsComponent: React.FC<ResultTabsProps> = ({
actionId,
agentIds,
endDate,
expirationDate,
isLive,
startDate,
}) => {
const tabs = useMemo(
() => [
{
id: 'results',
name: 'Results',
content: (
<>
<EuiSpacer />
<ResultsTable
actionId={actionId}
agentIds={agentIds}
isLive={isLive}
startDate={startDate}
endDate={endDate}
/>
</>
),
},
{
id: 'status',
name: 'Status',
content: (
<>
<EuiSpacer />
<ActionResultsSummary
actionId={actionId}
agentIds={agentIds}
expirationDate={expirationDate}
isLive={isLive}
/>
</>
),
},
],
[actionId, agentIds, endDate, expirationDate, isLive, startDate]
);
return (
<EuiTabbedContent
tabs={tabs}
initialSelectedTab={tabs[0]}
autoFocus="selected"
expand={false}
/>
);
};
export const ResultTabs = React.memo(ResultTabsComponent);

View file

@ -0,0 +1,35 @@
/*
* 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 React from 'react';
import { Switch, Route, useRouteMatch } from 'react-router-dom';
import { QueriesPage } from './list';
import { NewSavedQueryPage } from './new';
import { EditSavedQueryPage } from './edit';
import { useBreadcrumbs } from '../../common/hooks/use_breadcrumbs';
const SavedQueriesComponent = () => {
useBreadcrumbs('saved_queries');
const match = useRouteMatch();
return (
<Switch>
<Route path={`${match.url}/new`}>
<NewSavedQueryPage />
</Route>
<Route path={`${match.url}/:savedQueryId`}>
<EditSavedQueryPage />
</Route>
<Route path={`${match.url}`}>
<QueriesPage />
</Route>
</Switch>
);
};
export const SavedQueries = React.memo(SavedQueriesComponent);

View file

@ -0,0 +1,217 @@
/*
* 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 moment from 'moment';
import {
EuiInMemoryTable,
EuiButton,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { SavedObject } from 'kibana/public';
import { WithHeaderLayout } from '../../../components/layouts';
import { useBreadcrumbs } from '../../../common/hooks/use_breadcrumbs';
import { useRouterNavigate } from '../../../common/lib/kibana';
import { BetaBadge, BetaBadgeRowWrapper } from '../../../components/beta_badge';
import { useSavedQueries } from '../../../saved_queries/use_saved_queries';
interface EditButtonProps {
savedQueryId: string;
savedQueryName: string;
}
const EditButtonComponent: React.FC<EditButtonProps> = ({ savedQueryId, savedQueryName }) => {
const buttonProps = useRouterNavigate(`saved_queries/${savedQueryId}`);
return (
<EuiButtonIcon
color="primary"
{...buttonProps}
iconType="pencil"
aria-label={i18n.translate('xpack.osquery.savedQueryList.queriesTable.editActionAriaLabel', {
defaultMessage: 'Edit {savedQueryName}',
values: {
savedQueryName,
},
})}
/>
);
};
const EditButton = React.memo(EditButtonComponent);
const SavedQueriesPageComponent = () => {
useBreadcrumbs('saved_queries');
const newQueryLinkProps = useRouterNavigate('saved_queries/new');
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [sortField, setSortField] = useState('updated_at');
const [sortDirection, setSortDirection] = useState('desc');
const { data } = useSavedQueries({ isLive: true });
// const handlePlayClick = useCallback(
// (item) =>
// push({
// search: qs.stringify({
// tab: 'live_query',
// }),
// state: {
// query: {
// id: item.id,
// query: item.attributes.query,
// },
// },
// }),
// [push]
// );
const renderEditAction = useCallback(
(item: SavedObject<{ name: string }>) => (
<EditButton savedQueryId={item.id} savedQueryName={item.attributes.name} />
),
[]
);
const renderUpdatedAt = useCallback((updatedAt, item) => {
if (!updatedAt) return '-';
const updatedBy =
item.attributes.updated_by !== item.attributes.created_by
? ` @ ${item.attributes.updated_by}`
: '';
return updatedAt ? `${moment(updatedAt).fromNow()}${updatedBy}` : '-';
}, []);
const columns = useMemo(
() => [
{
field: 'attributes.id',
name: 'Query ID',
sortable: true,
truncateText: true,
},
{
field: 'attributes.description',
name: 'Description',
sortable: true,
truncateText: true,
},
{
field: 'attributes.created_by',
name: 'Created by',
sortable: true,
truncateText: true,
},
{
field: 'attributes.updated_at',
name: 'Last updated at',
sortable: (item: SavedObject<{ updated_at: string }>) =>
item.attributes.updated_at ? Date.parse(item.attributes.updated_at) : 0,
truncateText: true,
render: renderUpdatedAt,
},
{
name: 'Actions',
actions: [
// {
// name: 'Live query',
// description: 'Run live query',
// type: 'icon',
// icon: 'play',
// onClick: handlePlayClick,
// },
{ render: renderEditAction },
],
},
],
[renderEditAction, renderUpdatedAt]
);
const onTableChange = useCallback(({ page = {}, sort = {} }) => {
setPageIndex(page.index);
setPageSize(page.size);
setSortField(sort.field);
setSortDirection(sort.direction);
}, []);
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
totalItemCount: data?.total ?? 0,
pageSizeOptions: [10, 20, 50, 100],
}),
[pageIndex, pageSize, data?.total]
);
const sorting = useMemo(
() => ({
sort: {
field: sortField,
direction: sortDirection,
},
}),
[sortDirection, sortField]
);
const LeftColumn = useMemo(
() => (
<EuiFlexGroup alignItems="flexStart" direction="column" gutterSize="m">
<EuiFlexItem>
<BetaBadgeRowWrapper>
<h1>
<FormattedMessage
id="xpack.osquery.savedQueryList.pageTitle"
defaultMessage="Saved queries"
/>
</h1>
<BetaBadge />
</BetaBadgeRowWrapper>
</EuiFlexItem>
</EuiFlexGroup>
),
[]
);
const RightColumn = useMemo(
() => (
<EuiButton fill {...newQueryLinkProps} iconType="plusInCircle">
<FormattedMessage
id="xpack.osquery.savedQueryList.addSavedQueryButtonLabel"
defaultMessage="Add saved query"
/>
</EuiButton>
),
[newQueryLinkProps]
);
return (
<WithHeaderLayout leftColumn={LeftColumn} rightColumn={RightColumn} rightColumnGrow={false}>
{data?.savedObjects && (
<EuiInMemoryTable
items={data?.savedObjects}
itemId="id"
// @ts-expect-error update types
columns={columns}
pagination={pagination}
// @ts-expect-error update types
sorting={sorting}
onChange={onTableChange}
rowHeader="id"
/>
)}
</WithHeaderLayout>
);
};
export const QueriesPage = React.memo(SavedQueriesPageComponent);

View file

@ -0,0 +1,81 @@
/*
* 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 {
EuiBottomBar,
EuiButtonEmpty,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
} from '@elastic/eui';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { useRouterNavigate } from '../../../common/lib/kibana';
import { Form } from '../../../shared_imports';
import { SavedQueryForm } from '../../../saved_queries/form';
import { useSavedQueryForm } from '../../../saved_queries/form/use_saved_query_form';
interface NewSavedQueryFormProps {
defaultValue?: unknown;
handleSubmit: () => Promise<void>;
}
const NewSavedQueryFormComponent: React.FC<NewSavedQueryFormProps> = ({
defaultValue,
handleSubmit,
}) => {
const savedQueryListProps = useRouterNavigate('saved_queries');
const { form } = useSavedQueryForm({
defaultValue,
handleSubmit,
});
return (
<Form form={form}>
<SavedQueryForm />
<EuiBottomBar>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="m">
<EuiFlexItem grow={false}>
<EuiButtonEmpty color="ghost" {...savedQueryListProps}>
<FormattedMessage
id="xpack.osquery.addSavedQuery.form.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
// isLoading={isLoading}
color="primary"
fill
size="m"
iconType="save"
onClick={form.submit}
>
<FormattedMessage
id="xpack.osquery.addSavedQuery.form.saveQueryButtonLabel"
defaultMessage="Save query"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiBottomBar>
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
</Form>
);
};
export const NewSavedQueryForm = React.memo(NewSavedQueryFormComponent);

View file

@ -0,0 +1,62 @@
/*
* 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 { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { useRouterNavigate } from '../../../common/lib/kibana';
import { WithHeaderLayout } from '../../../components/layouts';
import { useBreadcrumbs } from '../../../common/hooks/use_breadcrumbs';
import { BetaBadge, BetaBadgeRowWrapper } from '../../../components/beta_badge';
import { NewSavedQueryForm } from './form';
import { useCreateSavedQuery } from '../../../saved_queries/use_create_saved_query';
const NewSavedQueryPageComponent = () => {
useBreadcrumbs('saved_query_new');
const savedQueryListProps = useRouterNavigate('saved_queries');
const createSavedQueryMutation = useCreateSavedQuery({ withRedirect: true });
const LeftColumn = useMemo(
() => (
<EuiFlexGroup alignItems="flexStart" direction="column" gutterSize="m">
<EuiFlexItem>
<EuiButtonEmpty iconType="arrowLeft" {...savedQueryListProps} flush="left" size="xs">
<FormattedMessage
id="xpack.osquery.addSavedQuery.viewSavedQueriesListTitle"
defaultMessage="View all saved queries"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<BetaBadgeRowWrapper>
<h1>
<FormattedMessage
id="xpack.osquery.addSavedQuery.pageTitle"
defaultMessage="Add saved query"
/>
</h1>
<BetaBadge />
</BetaBadgeRowWrapper>
</EuiFlexItem>
</EuiFlexGroup>
),
[savedQueryListProps]
);
return (
<WithHeaderLayout leftColumn={LeftColumn}>
{
// @ts-expect-error update types
<NewSavedQueryForm handleSubmit={createSavedQueryMutation.mutateAsync} />
}
</WithHeaderLayout>
);
};
export const NewSavedQueryPage = React.memo(NewSavedQueryPageComponent);

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 const SAVED_QUERIES_ID = 'savedQueryList';

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import { FormattedMessage } from '@kbn/i18n/react';
import { isEmpty } from 'lodash/fp';
import { EuiFormRow, EuiLink, EuiText } from '@elastic/eui';
import { EuiFormRow } from '@elastic/eui';
import React from 'react';
import { OsquerySchemaLink } from '../../components/osquery_schema_link';
import { OsqueryEditor } from '../../editor';
import { FieldHook } from '../../shared_imports';
@ -17,19 +17,6 @@ interface CodeEditorFieldProps {
field: FieldHook<string>;
}
const OsquerySchemaLink = React.memo(() => (
<EuiText size="xs">
<EuiLink href="https://osquery.io/schema/4.7.0" target="_blank">
<FormattedMessage
id="xpack.osquery.codeEditorField.osquerySchemaLinkLabel"
defaultMessage="Osquery schema"
/>
</EuiLink>
</EuiText>
));
OsquerySchemaLink.displayName = 'OsquerySchemaLink';
const CodeEditorFieldComponent: React.FC<CodeEditorFieldProps> = ({ field }) => {
const { value, label, labelAppend, helpText, setValue, errors } = field;
const error = errors[0]?.message;

View file

@ -0,0 +1,74 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, EuiText } from '@elastic/eui';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { ALL_OSQUERY_VERSIONS_OPTIONS } from '../../scheduled_query_groups/queries/constants';
import { PlatformCheckBoxGroupField } from '../../scheduled_query_groups/queries/platform_checkbox_group_field';
import { Field, getUseField, UseField } from '../../shared_imports';
import { CodeEditorField } from './code_editor_field';
export const CommonUseField = getUseField({ component: Field });
const SavedQueryFormComponent = () => (
<>
<CommonUseField path="id" />
<EuiSpacer />
<CommonUseField path="description" />
<EuiSpacer />
<UseField path="query" component={CodeEditorField} />
<EuiSpacer size="xl" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.osquery.savedQueries.form.scheduledQueryGroupConfigSection.title"
defaultMessage="Scheduled query group configuration"
/>
</h5>
</EuiTitle>
<EuiText color="subdued">
<FormattedMessage
id="xpack.osquery.savedQueries.form.scheduledQueryGroupConfigSection.description"
defaultMessage="The options listed below are optional and are only applied when the query is assigned to a scheduled query group."
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<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"
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{
noSuggestions: false,
singleSelection: { asPlainText: true },
placeholder: ALL_OSQUERY_VERSIONS_OPTIONS[0].label,
options: ALL_OSQUERY_VERSIONS_OPTIONS,
onCreateOption: undefined,
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<CommonUseField path="platform" component={PlatformCheckBoxGroupField} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
</>
);
export const SavedQueryForm = React.memo(SavedQueryFormComponent);

View file

@ -0,0 +1,64 @@
/*
* 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 { isArray } from 'lodash';
import uuid from 'uuid';
import { produce } from 'immer';
import { useForm } from '../../shared_imports';
import { formSchema } from '../../scheduled_query_groups/queries/schema';
import { ScheduledQueryGroupFormData } from '../../scheduled_query_groups/queries/use_scheduled_query_group_query_form';
const SAVED_QUERY_FORM_ID = 'savedQueryForm';
interface UseSavedQueryFormProps {
defaultValue?: unknown;
handleSubmit: (payload: unknown) => Promise<void>;
}
export const useSavedQueryForm = ({ defaultValue, handleSubmit }: UseSavedQueryFormProps) =>
useForm({
id: SAVED_QUERY_FORM_ID + uuid.v4(),
schema: formSchema,
onSubmit: handleSubmit,
options: {
stripEmptyFields: false,
},
// @ts-expect-error update types
defaultValue,
serializer: (payload) =>
produce(payload, (draft) => {
// @ts-expect-error update types
if (draft.platform?.split(',').length === 3) {
// if all platforms are checked then use undefined
// @ts-expect-error update types
delete draft.platform;
}
if (isArray(draft.version)) {
if (!draft.version.length) {
// @ts-expect-error update types
delete draft.version;
} else {
draft.version = draft.version[0];
}
}
return draft;
}),
// @ts-expect-error update types
deserializer: (payload) => {
if (!payload) return {} as ScheduledQueryGroupFormData;
return {
id: payload.id,
description: payload.description,
query: payload.query,
interval: payload.interval ? parseInt(payload.interval, 10) : undefined,
platform: payload.platform,
version: payload.version ? [payload.version] : [],
};
},
});

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.
*/
export * from './saved_query_flyout';
export * from './use_saved_query';
export * from './use_saved_queries';
export * from './use_update_saved_query';
export * from './use_delete_saved_query';

View file

@ -0,0 +1,104 @@
/*
* 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 { find } from 'lodash/fp';
import { EuiCodeBlock, EuiFormRow, EuiComboBox, EuiText } from '@elastic/eui';
import React, { useCallback, useState } from 'react';
import { SimpleSavedObject } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { useSavedQueries } from './use_saved_queries';
interface SavedQueriesDropdownProps {
disabled?: boolean;
onChange: (
value: SimpleSavedObject<{
id: string;
description?: string | undefined;
query: string;
}>['attributes']
) => void;
}
const SavedQueriesDropdownComponent: React.FC<SavedQueriesDropdownProps> = ({
disabled,
onChange,
}) => {
const [selectedOptions, setSelectedOptions] = useState([]);
const { data } = useSavedQueries({});
const queryOptions =
data?.savedObjects?.map((savedQuery) => ({
label: savedQuery.attributes.id ?? '',
value: {
id: savedQuery.attributes.id,
description: savedQuery.attributes.description,
query: savedQuery.attributes.query,
},
})) ?? [];
const handleSavedQueryChange = useCallback(
(newSelectedOptions) => {
const selectedSavedQuery = find(
['attributes.id', newSelectedOptions[0].value.id],
data?.savedObjects
);
if (selectedSavedQuery) {
onChange(selectedSavedQuery.attributes);
}
setSelectedOptions(newSelectedOptions);
},
[data?.savedObjects, onChange]
);
const renderOption = useCallback(
({ value }) => (
<>
<strong>{value.id}</strong>
<EuiText size="s" color="subdued">
<p className="euiTextColor--subdued">{value.description}</p>
</EuiText>
<EuiCodeBlock language="sql" fontSize="m" paddingSize="s">
{value.query}
</EuiCodeBlock>
</>
),
[]
);
return (
<EuiFormRow
label={
<FormattedMessage
id="xpack.osquery.savedQueries.dropdown.searchFieldLabel"
defaultMessage="Build from a saved query (optional)"
/>
}
fullWidth
>
<EuiComboBox
isDisabled={disabled}
fullWidth
placeholder={i18n.translate('xpack.osquery.savedQueries.dropdown.searchFieldPlaceholder', {
defaultMessage: 'Search for saved queries',
})}
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
singleSelection={{ asPlainText: true }}
options={queryOptions}
selectedOptions={selectedOptions}
onChange={handleSavedQueryChange}
renderOption={renderOption}
rowHeight={90}
/>
</EuiFormRow>
);
};
export const SavedQueriesDropdown = React.memo(SavedQueriesDropdownComponent);

View file

@ -0,0 +1,89 @@
/*
* 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 {
EuiFlyout,
EuiTitle,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiFlyoutFooter,
EuiPortal,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiButton,
} from '@elastic/eui';
import React, { useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { Form } from '../shared_imports';
import { useSavedQueryForm } from './form/use_saved_query_form';
import { SavedQueryForm } from './form';
import { useCreateSavedQuery } from './use_create_saved_query';
interface AddQueryFlyoutProps {
defaultValue: unknown;
onClose: () => void;
}
const SavedQueryFlyoutComponent: React.FC<AddQueryFlyoutProps> = ({ defaultValue, onClose }) => {
const createSavedQueryMutation = useCreateSavedQuery({ withRedirect: false });
const handleSubmit = useCallback(
(payload) => createSavedQueryMutation.mutateAsync(payload).then(() => onClose()),
[createSavedQueryMutation, onClose]
);
const { form } = useSavedQueryForm({
defaultValue,
handleSubmit,
});
return (
<EuiPortal>
<EuiFlyout size="m" ownFocus onClose={onClose} aria-labelledby="flyoutTitle">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 id="flyoutTitle">
<FormattedMessage
id="xpack.osquery.savedQuery.saveQueryFlyoutForm.addFormTitle"
defaultMessage="Save query"
/>
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<Form form={form}>
<SavedQueryForm />
</Form>
</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={form.submit} fill>
<FormattedMessage
id="xpack.osquery.scheduledQueryGroup.queryFlyoutForm.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</EuiPortal>
);
};
export const SavedQueryFlyout = React.memo(SavedQueryFlyoutComponent);

View file

@ -0,0 +1,70 @@
/*
* 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 { useMutation, useQueryClient } from 'react-query';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../common/lib/kibana';
import { savedQuerySavedObjectType } from '../../common/types';
import { PLUGIN_ID } from '../../common';
import { pagePathGetters } from '../common/page_paths';
import { SAVED_QUERIES_ID } from './constants';
import { useErrorToast } from '../common/hooks/use_error_toast';
interface UseCreateSavedQueryProps {
withRedirect?: boolean;
}
export const useCreateSavedQuery = ({ withRedirect }: UseCreateSavedQueryProps) => {
const queryClient = useQueryClient();
const {
application: { navigateToApp },
savedObjects,
security,
notifications: { toasts },
} = useKibana().services;
const setErrorToast = useErrorToast();
return useMutation(
async (payload) => {
const currentUser = await security.authc.getCurrentUser();
if (!currentUser) {
throw new Error('CurrentUser is missing');
}
return savedObjects.client.create(savedQuerySavedObjectType, {
// @ts-expect-error update types
...payload,
created_by: currentUser.username,
created_at: new Date(Date.now()).toISOString(),
updated_by: currentUser.username,
updated_at: new Date(Date.now()).toISOString(),
});
},
{
onError: (error) => {
// @ts-expect-error update types
setErrorToast(error, { title: error.body.error, toastMessage: error.body.message });
},
onSuccess: (payload) => {
queryClient.invalidateQueries(SAVED_QUERIES_ID);
if (withRedirect) {
navigateToApp(PLUGIN_ID, { path: pagePathGetters.saved_queries() });
}
toasts.addSuccess(
i18n.translate('xpack.osquery.newSavedQuery.successToastMessageText', {
defaultMessage: 'Successfully saved "{savedQueryId}" query',
values: {
savedQueryId: payload.attributes?.id ?? '',
},
})
);
},
}
);
};

View file

@ -0,0 +1,46 @@
/*
* 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 { useMutation, useQueryClient } from 'react-query';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../common/lib/kibana';
import { savedQuerySavedObjectType } from '../../common/types';
import { PLUGIN_ID } from '../../common';
import { pagePathGetters } from '../common/page_paths';
import { SAVED_QUERIES_ID } from './constants';
import { useErrorToast } from '../common/hooks/use_error_toast';
interface UseDeleteSavedQueryProps {
savedQueryId: string;
}
export const useDeleteSavedQuery = ({ savedQueryId }: UseDeleteSavedQueryProps) => {
const queryClient = useQueryClient();
const {
application: { navigateToApp },
savedObjects,
notifications: { toasts },
} = useKibana().services;
const setErrorToast = useErrorToast();
return useMutation(() => savedObjects.client.delete(savedQuerySavedObjectType, savedQueryId), {
onError: (error) => {
// @ts-expect-error update types
setErrorToast(error, { title: error.body.error, toastMessage: error.body.message });
},
onSuccess: () => {
queryClient.invalidateQueries(SAVED_QUERIES_ID);
navigateToApp(PLUGIN_ID, { path: pagePathGetters.saved_queries() });
toasts.addSuccess(
i18n.translate('xpack.osquery.editSavedQuery.deleteSuccessToastMessageText', {
defaultMessage: 'Successfully deleted saved query',
})
);
},
});
};

View file

@ -0,0 +1,46 @@
/*
* 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';
import { savedQuerySavedObjectType } from '../../common/types';
import { SAVED_QUERIES_ID } from './constants';
export const useSavedQueries = ({
isLive = false,
pageIndex = 0,
pageSize = 10000,
sortField = 'updated_at',
sortDirection = 'desc',
}) => {
const { savedObjects } = useKibana().services;
return useQuery(
[SAVED_QUERIES_ID, { pageIndex, pageSize, sortField, sortDirection }],
async () =>
savedObjects.client.find<{
id: string;
description?: string;
query: string;
updated_at: string;
updated_by: string;
created_at: string;
created_by: string;
}>({
type: savedQuerySavedObjectType,
page: pageIndex + 1,
perPage: pageSize,
sortField,
}),
{
keepPreviousData: true,
// Refetch the data every 10 seconds
refetchInterval: isLive ? 10000 : false,
}
);
};

View file

@ -0,0 +1,54 @@
/*
* 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 { PLUGIN_ID } from '../../common';
import { useKibana } from '../common/lib/kibana';
import { savedQuerySavedObjectType } from '../../common/types';
import { pagePathGetters } from '../common/page_paths';
import { useErrorToast } from '../common/hooks/use_error_toast';
export const SAVED_QUERY_ID = 'savedQuery';
interface UseSavedQueryProps {
savedQueryId: string;
}
export const useSavedQuery = ({ savedQueryId }: UseSavedQueryProps) => {
const {
application: { navigateToApp },
savedObjects,
} = useKibana().services;
const setErrorToast = useErrorToast();
return useQuery(
[SAVED_QUERY_ID, { savedQueryId }],
async () =>
savedObjects.client.get<{
id: string;
description?: string;
query: string;
}>(savedQuerySavedObjectType, savedQueryId),
{
keepPreviousData: true,
onSuccess: (data) => {
if (data.error) {
setErrorToast(data.error, {
title: data.error.error,
toastMessage: data.error.message,
});
navigateToApp(PLUGIN_ID, { path: pagePathGetters.saved_queries() });
}
},
onError: (error) => {
// @ts-expect-error update types
setErrorToast(error, { title: error.body.error, toastMessage: error.body.message });
},
}
);
};

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from 'react-query';
import { useKibana } from '../common/lib/kibana';
import { GetOnePackagePolicyResponse, packagePolicyRouteService } from '../../../fleet/common';
import { OsqueryManagerPackagePolicy } from '../../common/types';
interface UseScheduledQueryGroup {
scheduledQueryGroupId: string;
skip?: boolean;
}
export const useScheduledQueryGroup = ({
scheduledQueryGroupId,
skip = false,
}: UseScheduledQueryGroup) => {
const { http } = useKibana().services;
return useQuery<
Omit<GetOnePackagePolicyResponse, 'item'> & { item: OsqueryManagerPackagePolicy },
unknown,
OsqueryManagerPackagePolicy
>(
['scheduledQueryGroup', { scheduledQueryGroupId }],
() => http.get(packagePolicyRouteService.getInfoPath(scheduledQueryGroupId)),
{
keepPreviousData: true,
enabled: !skip,
select: (response) => response.item,
}
);
};

View file

@ -0,0 +1,66 @@
/*
* 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 { useMutation, useQueryClient } from 'react-query';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../common/lib/kibana';
import { savedQuerySavedObjectType } from '../../common/types';
import { PLUGIN_ID } from '../../common';
import { pagePathGetters } from '../common/page_paths';
import { SAVED_QUERIES_ID } from './constants';
import { useErrorToast } from '../common/hooks/use_error_toast';
interface UseUpdateSavedQueryProps {
savedQueryId: string;
}
export const useUpdateSavedQuery = ({ savedQueryId }: UseUpdateSavedQueryProps) => {
const queryClient = useQueryClient();
const {
application: { navigateToApp },
savedObjects,
security,
notifications: { toasts },
} = useKibana().services;
const setErrorToast = useErrorToast();
return useMutation(
async (payload) => {
const currentUser = await security.authc.getCurrentUser();
if (!currentUser) {
throw new Error('CurrentUser is missing');
}
return savedObjects.client.update(savedQuerySavedObjectType, savedQueryId, {
// @ts-expect-error update types
...payload,
updated_by: currentUser.username,
updated_at: new Date(Date.now()).toISOString(),
});
},
{
onError: (error) => {
// @ts-expect-error update types
setErrorToast(error, { title: error.body.error, toastMessage: error.body.message });
},
onSuccess: (payload) => {
queryClient.invalidateQueries(SAVED_QUERIES_ID);
navigateToApp(PLUGIN_ID, { path: pagePathGetters.saved_queries() });
toasts.addSuccess(
i18n.translate('xpack.osquery.editSavedQuery.successToastMessageText', {
defaultMessage: 'Successfully updated "{savedQueryName}" query',
values: {
savedQueryName: payload.attributes?.name ?? '',
},
})
);
},
}
);
};

View file

@ -6,6 +6,9 @@
*/
export const ALL_OSQUERY_VERSIONS_OPTIONS = [
{
label: '4.8.0',
},
{
label: '4.7.0',
},

View file

@ -6,7 +6,7 @@
*/
import { isEmpty, pickBy } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
@ -112,6 +112,15 @@ export const PlatformCheckBoxGroupField = ({
const describedByIds = useMemo(() => (idAria ? [idAria] : []), [idAria]);
useEffect(() => {
setCheckboxIdToSelectedMap(() =>
(options as EuiCheckboxGroupOption[]).reduce((acc, option) => {
acc[option.id] = isEmpty(field.value) ? true : field.value?.includes(option.id) ?? false;
return acc;
}, {} as Record<string, boolean>)
);
}, [field.value, options]);
return (
<EuiFormRow
label={field.label}

View file

@ -18,13 +18,14 @@ import {
EuiFlexItem,
EuiButtonEmpty,
EuiButton,
EuiDescribedFormGroup,
} from '@elastic/eui';
import React, { useMemo } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { satisfies } from 'semver';
import { OsqueryManagerPackagePolicyConfigRecord } from '../../../common/types';
import { CodeEditorField } from '../../queries/form/code_editor_field';
import { CodeEditorField } from '../../saved_queries/form/code_editor_field';
import { Form, getUseField, Field } from '../../shared_imports';
import { PlatformCheckBoxGroupField } from './platform_checkbox_group_field';
import { ALL_OSQUERY_VERSIONS_OPTIONS } from './constants';
@ -33,6 +34,7 @@ import {
useScheduledQueryGroupQueryForm,
} from './use_scheduled_query_group_query_form';
import { ManageIntegrationLink } from '../../components/manage_integration_link';
import { SavedQueriesDropdown } from '../../saved_queries/saved_queries_dropdown';
const CommonUseField = getUseField({ component: Field });
@ -49,6 +51,7 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
onSave,
onClose,
}) => {
const [isEditMode] = useState(!!defaultValue);
const { form } = useScheduledQueryGroupQueryForm({
defaultValue,
handleSubmit: (payload, isValid) =>
@ -67,7 +70,31 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
[integrationPackageVersion]
);
const { submit } = form;
const { submit, setFieldValue } = form;
const handleSetQueryValue = useCallback(
(savedQuery) => {
setFieldValue('id', savedQuery.id);
setFieldValue('query', savedQuery.query);
if (savedQuery.description) {
setFieldValue('description', savedQuery.description);
}
if (savedQuery.interval) {
setFieldValue('interval', savedQuery.interval);
}
if (isFieldSupported && savedQuery.platform) {
setFieldValue('platform', savedQuery.platform);
}
if (isFieldSupported && savedQuery.version) {
setFieldValue('version', [savedQuery.version]);
}
},
[isFieldSupported, setFieldValue]
);
return (
<EuiPortal>
@ -75,7 +102,7 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 id="flyoutTitle">
{defaultValue ? (
{isEditMode ? (
<FormattedMessage
id="xpack.osquery.scheduleQueryGroup.queryFlyoutForm.editFormTitle"
defaultMessage="Edit query"
@ -91,11 +118,20 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
</EuiFlyoutHeader>
<EuiFlyoutBody>
<Form form={form}>
{!isEditMode ? (
<>
<SavedQueriesDropdown onChange={handleSetQueryValue} />
<EuiSpacer />
</>
) : null}
<CommonUseField path="id" />
<EuiSpacer />
<CommonUseField path="query" component={CodeEditorField} />
<EuiSpacer />
<EuiFlexGroup>
<EuiDescribedFormGroup
title={<h3>Set heading level based on context</h3>}
description={'Will be wrapped in a small, subdued EuiText block.'}
>
<EuiFlexItem>
<CommonUseField
path="interval"
@ -124,7 +160,7 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
euiFieldProps={{ disabled: !isFieldSupported }}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiDescribedFormGroup>
<EuiSpacer />
</Form>
{!isFieldSupported ? (

View file

@ -22,6 +22,16 @@ export const formSchema = {
}),
validations: idFieldValidations.map((validator) => ({ validator })),
},
description: {
type: FIELD_TYPES.TEXT,
label: i18n.translate(
'xpack.osquery.scheduledQueryGroup.queryFlyoutForm.descriptionFieldLabel',
{
defaultMessage: 'Description',
}
),
validations: [],
},
query: {
type: FIELD_TYPES.TEXT,
label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.queryFieldLabel', {

View file

@ -45,6 +45,9 @@ export const useScheduledQueryGroupQueryForm = ({
// @ts-expect-error update types
serializer: (payload) =>
produce(payload, (draft) => {
if (isArray(draft.platform)) {
draft.platform.join(',');
}
if (draft.platform?.split(',').length === 3) {
// if all platforms are checked then use undefined
delete draft.platform;

View file

@ -6,6 +6,7 @@
*/
import { EuiInMemoryTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui';
import moment from 'moment';
import React, { useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
@ -37,6 +38,13 @@ const ScheduledQueryGroupsTableComponent = () => {
const renderActive = useCallback((_, item) => <ActiveStateSwitch item={item} />, []);
const renderUpdatedAt = useCallback((updatedAt, item) => {
if (!updatedAt) return '-';
const updatedBy = item.updated_by !== item.created_by ? ` @ ${item.updated_by}` : '';
return updatedAt ? `${moment(updatedAt).fromNow()}${updatedBy}` : '-';
}, []);
const columns: Array<EuiBasicTableColumn<PackagePolicy>> = useMemo(
() => [
{
@ -66,6 +74,21 @@ const ScheduledQueryGroupsTableComponent = () => {
render: renderQueries,
width: '150px',
},
{
field: 'created_by',
name: i18n.translate('xpack.osquery.scheduledQueryGroups.table.createdByColumnTitle', {
defaultMessage: 'Created by',
}),
sortable: true,
truncateText: true,
},
{
field: 'updated_at',
name: 'Last updated',
sortable: (item) => (item.updated_at ? Date.parse(item.updated_at) : 0),
truncateText: true,
render: renderUpdatedAt,
},
{
field: 'enabled',
name: i18n.translate('xpack.osquery.scheduledQueryGroups.table.activeColumnTitle', {
@ -77,7 +100,7 @@ const ScheduledQueryGroupsTableComponent = () => {
render: renderActive,
},
],
[renderActive, renderAgentPolicy, renderQueries]
[renderActive, renderAgentPolicy, renderQueries, renderUpdatedAt]
);
const sorting = useMemo(

View file

@ -8,7 +8,8 @@
import { DiscoverStart } from '../../../../src/plugins/discover/public';
import { DataPublicPluginStart } from '../../../../src/plugins/data/public';
import { FleetStart } from '../../fleet/public';
import { LensPublicStart } from '../../../plugins/lens/public';
import { LensPublicStart } from '../../lens/public';
import { SecurityPluginStart } from '../../security/public';
import { CoreStart } from '../../../../src/core/public';
import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public';
import {
@ -30,6 +31,7 @@ export interface StartPlugins {
data: DataPublicPluginStart;
fleet: FleetStart;
lens?: LensPublicStart;
security: SecurityPluginStart;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
}

View file

@ -37,7 +37,7 @@ run(
const mapFunc = pullFields.bind(null, { name: true });
const formattedSchema = schemaData.map(mapFunc);
await fs.writeFile(
path.join(schemaPath, `${flags.schema_version}-formatted`),
path.join(schemaPath, `${flags.schema_version}-formatted.json`),
JSON.stringify(formattedSchema)
);
},

View file

@ -10,7 +10,7 @@ import { TypeOf, schema } from '@kbn/config-schema';
export const ConfigSchema = schema.object({
enabled: schema.boolean({ defaultValue: true }),
actionEnabled: schema.boolean({ defaultValue: false }),
savedQueries: schema.boolean({ defaultValue: false }),
savedQueries: schema.boolean({ defaultValue: true }),
packs: schema.boolean({ defaultValue: false }),
});

View file

@ -6,6 +6,7 @@
*/
import { Logger, LoggerFactory } from 'src/core/server';
import { SecurityPluginStart } from '../../../security/server';
import {
AgentService,
FleetStartContract,
@ -69,7 +70,7 @@ export class OsqueryAppContextService {
export interface OsqueryAppContext {
logFactory: LoggerFactory;
config(): ConfigType;
security: SecurityPluginStart;
/**
* Object readiness is tied to plugin start method
*/

View file

@ -14,34 +14,40 @@ export const savedQuerySavedObjectMappings: SavedObjectsType['mappings'] = {
description: {
type: 'text',
},
name: {
type: 'text',
id: {
type: 'keyword',
},
query: {
type: 'text',
},
created: {
created_at: {
type: 'date',
},
createdBy: {
created_by: {
type: 'text',
},
platform: {
type: 'keyword',
},
updated: {
version: {
type: 'keyword',
},
updated_at: {
type: 'date',
},
updatedBy: {
updated_by: {
type: 'text',
},
interval: {
type: 'keyword',
},
},
};
export const savedQueryType: SavedObjectsType = {
name: savedQuerySavedObjectType,
hidden: false,
namespaceType: 'single',
namespaceType: 'multiple-isolated',
mappings: savedQuerySavedObjectMappings,
};
@ -53,16 +59,16 @@ export const packSavedObjectMappings: SavedObjectsType['mappings'] = {
name: {
type: 'text',
},
created: {
created_at: {
type: 'date',
},
createdBy: {
created_by: {
type: 'text',
},
updated: {
updated_at: {
type: 'date',
},
updatedBy: {
updated_by: {
type: 'text',
},
queries: {
@ -81,6 +87,6 @@ export const packSavedObjectMappings: SavedObjectsType['mappings'] = {
export const packType: SavedObjectsType = {
name: packSavedObjectType,
hidden: false,
namespaceType: 'single',
namespaceType: 'multiple-isolated',
mappings: packSavedObjectMappings,
};

View file

@ -46,6 +46,7 @@ export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginSt
logFactory: this.context.logger,
service: this.osqueryAppContextService,
config: (): ConfigType => config,
security: plugins.security,
};
initSavedObjects(core.savedObjects, osqueryContext);

View file

@ -48,13 +48,15 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon
}
try {
const currentUser = await osqueryContext.security.authc.getCurrentUser(request)?.username;
const action = {
action_id: uuid.v4(),
'@timestamp': moment().toISOString(),
expiration: moment().add(1, 'days').toISOString(),
expiration: moment().add(5, 'minutes').toISOString(),
type: 'INPUT_ACTION',
input_type: 'osquery',
agents: selectedAgents,
user_id: currentUser,
data: {
id: uuid.v4(),
query: request.body.query,
@ -75,7 +77,7 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon
incrementCount(soClient, 'live_query', 'errors');
return response.customError({
statusCode: 500,
body: new Error(`Error occurred whlie processing ${error}`),
body: new Error(`Error occurred while processing ${error}`),
});
}
}

View file

@ -28,13 +28,15 @@ export const createSavedQueryRoute = (router: IRouter) => {
async (context, request, response) => {
const savedObjectsClient = context.core.savedObjects.client;
const { name, description, platform, query } = request.body;
const { id, description, platform, query, version, interval } = request.body;
const savedQuerySO = await savedObjectsClient.create(savedQuerySavedObjectType, {
name,
id,
description,
query,
platform,
version,
interval,
});
return response.ok({

View file

@ -21,18 +21,14 @@ export const readSavedQueryRoute = (router: IRouter) => {
async (context, request, response) => {
const savedObjectsClient = context.core.savedObjects.client;
const { attributes, ...savedQuery } = await savedObjectsClient.get(
const savedQuery = await savedObjectsClient.get(
savedQuerySavedObjectType,
// @ts-expect-error update types
request.params.id
);
return response.ok({
body: {
...savedQuery,
// @ts-expect-error update types
...attributes,
},
body: savedQuery,
});
}
);

View file

@ -23,17 +23,19 @@ export const updateSavedQueryRoute = (router: IRouter) => {
const savedObjectsClient = context.core.savedObjects.client;
// @ts-expect-error update types
const { name, description, platform, query } = request.body;
const { id, description, platform, query, version, interval } = request.body;
const savedQuerySO = await savedObjectsClient.update(
savedQuerySavedObjectType,
// @ts-expect-error update types
request.params.id,
{
name,
id,
description,
platform,
query,
version,
interval,
}
);

View file

@ -13,6 +13,7 @@ import {
import { FleetStartContract } from '../../fleet/server';
import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server';
import { PluginSetupContract } from '../../features/server';
import { SecurityPluginStart } from '../../security/server';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface OsqueryPluginSetup {}
@ -24,6 +25,7 @@ export interface SetupPlugins {
actions: ActionsPlugin['setup'];
data: DataPluginSetup;
features: PluginSetupContract;
security: SecurityPluginStart;
}
export interface StartPlugins {

View file

@ -17336,7 +17336,7 @@
"xpack.osquery.breadcrumbs.newLiveQueryPageTitle": "新規",
"xpack.osquery.breadcrumbs.overviewPageTitle": "概要",
"xpack.osquery.breadcrumbs.scheduledQueryGroupsPageTitle": "スケジュールされたクエリグループ",
"xpack.osquery.codeEditorField.osquerySchemaLinkLabel": "Osqueryスキーマ",
"xpack.osquery.osquerySchemaLinkLabel": "Osqueryスキーマ",
"xpack.osquery.common.tabBetaBadgeLabel": "ベータ",
"xpack.osquery.common.tabBetaBadgeTooltipContent": "この機能は現在開発中です。他にも機能が追加され、機能によっては変更されるものもあります。",
"xpack.osquery.editScheduledQuery.pageTitle": "{queryName}を編集",

View file

@ -17573,7 +17573,7 @@
"xpack.osquery.breadcrumbs.newLiveQueryPageTitle": "新建",
"xpack.osquery.breadcrumbs.overviewPageTitle": "概览",
"xpack.osquery.breadcrumbs.scheduledQueryGroupsPageTitle": "已计划查询组",
"xpack.osquery.codeEditorField.osquerySchemaLinkLabel": "Osquery 架构",
"xpack.osquery.osquerySchemaLinkLabel": "Osquery 架构",
"xpack.osquery.common.tabBetaBadgeLabel": "公测版",
"xpack.osquery.common.tabBetaBadgeTooltipContent": "我们正在开发此功能。将会有更多的功能,某些功能可能有变更。",
"xpack.osquery.createScheduledQuery.agentPolicyAgentsCountText": "{count, plural, other {# 个代理}}已注册",