[Security Solution][Endpoint] UX adjustment and improvement for Responder (#136347)

- Remove the word "ENDPOINT" from the console's header information
- Add additional spacing between the Host name and the "Last seen" information that is shown in the console's header
- Change console header area to be same color as page background (white in light theme)
- adjust the header area (and other primary console containers) to have 16px padding
- Change input area placeholder text
- increase the bottom border of the input area, when focused, to 2px (`euiBorderThick`)
- adjust the footer hint test area to have 4px padding at top/bottom.
- adjust padding and spacing for the command output area
- change behaviour around the focusing on the input area:
    -  it no longer auto-focuses on the input area every time the user clicks on the page. It now behaves more like how other UI interfaces work (original intent was more for when the POC was done where it was attempting to mirror a "real" CLI terminal)
    - The input area now also support "tab"'ing into it
    - The input area will continue to receive focus when Responder is initially opened
This commit is contained in:
Paul Tavares 2022-07-18 17:15:36 -04:00 committed by GitHub
parent fef8e72286
commit ec5eb448d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 112 additions and 127 deletions

View file

@ -95,7 +95,9 @@ export const CommandExecutionOutput = memo<CommandExecutionOutputProps>(
<UserCommandInput input={command.input} />
</div>
<div>
{/* UX desire for 12px (current theme): achieved with EuiSpace sizes - s (8px) + xs (4px) */}
<EuiSpacer size="s" />
<EuiSpacer size="xs" />
<RenderComponent
command={command}

View file

@ -17,15 +17,15 @@ import { ConsoleText } from './console_text';
const COMMAND_EXECUTION_RESULT_SUCCESS_TITLE = i18n.translate(
'xpack.securitySolution.commandExecutionResult.successTitle',
{ defaultMessage: 'Success. Action was complete.' }
{ defaultMessage: 'Action completed.' }
);
const COMMAND_EXECUTION_RESULT_FAILURE_TITLE = i18n.translate(
'xpack.securitySolution.commandExecutionResult.failureTitle',
{ defaultMessage: 'Error. Action failed.' }
{ defaultMessage: 'Action failed.' }
);
const COMMAND_EXECUTION_RESULT_PENDING = i18n.translate(
'xpack.securitySolution.commandExecutionResult.pending',
{ defaultMessage: 'Action pending' }
{ defaultMessage: 'Action pending.' }
);
export type CommandExecutionResultProps = PropsWithChildren<{

View file

@ -260,7 +260,7 @@ describe('When entering data into the Console input', () => {
expect(getUserInputText()).toEqual('c');
expect(getRightOfCursorText()).toEqual('md1 ');
expect(getFooterText()).toEqual('cmd1 ');
expect(getFooterText()).toEqual('Hit enter to execute');
});
// FIXME:PT uncomment once task OLM task #4384 is implemented

View file

@ -33,8 +33,8 @@ const CommandInputContainer = styled.div`
}
&.active {
border-bottom: solid ${({ theme: { eui } }) => eui.euiBorderWidthThin}
${({ theme: { eui } }) => eui.euiColorPrimary};
border-bottom: ${({ theme: { eui } }) => eui.euiBorderThick};
border-bottom-color: ${({ theme: { eui } }) => eui.euiColorPrimary};
}
.textEntered {
@ -256,6 +256,12 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
[dispatch, rightOfCursor.text]
);
const handleOnFocus = useCallback(() => {
if (!isKeyInputBeingCaptured) {
dispatch({ type: 'addFocusToKeyCapture' });
}
}, [dispatch, isKeyInputBeingCaptured]);
// Execute the command if one was ENTER'd.
useEffect(() => {
if (commandToExecute) {
@ -271,6 +277,8 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
className={focusClassName}
onClick={handleTypingAreaClick}
ref={containerRef}
tabIndex={0}
onFocus={handleOnFocus}
>
<EuiFlexGroup
wrap={true}

View file

@ -20,13 +20,9 @@ const UNKNOWN_COMMAND_HINT = (commandName: string) =>
values: { commandName },
});
const COMMAND_USAGE_HINT = (usage: string) =>
i18n.translate('xpack.securitySolution.useInputHints.commandUsage', {
defaultMessage: '{usage}',
values: {
usage,
},
});
const NO_ARGUMENTS_HINT = i18n.translate('xpack.securitySolution.useInputHints.noArguments', {
defaultMessage: 'Hit enter to execute',
});
/**
* Auto-generates console footer "hints" while user is interacting with the input area
@ -48,18 +44,43 @@ export const useInputHints = () => {
if (commandEntered && !isInputPopoverOpen) {
// Is valid command name? ==> show usage
if (commandEnteredDefinition) {
const exampleInstruction = commandEnteredDefinition?.exampleInstruction ?? '';
const exampleUsage = commandEnteredDefinition?.exampleUsage ?? '';
let hint = exampleInstruction ?? '';
if (exampleUsage) {
if (exampleInstruction) {
// leading space below is intentional
hint += ` ${i18n.translate('xpack.securitySolution.useInputHints.exampleInstructions', {
defaultMessage: 'Ex: [ {exampleUsage} ]',
values: {
exampleUsage,
},
})}`;
} else {
hint += exampleUsage;
}
}
// If the command did not define any hint, then generate the command useage from the definition.
// If the command did define `exampleInstruction` but not `exampleUsage`, then generate the
// usage from the command definition and then append it.
//
// Generated usage is only created if the command has arguments.
if (!hint || !exampleUsage) {
const commandArguments = getArgumentsForCommand(commandEnteredDefinition);
if (commandArguments.length > 0) {
hint += `${commandEnteredDefinition.name} ${commandArguments}`;
} else {
hint += NO_ARGUMENTS_HINT;
}
}
dispatch({
type: 'updateFooterContent',
payload: {
value:
commandEnteredDefinition.exampleUsage && commandEnteredDefinition.exampleInstruction
? `${commandEnteredDefinition.exampleInstruction} Ex: [${commandEnteredDefinition.exampleUsage}]`
: COMMAND_USAGE_HINT(
`${commandEnteredDefinition.name} ${getArgumentsForCommand(
commandEnteredDefinition
)}`
),
},
payload: { value: hint },
});
} else {
dispatch({

View file

@ -93,6 +93,8 @@ export const CommandList = memo<CommandListProps>(({ commands, display = 'defaul
};
},
});
dispatch({ type: 'addFocusToKeyCapture' });
},
[dispatch]
);

View file

@ -17,7 +17,7 @@ import { PageOverlay } from '../../../../page_overlay/page_overlay';
import { ConsoleExitModal } from './console_exit_modal';
const BACK_LABEL = i18n.translate('xpack.securitySolution.consolePageOverlay.backButtonLabel', {
defaultMessage: 'Return to page content',
defaultMessage: 'Back',
});
export interface ConsolePageOverlayProps {

View file

@ -111,7 +111,7 @@ describe('When a Console command is entered by the user', () => {
await waitFor(() => {
expect(renderResult.getByTestId('test-unknownCommandError').textContent).toEqual(
'Unsupported text/commandThe text you entered foo-foo is unsupported! Click or type help for assistance.'
'Unsupported text/commandThe text you entered foo-foo is unsupported! Click Help or type help for assistance.'
);
});
});

View file

@ -13,8 +13,7 @@ import type { ConsoleDataAction, ConsoleStoreReducer } from '../types';
export const INPUT_DEFAULT_PLACEHOLDER_TEXT = i18n.translate(
'xpack.securitySolution.handleInputAreaState.inputPlaceholderText',
{
defaultMessage:
'Click here to type and submit an action. For assistance, use the "help" action',
defaultMessage: 'Submit response action',
}
);

View file

@ -22,7 +22,7 @@ export const UnknownCommand = memo<CommandExecutionComponentProps>(({ command, s
<ConsoleCodeBlock>
<FormattedMessage
id="xpack.securitySolution.console.unknownCommand.helpMessage"
defaultMessage="The text you entered {userInput} is unsupported! Click {helpIcon} or type {helpCmd} for assistance."
defaultMessage="The text you entered {userInput} is unsupported! Click {helpIcon} Help or type {helpCmd} for assistance."
values={{
userInput: (
<ConsoleCodeBlock bold inline>

View file

@ -34,7 +34,7 @@ describe('When using Console component', () => {
it('should focus on input area when it gains focus', () => {
render();
userEvent.click(renderResult.getByTestId('test-mainPanel'));
userEvent.click(renderResult.getByTestId('test-mainPanel-inputArea'));
expect(document.activeElement!.classList.contains('invisible-input')).toBe(true);
});

View file

@ -40,19 +40,28 @@ const ConsoleWindow = styled.div`
&-container {
padding: ${({ theme: { eui } }) => eui.euiSizeL} ${({ theme: { eui } }) => eui.euiSizeL}
${({ theme: { eui } }) => eui.euiSizeS} ${({ theme: { eui } }) => eui.euiSizeM};
${({ theme: { eui } }) => eui.euiSizeL} ${({ theme: { eui } }) => eui.euiSizeL};
}
&-header {
background-color: ${({ theme: { eui } }) => eui.euiColorEmptyShade};
border-bottom: 1px solid ${({ theme: { eui } }) => eui.euiColorLightShade};
border-top-left-radius: ${({ theme: { eui } }) => eui.euiBorderRadiusSmall};
border-top-right-radius: ${({ theme: { eui } }) => eui.euiBorderRadiusSmall};
padding: ${({ theme: { eui } }) => eui.euiSize} ${({ theme: { eui } }) => eui.euiSize}
${({ theme: { eui } }) => eui.euiSize} ${({ theme: { eui } }) => eui.euiSize};
}
&-footer,
&-commandInput {
padding-top: ${({ theme: { eui } }) => eui.euiSizeXS};
padding-bottom: ${({ theme: { eui } }) => eui.euiSizeXS};
}
&-footer {
padding-top: 0;
padding-bottom: ${({ theme: { eui } }) => eui.euiSizeXS};
}
&-rightPanel {
width: 35%;
background-color: ${({ theme: { eui } }) => eui.euiFormBackgroundColor};
@ -138,7 +147,7 @@ export const Console = memo<ConsoleProps>(
HelpComponent={HelpComponent}
dataTestSubj={commonProps['data-test-subj']}
>
<ConsoleWindow onClick={setFocusOnInput} {...commonProps}>
<ConsoleWindow {...commonProps}>
<EuiFlexGroup className="layout" gutterSize="none" responsive={false}>
<EuiFlexItem>
<EuiFlexGroup
@ -148,7 +157,7 @@ export const Console = memo<ConsoleProps>(
responsive={false}
data-test-subj={getTestId('mainPanel')}
>
<EuiFlexItem grow={false} className="layout-container layout-header">
<EuiFlexItem grow={false} className="layout-header">
<ConsoleHeader TitleComponent={TitleComponent} />
</EuiFlexItem>
@ -173,7 +182,12 @@ export const Console = memo<ConsoleProps>(
<HistoryOutput />
</div>
</EuiFlexItem>
<EuiFlexItem grow={false} className="layout-container layout-commandInput">
<EuiFlexItem
onClick={setFocusOnInput}
grow={false}
className="layout-container layout-commandInput"
data-test-subj={getTestId('mainPanel-inputArea')}
>
<CommandInput prompt={prompt} focusRef={inputFocusRef} />
</EuiFlexItem>
<EuiFlexItem grow={false} className="layout-container layout-footer">

View file

@ -181,7 +181,9 @@ export const getArgumentsForCommand = (command: CommandDefinition): string[] =>
optional: optionalArgs,
});
})
: [buildArgumentText({ required: requiredArgs, optional: optionalArgs })];
: requiredArgs || optionalArgs
? [buildArgumentText({ required: requiredArgs, optional: optionalArgs })]
: [];
};
export const parsedPidOrEntityIdParameter = (parameters: {

View file

@ -63,9 +63,18 @@ export interface CommandDefinition<TMeta = any> {
/** If all args are optional, but at least one must be defined, set to true */
mustHaveArgs?: boolean;
exampleUsage?: string;
/**
* Displayed in the input hint area when the user types the command. The Command usage will be
* appended to this value
*/
exampleInstruction?: string;
/**
* Displayed in the input hint area when the user types the command. This value will override
* the command usage generated by the console from the Command Definition
*/
exampleUsage?: string;
/**
* Validate the command entered by the user. This is called only after the Console has ran
* through all of its builtin validations (based on `CommandDefinition`).

View file

@ -12,10 +12,10 @@ import type { CommandExecutionResultComponent } from '../console/components/comm
import type { ImmutableArray } from '../../../../common/endpoint/types';
export const ActionError = memo<{
title: string;
dataTestSubj?: string;
errors: ImmutableArray<string>;
title?: string;
ResultComponent: CommandExecutionResultComponent;
dataTestSubj?: string;
}>(({ title, dataTestSubj, errors, ResultComponent }) => {
return (
<ResultComponent showAs="failure" title={title} data-test-subj={dataTestSubj}>

View file

@ -50,7 +50,7 @@ describe('Responder header endpoint info', () => {
});
it('should show endpoint name', async () => {
const name = await renderResult.findByTestId('responderHeaderEndpointName');
expect(name.textContent).toBe(`ENDPOINT ${endpointDetails.metadata.host.name}`);
expect(name.textContent).toBe(`${endpointDetails.metadata.host.name}`);
});
it('should show agent and isolation status', async () => {
const agentStatus = await renderResult.findByTestId(

View file

@ -6,7 +6,14 @@
*/
import React, { memo, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiLoadingContent, EuiToolTip } from '@elastic/eui';
import {
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiLoadingContent,
EuiToolTip,
EuiSpacer,
} from '@elastic/eui';
import { FormattedMessage, FormattedRelative } from '@kbn/i18n-react';
import { useGetEndpointDetails } from '../../hooks/endpoint/use_get_endpoint_details';
import { useGetEndpointPendingActionsSummary } from '../../hooks/endpoint/use_get_endpoint_pending_actions_summary';
@ -60,13 +67,7 @@ export const HeaderEndpointInfo = memo<HeaderEndpointInfoProps>(({ endpointId })
anchorClassName="eui-textTruncate"
>
<EuiText size="s" data-test-subj="responderHeaderEndpointName">
<h6 className="eui-textTruncate">
<FormattedMessage
id="xpack.securitySolution.responder.header.endpointName"
defaultMessage="ENDPOINT {name}"
values={{ name: endpointDetails.metadata.host.name }}
/>
</h6>
<h6 className="eui-textTruncate">{endpointDetails.metadata.host.name}</h6>
</EuiText>
</EuiToolTip>
</EuiFlexItem>
@ -81,6 +82,7 @@ export const HeaderEndpointInfo = memo<HeaderEndpointInfoProps>(({ endpointId })
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSpacer size="xs" />
<EuiText color="subdued" size="s" data-test-subj="responderHeaderLastSeen">
<FormattedMessage
id="xpack.securitySolution.responder.header.lastSeen"

View file

@ -6,7 +6,6 @@
*/
import React, { memo, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import type { ActionDetails } from '../../../../common/endpoint/types';
import { useGetActionDetails } from '../../hooks/endpoint/use_get_action_details';
import type { EndpointCommandDefinitionMeta } from './types';
@ -81,10 +80,6 @@ export const IsolateActionResult = memo<
if (completedActionDetails?.errors) {
return (
<ActionError
title={i18n.translate(
'xpack.securitySolution.endpointResponseActions.isolate.errorMessageTitle',
{ defaultMessage: 'Error. Isolate action failed.' }
)}
dataTestSubj={'isolateErrorCallout'}
errors={completedActionDetails?.errors}
ResultComponent={ResultComponent}
@ -93,14 +88,6 @@ export const IsolateActionResult = memo<
}
// Show Success
return (
<ResultComponent
title={i18n.translate(
'xpack.securitySolution.endpointResponseActions.isolate.successMessageTitle',
{ defaultMessage: 'Success. Host isolated.' }
)}
data-test-subj="isolateSuccessCallout"
/>
);
return <ResultComponent showAs="success" data-test-subj="isolateSuccessCallout" />;
});
IsolateActionResult.displayName = 'IsolateActionResult';

View file

@ -6,8 +6,6 @@
*/
import React, { memo, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { ActionDetails } from '../../../../common/endpoint/types';
import { useGetActionDetails } from '../../hooks/endpoint/use_get_action_details';
import type { EndpointCommandDefinitionMeta } from './types';
@ -84,24 +82,13 @@ export const KillProcessActionResult = memo<
// Show nothing if still pending
if (isPending) {
return (
<ResultComponent showAs="pending">
<FormattedMessage
id="xpack.securitySolution.endpointResponseActions.killProcess.pendingMessage"
defaultMessage="Killing process"
/>
</ResultComponent>
);
return <ResultComponent showAs="pending" />;
}
// Show errors
if (completedActionDetails?.errors) {
return (
<ActionError
title={i18n.translate(
'xpack.securitySolution.endpointResponseActions.killProcess.errorMessageTitle',
{ defaultMessage: 'Kill process action failure' }
)}
dataTestSubj={'killProcessErrorCallout'}
errors={completedActionDetails?.errors}
ResultComponent={ResultComponent}
@ -110,14 +97,6 @@ export const KillProcessActionResult = memo<
}
// Show Success
return (
<ResultComponent
title={i18n.translate(
'xpack.securitySolution.endpointResponseActions.killProcess.successMessageTitle',
{ defaultMessage: 'Process killed successfully!' }
)}
data-test-subj="killProcessSuccessCallout"
/>
);
return <ResultComponent showAs="success" data-test-subj="killProcessSuccessCallout" />;
});
KillProcessActionResult.displayName = 'KillProcessActionResult';

View file

@ -6,7 +6,6 @@
*/
import React, { memo, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import type { ActionDetails } from '../../../../common/endpoint/types';
import { useGetActionDetails } from '../../hooks/endpoint/use_get_action_details';
import type { EndpointCommandDefinitionMeta } from './types';
@ -81,10 +80,6 @@ export const ReleaseActionResult = memo<
if (completedActionDetails?.errors) {
return (
<ActionError
title={i18n.translate(
'xpack.securitySolution.endpointResponseActions.release.errorMessageTitle',
{ defaultMessage: 'Error. Release action failed.' }
)}
dataTestSubj={'releaseErrorCallout'}
errors={completedActionDetails?.errors}
ResultComponent={ResultComponent}
@ -93,14 +88,6 @@ export const ReleaseActionResult = memo<
}
// Show Success
return (
<ResultComponent
title={i18n.translate(
'xpack.securitySolution.endpointResponseActions.release.successMessageTitle',
{ defaultMessage: 'Success. Host released.' }
)}
data-test-subj="releaseSuccessCallout"
/>
);
return <ResultComponent data-test-subj="releaseSuccessCallout" />;
});
ReleaseActionResult.displayName = 'ReleaseActionResult';

View file

@ -6,8 +6,6 @@
*/
import React, { memo, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { ActionDetails } from '../../../../common/endpoint/types';
import { useGetActionDetails } from '../../hooks/endpoint/use_get_action_details';
import type { EndpointCommandDefinitionMeta } from './types';
@ -78,24 +76,13 @@ export const SuspendProcessActionResult = memo<
// Show nothing if still pending
if (isPending) {
return (
<ResultComponent showAs="pending">
<FormattedMessage
id="xpack.securitySolution.endpointResponseActions.suspendProcess.pendingMessage"
defaultMessage="Suspending process"
/>
</ResultComponent>
);
return <ResultComponent showAs="pending" />;
}
// Show errors
if (completedActionDetails?.errors) {
return (
<ActionError
title={i18n.translate(
'xpack.securitySolution.endpointResponseActions.suspendProcess.errorMessageTitle',
{ defaultMessage: 'Suspend process action failure' }
)}
dataTestSubj={'suspendProcessErrorCallout'}
errors={completedActionDetails?.errors}
ResultComponent={ResultComponent}
@ -104,14 +91,6 @@ export const SuspendProcessActionResult = memo<
}
// Show Success
return (
<ResultComponent
title={i18n.translate(
'xpack.securitySolution.endpointResponseActions.suspendProcess.successMessageTitle',
{ defaultMessage: 'Process suspended successfully' }
)}
data-test-subj="suspendProcessSuccessCallout"
/>
);
return <ResultComponent data-test-subj="suspendProcessSuccessCallout" />;
});
SuspendProcessActionResult.displayName = 'SuspendProcessActionResult';

View file

@ -26444,8 +26444,6 @@
"xpack.securitySolution.endpointManagemnet.noPermissionsText": "Vous ne disposez pas des autorisations Kibana requises pour utiliser Elastic Security Administration",
"xpack.securitySolution.endpointPolicyStatus.revisionNumber": "rév. {revNumber}",
"xpack.securitySolution.endpointPolicyStatus.tooltipTitleLabel": "Politique appliquée",
"xpack.securitySolution.endpointResponseActions.isolate.errorMessageTitle": "Échec",
"xpack.securitySolution.endpointResponseActions.isolate.successMessageTitle": "Réussite",
"xpack.securitySolution.endpointResponseActions.status.agentStatus": "Statut de l'agent",
"xpack.securitySolution.endpointResponseActions.status.lastActive": "Dernière activité",
"xpack.securitySolution.endpointResponseActions.status.policyStatus": "Statut de la politique",

View file

@ -26429,8 +26429,6 @@
"xpack.securitySolution.endpointManagemnet.noPermissionsText": "Elastic Security Administrationを使用するために必要なKibana権限がありません。",
"xpack.securitySolution.endpointPolicyStatus.revisionNumber": "rev. {revNumber}",
"xpack.securitySolution.endpointPolicyStatus.tooltipTitleLabel": "ポリシーが適用されました",
"xpack.securitySolution.endpointResponseActions.isolate.errorMessageTitle": "失敗",
"xpack.securitySolution.endpointResponseActions.isolate.successMessageTitle": "成功",
"xpack.securitySolution.endpointResponseActions.status.agentStatus": "エージェントステータス",
"xpack.securitySolution.endpointResponseActions.status.lastActive": "前回のアーカイブ",
"xpack.securitySolution.endpointResponseActions.status.policyStatus": "ポリシーステータス",

View file

@ -26456,8 +26456,6 @@
"xpack.securitySolution.endpointManagemnet.noPermissionsText": "您没有所需的 Kibana 权限,无法使用 Elastic Security 管理",
"xpack.securitySolution.endpointPolicyStatus.revisionNumber": "修订版 {revNumber}",
"xpack.securitySolution.endpointPolicyStatus.tooltipTitleLabel": "已应用策略",
"xpack.securitySolution.endpointResponseActions.isolate.errorMessageTitle": "失败",
"xpack.securitySolution.endpointResponseActions.isolate.successMessageTitle": "成功",
"xpack.securitySolution.endpointResponseActions.status.agentStatus": "代理状态",
"xpack.securitySolution.endpointResponseActions.status.lastActive": "上次活动时间",
"xpack.securitySolution.endpointResponseActions.status.policyStatus": "策略状态",