Skip to content

Commit

Permalink
[Fleet] Update agent listing for better status reporting
Browse files Browse the repository at this point in the history
  • Loading branch information
nchaulet committed Dec 2, 2020
1 parent 6e80d9f commit b0dc70d
Show file tree
Hide file tree
Showing 16 changed files with 532 additions and 354 deletions.
6 changes: 5 additions & 1 deletion x-pack/plugins/fleet/common/services/agent_status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export function buildKueryForOfflineAgents() {
}s AND not (${buildKueryForErrorAgents()})`;
}

export function buildKueryForUpdatingAgents() {
export function buildKueryForUpgradingAgents() {
return `${AGENT_SAVED_OBJECT_TYPE}.upgrade_started_at:*`;
}

export function buildKueryForUpdatingAgents() {
return `(${buildKueryForUpgradingAgents()}) or (${buildKueryForEnrollingAgents()}) or (${buildKueryForUnenrollingAgents()})`;
}
2 changes: 2 additions & 0 deletions x-pack/plugins/fleet/common/types/models/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export type AgentStatus =
| 'updating'
| 'degraded';

export type SimplifiedAgentStatus = 'healthy' | 'unhealthy' | 'updating' | 'offline' | 'inactive';

export type AgentActionType =
| 'POLICY_CHANGE'
| 'UNENROLL'
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/fleet/common/types/rest_spec/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ export interface UpdateAgentRequest {

export interface GetAgentStatusRequest {
query: {
kuery?: string;
policyId?: string;
};
}
Expand Down
207 changes: 67 additions & 140 deletions x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,21 @@
* you may not use this file except in compliance with the Elastic License.
*/

import React, { useState, useEffect } from 'react';
import { IFieldType } from 'src/plugins/data/public';
// @ts-ignore
import { EuiSuggest, EuiSuggestItemProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useDebounce, useStartServices } from '../hooks';
import React, { useState, useEffect, useMemo } from 'react';
import {
QueryStringInput,
IFieldType,
esKuery,
} from '../../../../../../../src/plugins/data/public';
import { useStartServices } from '../hooks';
import { INDEX_NAME, AGENT_SAVED_OBJECT_TYPE } from '../constants';

const DEBOUNCE_SEARCH_MS = 150;
const HIDDEN_FIELDS = [`${AGENT_SAVED_OBJECT_TYPE}.actions`];

interface Suggestion {
label: string;
description: string;
value: string;
type: {
color: string;
iconType: string;
};
start: number;
end: number;
}

interface Props {
value: string;
fieldPrefix: string;
onChange: (newValue: string) => void;
onChange: (newValue: string, submit?: boolean) => void;
placeholder?: string;
}

Expand All @@ -40,135 +28,74 @@ export const SearchBar: React.FunctionComponent<Props> = ({
onChange,
placeholder,
}) => {
const { suggestions } = useSuggestions(fieldPrefix, value);

// TODO fix type when correctly typed in EUI
const onAutocompleteClick = (suggestion: any) => {
onChange(
[value.slice(0, suggestion.start), suggestion.value, value.slice(suggestion.end, -1)].join('')
);
};
// TODO fix type when correctly typed in EUI
const onChangeSearch = (e: any) => {
onChange(e.value);
};

return (
<EuiSuggest
// TODO fix when correctly typed
// @ts-ignore
value={value}
icon={'search'}
placeholder={
placeholder ||
i18n.translate('xpack.fleet.defaultSearchPlaceholderText', {
defaultMessage: 'Search',
})
}
onInputChange={onChangeSearch}
onItemClick={onAutocompleteClick}
suggestions={suggestions.map((suggestion) => {
return {
...suggestion,
// For type
onClick: () => {},
descriptionDisplay: 'wrap',
labelWidth: '40',
};
})}
/>
);
};

export function transformSuggestionType(type: string): { iconType: string; color: string } {
switch (type) {
case 'field':
return { iconType: 'kqlField', color: 'tint4' };
case 'value':
return { iconType: 'kqlValue', color: 'tint0' };
case 'conjunction':
return { iconType: 'kqlSelector', color: 'tint3' };
case 'operator':
return { iconType: 'kqlOperand', color: 'tint1' };
default:
return { iconType: 'kqlOther', color: 'tint1' };
}
}

function useSuggestions(fieldPrefix: string, search: string) {
const { data } = useStartServices();
const [indexPatternFields, setIndexPatternFields] = useState<IFieldType[]>();

const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH_MS);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const isQueryValid = useMemo(() => {
if (!value || value === '') {
return true;
}

const fetchSuggestions = async () => {
try {
const res = (await data.indexPatterns.getFieldsForWildcard({
pattern: INDEX_NAME,
})) as IFieldType[];
if (!data || !data.autocomplete) {
throw new Error('Missing data plugin');
}
const query = debouncedSearch || '';
// @ts-ignore
const esSuggestions = (
await data.autocomplete.getQuerySuggestions({
language: 'kuery',
indexPatterns: [
{
title: INDEX_NAME,
fields: res,
},
],
boolFilter: [],
query,
selectionStart: query.length,
selectionEnd: query.length,
})
)
.filter((suggestion) => {
if (suggestion.type === 'conjunction') {
return true;
}
if (suggestion.type === 'value') {
return true;
}
if (suggestion.type === 'operator') {
return true;
}
esKuery.fromKueryExpression(value);
return true;
} catch (e) {
return false;
}
}, [value]);

if (fieldPrefix && suggestion.text.startsWith(fieldPrefix)) {
useEffect(() => {
const fetchFields = async () => {
try {
const fields = (
((await data.indexPatterns.getFieldsForWildcard({
pattern: INDEX_NAME,
})) as IFieldType[]) || []
).filter((field) => {
if (fieldPrefix && field.name.startsWith(fieldPrefix)) {
for (const hiddenField of HIDDEN_FIELDS) {
if (suggestion.text.startsWith(hiddenField)) {
if (field.name.startsWith(hiddenField)) {
return false;
}
}
return true;
}
});
setIndexPatternFields(fields);
} catch (err) {
setIndexPatternFields(undefined);
}
};
fetchFields();
}, [data.indexPatterns, fieldPrefix]);

return false;
})
.map((suggestion: any) => ({
label: suggestion.text,
description: suggestion.description || '',
type: transformSuggestionType(suggestion.type),
start: suggestion.start,
end: suggestion.end,
value: suggestion.text,
}));

setSuggestions(esSuggestions);
} catch (err) {
setSuggestions([]);
}
};

useEffect(() => {
fetchSuggestions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch]);

return {
suggestions,
};
}
return (
<QueryStringInput
iconType="search"
disableLanguageSwitcher={true}
indexPatterns={
indexPatternFields
? [
{
title: INDEX_NAME,
fields: indexPatternFields,
},
]
: []
}
query={{
query: value,
language: 'kuery',
}}
isInvalid={!isQueryValid}
disableAutoFocus={true}
placeholder={placeholder}
onChange={(newQuery) => {
onChange(newQuery.query as string);
}}
onSubmit={(newQuery) => {
onChange(newQuery.query as string, true);
}}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,10 @@ export function useGetAgents(query: GetAgentsRequest['query'], options?: Request
});
}

export function sendGetAgentStatus(
query: GetAgentStatusRequest['query'],
options?: RequestOptions
) {
return sendRequest<GetAgentStatusResponse>({
export function sendGetAgents(query: GetAgentsRequest['query'], options?: RequestOptions) {
return sendRequest<GetAgentsResponse>({
method: 'get',
path: agentRouteService.getStatusPath(),
path: agentRouteService.getListPath(),
query,
...options,
});
Expand All @@ -83,6 +80,18 @@ export function useGetAgentStatus(query: GetAgentStatusRequest['query'], options
});
}

export function sendGetAgentStatus(
query: GetAgentStatusRequest['query'],
options?: RequestOptions
) {
return sendRequest<GetAgentStatusResponse>({
method: 'get',
path: agentRouteService.getStatusPath(),
query,
...options,
});
}

export function sendPutAgentReassign(
agentId: string,
body: PutAgentReassignRequest['body'],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* 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 { EuiFlexGroup, EuiHealth, EuiNotificationBadge, EuiFlexItem } from '@elastic/eui';
import React, { memo, useMemo } from 'react';
import {
AGENT_STATUSES,
getColorForAgentStatus,
getLabelForAgentStatus,
} from '../../services/agent_status';
import { SimplifiedAgentStatus } from '../../../../types';

export const AgentStatusBadges: React.FC<{
showInactive?: boolean;
agentStatus: { [k in SimplifiedAgentStatus]: number };
}> = memo(({ agentStatus, showInactive }) => {
const agentStatuses = useMemo(() => {
return AGENT_STATUSES.filter((status) => (showInactive ? true : status !== 'inactive'));
}, [showInactive]);

return (
<EuiFlexGroup gutterSize="m">
{agentStatuses.map((status) => (
<EuiFlexItem key={status} grow={false}>
<AgentStatusBadge status={status} count={agentStatus[status] || 0} />
</EuiFlexItem>
))}
</EuiFlexGroup>
);
});

const AgentStatusBadge: React.FC<{ status: SimplifiedAgentStatus; count: number }> = memo(
({ status, count }) => {
return (
<>
<EuiHealth color={getColorForAgentStatus(status)}>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>{getLabelForAgentStatus(status)}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiNotificationBadge size="s" color="subdued">
{count}
</EuiNotificationBadge>
</EuiFlexItem>
</EuiFlexGroup>
</EuiHealth>
</>
);
}
);
Loading

0 comments on commit b0dc70d

Please sign in to comment.