Skip to content

Commit

Permalink
[Fleet] Display view in logs button when logs app is available (elast…
Browse files Browse the repository at this point in the history
…ic#187871)

Closes elastic#185711

## Summary
This change fixes elastic#185711, but
while working on that I also realised that we should move away from
using hardcoded urls. So this PR does two things:
- Displays the button only when the user has `authz.fleet.readAgents`
privilege
- Refactors the button functionality to use the new locators that take
care of linking to the observability logs/discover app

### Why the refactor
While testing this button, I noticed that the functionality was broken
in some cases, that's because we were manually routing the urls to Logs
UI/Discover apps based if we are in serverless or not.

I found a PR that already implements this functionality:
elastic#155156
I also found elastic#154145 that takes
care of the redirect to the correct app.
So I'm replacing the current manual functionality with these utilities
so that `getLogsLocatorsFromUrlService` takes care of where the open in
logs button should link.

### ESS

https://github.com/elastic/kibana/assets/16084106/3f0760c9-3afb-4793-a3af-317f625b36d7

https://github.com/elastic/kibana/assets/16084106/3436cf5a-36c9-425d-a114-e116ddaa1a03

### Serverless

https://github.com/elastic/kibana/assets/16084106/84176f09-96a4-4932-9508-5f7682d03aae

---------

Co-authored-by: kibanamachine <[email protected]>
Co-authored-by: Elastic Machine <[email protected]>
(cherry picked from commit 696bb88)
  • Loading branch information
criamico committed Jul 10, 2024
1 parent 4b9a9c3 commit c85fd47
Show file tree
Hide file tree
Showing 13 changed files with 165 additions and 112 deletions.
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

0 comments on commit c85fd47

Please sign in to comment.