[Terminal Output] Truncated process output msg (#143304)

* xterm updated to v5. initial work done for max kb warning msg

* max bytes exceeded msg implemented, along with authz gated link to security policies page.

* marker indication on max bytes exceeded fixed

* tests written

* tests written

* fixed a bug where an active search would prevent normal playback/seeking

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* updates per review comments

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* fixed text

* fixed ts error

* fix to scroll hack

Co-authored-by: Karl Godard <karlgodard@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Karl Godard 2022-10-25 16:44:16 -07:00 committed by GitHub
parent 73d2b0da09
commit 97a68d8558
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 244 additions and 74 deletions

View file

@ -666,7 +666,7 @@
"vinyl": "^2.2.0",
"whatwg-fetch": "^3.0.0",
"xml2js": "^0.4.22",
"xterm": "^4.18.0",
"xterm": "^5.0.0",
"yauzl": "^2.10.0",
"yazl": "^2.5.1"
},

View file

@ -162,6 +162,7 @@ describe('useSessionView with active timeline and a session id and graph event i
height: 1000,
sessionEntityId: 'test',
loadAlertDetails: mockDetails,
canAccessEndpointManagement: false,
});
});

View file

@ -34,6 +34,7 @@ import {
useGlobalFullScreen,
} from '../../../../common/containers/use_full_screen';
import { detectionsTimelineIds } from '../../../containers/helpers';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
import { timelineActions, timelineSelectors } from '../../../store/timeline';
import { timelineDefaults } from '../../../store/timeline/defaults';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
@ -266,6 +267,7 @@ export const useSessionView = ({
}, [scopeId]);
const { globalFullScreen } = useGlobalFullScreen();
const { timelineFullScreen } = useTimelineFullScreen();
const { canAccessEndpointManagement } = useUserPrivileges().endpointPrivileges;
const defaults = isTimelineScope(scopeId) ? timelineDefaults : tableDefaults;
const { sessionViewConfig, activeTab } = useDeepEqualSelector((state) => ({
@ -310,9 +312,17 @@ export const useSessionView = ({
loadAlertDetails: openDetailsPanel,
isFullScreen: fullScreen,
height: heightMinusSearchBar,
canAccessEndpointManagement,
})
: null;
}, [fullScreen, openDetailsPanel, sessionView, sessionViewConfig, height]);
}, [
height,
sessionViewConfig,
sessionView,
openDetailsPanel,
fullScreen,
canAccessEndpointManagement,
]);
return {
openDetailsPanel,

View file

@ -5,6 +5,8 @@
* 2.0.
*/
export const SESSION_VIEW_APP_ID = 'sessionView';
// routes
export const PROCESS_EVENTS_ROUTE = '/internal/session_view/process_events';
export const ALERTS_ROUTE = '/internal/session_view/alerts';
@ -12,6 +14,9 @@ export const ALERT_STATUS_ROUTE = '/internal/session_view/alert_status';
export const IO_EVENTS_ROUTE = '/internal/session_view/io_events';
export const GET_TOTAL_IO_BYTES_ROUTE = '/internal/session_view/get_total_io_bytes';
export const SECURITY_APP_ID = 'security';
export const POLICIES_PAGE_PATH = '/administration/policy';
// index patterns
export const PROCESS_EVENTS_INDEX = '*:logs-endpoint.events.process*,logs-endpoint.events.process*'; // match on both cross cluster and local indices
export const PREVIEW_ALERTS_INDEX = '.preview.alerts-security.alerts-default';

View file

@ -282,6 +282,7 @@ export const sessionViewIOEventsMock: ProcessEventResults = {
total_bytes_captured: 1024,
total_bytes_skipped: 0,
bytes_skipped: [],
max_bytes_per_process_exceeded: true,
text: '\u001b[38;5;130m 84 \n 85 \u001b[mif [[ $KILL_IMMEDIATELY == 1 ]]; then\n\u001b[38;5;130m 86 \u001b[m echo "WARNING: Not waiting for connections to close gracefully"\n\u001b[38;5;130m 87 \u001b[m echo "Press any key to continue... wsrep_reject_queries will be set to \'ALL_KILL\'"\n\u001b[38;5;130m 88 \u001b[m read a\n\u001b[38;5;130m 89 \u001b[m mysql -h127.0.0.1 -P3306 -uroot -e "set global wsrep_reject_queries=\'ALL_KILL\'"\n\u001b[38;5;130m 90 \u001b[melse\n\u001b[38;5;130m 91 \u001b[m # Stop accepting queries in mariadb, do not kill opened connections\n\u001b[38;5;130m 92 \u001b[m mysql -h127.0.0.1 -P3306 -uroot -e "set global wsrep_reject_queries=\'ALL\'"\n\u001b[38;5;130m 93 \u001b[mfi\n\u001b[38;5;130m 94 \n 95 \u001b[mexit_code=$?\n\u001b[38;5;130m 96 \u001b[mif [[ $exit_code != 0 ]]; then\n\u001b[38;5;130m 97 \u001b[m >&2 echo "Failed to set the reject of queries on Mysql node, exiting."\n\u001b[38;5;130m 98 \u001b[m exit $exit_code\n\u001b[38;5;130m 99 \u001b[melse\n\u001b[38;5;130m 100 \u001b[m echo "Successfully stopped accepting queries."\n\u001b[38;5;130m 101 \u001b[m if [[ $KILL_IMMEDIATELY == 1 ]]; then\n\u001b[38;5;130m 102 \u001b[m\u001b[8Cexit\n\u001b[38;5;130m 103 \u001b[m fi\n\u001b[38;5;130m 104 \u001b[mfi\n\u001b[38;5;130m 105 \n 106 \u001b[mif [[ $GRACE_PERIOD == -1 ]]; then\n\u001b[38;5;130m 107 \u001b[m set_number_grace_seconds\n\u001b[38;5;130m 108 \u001b[mfi\n\u001b[38;5;130m 109 \n 110 \u001b[mwait_for_connections\n\u001b[38;5;130m 111 \u001b[mif [[ $DB_CONNECTIONS_NUMBER != 0 ]]; then\n\u001b[38;5;130m 112 \u001b[m get_number_db_connections\n\u001b[38;5;130m 113 \u001b[m >&2 echo "ERROR: There are still $DB_CONNECTIONS_NUMBER opened DB connections."\n\u001b[38;5;130m 114 \u001b[m exit 3\n\u001b[38;5;130m 115 \u001b[mfi\b\b\u001b[?25h\u001b[?25l\nType :qa! and press <Enter> to abandon all changes and exit Vim\u0007\u001b[58;9H\u001b[?25h\u0007\u001b[?25l\u001b[59;1H\u001b[K\u001b[59;1H:\u001b[?2004h\u001b[?25hqa!\r\u001b[?25l\u001b[?2004l\u001b[59;1H\u001b[K\u001b[59;1H\u001b[?2004l\u001b[?1l\u001b>\u001b[?25h\u001b[?1049l\u001b[23;0;0t,\u001bkroot@staging-host:~\u001b\\\n',
},
tty: {

View file

@ -72,6 +72,7 @@ export interface IOLine {
export interface ProcessStartMarker {
event: ProcessEvent;
line: number;
maxBytesExceeded?: boolean;
}
export interface IOFields {

View file

@ -53,6 +53,7 @@ export const SessionView = ({
jumpToCursor,
investigatedAlertId,
loadAlertDetails,
canAccessEndpointManagement,
}: SessionViewDeps) => {
// don't engage jumpTo if jumping to session leader.
if (jumpToEntityId === sessionEntityId) {
@ -422,6 +423,7 @@ export const SessionView = ({
isFullscreen={isFullScreen}
onJumpToEvent={onJumpToEvent}
autoSeekToEntityId={currentJumpToOutputEntityId}
canAccessEndpointManagement={canAccessEndpointManagement}
/>
</div>
);

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Teletype } from '../../../common/types/process_tree';
import {
PROCESS_DATA_LIMIT_EXCEEDED_START,
PROCESS_DATA_LIMIT_EXCEEDED_END,
VIEW_POLICIES,
} from './translations';
export const renderTruncatedMsg = (tty?: Teletype, policiesUrl?: string, processName?: string) => {
if (tty?.columns) {
const lineBreak = '-'.repeat(tty.columns);
const message = `${PROCESS_DATA_LIMIT_EXCEEDED_START} \x1b[1m${processName}.\x1b[22m ${PROCESS_DATA_LIMIT_EXCEEDED_END}`;
const link = policiesUrl
? `\x1b[${Math.min(
message.length + 2,
tty.columns - VIEW_POLICIES.length - 4
)}G\x1b[1m\x1b]8;;${policiesUrl}\x1b\\[ ${VIEW_POLICIES} ]\x1b]8;;\x1b\\\x1b[22m`
: '';
return `\n\x1b[33m${lineBreak}\n${message}${link}\n${lineBreak}\x1b[0m\n\n`;
}
};

View file

@ -12,6 +12,7 @@ import { CoreStart } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { SearchAddon } from './xterm_search';
import { useEuiTheme } from '../../hooks';
import { renderTruncatedMsg } from './ansi_helpers';
import {
IOLine,
@ -103,6 +104,15 @@ export const useIOLines = (pages: ProcessEventsPage[] | undefined) => {
newMarkers.push(processLineInfo);
}
if (process.io.max_bytes_per_process_exceeded) {
const marker = newMarkers.find(
(item) => item.event.process?.entity_id === process.entity_id
);
if (marker) {
marker.maxBytesExceeded = true;
}
}
const splitLines = process.io.text.split(TTY_LINE_SPLITTER_REGEX);
const combinedLines = [splitLines[0]];
@ -158,6 +168,7 @@ export interface XtermPlayerDeps {
hasNextPage?: boolean;
fetchNextPage?: () => void;
isFetching?: boolean;
policiesUrl?: string;
}
export const useXtermPlayer = ({
@ -169,17 +180,20 @@ export const useXtermPlayer = ({
hasNextPage,
fetchNextPage,
isFetching,
policiesUrl,
}: XtermPlayerDeps) => {
const { euiTheme } = useEuiTheme();
const { font, colors } = euiTheme;
const [currentLine, setCurrentLine] = useState(0);
const [playSpeed] = useState(DEFAULT_TTY_PLAYSPEED_MS); // potentially configurable
const tty = lines?.[currentLine]?.event.process?.tty;
const processName = lines?.[currentLine]?.event.process?.name;
const [terminal, searchAddon] = useMemo(() => {
const term = new Terminal({
theme: {
selection: colors.warning,
selectionBackground: colors.warning,
selectionForeground: colors.ink,
yellow: colors.warning,
},
fontFamily: font.familyCode,
fontSize: DEFAULT_TTY_FONT_SIZE,
@ -187,6 +201,8 @@ export const useXtermPlayer = ({
convertEol: true,
rows: DEFAULT_TTY_ROWS,
cols: DEFAULT_TTY_COLS,
allowProposedApi: true,
allowTransparency: true,
});
const searchInstance = new SearchAddon();
@ -203,7 +219,7 @@ export const useXtermPlayer = ({
// even though we set scrollback: 0 above, xterm steals the wheel events and prevents the outer container from scrolling
// this handler fixes that
const onScroll = (event: WheelEvent) => {
if ((event?.target as HTMLDivElement)?.className === 'xterm-cursor-layer') {
if ((event?.target as HTMLDivElement)?.offsetParent?.classList.contains('xterm-screen')) {
event.stopImmediatePropagation();
}
};
@ -212,6 +228,7 @@ export const useXtermPlayer = ({
return () => {
window.removeEventListener('wheel', onScroll, true);
terminal.dispose();
};
}, [terminal, ref]);
@ -241,17 +258,30 @@ export const useXtermPlayer = ({
if (line?.value !== undefined) {
terminal.write(line.value);
}
const nextLine = lines[lineNumber + index + 1];
const maxBytesExceeded = line.event.process?.io?.max_bytes_per_process_exceeded;
// if next line is start of next event
// and process has exceeded max bytes
// render msg
if (!clear && (!nextLine || nextLine.event !== line.event) && maxBytesExceeded) {
const msg = renderTruncatedMsg(tty, policiesUrl, processName);
if (msg) {
terminal.write(msg);
}
}
});
},
[lines, terminal]
[lines, policiesUrl, processName, terminal, tty]
);
useEffect(() => {
const fontChanged = terminal.getOption('fontSize') !== fontSize;
const fontChanged = terminal.options.fontSize !== fontSize;
const ttyChanged = tty && (terminal.rows !== tty?.rows || terminal.cols !== tty?.columns);
if (fontChanged) {
terminal.setOption('fontSize', fontSize);
terminal.options.fontSize = fontSize;
}
if (tty?.rows && tty?.columns && ttyChanged) {

View file

@ -6,10 +6,11 @@
*/
import React from 'react';
import { waitFor } from '@testing-library/react';
import { waitFor, act } from '@testing-library/react';
import { sessionViewIOEventsMock } from '../../../common/mocks/responses/session_view_io_events.mock';
import { AppContextTestRender, createAppRootMockRenderer } from '../../test';
import { TTYPlayerDeps, TTYPlayer } from '.';
import userEvent from '@testing-library/user-event';
describe('TTYPlayer component', () => {
beforeAll(() => {
@ -81,5 +82,38 @@ describe('TTYPlayer component', () => {
await waitForApiCall();
});
it('renders a message warning when max_bytes exceeded', async () => {
renderResult = mockedContext.render(<TTYPlayer {...props} />);
await waitForApiCall();
await new Promise((r) => setTimeout(r, 10));
const seekToEndBtn = renderResult.getByTestId('sessionView:TTYPlayerControlsEnd');
act(() => {
userEvent.click(seekToEndBtn);
});
waitFor(() => expect(renderResult.queryAllByText('Data limit reached')).toHaveLength(1));
expect(renderResult.queryByText('[ VIEW POLICIES ]')).toBeFalsy();
});
it('renders a message warning when max_bytes exceeded with link to policies page', async () => {
renderResult = mockedContext.render(
<TTYPlayer {...props} canAccessEndpointManagement={true} />
);
await waitForApiCall();
await new Promise((r) => setTimeout(r, 10));
const seekToEndBtn = renderResult.getByTestId('sessionView:TTYPlayerControlsEnd');
act(() => {
userEvent.click(seekToEndBtn);
});
waitFor(() => expect(renderResult.queryAllByText('[ VIEW POLICIES ]')).toHaveLength(1));
});
});
});

View file

@ -13,6 +13,8 @@ import {
EuiButton,
EuiBetaBadge,
} from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { CoreStart } from '@kbn/core/public';
import useResizeObserver from 'use-resize-observer';
import { throttle } from 'lodash';
import { ProcessEvent } from '../../../common/types/process_tree';
@ -23,6 +25,8 @@ import {
DEFAULT_TTY_ROWS,
DEFAULT_TTY_COLS,
DEFAULT_TTY_FONT_SIZE,
POLICIES_PAGE_PATH,
SECURITY_APP_ID,
} from '../../../common/constants';
import { useFetchIOEvents, useIOLines, useXtermPlayer } from './hooks';
import { TTYPlayerControls } from '../tty_player_controls';
@ -35,6 +39,7 @@ export interface TTYPlayerDeps {
isFullscreen: boolean;
onJumpToEvent(event: ProcessEvent): void;
autoSeekToEntityId?: string;
canAccessEndpointManagement?: boolean;
}
export const TTYPlayer = ({
@ -44,6 +49,7 @@ export const TTYPlayer = ({
isFullscreen,
onJumpToEvent,
autoSeekToEntityId,
canAccessEndpointManagement,
}: TTYPlayerDeps) => {
const ref = useRef<HTMLDivElement>(null);
const { ref: scrollRef, height: containerHeight = 1 } = useResizeObserver<HTMLDivElement>({});
@ -53,7 +59,16 @@ export const TTYPlayer = ({
const { lines, processStartMarkers } = useIOLines(data?.pages);
const [fontSize, setFontSize] = useState(DEFAULT_TTY_FONT_SIZE);
const [isPlaying, setIsPlaying] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [currentAutoSeekEntityId, setCurrentAutoSeekEntityId] = useState('');
const { getUrlForApp } = useKibana<CoreStart>().services.application;
const policiesUrl = useMemo(
() =>
canAccessEndpointManagement
? getUrlForApp(SECURITY_APP_ID, { path: POLICIES_PAGE_PATH })
: '',
[canAccessEndpointManagement, getUrlForApp]
);
const { search, currentLine, seekToLine } = useXtermPlayer({
ref,
@ -64,6 +79,7 @@ export const TTYPlayer = ({
hasNextPage,
fetchNextPage,
isFetching,
policiesUrl,
});
const currentProcessEvent = lines[Math.min(lines.length - 1, currentLine)]?.event;
@ -113,11 +129,18 @@ export const TTYPlayer = ({
const styles = useStyles(tty, show);
const clearSearch = useCallback(() => {
if (searchQuery) {
setSearchQuery('');
}
}, [searchQuery]);
const onSeekLine = useMemo(() => {
return throttle((line: number) => {
clearSearch();
seekToLine(line);
}, 100);
}, [seekToLine]);
}, [clearSearch, seekToLine]);
const onTogglePlayback = useCallback(() => {
// if at the end, seek to beginning
@ -127,6 +150,12 @@ export const TTYPlayer = ({
setIsPlaying(!isPlaying);
}, [currentLine, isPlaying, lines.length, seekToLine]);
useEffect(() => {
if (isPlaying) {
clearSearch();
}
}, [clearSearch, isPlaying]);
return (
<div css={styles.container}>
<EuiPanel hasShadow={false} borderRadius="none" hasBorder={false} css={styles.header}>
@ -140,6 +169,8 @@ export const TTYPlayer = ({
seekToLine={seekToLine}
xTermSearchFn={search}
setIsPlaying={setIsPlaying}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
</EuiFlexItem>
@ -157,11 +188,17 @@ export const TTYPlayer = ({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon iconType="refresh" display="empty" size="m" disabled={true} />
<EuiButtonIcon
iconType="refresh"
display="empty"
size="m"
disabled={true}
aria-label="disabled"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon iconType="eye" disabled={true} size="m" />
<EuiButtonIcon iconType="eye" disabled={true} size="m" aria-label="disabled" />
</EuiFlexItem>
<EuiFlexItem grow={false}>

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const PROCESS_DATA_LIMIT_EXCEEDED_START = i18n.translate(
'xpack.sessionView.processDataLimitExceededStart',
{
defaultMessage: 'Data limit reached for',
}
);
export const PROCESS_DATA_LIMIT_EXCEEDED_END = i18n.translate(
'xpack.sessionView.processDataLimitExceededEnd',
{
defaultMessage: 'See "max_kilobytes_per_process" in advanced policy configuration.',
}
);
export const VIEW_POLICIES = i18n.translate('xpack.sessionView.viewPoliciesLink', {
defaultMessage: 'VIEW POLICIES',
});

View file

@ -9,7 +9,7 @@
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { Terminal, IDisposable, ITerminalAddon, ISelectionPosition } from 'xterm';
import { Terminal, IDisposable, ITerminalAddon, IBufferRange } from 'xterm';
export interface ISearchOptions {
regex?: boolean;
@ -83,14 +83,14 @@ export class SearchAddon implements ITerminalAddon {
let startCol = 0;
let startRow = 0;
let currentSelection: ISelectionPosition | undefined;
let currentSelection: IBufferRange | undefined;
if (this._terminal.hasSelection()) {
const incremental = searchOptions ? searchOptions.incremental : false;
// Start from the selection end if there is a selection
// For incremental search, use existing row
currentSelection = this._terminal.getSelectionPosition()!;
startRow = incremental ? currentSelection.startRow : currentSelection.endRow;
startCol = incremental ? currentSelection.startColumn : currentSelection.endColumn;
startRow = incremental ? currentSelection.start.y : currentSelection.end.y;
startCol = incremental ? currentSelection.start.x : currentSelection.end.x;
}
if (searchOptions?.lastLineOnly) {
@ -139,7 +139,7 @@ export class SearchAddon implements ITerminalAddon {
// If there is only one result, wrap back and return selection if it exists.
if (!result && currentSelection) {
searchPosition.startRow = currentSelection.startRow;
searchPosition.startRow = currentSelection.start.y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
}
@ -170,12 +170,12 @@ export class SearchAddon implements ITerminalAddon {
let startCol = this._terminal.cols;
let result: ISearchResult | undefined;
const incremental = searchOptions ? searchOptions.incremental : false;
let currentSelection: ISelectionPosition | undefined;
let currentSelection: IBufferRange | undefined;
if (this._terminal.hasSelection()) {
currentSelection = this._terminal.getSelectionPosition()!;
// Start from selection start if there is a selection
startRow = currentSelection.startRow;
startCol = currentSelection.startColumn;
startRow = currentSelection.start.y;
startCol = currentSelection.start.x;
} else if (searchOptions?.lastLineOnly) {
startRow = this._terminal.buffer.active.cursorY - 1;
startCol = this._terminal.cols;
@ -194,8 +194,8 @@ export class SearchAddon implements ITerminalAddon {
if (!isOldResultHighlighted) {
// If selection was not able to be expanded to the right, then try reverse search
if (currentSelection) {
searchPosition.startRow = currentSelection.endRow;
searchPosition.startCol = currentSelection.endColumn;
searchPosition.startRow = currentSelection.end.y;
searchPosition.startCol = currentSelection.end.x;
}
result = this._findInLine(term, searchPosition, searchOptions, true);
}

View file

@ -8,6 +8,7 @@ import React from 'react';
import { AppContextTestRender, createAppRootMockRenderer } from '../../test';
import { ProcessEvent } from '../../../common/types/process_tree';
import { TTYPlayerControls, TTYPlayerControlsDeps } from '.';
import { TTYPlayerLineMarkerType } from './tty_player_controls_markers';
const MOCK_PROCESS_EVENT_START: ProcessEvent = {
process: {
@ -100,11 +101,11 @@ describe('TTYPlayerControls component', () => {
expect(props.onSeekLine).toHaveBeenCalledWith(9);
});
it('render output markers', async () => {
it('render process_changed markers', async () => {
renderResult = mockedContext.render(<TTYPlayerControls {...props} />);
expect(
renderResult.queryAllByRole('button', {
name: 'output',
name: TTYPlayerLineMarkerType.ProcessChanged,
})
).toHaveLength(props.processStartMarkers.length);
});
@ -115,12 +116,10 @@ describe('TTYPlayerControls component', () => {
event: {
process: {
...MOCK_PROCESS_EVENT_MIDDLE,
io: {
max_bytes_per_process_exceeded: true,
},
},
},
line: 2,
maxBytesExceeded: true,
},
{ event: MOCK_PROCESS_EVENT_END, line: 4 },
];
@ -129,12 +128,12 @@ describe('TTYPlayerControls component', () => {
);
expect(
renderResult.queryAllByRole('button', {
name: 'output',
name: TTYPlayerLineMarkerType.ProcessChanged,
})
).toHaveLength(2);
expect(
renderResult.queryAllByRole('button', {
name: 'data_limited',
name: TTYPlayerLineMarkerType.ProcessDataLimitReached,
})
).toHaveLength(1);
});

View file

@ -19,9 +19,14 @@ type Props = {
onSeekLine(line: number): void;
};
export enum TTYPlayerLineMarkerType {
ProcessChanged = 'process_changed',
ProcessDataLimitReached = 'data_limited',
}
type TTYPlayerLineMarker = {
line: number;
type: 'output' | 'data_limited';
type: TTYPlayerLineMarkerType;
name: string;
};
@ -44,10 +49,11 @@ export const TTYPlayerControlsMarkers = ({
return [];
}
return processStartMarkers.map(
({ event, line }) =>
({ event, line, maxBytesExceeded }) =>
({
type:
event.process?.io?.max_bytes_per_process_exceeded === true ? 'data_limited' : 'output',
type: maxBytesExceeded
? TTYPlayerLineMarkerType.ProcessDataLimitReached
: TTYPlayerLineMarkerType.ProcessChanged,
line,
name: event.process?.name,
} as TTYPlayerLineMarker)

View file

@ -9,7 +9,7 @@ import { useMemo } from 'react';
import { CSSObject } from '@emotion/react';
import { useEuiTheme } from '../../../hooks';
type TTYPlayerLineMarkerType = 'output' | 'data_limited';
import { TTYPlayerLineMarkerType } from '.';
export const useStyles = (progress: number) => {
const { euiTheme, euiVars } = useEuiTheme();
@ -30,7 +30,7 @@ export const useStyles = (progress: number) => {
};
const getMarkerBackgroundColor = (type: TTYPlayerLineMarkerType, selected: boolean) => {
if (type === 'data_limited') {
if (type === TTYPlayerLineMarkerType.ProcessDataLimitReached) {
return euiVars.terminalOutputMarkerWarning;
}
if (selected) {
@ -105,7 +105,7 @@ export const useStyles = (progress: number) => {
left: progress + '%',
top: 16,
fill:
type === 'data_limited'
type === TTYPlayerLineMarkerType.ProcessDataLimitReached
? euiVars.terminalOutputMarkerWarning
: euiVars.terminalOutputMarkerAccent,
});

View file

@ -7,7 +7,6 @@
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import userEvent from '@testing-library/user-event';
import { fireEvent } from '@testing-library/dom';
import { AppContextTestRender, createAppRootMockRenderer } from '../../test';
import { sessionViewIOEventsMock } from '../../../common/mocks/responses/session_view_io_events.mock';
import { useIOLines } from '../tty_player/hooks';
@ -35,6 +34,8 @@ describe('TTYSearchBar component', () => {
seekToLine: jest.fn(),
xTermSearchFn: jest.fn(),
setIsPlaying: jest.fn(),
searchQuery: '',
setSearchQuery: jest.fn(),
};
});
@ -44,33 +45,20 @@ describe('TTYSearchBar component', () => {
});
it('does a search when a user enters text and hits enter', async () => {
renderResult = mockedContext.render(<TTYSearchBar {...props} />);
const searchInput = renderResult.queryByTestId('sessionView:searchBar')?.querySelector('input');
if (searchInput) {
userEvent.type(searchInput, '-h');
fireEvent.keyUp(searchInput, { key: 'Enter', code: 'Enter' });
}
renderResult = mockedContext.render(<TTYSearchBar {...props} searchQuery="-h" />);
expect(props.seekToLine).toHaveBeenCalledTimes(1);
// there is a slight delay in the seek in xtermjs, so we wait 100ms before trying to highlight a result.
await new Promise((r) => setTimeout(r, 100));
expect(props.xTermSearchFn).toHaveBeenCalledTimes(2);
expect(props.xTermSearchFn).toHaveBeenNthCalledWith(1, '', 0);
expect(props.xTermSearchFn).toHaveBeenNthCalledWith(2, '-h', 6);
expect(props.xTermSearchFn).toHaveBeenCalledTimes(1);
expect(props.xTermSearchFn).toHaveBeenNthCalledWith(1, '-h', 6);
expect(props.setIsPlaying).toHaveBeenCalledWith(false);
});
it('calls seekToline and xTermSearchFn when currentMatch changes', async () => {
renderResult = mockedContext.render(<TTYSearchBar {...props} />);
const searchInput = renderResult.queryByTestId('sessionView:searchBar')?.querySelector('input');
if (searchInput) {
userEvent.type(searchInput, '-h');
fireEvent.keyUp(searchInput, { key: 'Enter', code: 'Enter' });
}
renderResult = mockedContext.render(<TTYSearchBar {...props} searchQuery="-h" />);
await new Promise((r) => setTimeout(r, 100));
@ -83,27 +71,23 @@ describe('TTYSearchBar component', () => {
expect(props.seekToLine).toHaveBeenNthCalledWith(1, 26);
expect(props.seekToLine).toHaveBeenNthCalledWith(2, 100);
expect(props.xTermSearchFn).toHaveBeenCalledTimes(3);
expect(props.xTermSearchFn).toHaveBeenNthCalledWith(1, '', 0);
expect(props.xTermSearchFn).toHaveBeenNthCalledWith(2, '-h', 6);
expect(props.xTermSearchFn).toHaveBeenNthCalledWith(3, '-h', 13);
expect(props.setIsPlaying).toHaveBeenCalledTimes(3);
expect(props.xTermSearchFn).toHaveBeenCalledTimes(2);
expect(props.xTermSearchFn).toHaveBeenNthCalledWith(1, '-h', 6);
expect(props.xTermSearchFn).toHaveBeenNthCalledWith(2, '-h', 13);
expect(props.setIsPlaying).toHaveBeenCalledTimes(2);
});
it('calls xTermSearchFn with empty query when search is cleared', async () => {
renderResult = mockedContext.render(<TTYSearchBar {...props} />);
const searchInput = renderResult.queryByTestId('sessionView:searchBar')?.querySelector('input');
if (searchInput) {
userEvent.type(searchInput, '-h');
fireEvent.keyUp(searchInput, { key: 'Enter', code: 'Enter' });
}
renderResult = mockedContext.render(<TTYSearchBar {...props} searchQuery="-h" />);
await new Promise((r) => setTimeout(r, 100));
userEvent.click(renderResult.getByTestId('clearSearchButton'));
await new Promise((r) => setTimeout(r, 100));
expect(props.xTermSearchFn).toHaveBeenNthCalledWith(3, '', 0);
renderResult.rerender(<TTYSearchBar {...props} />);
expect(props.setSearchQuery).toHaveBeenNthCalledWith(1, '');
expect(props.xTermSearchFn).toHaveBeenNthCalledWith(2, '', 0);
expect(props.setIsPlaying).toHaveBeenCalledWith(false);
});
});

View file

@ -20,6 +20,8 @@ export interface TTYSearchBarDeps {
seekToLine(index: number): void;
xTermSearchFn(query: string, index: number): void;
setIsPlaying(value: boolean): void;
searchQuery: string;
setSearchQuery(value: string): void;
}
const STRIP_NEWLINES_REGEX = /^(\r\n|\r|\n|\n\r)/;
@ -29,9 +31,10 @@ export const TTYSearchBar = ({
seekToLine,
xTermSearchFn,
setIsPlaying,
searchQuery,
setSearchQuery,
}: TTYSearchBarDeps) => {
const [currentMatch, setCurrentMatch] = useState<SearchResult | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const jumpToMatch = useCallback(
(match) => {
@ -105,7 +108,7 @@ export const TTYSearchBar = ({
setSearchQuery(query);
setCurrentMatch(null);
},
[setIsPlaying]
[setIsPlaying, setSearchQuery]
);
const onSetCurrentMatch = useCallback(

View file

@ -17,6 +17,7 @@ import { CoreStart } from '@kbn/core/public';
import { coreMock } from '@kbn/core/public/mocks';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { SECURITY_APP_ID, SESSION_VIEW_APP_ID } from '../../common/constants';
type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;
@ -46,8 +47,10 @@ const createCoreStartMock = (
// Mock the certain APP Ids returned by `application.getUrlForApp()`
coreStart.application.getUrlForApp.mockImplementation((appId) => {
switch (appId) {
case 'sessionView':
case SESSION_VIEW_APP_ID:
return '/app/sessionView';
case SECURITY_APP_ID:
return '/app/security';
default:
return `${appId} not mocked!`;
}

View file

@ -27,6 +27,7 @@ export interface SessionViewDeps {
// Callback used when alert flyout panel is closed
handleOnAlertDetailsClosed: () => void
) => void;
canAccessEndpointManagement?: boolean;
}
export interface EuiTabProps {

View file

@ -29229,10 +29229,10 @@ xtend@~2.1.1:
dependencies:
object-keys "~0.4.0"
xterm@^4.18.0:
version "4.18.0"
resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.18.0.tgz#a1f6ab2c330c3918fb094ae5f4c2562987398ea1"
integrity sha512-JQoc1S0dti6SQfI0bK1AZvGnAxH4MVw45ZPFSO6FHTInAiau3Ix77fSxNx3mX4eh9OL4AYa8+4C8f5UvnSfppQ==
xterm@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/xterm/-/xterm-5.0.0.tgz#0af50509b33d0dc62fde7a4ec17750b8e453cc5c"
integrity sha512-tmVsKzZovAYNDIaUinfz+VDclraQpPUnAME+JawosgWRMphInDded/PuY0xmU5dOhyeYZsI0nz5yd8dPYsdLTA==
y18n@^3.2.0:
version "3.2.2"