Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions x-pack/plugins/fleet/common/constants/locators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
export const LOCATORS_IDS = {
APM_LOCATOR: 'APM_LOCATOR',
DASHBOARD_APP: 'DASHBOARD_APP_LOCATOR',
DISCOVER_APP_LOCATOR: 'DISCOVER_APP_LOCATOR',
} as const;

// Dashboards ids
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import { createFleetTestRendererMock } from '../../../../../../../mock';

import { AgentLogsUI } from './agent_logs';

jest.mock('../../../../../../../hooks/use_authz');

jest.mock('@kbn/kibana-utils-plugin/public', () => {
return {
...jest.requireActual('@kbn/kibana-utils-plugin/public'),
Expand All @@ -28,6 +26,13 @@ jest.mock('@kbn/logs-shared-plugin/public', () => {
LogStream: () => <div />,
};
});
jest.mock('@kbn/logs-shared-plugin/common', () => {
return {
getLogsLocatorsFromUrlService: jest.fn().mockReturnValue({
logsLocator: { getRedirectUrl: jest.fn(() => 'https://discover-redirect-url') },
}),
};
});

jest.mock('@kbn/shared-ux-link-redirect-app', () => {
return {
Expand All @@ -52,6 +57,13 @@ jest.mock('../../../../../hooks', () => {
...jest.requireActual('../../../../../hooks'),
useLink: jest.fn(),
useStartServices: jest.fn(),
useAuthz: jest.fn(),
useDiscoverLocator: jest.fn().mockImplementation(() => {
return {
id: 'DISCOVER_APP_LOCATOR',
getRedirectUrl: jest.fn().mockResolvedValue('app/discover/logs/someview'),
};
}),
};
});

Expand All @@ -62,6 +74,7 @@ describe('AgentLogsUI', () => {
jest.mocked(useAuthz).mockReturnValue({
fleet: {
allAgents: true,
readAgents: true,
},
} as any);
});
Expand Down Expand Up @@ -100,34 +113,36 @@ describe('AgentLogsUI', () => {
},
},
},
http: {
basePath: {
prepend: (url: string) => 'http://localhost:5620' + url,
share: {
url: {
locators: {
get: () => ({
useUrl: () => 'https://locator.url',
}),
},
},
},
cloud: {
isServerlessEnabled,
},
});
};

it('should render Open in Logs UI if capabilities not set', () => {
it('should render Open in Logs button if privileges are set', () => {
mockStartServices();
const result = renderComponent();
expect(result.getByTestId('viewInLogsBtn')).toHaveAttribute(
'href',
`http://localhost:5620/app/logs/stream?logPosition=(end%3A'2023-20-04T14%3A20%3A00.340Z'%2Cstart%3A'2023-20-04T14%3A00%3A00.340Z'%2CstreamLive%3A!f)&logFilter=(expression%3A'elastic_agent.id%3Aagent1%20and%20(data_stream.dataset%3Aelastic_agent)%20and%20(log.level%3Ainfo%20or%20log.level%3Aerror)'%2Ckind%3Akuery)`
`https://discover-redirect-url`
);
});

it('should render Open in Discover if serverless enabled', () => {
mockStartServices(true);
it('should not render Open in Logs button if privileges are not set', () => {
jest.mocked(useAuthz).mockReturnValue({
fleet: {
readAgents: false,
},
} as any);
mockStartServices();
const result = renderComponent();
const viewInDiscover = result.getByTestId('viewInDiscoverBtn');
expect(viewInDiscover).toHaveAttribute(
'href',
`http://localhost:5620/app/discover#/?_g=(filters:!(),refreshInterval:(pause:!t,value:60000),time:(from:'2023-20-04T14:00:00.340Z',to:'2023-20-04T14:20:00.340Z'))&_a=(columns:!(event.dataset,message),index:'logs-*',query:(language:kuery,query:'elastic_agent.id:agent1 and (data_stream.dataset:elastic_agent) and (log.level:info or log.level:error)'))`
);
expect(result.queryByTestId('viewInLogsBtn')).not.toBeInTheDocument();
});

it('should show log level dropdown with correct value', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { LogLevelFilter } from './filter_log_level';
import { LogQueryBar } from './query_bar';
import { buildQuery } from './build_query';
import { SelectLogLevel } from './select_log_level';
import { ViewLogsButton } from './view_logs_button';
import { ViewLogsButton, getFormattedRange } from './view_logs_button';

const WrapperFlexGroup = styled(EuiFlexGroup)`
height: 100%;
Expand Down Expand Up @@ -112,9 +112,8 @@ const AgentPolicyLogsNotEnabledCallout: React.FunctionComponent<{ agentPolicy: A

export const AgentLogsUI: React.FunctionComponent<AgentLogsProps> = memo(
({ agent, agentPolicy, state }) => {
const { data, application, cloud } = useStartServices();
const { data, application } = useStartServices();
const { update: updateState } = AgentLogsUrlStateHelper.useTransitions();
const isLogsUIAvailable = !cloud?.isServerlessEnabled;

// Util to convert date expressions (returned by datepicker) to timestamps (used by LogStream)
const getDateRangeTimestamps = useCallback(
Expand Down Expand Up @@ -321,10 +320,9 @@ export const AgentLogsUI: React.FunctionComponent<AgentLogsProps> = memo(
}}
>
<ViewLogsButton
viewInLogs={isLogsUIAvailable}
logStreamQuery={logStreamQuery}
startTime={state.start}
endTime={state.end}
startTime={getFormattedRange(state.start)}
endTime={getFormattedRange(state.end)}
/>
</RedirectAppLinks>
</EuiFlexItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,81 +5,61 @@
* 2.0.
*/

import url from 'url';
import { stringify } from 'querystring';

import React, { useMemo } from 'react';
import { encode } from '@kbn/rison';
import { EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';

import { useStartServices } from '../../../../../hooks';
import { getLogsLocatorsFromUrlService } from '@kbn/logs-shared-plugin/common';

import moment from 'moment';

import { useDiscoverLocator, useStartServices, useAuthz } from '../../../../../hooks';

interface ViewLogsProps {
viewInLogs: boolean;
logStreamQuery: string;
startTime: string;
endTime: string;
startTime: number;
endTime: number;
}

export const getFormattedRange = (date: string) => new Date(date).getTime();

/*
Button that takes to the Logs view Ui when that is available, otherwise fallback to the Discover UI
The urls are built using same logStreamQuery (provided by a prop), startTime and endTime, ensuring that they'll both will target same log lines
Button that takes to the Logs view UI or the Discover logs, depending on what's available
If none is available, don't display the button at all
*/
export const ViewLogsButton: React.FunctionComponent<ViewLogsProps> = ({
viewInLogs,
logStreamQuery,
startTime,
endTime,
}) => {
const { http } = useStartServices();
const discoverLocator = useDiscoverLocator();

// Generate URL to pass page state to Logs UI
const viewInLogsUrl = useMemo(
() =>
http.basePath.prepend(
url.format({
pathname: '/app/logs/stream',
search: stringify({
logPosition: encode({
start: startTime,
end: endTime,
streamLive: false,
}),
logFilter: encode({
expression: logStreamQuery,
kind: 'kuery',
}),
}),
})
),
[http.basePath, startTime, endTime, logStreamQuery]
);
const { share } = useStartServices();
const { logsLocator } = getLogsLocatorsFromUrlService(share.url);
const authz = useAuthz();

const viewInDiscoverUrl = useMemo(() => {
const index = 'logs-*';
const query = encode({
query: logStreamQuery,
language: 'kuery',
const logsUrl = useMemo(() => {
const now = moment().toISOString();
const oneDayAgo = moment().subtract(1, 'day').toISOString();
const defaultStartTime = getFormattedRange(oneDayAgo);
const defaultEndTime = getFormattedRange(now);

return logsLocator.getRedirectUrl({
time: endTime ? endTime : defaultEndTime,
timeRange: {
startTime: startTime ? startTime : defaultStartTime,
endTime: endTime ? endTime : defaultEndTime,
},
filter: logStreamQuery,
});
return http.basePath.prepend(
`/app/discover#/?_g=(filters:!(),refreshInterval:(pause:!t,value:60000),time:(from:'${startTime}',to:'${endTime}'))&_a=(columns:!(event.dataset,message),index:'${index}',query:${query})`
);
}, [logStreamQuery, http.basePath, startTime, endTime]);
}, [endTime, logStreamQuery, logsLocator, startTime]);

return viewInLogs ? (
<EuiButton href={viewInLogsUrl} iconType="popout" data-test-subj="viewInLogsBtn">
return authz.fleet.readAgents && (logsLocator || discoverLocator) ? (
<EuiButton href={logsUrl} iconType="popout" data-test-subj="viewInLogsBtn">
<FormattedMessage
id="xpack.fleet.agentLogs.openInLogsUiLinkText"
defaultMessage="Open in Logs"
/>
</EuiButton>
) : (
<EuiButton href={viewInDiscoverUrl} iconType="popout" data-test-subj="viewInDiscoverBtn">
<FormattedMessage
id="xpack.fleet.agentLogs.openInDiscoverUiLinkText"
defaultMessage="Open in Discover"
/>
</EuiButton>
);
) : null;
};
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { act, render, fireEvent } from '@testing-library/react';
import { IntlProvider } from 'react-intl';

import { useActionStatus } from '../../hooks';
import { useGetAgentPolicies, useStartServices } from '../../../../../hooks';
import { useGetAgentPolicies, useStartServices, useAuthz } from '../../../../../hooks';

import { AgentActivityFlyout } from '.';

Expand All @@ -25,6 +25,15 @@ jest.mock('@kbn/shared-ux-link-redirect-app', () => ({
const mockUseActionStatus = useActionStatus as jest.Mock;
const mockUseGetAgentPolicies = useGetAgentPolicies as jest.Mock;
const mockUseStartServices = useStartServices as jest.Mock;
const mockedUseAuthz = useAuthz as jest.Mock;

jest.mock('@kbn/logs-shared-plugin/common', () => {
return {
getLogsLocatorsFromUrlService: jest.fn().mockReturnValue({
logsLocator: { getRedirectUrl: jest.fn(() => 'https://discover-redirect-url') },
}),
};
});

describe('AgentActivityFlyout', () => {
const mockOnClose = jest.fn();
Expand Down Expand Up @@ -65,7 +74,22 @@ describe('AgentActivityFlyout', () => {
docLinks: { links: { fleet: { upgradeElasticAgent: 'https://elastic.co' } } },
application: { navigateToUrl: jest.fn() },
http: { basePath: { prepend: jest.fn() } },
share: {
url: {
locators: {
get: () => ({
useUrl: () => 'https://locator.url',
}),
},
},
},
});
mockedUseAuthz.mockReturnValue({
fleet: {
readAgents: true,
allAgents: true,
},
} as any);
});

beforeEach(() => {
Expand Down
Loading