diff --git a/x-pack/plugins/beats_management/public/app.d.ts b/x-pack/plugins/beats_management/public/app.d.ts new file mode 100644 index 0000000000000..736b6c2d59dcc --- /dev/null +++ b/x-pack/plugins/beats_management/public/app.d.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export type FlatObject = { [Key in keyof T]: string }; + +export interface AppURLState { + beatsKBar: string; + tagsKBar: string; +} diff --git a/x-pack/plugins/beats_management/public/components/autocomplete_field/index.tsx b/x-pack/plugins/beats_management/public/components/autocomplete_field/index.tsx new file mode 100644 index 0000000000000..7ff5296ef4d92 --- /dev/null +++ b/x-pack/plugins/beats_management/public/components/autocomplete_field/index.tsx @@ -0,0 +1,290 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiFieldSearch, + EuiFieldSearchProps, + EuiOutsideClickDetector, + EuiPanel, +} from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +// @ts-ignore +import { AutocompleteSuggestion } from 'ui/autocomplete_providers'; + +import { composeStateUpdaters } from '../../utils/typed_react'; +import { SuggestionItem } from './suggestion_item'; + +interface AutocompleteFieldProps { + isLoadingSuggestions: boolean; + isValid: boolean; + loadSuggestions: (value: string, cursorPosition: number, maxCount?: number) => void; + onSubmit?: (value: string) => void; + onChange?: (value: string) => void; + placeholder?: string; + suggestions: AutocompleteSuggestion[]; + value: string; +} + +interface AutocompleteFieldState { + areSuggestionsVisible: boolean; + selectedIndex: number | null; +} + +export class AutocompleteField extends React.Component< + AutocompleteFieldProps, + AutocompleteFieldState +> { + public readonly state: AutocompleteFieldState = { + areSuggestionsVisible: false, + selectedIndex: null, + }; + + private inputElement: HTMLInputElement | null = null; + + public render() { + const { suggestions, isLoadingSuggestions, isValid, placeholder, value } = this.props; + const { areSuggestionsVisible, selectedIndex } = this.state; + + return ( + + + + {areSuggestionsVisible && !isLoadingSuggestions && suggestions.length > 0 ? ( + + {suggestions.map((suggestion, suggestionIndex) => ( + + ))} + + ) : null} + + + ); + } + + public componentDidUpdate(prevProps: AutocompleteFieldProps, prevState: AutocompleteFieldState) { + const hasNewSuggestions = prevProps.suggestions !== this.props.suggestions; + const hasNewValue = prevProps.value !== this.props.value; + + if (hasNewValue) { + this.updateSuggestions(); + } + + if (hasNewSuggestions) { + this.showSuggestions(); + } + } + + private handleChangeInputRef = (element: HTMLInputElement | null) => { + this.inputElement = element; + }; + + private handleChange = (evt: React.ChangeEvent) => { + this.changeValue(evt.currentTarget.value); + }; + + private handleKeyDown = (evt: React.KeyboardEvent) => { + const { suggestions } = this.props; + + switch (evt.key) { + case 'ArrowUp': + evt.preventDefault(); + if (suggestions.length > 0) { + this.setState( + composeStateUpdaters(withSuggestionsVisible, withPreviousSuggestionSelected) + ); + } + break; + case 'ArrowDown': + evt.preventDefault(); + if (suggestions.length > 0) { + this.setState(composeStateUpdaters(withSuggestionsVisible, withNextSuggestionSelected)); + } else { + this.updateSuggestions(); + } + break; + case 'Enter': + evt.preventDefault(); + if (this.state.selectedIndex !== null) { + this.applySelectedSuggestion(); + } else { + this.submit(); + } + break; + case 'Escape': + evt.preventDefault(); + this.setState(withSuggestionsHidden); + break; + } + }; + + private handleKeyUp = (evt: React.KeyboardEvent) => { + switch (evt.key) { + case 'ArrowLeft': + case 'ArrowRight': + case 'Home': + case 'End': + this.updateSuggestions(); + break; + } + }; + + private selectSuggestionAt = (index: number) => () => { + this.setState(withSuggestionAtIndexSelected(index)); + }; + + private applySelectedSuggestion = () => { + if (this.state.selectedIndex !== null) { + this.applySuggestionAt(this.state.selectedIndex)(); + } + }; + + private applySuggestionAt = (index: number) => () => { + const { value, suggestions } = this.props; + const selectedSuggestion = suggestions[index]; + + if (!selectedSuggestion) { + return; + } + + const newValue = + value.substr(0, selectedSuggestion.start) + + selectedSuggestion.text + + value.substr(selectedSuggestion.end); + + this.setState(withSuggestionsHidden); + this.changeValue(newValue); + this.focusInputElement(); + }; + + private changeValue = (value: string) => { + const { onChange } = this.props; + if (onChange) { + onChange(value); + } + }; + + private focusInputElement = () => { + if (this.inputElement) { + this.inputElement.focus(); + } + }; + + private showSuggestions = () => { + this.setState(withSuggestionsVisible); + }; + + private hideSuggestions = () => { + this.setState(withSuggestionsHidden); + }; + + private submit = () => { + const { isValid, onSubmit, value } = this.props; + + if (isValid && onSubmit) { + onSubmit(value); + } + + this.setState(withSuggestionsHidden); + }; + + private updateSuggestions = (value?: string) => { + const inputCursorPosition = this.inputElement ? this.inputElement.selectionStart || 0 : 0; + this.props.loadSuggestions(value || this.props.value, inputCursorPosition, 10); + }; +} + +const withPreviousSuggestionSelected = ( + state: AutocompleteFieldState, + props: AutocompleteFieldProps +): AutocompleteFieldState => ({ + ...state, + selectedIndex: + props.suggestions.length === 0 + ? null + : state.selectedIndex !== null + ? (state.selectedIndex + props.suggestions.length - 1) % props.suggestions.length + : Math.max(props.suggestions.length - 1, 0), +}); + +const withNextSuggestionSelected = ( + state: AutocompleteFieldState, + props: AutocompleteFieldProps +): AutocompleteFieldState => ({ + ...state, + selectedIndex: + props.suggestions.length === 0 + ? null + : state.selectedIndex !== null + ? (state.selectedIndex + 1) % props.suggestions.length + : 0, +}); + +const withSuggestionAtIndexSelected = (suggestionIndex: number) => ( + state: AutocompleteFieldState, + props: AutocompleteFieldProps +): AutocompleteFieldState => ({ + ...state, + selectedIndex: + props.suggestions.length === 0 + ? null + : suggestionIndex >= 0 && suggestionIndex < props.suggestions.length + ? suggestionIndex + : 0, +}); + +const withSuggestionsVisible = (state: AutocompleteFieldState) => ({ + ...state, + areSuggestionsVisible: true, +}); + +const withSuggestionsHidden = (state: AutocompleteFieldState) => ({ + ...state, + areSuggestionsVisible: false, + selectedIndex: null, +}); + +const FixedEuiFieldSearch: React.SFC< + React.InputHTMLAttributes & + EuiFieldSearchProps & { + inputRef?: (element: HTMLInputElement | null) => void; + onSearch: (value: string) => void; + } +> = EuiFieldSearch as any; + +const AutocompleteContainer = styled.div` + position: relative; +`; + +const SuggestionsPanel = styled(EuiPanel).attrs({ + paddingSize: 'none', + hasShadow: true, +})` + position: absolute; + width: 100%; + margin-top: 2px; + overflow: hidden; + z-index: 1000; +`; diff --git a/x-pack/plugins/beats_management/public/components/autocomplete_field/suggestion_item.tsx b/x-pack/plugins/beats_management/public/components/autocomplete_field/suggestion_item.tsx new file mode 100644 index 0000000000000..c9dc832cf37b7 --- /dev/null +++ b/x-pack/plugins/beats_management/public/components/autocomplete_field/suggestion_item.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiIcon } from '@elastic/eui'; +import { tint } from 'polished'; +import React from 'react'; +import styled from 'styled-components'; + +// @ts-ignore +import { AutocompleteSuggestion } from 'ui/autocomplete_providers'; + +interface SuggestionItemProps { + isSelected?: boolean; + onClick?: React.MouseEventHandler; + onMouseEnter?: React.MouseEventHandler; + suggestion: AutocompleteSuggestion; +} + +export class SuggestionItem extends React.Component { + public static defaultProps: Partial = { + isSelected: false, + }; + + public render() { + const { isSelected, onClick, onMouseEnter, suggestion } = this.props; + + return ( + + + + + {suggestion.text} + + + ); + } +} + +const SuggestionItemContainer = styled.div<{ + isSelected?: boolean; +}>` + display: flex; + flex-direction: row; + font-size: ${props => props.theme.eui.euiFontSizeS}; + height: ${props => props.theme.eui.euiSizeXl}; + white-space: nowrap; + background-color: ${props => + props.isSelected ? props.theme.eui.euiColorLightestShade : 'transparent'}; +`; + +const SuggestionItemField = styled.div` + align-items: center; + cursor: pointer; + display: flex; + flex-direction: row; + height: ${props => props.theme.eui.euiSizeXl}; + padding: ${props => props.theme.eui.euiSizeXs}; +`; + +const SuggestionItemIconField = SuggestionItemField.extend<{ suggestionType: string }>` + background-color: ${props => tint(0.1, getEuiIconColor(props.theme, props.suggestionType))}; + color: ${props => getEuiIconColor(props.theme, props.suggestionType)}; + flex: 0 0 auto; + justify-content: center; + width: ${props => props.theme.eui.euiSizeXl}; +`; + +const SuggestionItemTextField = SuggestionItemField.extend` + flex: 2 0 0; + font-family: ${props => props.theme.eui.euiCodeFontFamily}; +`; + +const SuggestionItemDescriptionField = SuggestionItemField.extend` + flex: 3 0 0; + p { + display: inline; + span { + font-family: ${props => props.theme.eui.euiCodeFontFamily}; + } + } +`; + +const getEuiIconType = (suggestionType: string) => { + switch (suggestionType) { + case 'field': + return 'kqlField'; + case 'value': + return 'kqlValue'; + case 'recentSearch': + return 'search'; + case 'conjunction': + return 'kqlSelector'; + case 'operator': + return 'kqlOperand'; + default: + return 'empty'; + } +}; + +const getEuiIconColor = (theme: any, suggestionType: string): string => { + switch (suggestionType) { + case 'field': + return theme.eui.euiColorVis7; + case 'value': + return theme.eui.euiColorVis0; + case 'operator': + return theme.eui.euiColorVis1; + case 'conjunction': + return theme.eui.euiColorVis2; + case 'recentSearch': + default: + return theme.eui.euiColorMediumShade; + } +}; diff --git a/x-pack/plugins/beats_management/public/components/connected_link.tsx b/x-pack/plugins/beats_management/public/components/connected_link.tsx index c4b26b0ad93af..b2c0e8ad607af 100644 --- a/x-pack/plugins/beats_management/public/components/connected_link.tsx +++ b/x-pack/plugins/beats_management/public/components/connected_link.tsx @@ -11,12 +11,14 @@ import { Link, withRouter } from 'react-router-dom'; export function ConnectedLinkComponent({ location, path, + query, disabled, ...props }: { location: any; path: string; disabled: boolean; + query: any; [key: string]: any; }) { if (disabled) { @@ -29,7 +31,7 @@ export function ConnectedLinkComponent({ return ( ); diff --git a/x-pack/plugins/beats_management/public/components/layouts/header.tsx b/x-pack/plugins/beats_management/public/components/layouts/header.tsx new file mode 100644 index 0000000000000..4ad567b73fc77 --- /dev/null +++ b/x-pack/plugins/beats_management/public/components/layouts/header.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiBreadcrumbDefinition, + EuiHeader, + EuiHeaderBreadcrumbs, + EuiHeaderSection, +} from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +interface HeaderProps { + breadcrumbs?: EuiBreadcrumbDefinition[]; +} + +export class Header extends React.PureComponent { + public render() { + const { breadcrumbs = [] } = this.props; + + return ( + + + + + + ); + } +} + +const HeaderWrapper = styled(EuiHeader)` + height: 29px; +`; diff --git a/x-pack/plugins/beats_management/public/components/table/controls.tsx b/x-pack/plugins/beats_management/public/components/table/controls.tsx index c887f2e1521eb..27e0255602675 100644 --- a/x-pack/plugins/beats_management/public/components/table/controls.tsx +++ b/x-pack/plugins/beats_management/public/components/table/controls.tsx @@ -17,6 +17,15 @@ interface ControlBarProps { showAssignmentOptions: boolean; controlDefinitions: ControlDefinitions; selectionCount: number; + + isLoadingSuggestions: any; + onKueryBarSubmit: any; + kueryValue: any; + isKueryValid: any; + onKueryBarChange: any; + loadSuggestions: any; + suggestions: any; + filterQueryDraft: any; actionHandler(actionType: string, payload?: any): void; } @@ -29,6 +38,14 @@ export function ControlBar(props: ControlBarProps) { controlDefinitions, selectionCount, showAssignmentOptions, + isLoadingSuggestions, + isKueryValid, + kueryValue, + loadSuggestions, + onKueryBarChange, + onKueryBarSubmit, + suggestions, + filterQueryDraft, } = props; const filters = controlDefinitions.filters.length === 0 ? null : controlDefinitions.filters; @@ -43,6 +60,14 @@ export function ControlBar(props: ControlBarProps) { /> ) : ( actionHandler('search', query)} diff --git a/x-pack/plugins/beats_management/public/components/table/primary_options.tsx b/x-pack/plugins/beats_management/public/components/table/primary_options.tsx index bd92fb8e827e4..d804438212efd 100644 --- a/x-pack/plugins/beats_management/public/components/table/primary_options.tsx +++ b/x-pack/plugins/beats_management/public/components/table/primary_options.tsx @@ -4,19 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiFlexGroup, - EuiFlexItem, - // @ts-ignore typings for EuiSearchar not included in EUI - EuiSearchBar, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React from 'react'; +// @ts-ignore +import { AutocompleteSuggestion } from 'ui/autocomplete_providers'; +import { AutocompleteField } from '../autocomplete_field/index'; import { ActionDefinition, FilterDefinition } from '../table'; import { ActionButton } from './action_button'; interface PrimaryOptionsProps { filters: FilterDefinition[] | null; primaryActions: ActionDefinition[]; + isLoadingSuggestions: boolean; + loadSuggestions: () => any; + suggestions: AutocompleteSuggestion[]; + onKueryBarSubmit: any; + kueryValue: any; + filterQueryDraft: any; + isKueryValid: any; + onKueryBarChange: any; actionHandler(actionType: string, payload?: any): void; onSearchQueryChange(query: any): void; } @@ -34,7 +40,19 @@ export class PrimaryOptions extends React.PureComponent @@ -47,10 +65,15 @@ export class PrimaryOptions extends React.PureComponent - diff --git a/x-pack/plugins/beats_management/public/components/table/table.tsx b/x-pack/plugins/beats_management/public/components/table/table.tsx index d8390a16c8e4a..7cdbe14905077 100644 --- a/x-pack/plugins/beats_management/public/components/table/table.tsx +++ b/x-pack/plugins/beats_management/public/components/table/table.tsx @@ -16,12 +16,21 @@ import { ControlBar } from './controls'; import { TableType } from './table_type_configs'; interface TableProps { - assignmentOptions: any[] | null; - assignmentTitle: string | null; + assignmentOptions?: any[] | null; + assignmentTitle?: string | null; items: any[]; renderAssignmentOptions?: (item: any, key: string) => any; showAssignmentOptions: boolean; type: TableType; + + isLoadingSuggestions: boolean; + loadSuggestions: any; + onKueryBarSubmit: any; + isKueryValid: any; + kueryValue: any; + onKueryBarChange: any; + suggestions: any; + filterQueryDraft: any; actionHandler(action: string, payload?: any): void; } @@ -61,6 +70,14 @@ export class Table extends React.Component { items, showAssignmentOptions, type, + isLoadingSuggestions, + loadSuggestions, + onKueryBarSubmit, + isKueryValid, + kueryValue, + onKueryBarChange, + suggestions, + filterQueryDraft, } = this.props; const pagination = { @@ -78,10 +95,18 @@ export class Table extends React.Component { return ( void; + suggestions: AutocompleteSuggestion[]; + }>; +} + +interface WithKueryAutocompletionLifecycleState { + // lacking cancellation support in the autocompletion api, + // this is used to keep older, slower requests from clobbering newer ones + currentRequest: { + expression: string; + cursorPosition: number; + } | null; + suggestions: AutocompleteSuggestion[]; +} + +export class WithKueryAutocompletion extends React.Component< + WithKueryAutocompletionLifecycleProps, + WithKueryAutocompletionLifecycleState +> { + public readonly state: WithKueryAutocompletionLifecycleState = { + currentRequest: null, + suggestions: [], + }; + + public render() { + const { currentRequest, suggestions } = this.state; + + return this.props.children({ + isLoadingSuggestions: currentRequest !== null, + loadSuggestions: this.loadSuggestions, + suggestions, + }); + } + + private loadSuggestions = async ( + expression: string, + cursorPosition: number, + maxSuggestions?: number + ) => { + this.setState({ + currentRequest: { + expression, + cursorPosition, + }, + suggestions: [], + }); + let suggestions: any[] = []; + try { + suggestions = await this.props.libs.elasticsearch.getSuggestions( + expression, + cursorPosition, + this.props.fieldPrefix + ); + } catch (e) { + suggestions = []; + } + + this.setState( + state => + state.currentRequest && + state.currentRequest.expression !== expression && + state.currentRequest.cursorPosition !== cursorPosition + ? state // ignore this result, since a newer request is in flight + : { + ...state, + currentRequest: null, + suggestions: maxSuggestions ? suggestions.slice(0, maxSuggestions) : suggestions, + } + ); + }; +} diff --git a/x-pack/plugins/beats_management/public/containers/with_url_state.tsx b/x-pack/plugins/beats_management/public/containers/with_url_state.tsx new file mode 100644 index 0000000000000..384a42ce463b9 --- /dev/null +++ b/x-pack/plugins/beats_management/public/containers/with_url_state.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { parse, stringify } from 'querystring'; +import React from 'react'; +import { withRouter } from 'react-router-dom'; +import { FlatObject } from '../app'; +import { RendererFunction } from '../utils/typed_react'; + +type StateCallback = (previousState: T) => T; + +export interface URLStateProps { + goTo: (path: string) => void; + setUrlState: ( + newState: FlatObject | StateCallback | Promise> + ) => void; + urlState: URLState; +} +interface ComponentProps { + history: any; + match: any; + children: RendererFunction>; +} + +export class WithURLStateComponent extends React.Component< + ComponentProps +> { + private get URLState(): URLState { + // slice because parse does not account for the initial ? in the search string + return parse(decodeURIComponent(this.props.history.location.search).substring(1)) as URLState; + } + + private historyListener: (() => void) | null = null; + + public componentWillUnmount() { + if (this.historyListener) { + this.historyListener(); + } + } + public render() { + return this.props.children({ + goTo: this.goTo, + setUrlState: this.setURLState, + urlState: this.URLState || {}, + }); + } + + private setURLState = async ( + state: FlatObject | StateCallback | Promise> + ) => { + let newState; + const pastState = this.URLState; + if (typeof state === 'function') { + newState = await state(pastState); + } else { + newState = state; + } + + const search: string = stringify({ + ...(pastState as any), + ...(newState as any), + }); + + const newLocation = { + ...this.props.history.location, + search, + }; + + this.props.history.replace(newLocation); + }; + + private goTo = (path: string) => { + this.props.history.push({ + pathname: path, + search: this.props.history.location.search, + }); + }; +} +export const WithURLState = withRouter(WithURLStateComponent); + +export function withUrlState(UnwrappedComponent: React.ComponentType): React.SFC { + return (origProps: OP) => { + return ( + + {(URLProps: URLStateProps) => } + + ); + }; +} diff --git a/x-pack/plugins/beats_management/public/index.tsx b/x-pack/plugins/beats_management/public/index.tsx index 9da5a99fc7028..ac869eb83c6ac 100644 --- a/x-pack/plugins/beats_management/public/index.tsx +++ b/x-pack/plugins/beats_management/public/index.tsx @@ -14,11 +14,17 @@ import { FrontendLibs } from './lib/lib'; import { PageRouter } from './router'; // TODO use theme provided from parentApp when kibana supports it +import * as euiVars from '@elastic/eui/dist/eui_theme_k6_light.json'; import '@elastic/eui/dist/eui_theme_light.css'; +import { ThemeProvider } from 'styled-components'; function startApp(libs: FrontendLibs) { libs.framework.registerManagementSection('beats', 'Beats Management', BASE_PATH); - libs.framework.render(); + libs.framework.render( + + + + ); } startApp(compose()); diff --git a/x-pack/plugins/beats_management/public/lib/domains/__tests__/tags.test.ts b/x-pack/plugins/beats_management/public/lib/__tests__/tags.test.ts similarity index 96% rename from x-pack/plugins/beats_management/public/lib/domains/__tests__/tags.test.ts rename to x-pack/plugins/beats_management/public/lib/__tests__/tags.test.ts index f48ab021d8271..0c950be147f15 100644 --- a/x-pack/plugins/beats_management/public/lib/domains/__tests__/tags.test.ts +++ b/x-pack/plugins/beats_management/public/lib/__tests__/tags.test.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BeatTag } from '../../../../common/domain_types'; -import { supportedConfigs } from '../../../config_schemas'; -import { CMTagsAdapter } from '../../adapters/tags/adapter_types'; +import { BeatTag } from '../../../common/domain_types'; +import { supportedConfigs } from '../../config_schemas'; +import { CMTagsAdapter } from '../adapters/tags/adapter_types'; import { TagsLib } from '../tags'; describe('Tags Client Domain Lib', () => { diff --git a/x-pack/plugins/beats_management/public/lib/adapters/beats/adapter_types.ts b/x-pack/plugins/beats_management/public/lib/adapters/beats/adapter_types.ts index aaa41871cd068..3808ec1d57422 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/beats/adapter_types.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/beats/adapter_types.ts @@ -10,7 +10,7 @@ export interface CMBeatsAdapter { get(id: string): Promise; update(id: string, beatData: Partial): Promise; getBeatsWithTag(tagId: string): Promise; - getAll(): Promise; + getAll(ESQuery?: any): Promise; removeTagsFromBeats(removals: BeatsTagAssignment[]): Promise; assignTagsToBeats(assignments: BeatsTagAssignment[]): Promise; getBeatWithToken(enrollmentToken: string): Promise; diff --git a/x-pack/plugins/beats_management/public/lib/adapters/beats/rest_beats_adapter.ts b/x-pack/plugins/beats_management/public/lib/adapters/beats/rest_beats_adapter.ts index e0e8051e14a94..8649bf9c37e0e 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/beats/rest_beats_adapter.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/beats/rest_beats_adapter.ts @@ -24,8 +24,8 @@ export class RestBeatsAdapter implements CMBeatsAdapter { return beat; } - public async getAll(): Promise { - return (await this.REST.get<{ beats: CMBeat[] }>('/api/beats/agents/all')).beats; + public async getAll(ESQuery?: any): Promise { + return (await this.REST.get<{ beats: CMBeat[] }>('/api/beats/agents/all', { ESQuery })).beats; } public async getBeatsWithTag(tagId: string): Promise { diff --git a/x-pack/plugins/beats_management/public/lib/adapters/elasticsearch/adapter_types.ts b/x-pack/plugins/beats_management/public/lib/adapters/elasticsearch/adapter_types.ts new file mode 100644 index 0000000000000..4940857493275 --- /dev/null +++ b/x-pack/plugins/beats_management/public/lib/adapters/elasticsearch/adapter_types.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { AutocompleteSuggestion } from 'ui/autocomplete_providers'; + +export interface ElasticsearchAdapter { + convertKueryToEsQuery: (kuery: string) => Promise; + getSuggestions: (kuery: string, selectionStart: any) => Promise; + isKueryValid(kuery: string): boolean; +} diff --git a/x-pack/plugins/beats_management/public/lib/adapters/elasticsearch/rest.ts b/x-pack/plugins/beats_management/public/lib/adapters/elasticsearch/rest.ts new file mode 100644 index 0000000000000..75b201c8a4968 --- /dev/null +++ b/x-pack/plugins/beats_management/public/lib/adapters/elasticsearch/rest.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty } from 'lodash'; +import { AutocompleteSuggestion, getAutocompleteProvider } from 'ui/autocomplete_providers'; +// @ts-ignore TODO type this +import { fromKueryExpression, toElasticsearchQuery } from 'ui/kuery'; +import { RestAPIAdapter } from '../rest_api/adapter_types'; +import { ElasticsearchAdapter } from './adapter_types'; + +export class RestElasticsearchAdapter implements ElasticsearchAdapter { + private cachedIndexPattern: any = null; + constructor(private readonly api: RestAPIAdapter, private readonly indexPatternName: string) {} + + public isKueryValid(kuery: string): boolean { + try { + fromKueryExpression(kuery); + } catch (err) { + return false; + } + + return true; + } + public async convertKueryToEsQuery(kuery: string): Promise { + if (!this.isKueryValid(kuery)) { + return ''; + } + const ast = fromKueryExpression(kuery); + const indexPattern = await this.getIndexPattern(); + return toElasticsearchQuery(ast, indexPattern); + } + public async getSuggestions( + kuery: string, + selectionStart: any + ): Promise { + const autocompleteProvider = getAutocompleteProvider('kuery'); + if (!autocompleteProvider) { + return []; + } + const config = { + get: () => true, + }; + const indexPattern = await this.getIndexPattern(); + + const getAutocompleteSuggestions = autocompleteProvider({ + config, + indexPatterns: [indexPattern], + boolFilter: null, + }); + const results = getAutocompleteSuggestions({ + query: kuery || '', + selectionStart, + selectionEnd: selectionStart, + }); + return results; + } + + private async getIndexPattern() { + if (this.cachedIndexPattern) { + return this.cachedIndexPattern; + } + const res = await this.api.get( + `/api/index_patterns/_fields_for_wildcard?pattern=${this.indexPatternName}` + ); + if (isEmpty(res.fields)) { + return; + } + this.cachedIndexPattern = { + fields: res.fields, + title: `${this.indexPatternName}`, + }; + return this.cachedIndexPattern; + } +} diff --git a/x-pack/plugins/beats_management/public/lib/adapters/elasticsearch/test.ts b/x-pack/plugins/beats_management/public/lib/adapters/elasticsearch/test.ts new file mode 100644 index 0000000000000..1d45378451f1d --- /dev/null +++ b/x-pack/plugins/beats_management/public/lib/adapters/elasticsearch/test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AutocompleteSuggestion } from 'ui/autocomplete_providers'; +import { ElasticsearchAdapter } from './adapter_types'; + +export class TestElasticsearchAdapter implements ElasticsearchAdapter { + public async convertKueryToEsQuery(kuery: string): Promise { + return 'foo'; + } + public async getSuggestions( + kuery: string, + selectionStart: any + ): Promise { + return []; + } +} diff --git a/x-pack/plugins/beats_management/public/lib/adapters/rest_api/adapter_types.ts b/x-pack/plugins/beats_management/public/lib/adapters/rest_api/adapter_types.ts index 222807e7f6948..e9d9bf551f739 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/rest_api/adapter_types.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/rest_api/adapter_types.ts @@ -3,9 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { FlatObject } from '../../../app'; export interface RestAPIAdapter { - get(url: string): Promise; + get(url: string, query?: FlatObject): Promise; post(url: string, body?: { [key: string]: any }): Promise; delete(url: string): Promise; put(url: string, body?: any): Promise; diff --git a/x-pack/plugins/beats_management/public/lib/adapters/rest_api/axios_rest_api_adapter.ts b/x-pack/plugins/beats_management/public/lib/adapters/rest_api/axios_rest_api_adapter.ts index 56bd9b63df686..690843bbb1cf8 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/rest_api/axios_rest_api_adapter.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/rest_api/axios_rest_api_adapter.ts @@ -5,14 +5,15 @@ */ import axios, { AxiosInstance } from 'axios'; +import { FlatObject } from '../../../app'; import { RestAPIAdapter } from './adapter_types'; let globalAPI: AxiosInstance; export class AxiosRestAPIAdapter implements RestAPIAdapter { constructor(private readonly xsrfToken: string, private readonly basePath: string) {} - public async get(url: string): Promise { - return await this.REST.get(url).then(resp => resp.data); + public async get(url: string, query?: FlatObject): Promise { + return await this.REST.get(url, query ? { params: query } : {}).then(resp => resp.data); } public async post( diff --git a/x-pack/plugins/beats_management/public/lib/domains/beats.ts b/x-pack/plugins/beats_management/public/lib/beats.ts similarity index 88% rename from x-pack/plugins/beats_management/public/lib/domains/beats.ts rename to x-pack/plugins/beats_management/public/lib/beats.ts index a0aeaabbcfe67..f676f4611be63 100644 --- a/x-pack/plugins/beats_management/public/lib/domains/beats.ts +++ b/x-pack/plugins/beats_management/public/lib/beats.ts @@ -5,14 +5,14 @@ */ import { flatten } from 'lodash'; -import { CMBeat, CMPopulatedBeat } from '../../../common/domain_types'; +import { CMBeat, CMPopulatedBeat } from './../../common/domain_types'; import { BeatsRemovalReturn, BeatsTagAssignment, CMAssignmentReturn, CMBeatsAdapter, -} from '../adapters/beats/adapter_types'; -import { FrontendDomainLibs } from '../lib'; +} from './adapters/beats/adapter_types'; +import { FrontendDomainLibs } from './lib'; export class BeatsLib { constructor( @@ -35,8 +35,8 @@ export class BeatsLib { return await this.mergeInTags(beats); } - public async getAll(): Promise { - const beats = await this.adapter.getAll(); + public async getAll(ESQuery?: any): Promise { + const beats = await this.adapter.getAll(ESQuery); return await this.mergeInTags(beats); } diff --git a/x-pack/plugins/beats_management/public/lib/compose/kibana.ts b/x-pack/plugins/beats_management/public/lib/compose/kibana.ts index 3488a5d23a1ef..9ff3bbe613d83 100644 --- a/x-pack/plugins/beats_management/public/lib/compose/kibana.ts +++ b/x-pack/plugins/beats_management/public/lib/compose/kibana.ts @@ -19,18 +19,22 @@ import { Notifier } from 'ui/notify'; // @ts-ignore: path dynamic for kibana import routes from 'ui/routes'; +import { INDEX_NAMES } from '../../../common/constants/index_names'; import { supportedConfigs } from '../../config_schemas'; import { RestBeatsAdapter } from '../adapters/beats/rest_beats_adapter'; +import { RestElasticsearchAdapter } from '../adapters/elasticsearch/rest'; import { KibanaFrameworkAdapter } from '../adapters/framework/kibana_framework_adapter'; import { AxiosRestAPIAdapter } from '../adapters/rest_api/axios_rest_api_adapter'; import { RestTagsAdapter } from '../adapters/tags/rest_tags_adapter'; import { RestTokensAdapter } from '../adapters/tokens/rest_tokens_adapter'; -import { BeatsLib } from '../domains/beats'; -import { TagsLib } from '../domains/tags'; +import { BeatsLib } from '../beats'; +import { ElasticsearchLib } from '../elasticsearch'; import { FrontendDomainLibs, FrontendLibs } from '../lib'; +import { TagsLib } from '../tags'; export function compose(): FrontendLibs { const api = new AxiosRestAPIAdapter(chrome.getXsrfToken(), chrome.getBasePath()); + const esAdapter = new RestElasticsearchAdapter(api, INDEX_NAMES.BEATS); const tags = new TagsLib(new RestTagsAdapter(api), supportedConfigs); const tokens = new RestTokensAdapter(api); @@ -56,6 +60,7 @@ export function compose(): FrontendLibs { const libs: FrontendLibs = { framework, + elasticsearch: new ElasticsearchLib(esAdapter), ...domainLibs, }; return libs; diff --git a/x-pack/plugins/beats_management/public/lib/compose/memory.ts b/x-pack/plugins/beats_management/public/lib/compose/memory.ts index ab56f6708123d..d96719ecfe129 100644 --- a/x-pack/plugins/beats_management/public/lib/compose/memory.ts +++ b/x-pack/plugins/beats_management/public/lib/compose/memory.ts @@ -17,13 +17,16 @@ import { KibanaFrameworkAdapter } from '../adapters/framework/kibana_framework_a import { MemoryTagsAdapter } from '../adapters/tags/memory_tags_adapter'; import { MemoryTokensAdapter } from '../adapters/tokens/memory_tokens_adapter'; -import { BeatsLib } from '../domains/beats'; +import { BeatsLib } from '../beats'; import { FrontendDomainLibs, FrontendLibs } from '../lib'; import { supportedConfigs } from '../../config_schemas'; -import { TagsLib } from '../domains/tags'; +import { TagsLib } from '../tags'; +import { TestElasticsearchAdapter } from './../adapters/elasticsearch/test'; +import { ElasticsearchLib } from './../elasticsearch'; export function compose(): FrontendLibs { + const esAdapter = new TestElasticsearchAdapter(); const tags = new TagsLib(new MemoryTagsAdapter([]), supportedConfigs); const tokens = new MemoryTokensAdapter(); const beats = new BeatsLib(new MemoryBeatsAdapter([]), { tags }); @@ -45,6 +48,7 @@ export function compose(): FrontendLibs { ); const libs: FrontendLibs = { ...domainLibs, + elasticsearch: new ElasticsearchLib(esAdapter), framework, }; return libs; diff --git a/x-pack/plugins/beats_management/public/lib/elasticsearch.ts b/x-pack/plugins/beats_management/public/lib/elasticsearch.ts new file mode 100644 index 0000000000000..7ea32a2eb9467 --- /dev/null +++ b/x-pack/plugins/beats_management/public/lib/elasticsearch.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AutocompleteSuggestion } from 'ui/autocomplete_providers'; +import { ElasticsearchAdapter } from './adapters/elasticsearch/adapter_types'; + +interface HiddenFields { + op: 'is' | 'startsWith' | 'withoutPrefix'; + value: string; +} + +export class ElasticsearchLib { + private readonly hiddenFields: HiddenFields[] = [ + { op: 'startsWith', value: 'enrollment_token' }, + { op: 'is', value: 'beat.active' }, + { op: 'is', value: 'beat.enrollment_token' }, + { op: 'is', value: 'beat.access_token' }, + { op: 'is', value: 'beat.ephemeral_id' }, + { op: 'is', value: 'beat.verified_on' }, + ]; + + constructor(private readonly adapter: ElasticsearchAdapter) {} + + public isKueryValid(kuery: string): boolean { + return this.adapter.isKueryValid(kuery); + } + public async convertKueryToEsQuery(kuery: string): Promise { + return await this.adapter.convertKueryToEsQuery(kuery); + } + + public async getSuggestions( + kuery: string, + selectionStart: any, + fieldPrefix?: string + ): Promise { + const suggestions = await this.adapter.getSuggestions(kuery, selectionStart); + + const filteredSuggestions = suggestions.filter(suggestion => { + const hiddenFieldsCheck = this.hiddenFields; + + if (fieldPrefix) { + hiddenFieldsCheck.push({ + op: 'withoutPrefix', + value: `${fieldPrefix}.`, + }); + } + + return hiddenFieldsCheck.reduce((isvalid, field) => { + if (!isvalid) { + return false; + } + + switch (field.op) { + case 'startsWith': + return !suggestion.text.startsWith(field.value); + case 'is': + return suggestion.text.trim() !== field.value; + case 'withoutPrefix': + return suggestion.text.startsWith(field.value); + } + }, true); + }); + + return filteredSuggestions; + } +} diff --git a/x-pack/plugins/beats_management/public/lib/lib.ts b/x-pack/plugins/beats_management/public/lib/lib.ts index 7c059aa2440e4..9f589af5344f7 100644 --- a/x-pack/plugins/beats_management/public/lib/lib.ts +++ b/x-pack/plugins/beats_management/public/lib/lib.ts @@ -8,8 +8,9 @@ import { IModule, IScope } from 'angular'; import { AxiosRequestConfig } from 'axios'; import React from 'react'; import { CMTokensAdapter } from './adapters/tokens/adapter_types'; -import { BeatsLib } from './domains/beats'; -import { TagsLib } from './domains/tags'; +import { BeatsLib } from './beats'; +import { ElasticsearchLib } from './elasticsearch'; +import { TagsLib } from './tags'; export interface FrontendDomainLibs { beats: BeatsLib; @@ -18,6 +19,7 @@ export interface FrontendDomainLibs { } export interface FrontendLibs extends FrontendDomainLibs { + elasticsearch: ElasticsearchLib; framework: FrameworkAdapter; } diff --git a/x-pack/plugins/beats_management/public/lib/domains/tags.ts b/x-pack/plugins/beats_management/public/lib/tags.ts similarity index 90% rename from x-pack/plugins/beats_management/public/lib/domains/tags.ts rename to x-pack/plugins/beats_management/public/lib/tags.ts index 18fbb0d3143d1..e204585e85698 100644 --- a/x-pack/plugins/beats_management/public/lib/domains/tags.ts +++ b/x-pack/plugins/beats_management/public/lib/tags.ts @@ -5,9 +5,9 @@ */ import yaml from 'js-yaml'; import { omit, pick } from 'lodash'; -import { BeatTag, ConfigurationBlock } from '../../../common/domain_types'; -import { CMTagsAdapter } from '../adapters/tags/adapter_types'; -import { ConfigContent } from './../../../common/domain_types'; +import { BeatTag, ConfigurationBlock } from '../../common/domain_types'; +import { ConfigContent } from '../../common/domain_types'; +import { CMTagsAdapter } from './adapters/tags/adapter_types'; export class TagsLib { constructor(private readonly adapter: CMTagsAdapter, private readonly tagConfigs: any) {} @@ -38,7 +38,7 @@ export class TagsLib { // NOTE: The perk of this, is that as we support more features via controls // vs yaml editing, it should "just work", and things that were in YAML // will now be in the UI forms... - transformedTag.configuration_blocks = tag.configuration_blocks.map(block => { + transformedTag.configuration_blocks = (tag.configuration_blocks || []).map(block => { const { type, description, configs } = block; const activeConfig = configs[0]; const thisConfig = this.tagConfigs.find((conf: any) => conf.value === type).config; @@ -72,7 +72,7 @@ export class TagsLib { // configurations is the JS representation of the config yaml, // so here we take that JS and convert it into a YAML string. // we do so while also flattening "other" into the flat yaml beats expect - transformedTag.configuration_blocks = tag.configuration_blocks.map(block => { + transformedTag.configuration_blocks = (tag.configuration_blocks || []).map(block => { const { type, description, configs } = block; const activeConfig = configs[0]; const thisConfig = this.tagConfigs.find((conf: any) => conf.value === type).config; diff --git a/x-pack/plugins/beats_management/public/pages/beat/detail.tsx b/x-pack/plugins/beats_management/public/pages/beat/detail.tsx index 915cbb9d1f91a..1a5d260b89d93 100644 --- a/x-pack/plugins/beats_management/public/pages/beat/detail.tsx +++ b/x-pack/plugins/beats_management/public/pages/beat/detail.tsx @@ -32,6 +32,7 @@ export const BeatDetailPage = (props: BeatDetailPageProps) => { } const configurationBlocks = flatten( beat.full_tags.map((tag: BeatTag) => { + return tag.configuration_blocks.map(configuration => ({ // @ts-ignore one of the types on ConfigurationBlock doesn't define a "module" property module: configuration.configs[0].module || null, diff --git a/x-pack/plugins/beats_management/public/pages/beat/index.tsx b/x-pack/plugins/beats_management/public/pages/beat/index.tsx index 14ae90609b648..ef08dcdd7bb48 100644 --- a/x-pack/plugins/beats_management/public/pages/beat/index.tsx +++ b/x-pack/plugins/beats_management/public/pages/beat/index.tsx @@ -14,7 +14,9 @@ import { import React from 'react'; import { Route, Switch } from 'react-router-dom'; import { CMPopulatedBeat } from '../../../common/domain_types'; +import { AppURLState } from '../../app'; import { PrimaryLayout } from '../../components/layouts/primary'; +import { URLStateProps, withUrlState } from '../../containers/with_url_state'; import { FrontendLibs } from '../../lib/lib'; import { BeatDetailsActionSection } from './action_section'; import { BeatActivityPage } from './activity'; @@ -25,7 +27,8 @@ interface Match { params: any; } -interface BeatDetailsPageProps { +interface BeatDetailsPageProps extends URLStateProps { + location: any; history: any; libs: FrontendLibs; match: Match; @@ -37,7 +40,7 @@ interface BeatDetailsPageState { isLoading: boolean; } -export class BeatDetailsPage extends React.PureComponent< +class BeatDetailsPageComponent extends React.PureComponent< BeatDetailsPageProps, BeatDetailsPageState > { @@ -53,7 +56,10 @@ export class BeatDetailsPage extends React.PureComponent< } public onSelectedTabChanged = (id: string) => { - this.props.history.push(id); + this.props.history.push({ + pathname: id, + search: this.props.location.search, + }); }; public render() { @@ -72,11 +78,11 @@ export class BeatDetailsPage extends React.PureComponent< name: 'Config', disabled: false, }, - { - id: `/beat/${id}/activity`, - name: 'Beat Activity', - disabled: false, - }, + // { + // id: `/beat/${id}/activity`, + // name: 'Beat Activity', + // disabled: false, + // }, { id: `/beat/${id}/tags`, name: 'Tags', @@ -93,7 +99,10 @@ export class BeatDetailsPage extends React.PureComponent< key={index} isSelected={tab.id === this.props.history.location.pathname} onClick={() => { - this.props.history.push(tab.id); + this.props.history.push({ + pathname: tab.id, + search: this.props.location.search, + }); }} > {tab.name} @@ -142,3 +151,4 @@ export class BeatDetailsPage extends React.PureComponent< this.setState({ beat, isLoading: false }); } } +export const BeatDetailsPage = withUrlState(BeatDetailsPageComponent); diff --git a/x-pack/plugins/beats_management/public/pages/main/beats.tsx b/x-pack/plugins/beats_management/public/pages/main/beats.tsx index 52d9091048a8e..d524e6684218a 100644 --- a/x-pack/plugins/beats_management/public/pages/main/beats.tsx +++ b/x-pack/plugins/beats_management/public/pages/main/beats.tsx @@ -10,12 +10,15 @@ import moment from 'moment'; import React from 'react'; import { BeatTag, CMPopulatedBeat } from '../../../common/domain_types'; import { BeatsTagAssignment } from '../../../server/lib/adapters/beats/adapter_types'; +import { AppURLState } from '../../app'; import { BeatsTableType, Table } from '../../components/table'; import { TagAssignment } from '../../components/tag'; +import { WithKueryAutocompletion } from '../../containers/with_kuery_autocompletion'; +import { URLStateProps } from '../../containers/with_url_state'; import { FrontendLibs } from '../../lib/lib'; import { BeatsActionArea } from './beats_action_area'; -interface BeatsPageProps { +interface BeatsPageProps extends URLStateProps { libs: FrontendLibs; location: any; } @@ -44,6 +47,7 @@ export class BeatsPage extends React.PureComponent - + + {autocompleteProps => ( +
this.props.setUrlState({ beatsKBar: value })} // todo + onKueryBarSubmit={() => null} // todo + filterQueryDraft={'false'} // todo + actionHandler={this.handleBeatsActions} + assignmentOptions={this.state.tags} + assignmentTitle="Set tags" + items={sortBy(this.state.beats, 'id') || []} + ref={this.state.tableRef} + showAssignmentOptions={true} + renderAssignmentOptions={this.renderTagAssignment} + type={BeatsTableType} + /> + )} + + this.setState({ notifications: [] })} @@ -116,7 +133,14 @@ export class BeatsPage extends React.PureComponent { +interface BeatsProps extends URLStateProps { + match: any + libs: FrontendLibs; +} +export class BeatsActionArea extends React.Component { private pinging = false - constructor(props: any) { + constructor(props: BeatsProps) { super(props) this.state = { @@ -55,108 +60,111 @@ export class BeatsActionArea extends React.Component { this.pinging = false } public render() { + const { match, - history, + goTo, libs, - } = this.props + } = this.props; return ( -
- { - window.alert('This will later go to more general beats install instructions.'); - window.location.href = '#/home/tutorial/dockerMetrics'; - }} - > - Learn how to install beats - - { - const token = await libs.tokens.createEnrollmentToken(); - history.push(`/overview/beats/enroll/${token}`); - this.waitForToken(token); - }} - > - Enroll Beats - +
+ { + // random, but spacific number ensures new tab does not overwrite another _newtab in chrome + // and at the same time not truly random so that many clicks of the link open many tabs at this same URL + window.open('https://www.elastic.co/guide/en/beats/libbeat/current/getting-started.html','_newtab35628937456'); + }} + > + Learn how to install beats + + { + const token = await libs.tokens.createEnrollmentToken(); + this.props.goTo(`/overview/beats/enroll/${token}`); + this.waitForToken(token); + }} + > + Enroll Beats + - {match.params.enrollmentToken != null && ( - - { - this.pinging = false; - this.setState({ - enrolledBeat: null - }, () => history.push('/overview/beats')) - }} style={{ width: '640px' }}> - - Enroll a new Beat - - {!this.state.enrolledBeat && ( - - To enroll a Beat with Centeral Management, run this command on the host that has Beats - installed. -
-
-
-
-
- $ beats enroll {window.location.protocol}//{window.location.host} {match.params.enrollmentToken} + {match.params.enrollmentToken != null && ( + + { + this.pinging = false; + this.setState({ + enrolledBeat: null + }, () => goTo(`/overview/beats`)) + }} style={{ width: '640px' }}> + + Enroll a new Beat + + {!this.state.enrolledBeat && ( + + To enroll a Beat with Centeral Management, run this command on the host that has Beats + installed. +
+
+
+
+
+ $ beats enroll {window.location.protocol}//{window.location.host} {match.params.enrollmentToken} +
-
-
-
- -
-
- Waiting for enroll command to be run... +
+
+ +
+
+ Waiting for enroll command to be run... - - )} - {this.state.enrolledBeat && ( - - A Beat was enrolled with the following data: -
-
-
- -
-
- { - this.setState({ - enrolledBeat: null - }) - const token = await libs.tokens.createEnrollmentToken(); - history.push(`/overview/beats/enroll/${token}`); - this.waitForToken(token); - }} - > - Enroll Another Beat - -
- )} + + )} + {this.state.enrolledBeat && ( + + A Beat was enrolled with the following data: +
+
+
+ +
+
+ { + this.setState({ + enrolledBeat: null + }) + const token = await libs.tokens.createEnrollmentToken(); + goTo(`/overview/beats/enroll/${token}`) + this.waitForToken(token); + }} + > + Enroll Another Beat + +
+ )} - - - )} -
-)}} \ No newline at end of file +
+
+ )} +
+ )} +} \ No newline at end of file diff --git a/x-pack/plugins/beats_management/public/pages/main/index.tsx b/x-pack/plugins/beats_management/public/pages/main/index.tsx index dfac6ac994e2f..989af24d0a0e2 100644 --- a/x-pack/plugins/beats_management/public/pages/main/index.tsx +++ b/x-pack/plugins/beats_management/public/pages/main/index.tsx @@ -12,15 +12,17 @@ import { } from '@elastic/eui'; import React from 'react'; import { Route, Switch } from 'react-router-dom'; +import { AppURLState } from '../../app'; import { PrimaryLayout } from '../../components/layouts/primary'; +import { URLStateProps, withUrlState } from '../../containers/with_url_state'; import { FrontendLibs } from '../../lib/lib'; import { ActivityPage } from './activity'; import { BeatsPage } from './beats'; import { TagsPage } from './tags'; -interface MainPagesProps { - history: any; +interface MainPagesProps extends URLStateProps { libs: FrontendLibs; + location: any; } interface MainPagesState { @@ -29,13 +31,12 @@ interface MainPagesState { } | null; } -export class MainPages extends React.PureComponent { - constructor(props: any) { +class MainPagesComponent extends React.PureComponent { + constructor(props: MainPagesProps) { super(props); } - public onSelectedTabChanged = (id: string) => { - this.props.history.push(id); + this.props.goTo(id); }; public render() { @@ -45,11 +46,11 @@ export class MainPages extends React.PureComponent ( this.onSelectedTabChanged(tab.id)} - isSelected={tab.id === this.props.history.location.pathname} + isSelected={tab.id === this.props.location.pathname} disabled={tab.disabled} key={index} > {tab.name} )); + return ( } + render={(props: any) => ( + + )} /> } + render={(props: any) => ( + + )} /> } @@ -88,20 +94,24 @@ export class MainPages extends React.PureComponent } + render={(props: any) => } /> } + render={(props: any) => ( + + )} /> } + render={(props: any) => } /> ); } } + +export const MainPages = withUrlState(MainPagesComponent); diff --git a/x-pack/plugins/beats_management/public/pages/main/tags.tsx b/x-pack/plugins/beats_management/public/pages/main/tags.tsx index 90e8ca88ac7c0..08f811303df5f 100644 --- a/x-pack/plugins/beats_management/public/pages/main/tags.tsx +++ b/x-pack/plugins/beats_management/public/pages/main/tags.tsx @@ -16,10 +16,13 @@ import { import React from 'react'; import { BeatTag, CMBeat } from '../../../common/domain_types'; import { BeatsTagAssignment } from '../../../server/lib/adapters/beats/adapter_types'; +import { AppURLState } from '../../app'; import { Table, TagsTableType } from '../../components/table'; +import { WithKueryAutocompletion } from '../../containers/with_kuery_autocompletion'; +import { URLStateProps } from '../../containers/with_url_state'; import { FrontendLibs } from '../../lib/lib'; -interface TagsPageProps { +interface TagsPageProps extends URLStateProps { libs: FrontendLibs; } @@ -29,12 +32,12 @@ interface TagsPageState { } export class TagsPage extends React.PureComponent { - public static ActionArea = ({ history }: any) => ( + public static ActionArea = ({ goTo }: TagsPageProps) => ( { - history.push(`/tag/create`); + goTo('/tag/create'); }} > Add Tag @@ -55,16 +58,24 @@ export class TagsPage extends React.PureComponent public render() { return ( -
item} - ref={this.tableRef} - showAssignmentOptions={true} - type={TagsTableType} - /> + + {autocompleteProps => ( +
this.props.setUrlState({ tagsKBar: value })} // todo + onKueryBarSubmit={() => null} // todo + filterQueryDraft={'false'} // todo + actionHandler={this.handleTagsAction} + items={this.state.tags || []} + renderAssignmentOptions={item => item} + ref={this.tableRef} + showAssignmentOptions={true} + type={TagsTableType} + /> + )} + ); } diff --git a/x-pack/plugins/beats_management/public/pages/tag/index.tsx b/x-pack/plugins/beats_management/public/pages/tag/index.tsx index f5e65493d3241..8b288db28a18e 100644 --- a/x-pack/plugins/beats_management/public/pages/tag/index.tsx +++ b/x-pack/plugins/beats_management/public/pages/tag/index.tsx @@ -10,13 +10,14 @@ import 'brace/mode/yaml'; import 'brace/theme/github'; import React from 'react'; import { BeatTag, CMPopulatedBeat } from '../../../common/domain_types'; +import { AppURLState } from '../../app'; import { PrimaryLayout } from '../../components/layouts/primary'; import { TagEdit } from '../../components/tag'; +import { URLStateProps, withUrlState } from '../../containers/with_url_state'; import { FrontendLibs } from '../../lib/lib'; -interface TagPageProps { +interface TagPageProps extends URLStateProps { libs: FrontendLibs; - history: any; match: any; } @@ -26,7 +27,7 @@ interface TagPageState { tag: BeatTag; } -export class TagPage extends React.PureComponent { +export class TagPageComponent extends React.PureComponent { private mode: 'edit' | 'create' = 'create'; constructor(props: TagPageProps) { super(props); @@ -78,7 +79,7 @@ export class TagPage extends React.PureComponent { - this.props.history.push('/overview/tags')}> + this.props.goTo('/overview/tags')}> Cancel @@ -106,6 +107,7 @@ export class TagPage extends React.PureComponent { }; private saveTag = async () => { await this.props.libs.tags.upsertTag(this.state.tag as BeatTag); - this.props.history.push('/overview/tags'); + this.props.goTo(`/overview/tags`); }; } +export const TagPage = withUrlState(TagPageComponent); diff --git a/x-pack/plugins/beats_management/public/router.tsx b/x-pack/plugins/beats_management/public/router.tsx index 03faf8f2d07a4..ef4b03c2e439e 100644 --- a/x-pack/plugins/beats_management/public/router.tsx +++ b/x-pack/plugins/beats_management/public/router.tsx @@ -7,29 +7,45 @@ import React from 'react'; import { HashRouter, Redirect, Route, Switch } from 'react-router-dom'; +import { Header } from './components/layouts/header'; +import { FrontendLibs } from './lib/lib'; import { BeatDetailsPage } from './pages/beat'; import { MainPages } from './pages/main'; import { TagPage } from './pages/tag'; -export const PageRouter: React.SFC<{ libs: any }> = ({ libs }) => { +export const PageRouter: React.SFC<{ libs: FrontendLibs }> = ({ libs }) => { return ( - - } +
+
- } /> - } - /> - } - /> - + + } + /> + } /> + } + /> + } + /> + +
); }; diff --git a/x-pack/plugins/beats_management/public/utils/typed_react.ts b/x-pack/plugins/beats_management/public/utils/typed_react.ts new file mode 100644 index 0000000000000..5557befa9d7e5 --- /dev/null +++ b/x-pack/plugins/beats_management/public/utils/typed_react.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { omit } from 'lodash'; +import React from 'react'; +import { InferableComponentEnhancerWithProps } from 'react-redux'; + +export type RendererResult = React.ReactElement | null; +export type RendererFunction = (args: RenderArgs) => Result; + +export type ChildFunctionRendererProps = { + children: RendererFunction; + initializeOnMount?: boolean; + resetOnUnmount?: boolean; +} & RenderArgs; + +interface ChildFunctionRendererOptions { + onInitialize?: (props: RenderArgs) => void; + onCleanup?: (props: RenderArgs) => void; +} + +export const asChildFunctionRenderer = ( + hoc: InferableComponentEnhancerWithProps, + { onInitialize, onCleanup }: ChildFunctionRendererOptions = {} +) => + hoc( + class ChildFunctionRenderer extends React.Component> { + public displayName = 'ChildFunctionRenderer'; + + public componentDidMount() { + if (this.props.initializeOnMount && onInitialize) { + onInitialize(this.getRendererArgs()); + } + } + + public componentWillUnmount() { + if (this.props.resetOnUnmount && onCleanup) { + onCleanup(this.getRendererArgs()); + } + } + + public render() { + return this.props.children(this.getRendererArgs()); + } + + private getRendererArgs = () => + omit(['children', 'initializeOnMount', 'resetOnUnmount'], this.props) as Pick< + ChildFunctionRendererProps, + keyof InjectedProps + >; + } + ); + +export type StateUpdater = ( + prevState: Readonly, + prevProps: Readonly +) => State | null; + +export function composeStateUpdaters(...updaters: Array>) { + return (state: State, props: Props) => + updaters.reduce((currentState, updater) => updater(currentState, props) || currentState, state); +} diff --git a/x-pack/plugins/beats_management/server/lib/adapters/beats/adapter_types.ts b/x-pack/plugins/beats_management/server/lib/adapters/beats/adapter_types.ts index c8d96a77df9f7..74c778fbe5672 100644 --- a/x-pack/plugins/beats_management/server/lib/adapters/beats/adapter_types.ts +++ b/x-pack/plugins/beats_management/server/lib/adapters/beats/adapter_types.ts @@ -11,7 +11,7 @@ export interface CMBeatsAdapter { insert(user: FrameworkUser, beat: CMBeat): Promise; update(user: FrameworkUser, beat: CMBeat): Promise; get(user: FrameworkUser, id: string): Promise; - getAll(user: FrameworkUser): Promise; + getAll(user: FrameworkUser, ESQuery?: any): Promise; getWithIds(user: FrameworkUser, beatIds: string[]): Promise; getAllWithTags(user: FrameworkUser, tagIds: string[]): Promise; getBeatWithToken(user: FrameworkUser, enrollmentToken: string): Promise; diff --git a/x-pack/plugins/beats_management/server/lib/adapters/beats/elasticsearch_beats_adapter.ts b/x-pack/plugins/beats_management/server/lib/adapters/beats/elasticsearch_beats_adapter.ts index c7f728c9c00d8..66fd00a300fa9 100644 --- a/x-pack/plugins/beats_management/server/lib/adapters/beats/elasticsearch_beats_adapter.ts +++ b/x-pack/plugins/beats_management/server/lib/adapters/beats/elasticsearch_beats_adapter.ts @@ -131,14 +131,40 @@ export class ElasticsearchBeatsAdapter implements CMBeatsAdapter { return omit(_get(beats[0], '_source.beat'), ['access_token']); } - public async getAll(user: FrameworkUser) { + public async getAll(user: FrameworkUser, ESQuery?: any) { const params = { index: INDEX_NAMES.BEATS, - q: 'type:beat', size: 10000, type: '_doc', + body: { + query: { + bool: { + must: { + term: { + type: 'beat', + }, + }, + }, + }, + }, }; - const response = await this.database.search(user, params); + + if (ESQuery) { + params.body.query = { + ...params.body.query, + ...ESQuery, + }; + } + + let response; + try { + response = await this.database.search(user, params); + } catch (e) { + // TODO something + } + if (!response) { + return []; + } const beats = _get(response, 'hits.hits', []); return beats.map((beat: any) => omit(beat._source.beat, ['access_token'])); diff --git a/x-pack/plugins/beats_management/server/lib/adapters/tags/adapter_types.ts b/x-pack/plugins/beats_management/server/lib/adapters/tags/adapter_types.ts index a5e01541ce386..f826115f9efd9 100644 --- a/x-pack/plugins/beats_management/server/lib/adapters/tags/adapter_types.ts +++ b/x-pack/plugins/beats_management/server/lib/adapters/tags/adapter_types.ts @@ -7,7 +7,7 @@ import { BeatTag } from '../../../../common/domain_types'; import { FrameworkUser } from '../framework/adapter_types'; export interface CMTagsAdapter { - getAll(user: FrameworkUser): Promise; + getAll(user: FrameworkUser, ESQuery?: any): Promise; delete(user: FrameworkUser, tagIds: string[]): Promise; getTagsWithIds(user: FrameworkUser, tagIds: string[]): Promise; upsertTag(user: FrameworkUser, tag: BeatTag): Promise<{}>; diff --git a/x-pack/plugins/beats_management/server/lib/adapters/tags/elasticsearch_tags_adapter.ts b/x-pack/plugins/beats_management/server/lib/adapters/tags/elasticsearch_tags_adapter.ts index c53cd2836d9ba..d44f3915555ad 100644 --- a/x-pack/plugins/beats_management/server/lib/adapters/tags/elasticsearch_tags_adapter.ts +++ b/x-pack/plugins/beats_management/server/lib/adapters/tags/elasticsearch_tags_adapter.ts @@ -19,13 +19,29 @@ export class ElasticsearchTagsAdapter implements CMTagsAdapter { this.database = database; } - public async getAll(user: FrameworkUser) { + public async getAll(user: FrameworkUser, ESQuery?: any) { const params = { _source: true, index: INDEX_NAMES.BEATS, - q: 'type:tag', type: '_doc', + body: { + query: { + bool: { + must: { + term: { + type: 'tag', + }, + }, + }, + }, + }, }; + if (ESQuery) { + params.body.query = { + ...params.body.query, + ...ESQuery, + }; + } const response = await this.database.search(user, params); const tags = get(response, 'hits.hits', []); diff --git a/x-pack/plugins/beats_management/server/lib/domains/beats.ts b/x-pack/plugins/beats_management/server/lib/domains/beats.ts index 04c43440822bd..2b0fe09afff1d 100644 --- a/x-pack/plugins/beats_management/server/lib/domains/beats.ts +++ b/x-pack/plugins/beats_management/server/lib/domains/beats.ts @@ -40,8 +40,10 @@ export class CMBeatsDomain { return beat && beat.active ? beat : null; } - public async getAll(user: FrameworkUser) { - return (await this.adapter.getAll(user)).filter((beat: CMBeat) => beat.active === true); + public async getAll(user: FrameworkUser, ESQuery?: any) { + return (await this.adapter.getAll(user, ESQuery)).filter( + (beat: CMBeat) => beat.active === true + ); } public async getAllWithTag(user: FrameworkUser, tagId: string) { diff --git a/x-pack/plugins/beats_management/server/lib/domains/tags.ts b/x-pack/plugins/beats_management/server/lib/domains/tags.ts index 192b16c0b9ed9..79ff2007d1160 100644 --- a/x-pack/plugins/beats_management/server/lib/domains/tags.ts +++ b/x-pack/plugins/beats_management/server/lib/domains/tags.ts @@ -15,8 +15,8 @@ import { CMTagsAdapter } from '../adapters/tags/adapter_types'; export class CMTagsDomain { constructor(private readonly adapter: CMTagsAdapter) {} - public async getAll(user: FrameworkUser) { - return await this.adapter.getAll(user); + public async getAll(user: FrameworkUser, ESQuery?: any) { + return await this.adapter.getAll(user, ESQuery); } public async getTagsWithIds(user: FrameworkUser, tagIds: string[]) { diff --git a/x-pack/plugins/beats_management/server/rest_api/beats/list.ts b/x-pack/plugins/beats_management/server/rest_api/beats/list.ts index b0ec9fc30526a..cdaaf05679a33 100644 --- a/x-pack/plugins/beats_management/server/rest_api/beats/list.ts +++ b/x-pack/plugins/beats_management/server/rest_api/beats/list.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import * as Joi from 'joi'; import { CMBeat } from '../../../common/domain_types'; import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; import { CMServerLibs } from '../../lib/lib'; @@ -12,6 +13,16 @@ import { wrapEsError } from '../../utils/error_wrappers'; export const createListAgentsRoute = (libs: CMServerLibs) => ({ method: 'GET', path: '/api/beats/agents/{listByAndValue*}', + validate: { + headers: Joi.object({ + 'kbn-beats-enrollment-token': Joi.string().required(), + }).options({ + allowUnknown: true, + }), + query: Joi.object({ + ESQuery: Joi.string(), + }), + }, licenseRequired: true, handler: async (request: FrameworkRequest, reply: any) => { const listByAndValueParts = request.params.listByAndValue @@ -27,13 +38,17 @@ export const createListAgentsRoute = (libs: CMServerLibs) => ({ try { let beats: CMBeat[]; + switch (listBy) { case 'tag': beats = await libs.beats.getAllWithTag(request.user, listByValue || ''); break; default: - beats = await libs.beats.getAll(request.user); + beats = await libs.beats.getAll( + request.user, + request.query && request.query.ESQuery ? JSON.parse(request.query.ESQuery) : undefined + ); break; } diff --git a/x-pack/plugins/beats_management/server/rest_api/tags/list.ts b/x-pack/plugins/beats_management/server/rest_api/tags/list.ts index 63ab0d5b52e7e..6ef3f70c7f6f2 100644 --- a/x-pack/plugins/beats_management/server/rest_api/tags/list.ts +++ b/x-pack/plugins/beats_management/server/rest_api/tags/list.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import * as Joi from 'joi'; import { BeatTag } from '../../../common/domain_types'; import { CMServerLibs } from '../../lib/lib'; import { wrapEsError } from '../../utils/error_wrappers'; @@ -11,11 +12,24 @@ import { wrapEsError } from '../../utils/error_wrappers'; export const createListTagsRoute = (libs: CMServerLibs) => ({ method: 'GET', path: '/api/beats/tags', + validate: { + headers: Joi.object({ + 'kbn-beats-enrollment-token': Joi.string().required(), + }).options({ + allowUnknown: true, + }), + query: Joi.object({ + ESQuery: Joi.string(), + }), + }, licenseRequired: true, handler: async (request: any, reply: any) => { let tags: BeatTag[]; try { - tags = await libs.tags.getAll(request.user); + tags = await libs.tags.getAll( + request.user + // request.query ? JSON.parse(request.query.ESQuery) : undefined + ); } catch (err) { return reply(wrapEsError(err)); } diff --git a/x-pack/plugins/beats_management/types/eui.d.ts b/x-pack/plugins/beats_management/types/eui.d.ts new file mode 100644 index 0000000000000..289e77bbdb04f --- /dev/null +++ b/x-pack/plugins/beats_management/types/eui.d.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * /!\ These type definitions are temporary until the upstream @elastic/eui + * package includes them. + */ + +import { EuiToolTipPosition } from '@elastic/eui'; +import { Moment } from 'moment'; +import { ChangeEventHandler, MouseEventHandler, ReactType, Ref, SFC } from 'react'; + +declare module '@elastic/eui' { + export interface EuiBreadcrumbDefinition { + text: React.ReactNode; + href?: string; + onClick?: React.MouseEventHandler; + } + type EuiBreadcrumbsProps = CommonProps & { + responsive?: boolean; + truncate?: boolean; + max?: number; + breadcrumbs: EuiBreadcrumbDefinition[]; + }; + + type EuiHeaderProps = CommonProps; + export const EuiHeader: React.SFC; + + export type EuiHeaderSectionSide = 'left' | 'right'; + type EuiHeaderSectionProps = CommonProps & { + side?: EuiHeaderSectionSide; + }; + export const EuiHeaderSection: React.SFC; + + type EuiHeaderBreadcrumbsProps = EuiBreadcrumbsProps; + export const EuiHeaderBreadcrumbs: React.SFC; + + interface EuiOutsideClickDetectorProps { + children: React.ReactNode; + isDisabled?: boolean; + onOutsideClick: React.MouseEventHandler; + } + export const EuiOutsideClickDetector: React.SFC; +} diff --git a/x-pack/plugins/beats_management/types/kibana.d.ts b/x-pack/plugins/beats_management/types/kibana.d.ts new file mode 100644 index 0000000000000..e95dc0df93bea --- /dev/null +++ b/x-pack/plugins/beats_management/types/kibana.d.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +declare module 'ui/index_patterns' { + export type IndexPattern = any; + + export interface StaticIndexPatternField { + name: string; + type: string; + aggregatable: boolean; + searchable: boolean; + } + + export interface StaticIndexPattern { + fields: StaticIndexPatternField[]; + title: string; + } +} + +declare module 'ui/autocomplete_providers' { + import { StaticIndexPattern } from 'ui/index_patterns'; + + export type AutocompleteProvider = ( + args: { + config: { + get(configKey: string): any; + }; + indexPatterns: StaticIndexPattern[]; + boolFilter: any; + } + ) => GetSuggestions; + + export type GetSuggestions = ( + args: { + query: string; + selectionStart: number; + selectionEnd: number; + } + ) => Promise; + + export type AutocompleteSuggestionType = 'field' | 'value' | 'operator' | 'conjunction'; + + export interface AutocompleteSuggestion { + description: string; + end: number; + start: number; + text: string; + type: AutocompleteSuggestionType; + } + + export function addAutocompleteProvider(language: string, provider: AutocompleteProvider): void; + + export function getAutocompleteProvider(language: string): AutocompleteProvider | undefined; +}