[Security Solution][Endpoint] Paginate actions log with infinite scroll (#102261)
* Show loading below the list when loading fixes elastic/security-team/issues/1245 * use intersection observer to load data when callout is visible fixes elastic/security-team/issues/1245 * remove unused `total` from API response refs4f7d18bee7
* toggle ability to paging based on API response and target intersection fixes elastic/security-team/issues/1245 * use a invisible target * display a message when end of log fixes elastic/security-team/issues/1245 * remove search bar fixes elastic/security-team/issues/1245 * refresh data fixes elastic/security-team/issues/1245 * rename refs85e5add14e
* add refresh button to empty state * add translations for copy * remove refresh button * load activity log for endpoint on activity log tab selection fixes elastic/security-team/issues/1312 * reset paging correctly on activity log tab selection * fix variable mixup refs elastic/kibana/pull/101032/commits/c4e933a9c5954ce249942ca66bab380c1dfa79e2#diff-41a74ad41665921620230a0729728f3bf6e27a6f9dc302fb37b0d2061637c212R81 * fix react warning refs697a3c3bac
* clean up review changes * use the complicated flyout version instead of styled version refs https://elastic.github.io/eui/#/layout/flyout#more-complicated-flyout refs https://github.com/elastic/kibana/pull/99795/files#r635810660 refsc26a7d47b4
* Page only when scrolled (so that info message is shown after paging once) fixes https://github.com/elastic/security-team/issues/1245#issuecomment-863440335 * add tests fixes elastic/security-team/issues/1312 fixes elastic/security-team/issues/1245 * increase the parent container's height to ensure that the scroll target is well hidden below the footer refs48e3291681
* Update x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> * Update x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> * address review changes * cleanup callback and effect Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com>
This commit is contained in:
parent
3e952faf88
commit
b12ddfabf0
|
@ -58,7 +58,6 @@ export interface ActivityLogActionResponse {
|
|||
}
|
||||
export type ActivityLogEntry = ActivityLogAction | ActivityLogActionResponse;
|
||||
export interface ActivityLog {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
data: ActivityLogEntry[];
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
import { ServerApiError } from '../../../../common/types';
|
||||
import { GetPolicyListResponse } from '../../policy/types';
|
||||
import { GetPackagesResponse } from '../../../../../../fleet/common';
|
||||
import { EndpointState } from '../types';
|
||||
import { EndpointIndexUIQueryParams, EndpointState } from '../types';
|
||||
import { IIndexPattern } from '../../../../../../../../src/plugins/data/public';
|
||||
|
||||
export interface ServerReturnedEndpointList {
|
||||
|
@ -163,12 +163,29 @@ export type EndpointPendingActionsStateChanged = Action<'endpointPendingActionsS
|
|||
payload: EndpointState['endpointPendingActions'];
|
||||
};
|
||||
|
||||
export interface EndpointDetailsActivityLogUpdatePaging {
|
||||
type: 'endpointDetailsActivityLogUpdatePaging';
|
||||
payload: {
|
||||
// disable paging when no more data after paging
|
||||
disabled: boolean;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EndpointDetailsFlyoutTabChanged {
|
||||
type: 'endpointDetailsFlyoutTabChanged';
|
||||
payload: { flyoutView: EndpointIndexUIQueryParams['show'] };
|
||||
}
|
||||
|
||||
export type EndpointAction =
|
||||
| ServerReturnedEndpointList
|
||||
| ServerFailedToReturnEndpointList
|
||||
| ServerReturnedEndpointDetails
|
||||
| ServerFailedToReturnEndpointDetails
|
||||
| AppRequestedEndpointActivityLog
|
||||
| EndpointDetailsActivityLogUpdatePaging
|
||||
| EndpointDetailsFlyoutTabChanged
|
||||
| EndpointDetailsActivityLogChanged
|
||||
| ServerReturnedEndpointPolicyResponse
|
||||
| ServerFailedToReturnEndpointPolicyResponse
|
||||
|
|
|
@ -19,9 +19,13 @@ export const initialEndpointPageState = (): Immutable<EndpointState> => {
|
|||
loading: false,
|
||||
error: undefined,
|
||||
endpointDetails: {
|
||||
flyoutView: undefined,
|
||||
activityLog: {
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
paging: {
|
||||
disabled: false,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
},
|
||||
logData: createUninitialisedResourceState(),
|
||||
},
|
||||
hostDetails: {
|
||||
|
|
|
@ -42,9 +42,13 @@ describe('EndpointList store concerns', () => {
|
|||
loading: false,
|
||||
error: undefined,
|
||||
endpointDetails: {
|
||||
flyoutView: undefined,
|
||||
activityLog: {
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
paging: {
|
||||
disabled: false,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
},
|
||||
logData: { type: 'UninitialisedResourceState' },
|
||||
},
|
||||
hostDetails: {
|
||||
|
|
|
@ -44,6 +44,7 @@ import {
|
|||
} from '../../../../common/lib/endpoint_isolation/mocks';
|
||||
import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator';
|
||||
import { endpointPageHttpMock } from '../mocks';
|
||||
import { EndpointDetailsTabsTypes } from '../view/details/components/endpoint_details_tabs';
|
||||
|
||||
jest.mock('../../policy/store/services/ingest', () => ({
|
||||
sendGetAgentConfigList: () => Promise.resolve({ items: [] }),
|
||||
|
@ -226,8 +227,16 @@ describe('endpoint list middleware', () => {
|
|||
const dispatchUserChangedUrl = () => {
|
||||
dispatchUserChangedUrlToEndpointList({ search: `?${search.split('?').pop()}` });
|
||||
};
|
||||
const dispatchFlyoutViewChange = () => {
|
||||
dispatch({
|
||||
type: 'endpointDetailsFlyoutTabChanged',
|
||||
payload: {
|
||||
flyoutView: EndpointDetailsTabsTypes.activityLog,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const fleetActionGenerator = new FleetActionGenerator(Math.random().toString());
|
||||
const fleetActionGenerator = new FleetActionGenerator('seed');
|
||||
const actionData = fleetActionGenerator.generate({
|
||||
agents: [endpointList.hosts[0].metadata.agent.id],
|
||||
});
|
||||
|
@ -265,6 +274,7 @@ describe('endpoint list middleware', () => {
|
|||
|
||||
it('should set ActivityLog state to loading', async () => {
|
||||
dispatchUserChangedUrl();
|
||||
dispatchFlyoutViewChange();
|
||||
|
||||
const loadingDispatched = waitForAction('endpointDetailsActivityLogChanged', {
|
||||
validate(action) {
|
||||
|
|
|
@ -35,6 +35,7 @@ import {
|
|||
getActivityLogDataPaging,
|
||||
getLastLoadedActivityLogData,
|
||||
detailsData,
|
||||
getEndpointDetailsFlyoutView,
|
||||
} from './selectors';
|
||||
import { AgentIdsPendingActions, EndpointState, PolicyIds } from '../types';
|
||||
import {
|
||||
|
@ -48,6 +49,7 @@ import {
|
|||
ENDPOINT_ACTION_LOG_ROUTE,
|
||||
HOST_METADATA_GET_ROUTE,
|
||||
HOST_METADATA_LIST_ROUTE,
|
||||
BASE_POLICY_RESPONSE_ROUTE,
|
||||
metadataCurrentIndexPattern,
|
||||
} from '../../../../../common/endpoint/constants';
|
||||
import { IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public';
|
||||
|
@ -61,6 +63,7 @@ import { AppAction } from '../../../../common/store/actions';
|
|||
import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables';
|
||||
import { ServerReturnedEndpointPackageInfo } from './action';
|
||||
import { fetchPendingActionsByAgentId } from '../../../../common/lib/endpoint_pending_actions';
|
||||
import { EndpointDetailsTabsTypes } from '../view/details/components/endpoint_details_tabs';
|
||||
|
||||
type EndpointPageStore = ImmutableMiddlewareAPI<EndpointState, AppAction>;
|
||||
|
||||
|
@ -339,6 +342,28 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState
|
|||
|
||||
loadEndpointsPendingActions(store);
|
||||
|
||||
// call the policy response api
|
||||
try {
|
||||
const policyResponse = await coreStart.http.get(BASE_POLICY_RESPONSE_ROUTE, {
|
||||
query: { agentId: selectedEndpoint },
|
||||
});
|
||||
dispatch({
|
||||
type: 'serverReturnedEndpointPolicyResponse',
|
||||
payload: policyResponse,
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: 'serverFailedToReturnEndpointPolicyResponse',
|
||||
payload: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
action.type === 'userChangedUrl' &&
|
||||
hasSelectedEndpoint(getState()) === true &&
|
||||
getEndpointDetailsFlyoutView(getState()) === EndpointDetailsTabsTypes.activityLog
|
||||
) {
|
||||
// call the activity log api
|
||||
dispatch({
|
||||
type: 'endpointDetailsActivityLogChanged',
|
||||
|
@ -365,22 +390,6 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState
|
|||
payload: createFailedResourceState<ActivityLog>(error.body ?? error),
|
||||
});
|
||||
}
|
||||
|
||||
// call the policy response api
|
||||
try {
|
||||
const policyResponse = await coreStart.http.get(`/api/endpoint/policy_response`, {
|
||||
query: { agentId: selectedEndpoint },
|
||||
});
|
||||
dispatch({
|
||||
type: 'serverReturnedEndpointPolicyResponse',
|
||||
payload: policyResponse,
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: 'serverFailedToReturnEndpointPolicyResponse',
|
||||
payload: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// page activity log API
|
||||
|
@ -408,17 +417,24 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState
|
|||
] as ActivityLog['data'];
|
||||
|
||||
const updatedLogData = {
|
||||
total: activityLog.total,
|
||||
page: activityLog.page,
|
||||
pageSize: activityLog.pageSize,
|
||||
data: updatedLogDataItems,
|
||||
data: activityLog.page === 1 ? activityLog.data : updatedLogDataItems,
|
||||
};
|
||||
dispatch({
|
||||
type: 'endpointDetailsActivityLogChanged',
|
||||
payload: createLoadedResourceState<ActivityLog>(updatedLogData),
|
||||
});
|
||||
// TODO dispatch 'noNewLogData' if !activityLog.length
|
||||
// resets paging to previous state
|
||||
if (!activityLog.data.length) {
|
||||
dispatch({
|
||||
type: 'endpointDetailsActivityLogUpdatePaging',
|
||||
payload: {
|
||||
disabled: true,
|
||||
page: activityLog.page - 1,
|
||||
pageSize: activityLog.pageSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'endpointDetailsActivityLogChanged',
|
||||
|
|
|
@ -29,12 +29,23 @@ const handleEndpointDetailsActivityLogChanged: CaseReducer<EndpointDetailsActivi
|
|||
state,
|
||||
action
|
||||
) => {
|
||||
const pagingOptions =
|
||||
action.payload.type === 'LoadedResourceState'
|
||||
? {
|
||||
...state.endpointDetails.activityLog,
|
||||
paging: {
|
||||
...state.endpointDetails.activityLog.paging,
|
||||
page: action.payload.data.page,
|
||||
pageSize: action.payload.data.pageSize,
|
||||
},
|
||||
}
|
||||
: { ...state.endpointDetails.activityLog };
|
||||
return {
|
||||
...state!,
|
||||
endpointDetails: {
|
||||
...state.endpointDetails!,
|
||||
activityLog: {
|
||||
...state.endpointDetails.activityLog,
|
||||
...pagingOptions,
|
||||
logData: action.payload,
|
||||
},
|
||||
},
|
||||
|
@ -138,7 +149,8 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
|
|||
},
|
||||
};
|
||||
} else if (action.type === 'appRequestedEndpointActivityLog') {
|
||||
const pageData = {
|
||||
const paging = {
|
||||
disabled: state.endpointDetails.activityLog.paging.disabled,
|
||||
page: action.payload.page,
|
||||
pageSize: action.payload.pageSize,
|
||||
};
|
||||
|
@ -148,10 +160,32 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
|
|||
...state.endpointDetails!,
|
||||
activityLog: {
|
||||
...state.endpointDetails.activityLog,
|
||||
...pageData,
|
||||
paging,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (action.type === 'endpointDetailsActivityLogUpdatePaging') {
|
||||
const paging = {
|
||||
...action.payload,
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
endpointDetails: {
|
||||
...state.endpointDetails!,
|
||||
activityLog: {
|
||||
...state.endpointDetails.activityLog,
|
||||
paging,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (action.type === 'endpointDetailsFlyoutTabChanged') {
|
||||
return {
|
||||
...state,
|
||||
endpointDetails: {
|
||||
...state.endpointDetails!,
|
||||
flyoutView: action.payload.flyoutView,
|
||||
},
|
||||
};
|
||||
} else if (action.type === 'endpointDetailsActivityLogChanged') {
|
||||
return handleEndpointDetailsActivityLogChanged(state, action);
|
||||
} else if (action.type === 'endpointPendingActionsStateChanged') {
|
||||
|
@ -255,8 +289,11 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
|
|||
|
||||
const activityLog = {
|
||||
logData: createUninitialisedResourceState(),
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
paging: {
|
||||
disabled: false,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
},
|
||||
};
|
||||
|
||||
// Reset `isolationRequestState` if needed
|
||||
|
|
|
@ -364,13 +364,14 @@ export const getIsolationRequestError: (
|
|||
}
|
||||
});
|
||||
|
||||
export const getEndpointDetailsFlyoutView = (
|
||||
state: Immutable<EndpointState>
|
||||
): EndpointIndexUIQueryParams['show'] => state.endpointDetails.flyoutView;
|
||||
|
||||
export const getActivityLogDataPaging = (
|
||||
state: Immutable<EndpointState>
|
||||
): Immutable<Omit<EndpointState['endpointDetails']['activityLog'], 'logData'>> => {
|
||||
return {
|
||||
page: state.endpointDetails.activityLog.page,
|
||||
pageSize: state.endpointDetails.activityLog.pageSize,
|
||||
};
|
||||
): Immutable<EndpointState['endpointDetails']['activityLog']['paging']> => {
|
||||
return state.endpointDetails.activityLog.paging;
|
||||
};
|
||||
|
||||
export const getActivityLogData = (
|
||||
|
|
|
@ -37,9 +37,13 @@ export interface EndpointState {
|
|||
/** api error from retrieving host list */
|
||||
error?: ServerApiError;
|
||||
endpointDetails: {
|
||||
flyoutView: EndpointIndexUIQueryParams['show'];
|
||||
activityLog: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
paging: {
|
||||
disabled: boolean;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
logData: AsyncResourceState<ActivityLog>;
|
||||
};
|
||||
hostDetails: {
|
||||
|
|
|
@ -5,10 +5,15 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui';
|
||||
import { EuiTab, EuiTabs, EuiFlyoutBody, EuiTabbedContentTab, EuiSpacer } from '@elastic/eui';
|
||||
import { EndpointIndexUIQueryParams } from '../../../types';
|
||||
import { EndpointAction } from '../../../store/action';
|
||||
import { useEndpointSelector } from '../../hooks';
|
||||
import { getActivityLogDataPaging } from '../../../store/selectors';
|
||||
import { EndpointDetailsFlyoutHeader } from './flyout_header';
|
||||
|
||||
export enum EndpointDetailsTabsTypes {
|
||||
overview = 'overview',
|
||||
activityLog = 'activity_log',
|
||||
|
@ -24,29 +29,18 @@ interface EndpointDetailsTabs {
|
|||
content: JSX.Element;
|
||||
}
|
||||
|
||||
const StyledEuiTabbedContent = styled(EuiTabbedContent)`
|
||||
overflow: hidden;
|
||||
padding-bottom: ${(props) => props.theme.eui.paddingSizes.xl};
|
||||
|
||||
> [role='tabpanel'] {
|
||||
height: 100%;
|
||||
padding-right: 12px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
::-webkit-scrollbar {
|
||||
-webkit-appearance: none;
|
||||
width: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 2px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
-webkit-box-shadow: 0 0 1px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const EndpointDetailsFlyoutTabs = memo(
|
||||
({ show, tabs }: { show: EndpointIndexUIQueryParams['show']; tabs: EndpointDetailsTabs[] }) => {
|
||||
({
|
||||
hostname,
|
||||
show,
|
||||
tabs,
|
||||
}: {
|
||||
hostname?: string;
|
||||
show: EndpointIndexUIQueryParams['show'];
|
||||
tabs: EndpointDetailsTabs[];
|
||||
}) => {
|
||||
const dispatch = useDispatch<(action: EndpointAction) => void>();
|
||||
const { pageSize } = useEndpointSelector(getActivityLogDataPaging);
|
||||
const [selectedTabId, setSelectedTabId] = useState<EndpointDetailsTabsId>(() => {
|
||||
return show === 'details'
|
||||
? EndpointDetailsTabsTypes.overview
|
||||
|
@ -54,8 +48,33 @@ export const EndpointDetailsFlyoutTabs = memo(
|
|||
});
|
||||
|
||||
const handleTabClick = useCallback(
|
||||
(tab: EuiTabbedContentTab) => setSelectedTabId(tab.id as EndpointDetailsTabsId),
|
||||
[setSelectedTabId]
|
||||
(tab: EuiTabbedContentTab) => {
|
||||
dispatch({
|
||||
type: 'endpointDetailsFlyoutTabChanged',
|
||||
payload: {
|
||||
flyoutView: tab.id as EndpointIndexUIQueryParams['show'],
|
||||
},
|
||||
});
|
||||
if (tab.id === EndpointDetailsTabsTypes.activityLog) {
|
||||
const paging = {
|
||||
page: 1,
|
||||
pageSize,
|
||||
};
|
||||
dispatch({
|
||||
type: 'appRequestedEndpointActivityLog',
|
||||
payload: paging,
|
||||
});
|
||||
dispatch({
|
||||
type: 'endpointDetailsActivityLogUpdatePaging',
|
||||
payload: {
|
||||
disabled: false,
|
||||
...paging,
|
||||
},
|
||||
});
|
||||
}
|
||||
return setSelectedTabId(tab.id as EndpointDetailsTabsId);
|
||||
},
|
||||
[dispatch, pageSize, setSelectedTabId]
|
||||
);
|
||||
|
||||
const selectedTab = useMemo(() => tabs.find((tab) => tab.id === selectedTabId), [
|
||||
|
@ -63,14 +82,27 @@ export const EndpointDetailsFlyoutTabs = memo(
|
|||
selectedTabId,
|
||||
]);
|
||||
|
||||
const renderTabs = tabs.map((tab) => (
|
||||
<EuiTab
|
||||
onClick={() => handleTabClick(tab)}
|
||||
isSelected={tab.id === selectedTabId}
|
||||
key={tab.id}
|
||||
data-test-subj={tab.id}
|
||||
>
|
||||
{tab.name}
|
||||
</EuiTab>
|
||||
));
|
||||
|
||||
return (
|
||||
<StyledEuiTabbedContent
|
||||
data-test-subj="endpointDetailsTabs"
|
||||
tabs={tabs}
|
||||
selectedTab={selectedTab}
|
||||
onTabClick={handleTabClick}
|
||||
key="endpoint-details-tabs"
|
||||
/>
|
||||
<>
|
||||
<EndpointDetailsFlyoutHeader hostname={hostname} hasBorder>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiTabs style={{ marginBottom: '-25px' }}>{renderTabs}</EuiTabs>
|
||||
</EndpointDetailsFlyoutHeader>
|
||||
<EuiFlyoutBody data-test-subj="endpointDetailsFlyoutBody">
|
||||
{selectedTab?.content}
|
||||
</EuiFlyoutBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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 React, { memo } from 'react';
|
||||
import { EuiFlyoutHeader, EuiLoadingContent, EuiToolTip, EuiTitle } from '@elastic/eui';
|
||||
import { useEndpointSelector } from '../../hooks';
|
||||
import { detailsLoading } from '../../../store/selectors';
|
||||
|
||||
export const EndpointDetailsFlyoutHeader = memo(
|
||||
({
|
||||
hasBorder = false,
|
||||
hostname,
|
||||
children,
|
||||
}: {
|
||||
hasBorder?: boolean;
|
||||
hostname?: string;
|
||||
children?: React.ReactNode | React.ReactNodeArray;
|
||||
}) => {
|
||||
const hostDetailsLoading = useEndpointSelector(detailsLoading);
|
||||
|
||||
return (
|
||||
<EuiFlyoutHeader hasBorder={hasBorder}>
|
||||
{hostDetailsLoading ? (
|
||||
<EuiLoadingContent lines={1} />
|
||||
) : (
|
||||
<EuiToolTip content={hostname} anchorClassName="eui-textTruncate">
|
||||
<EuiTitle>
|
||||
<h2
|
||||
style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
data-test-subj="endpointDetailsFlyoutTitle"
|
||||
>
|
||||
{hostname}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiToolTip>
|
||||
)}
|
||||
{children}
|
||||
</EuiFlyoutHeader>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
EndpointDetailsFlyoutHeader.displayName = 'EndpointDetailsFlyoutHeader';
|
|
@ -78,7 +78,7 @@ const useLogEntryUIProps = (
|
|||
if (isSuccessful) {
|
||||
return i18.ACTIVITY_LOG.LogEntry.response.isolationSuccessful;
|
||||
} else {
|
||||
return i18.ACTIVITY_LOG.LogEntry.response.isolationSuccessful;
|
||||
return i18.ACTIVITY_LOG.LogEntry.response.isolationFailed;
|
||||
}
|
||||
} else {
|
||||
if (isSuccessful) {
|
||||
|
|
|
@ -5,11 +5,19 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { EuiButton, EuiEmptyPrompt, EuiLoadingContent, EuiSpacer } from '@elastic/eui';
|
||||
import {
|
||||
EuiText,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiLoadingContent,
|
||||
EuiEmptyPrompt,
|
||||
} from '@elastic/eui';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { LogEntry } from './components/log_entry';
|
||||
import * as i18 from '../translations';
|
||||
import { Immutable, ActivityLog } from '../../../../../../common/endpoint/types';
|
||||
import { AsyncResourceState } from '../../../../state';
|
||||
import { useEndpointSelector } from '../hooks';
|
||||
|
@ -19,54 +27,95 @@ import {
|
|||
getActivityLogError,
|
||||
getActivityLogIterableData,
|
||||
getActivityLogRequestLoaded,
|
||||
getLastLoadedActivityLogData,
|
||||
getActivityLogRequestLoading,
|
||||
} from '../../store/selectors';
|
||||
|
||||
const LoadMoreTrigger = styled.div`
|
||||
height: 6px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const EndpointActivityLog = memo(
|
||||
({ activityLog }: { activityLog: AsyncResourceState<Immutable<ActivityLog>> }) => {
|
||||
const activityLogLoading = useEndpointSelector(getActivityLogRequestLoading);
|
||||
const activityLogLoaded = useEndpointSelector(getActivityLogRequestLoaded);
|
||||
const activityLastLogData = useEndpointSelector(getLastLoadedActivityLogData);
|
||||
const activityLogData = useEndpointSelector(getActivityLogIterableData);
|
||||
const activityLogSize = activityLogData.length;
|
||||
const activityLogError = useEndpointSelector(getActivityLogError);
|
||||
const dispatch = useDispatch<(a: EndpointAction) => void>();
|
||||
const { page, pageSize } = useEndpointSelector(getActivityLogDataPaging);
|
||||
const dispatch = useDispatch<(action: EndpointAction) => void>();
|
||||
const { page, pageSize, disabled: isPagingDisabled } = useEndpointSelector(
|
||||
getActivityLogDataPaging
|
||||
);
|
||||
|
||||
const getActivityLog = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'appRequestedEndpointActivityLog',
|
||||
payload: {
|
||||
page: page + 1,
|
||||
pageSize,
|
||||
},
|
||||
});
|
||||
}, [dispatch, page, pageSize]);
|
||||
const loadMoreTrigger = useRef<HTMLInputElement | null>(null);
|
||||
const getActivityLog = useCallback(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
const isTargetIntersecting = entries.some((entry) => entry.isIntersecting);
|
||||
if (isTargetIntersecting && activityLogLoaded && !isPagingDisabled) {
|
||||
dispatch({
|
||||
type: 'appRequestedEndpointActivityLog',
|
||||
payload: {
|
||||
page: page + 1,
|
||||
pageSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[activityLogLoaded, dispatch, isPagingDisabled, page, pageSize]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(getActivityLog);
|
||||
const element = loadMoreTrigger.current;
|
||||
if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [getActivityLog]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer size="l" />
|
||||
{activityLogLoading || activityLogError ? (
|
||||
<EuiEmptyPrompt
|
||||
iconType="editorUnorderedList"
|
||||
titleSize="s"
|
||||
title={<h2>{'No logged actions'}</h2>}
|
||||
body={<p>{'No actions have been logged for this endpoint.'}</p>}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<EuiSpacer size="l" />
|
||||
{activityLogLoading ? (
|
||||
<EuiLoadingContent lines={3} />
|
||||
) : (
|
||||
activityLogLoaded &&
|
||||
activityLogData.map((logEntry) => (
|
||||
<LogEntry key={`${logEntry.item.id}`} logEntry={logEntry} />
|
||||
))
|
||||
)}
|
||||
<EuiButton size="s" fill onClick={getActivityLog}>
|
||||
{'show more'}
|
||||
</EuiButton>
|
||||
</>
|
||||
)}
|
||||
<EuiFlexGroup direction="column" style={{ height: '85vh' }}>
|
||||
{(activityLogLoaded && !activityLogSize) || activityLogError ? (
|
||||
<EuiFlexItem>
|
||||
<EuiEmptyPrompt
|
||||
iconType="editorUnorderedList"
|
||||
titleSize="s"
|
||||
title={<h2>{i18.ACTIVITY_LOG.LogEntry.emptyState.title}</h2>}
|
||||
body={<p>{i18.ACTIVITY_LOG.LogEntry.emptyState.body}</p>}
|
||||
data-test-subj="activityLogEmpty"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
) : (
|
||||
<>
|
||||
<EuiFlexItem grow={true}>
|
||||
{activityLogLoaded &&
|
||||
activityLogData.map((logEntry) => (
|
||||
<LogEntry key={`${logEntry.item.id}`} logEntry={logEntry} />
|
||||
))}
|
||||
{activityLogLoading &&
|
||||
activityLastLogData?.data.map((logEntry) => (
|
||||
<LogEntry key={`${logEntry.item.id}`} logEntry={logEntry} />
|
||||
))}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
{activityLogLoading && <EuiLoadingContent lines={3} />}
|
||||
{(!activityLogLoading || !isPagingDisabled) && (
|
||||
<LoadMoreTrigger ref={loadMoreTrigger} />
|
||||
)}
|
||||
{isPagingDisabled && !activityLogLoading && (
|
||||
<EuiText color="subdued" textAlign="center">
|
||||
<p>{i18.ACTIVITY_LOG.LogEntry.endOfLog}</p>
|
||||
</EuiText>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -20,7 +20,6 @@ export const dummyEndpointActivityLog = (
|
|||
): AsyncResourceState<Immutable<ActivityLog>> => ({
|
||||
type: 'LoadedResourceState',
|
||||
data: {
|
||||
total: 20,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
data: [
|
||||
|
|
|
@ -5,21 +5,16 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import React, { useCallback, useEffect, useMemo, memo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutHeader,
|
||||
EuiFlyoutFooter,
|
||||
EuiLoadingContent,
|
||||
EuiTitle,
|
||||
EuiText,
|
||||
EuiSpacer,
|
||||
EuiEmptyPrompt,
|
||||
EuiToolTip,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
} from '@elastic/eui';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
@ -30,7 +25,6 @@ import {
|
|||
uiQueryParams,
|
||||
detailsData,
|
||||
detailsError,
|
||||
detailsLoading,
|
||||
getActivityLogData,
|
||||
showView,
|
||||
policyResponseConfigurations,
|
||||
|
@ -59,23 +53,12 @@ import { BackToEndpointDetailsFlyoutSubHeader } from './components/back_to_endpo
|
|||
import { FlyoutBodyNoTopPadding } from './components/flyout_body_no_top_padding';
|
||||
import { getEndpointListPath } from '../../../../common/routing';
|
||||
import { ActionsMenu } from './components/actions_menu';
|
||||
|
||||
const DetailsFlyoutBody = styled(EuiFlyoutBody)`
|
||||
overflow-y: hidden;
|
||||
flex: 1;
|
||||
|
||||
.euiFlyoutBody__overflow {
|
||||
overflow: hidden;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
.euiFlyoutBody__overflowContent {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
import { EndpointIndexUIQueryParams } from '../../types';
|
||||
import { EndpointAction } from '../../store/action';
|
||||
import { EndpointDetailsFlyoutHeader } from './components/flyout_header';
|
||||
|
||||
export const EndpointDetailsFlyout = memo(() => {
|
||||
const dispatch = useDispatch<(action: EndpointAction) => void>();
|
||||
const history = useHistory();
|
||||
const toasts = useToasts();
|
||||
const queryParams = useEndpointSelector(uiQueryParams);
|
||||
|
@ -86,13 +69,24 @@ export const EndpointDetailsFlyout = memo(() => {
|
|||
|
||||
const activityLog = useEndpointSelector(getActivityLogData);
|
||||
const hostDetails = useEndpointSelector(detailsData);
|
||||
const hostDetailsLoading = useEndpointSelector(detailsLoading);
|
||||
const hostDetailsError = useEndpointSelector(detailsError);
|
||||
|
||||
const policyInfo = useEndpointSelector(policyVersionInfo);
|
||||
const hostStatus = useEndpointSelector(hostStatusInfo);
|
||||
const show = useEndpointSelector(showView);
|
||||
|
||||
const setFlyoutView = useCallback(
|
||||
(flyoutView: EndpointIndexUIQueryParams['show']) => {
|
||||
dispatch({
|
||||
type: 'endpointDetailsFlyoutTabChanged',
|
||||
payload: {
|
||||
flyoutView,
|
||||
},
|
||||
});
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const ContentLoadingMarkup = useMemo(
|
||||
() => (
|
||||
<>
|
||||
|
@ -133,9 +127,11 @@ export const EndpointDetailsFlyout = memo(() => {
|
|||
...urlSearchParams,
|
||||
})
|
||||
);
|
||||
}, [history, queryParamsWithoutSelectedEndpoint]);
|
||||
setFlyoutView(undefined);
|
||||
}, [setFlyoutView, history, queryParamsWithoutSelectedEndpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
setFlyoutView(show);
|
||||
if (hostDetailsError !== undefined) {
|
||||
toasts.addDanger({
|
||||
title: i18n.translate('xpack.securitySolution.endpoint.details.errorTitle', {
|
||||
|
@ -146,7 +142,10 @@ export const EndpointDetailsFlyout = memo(() => {
|
|||
}),
|
||||
});
|
||||
}
|
||||
}, [hostDetailsError, toasts]);
|
||||
return () => {
|
||||
setFlyoutView(undefined);
|
||||
};
|
||||
}, [hostDetailsError, setFlyoutView, show, toasts]);
|
||||
|
||||
return (
|
||||
<EuiFlyout
|
||||
|
@ -156,22 +155,9 @@ export const EndpointDetailsFlyout = memo(() => {
|
|||
size="m"
|
||||
paddingSize="l"
|
||||
>
|
||||
<EuiFlyoutHeader>
|
||||
{hostDetailsLoading ? (
|
||||
<EuiLoadingContent lines={1} />
|
||||
) : (
|
||||
<EuiToolTip content={hostDetails?.host?.hostname} anchorClassName="eui-textTruncate">
|
||||
<EuiTitle>
|
||||
<h2
|
||||
style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
data-test-subj="endpointDetailsFlyoutTitle"
|
||||
>
|
||||
{hostDetails?.host?.hostname}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiToolTip>
|
||||
)}
|
||||
</EuiFlyoutHeader>
|
||||
{(show === 'policy_response' || show === 'isolate' || show === 'unisolate') && (
|
||||
<EndpointDetailsFlyoutHeader hostname={hostDetails?.host?.hostname} />
|
||||
)}
|
||||
{hostDetails === undefined ? (
|
||||
<EuiFlyoutBody>
|
||||
<EuiLoadingContent lines={3} /> <EuiSpacer size="l" /> <EuiLoadingContent lines={3} />
|
||||
|
@ -179,13 +165,11 @@ export const EndpointDetailsFlyout = memo(() => {
|
|||
) : (
|
||||
<>
|
||||
{(show === 'details' || show === 'activity_log') && (
|
||||
<DetailsFlyoutBody data-test-subj="endpointDetailsFlyoutBody">
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EndpointDetailsFlyoutTabs show={show} tabs={tabs} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</DetailsFlyoutBody>
|
||||
<EndpointDetailsFlyoutTabs
|
||||
hostname={hostDetails?.host?.hostname}
|
||||
show={show}
|
||||
tabs={tabs}
|
||||
/>
|
||||
)}
|
||||
|
||||
{show === 'policy_response' && <PolicyResponseFlyoutPanel hostMeta={hostDetails} />}
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from '../store/mock_endpoint_result_list';
|
||||
import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint';
|
||||
import {
|
||||
ActivityLog,
|
||||
HostInfo,
|
||||
HostPolicyResponse,
|
||||
HostPolicyResponseActionStatus,
|
||||
|
@ -32,12 +33,15 @@ import { KibanaServices, useKibana, useToasts } from '../../../../common/lib/kib
|
|||
import { hostIsolationHttpMocks } from '../../../../common/lib/endpoint_isolation/mocks';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import {
|
||||
createFailedResourceState,
|
||||
createLoadedResourceState,
|
||||
isFailedResourceState,
|
||||
isLoadedResourceState,
|
||||
isUninitialisedResourceState,
|
||||
} from '../../../state';
|
||||
import { getCurrentIsolationRequestState } from '../store/selectors';
|
||||
import { licenseService } from '../../../../common/hooks/use_license';
|
||||
import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator';
|
||||
|
||||
// not sure why this can't be imported from '../../../../common/mock/formatted_relative';
|
||||
// but sure enough it needs to be inline in this one file
|
||||
|
@ -625,6 +629,30 @@ describe('when on the endpoint list page', () => {
|
|||
});
|
||||
};
|
||||
|
||||
const dispatchEndpointDetailsActivityLogChanged = (
|
||||
dataState: 'failed' | 'success',
|
||||
data: ActivityLog
|
||||
) => {
|
||||
reactTestingLibrary.act(() => {
|
||||
const getPayload = () => {
|
||||
switch (dataState) {
|
||||
case 'failed':
|
||||
return createFailedResourceState({
|
||||
statusCode: 500,
|
||||
error: 'Internal Server Error',
|
||||
message: 'An internal server error occurred.',
|
||||
});
|
||||
case 'success':
|
||||
return createLoadedResourceState(data);
|
||||
}
|
||||
};
|
||||
store.dispatch({
|
||||
type: 'endpointDetailsActivityLogChanged',
|
||||
payload: getPayload(),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockEndpointListApi();
|
||||
|
||||
|
@ -746,6 +774,120 @@ describe('when on the endpoint list page', () => {
|
|||
expect(renderResult.getByTestId('endpointDetailsActionsButton')).not.toBeNull();
|
||||
});
|
||||
|
||||
describe('when showing Activity Log panel', () => {
|
||||
let renderResult: ReturnType<typeof render>;
|
||||
const agentId = 'some_agent_id';
|
||||
|
||||
let getMockData: () => ActivityLog;
|
||||
beforeEach(async () => {
|
||||
window.IntersectionObserver = jest.fn(() => ({
|
||||
root: null,
|
||||
rootMargin: '',
|
||||
thresholds: [],
|
||||
takeRecords: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
}));
|
||||
|
||||
const fleetActionGenerator = new FleetActionGenerator('seed');
|
||||
const responseData = fleetActionGenerator.generateResponse({
|
||||
agent_id: agentId,
|
||||
});
|
||||
const actionData = fleetActionGenerator.generate({
|
||||
agents: [agentId],
|
||||
});
|
||||
getMockData = () => ({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
data: [
|
||||
{
|
||||
type: 'response',
|
||||
item: {
|
||||
id: 'some_id_0',
|
||||
data: responseData,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
item: {
|
||||
id: 'some_id_1',
|
||||
data: actionData,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderResult = render();
|
||||
await reactTestingLibrary.act(async () => {
|
||||
await middlewareSpy.waitForAction('serverReturnedEndpointList');
|
||||
});
|
||||
const hostNameLinks = await renderResult.getAllByTestId('hostnameCellLink');
|
||||
reactTestingLibrary.fireEvent.click(hostNameLinks[0]);
|
||||
});
|
||||
|
||||
afterEach(reactTestingLibrary.cleanup);
|
||||
|
||||
it('should show the endpoint details flyout', async () => {
|
||||
const activityLogTab = await renderResult.findByTestId('activity_log');
|
||||
reactTestingLibrary.act(() => {
|
||||
reactTestingLibrary.fireEvent.click(activityLogTab);
|
||||
});
|
||||
await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged');
|
||||
reactTestingLibrary.act(() => {
|
||||
dispatchEndpointDetailsActivityLogChanged('success', getMockData());
|
||||
});
|
||||
const endpointDetailsFlyout = await renderResult.queryByTestId('endpointDetailsFlyoutBody');
|
||||
expect(endpointDetailsFlyout).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should display log accurately', async () => {
|
||||
const activityLogTab = await renderResult.findByTestId('activity_log');
|
||||
reactTestingLibrary.act(() => {
|
||||
reactTestingLibrary.fireEvent.click(activityLogTab);
|
||||
});
|
||||
await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged');
|
||||
reactTestingLibrary.act(() => {
|
||||
dispatchEndpointDetailsActivityLogChanged('success', getMockData());
|
||||
});
|
||||
const logEntries = await renderResult.queryAllByTestId('timelineEntry');
|
||||
expect(logEntries.length).toEqual(2);
|
||||
expect(`${logEntries[0]} .euiCommentTimeline__icon--update`).not.toBe(null);
|
||||
expect(`${logEntries[1]} .euiCommentTimeline__icon--regular`).not.toBe(null);
|
||||
});
|
||||
|
||||
it('should display empty state when API call has failed', async () => {
|
||||
const activityLogTab = await renderResult.findByTestId('activity_log');
|
||||
reactTestingLibrary.act(() => {
|
||||
reactTestingLibrary.fireEvent.click(activityLogTab);
|
||||
});
|
||||
await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged');
|
||||
reactTestingLibrary.act(() => {
|
||||
dispatchEndpointDetailsActivityLogChanged('failed', getMockData());
|
||||
});
|
||||
const emptyState = await renderResult.queryByTestId('activityLogEmpty');
|
||||
expect(emptyState).not.toBe(null);
|
||||
});
|
||||
|
||||
it('should display empty state when no log data', async () => {
|
||||
const activityLogTab = await renderResult.findByTestId('activity_log');
|
||||
reactTestingLibrary.act(() => {
|
||||
reactTestingLibrary.fireEvent.click(activityLogTab);
|
||||
});
|
||||
await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged');
|
||||
reactTestingLibrary.act(() => {
|
||||
dispatchEndpointDetailsActivityLogChanged('success', {
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
data: [],
|
||||
});
|
||||
});
|
||||
|
||||
const emptyState = await renderResult.queryByTestId('activityLogEmpty');
|
||||
expect(emptyState).not.toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when showing host Policy Response panel', () => {
|
||||
let renderResult: ReturnType<typeof render>;
|
||||
beforeEach(async () => {
|
||||
|
|
|
@ -16,6 +16,26 @@ export const ACTIVITY_LOG = {
|
|||
defaultMessage: 'Activity Log',
|
||||
}),
|
||||
LogEntry: {
|
||||
endOfLog: i18n.translate(
|
||||
'xpack.securitySolution.endpointDetails.activityLog.logEntry.action.endOfLog',
|
||||
{
|
||||
defaultMessage: 'Nothing more to show',
|
||||
}
|
||||
),
|
||||
emptyState: {
|
||||
title: i18n.translate(
|
||||
'xpack.securitySolution.endpointDetails.activityLog.logEntry.emptyState.title',
|
||||
{
|
||||
defaultMessage: 'No logged actions',
|
||||
}
|
||||
),
|
||||
body: i18n.translate(
|
||||
'xpack.securitySolution.endpointDetails.activityLog.logEntry.emptyState.body',
|
||||
{
|
||||
defaultMessage: 'No actions have been logged for this endpoint.',
|
||||
}
|
||||
),
|
||||
},
|
||||
action: {
|
||||
isolatedAction: i18n.translate(
|
||||
'xpack.securitySolution.endpointDetails.activityLog.logEntry.action.isolated',
|
||||
|
|
|
@ -56,7 +56,6 @@ export const getAuditLogResponse = async ({
|
|||
context: SecuritySolutionRequestHandlerContext;
|
||||
logger: Logger;
|
||||
}): Promise<{
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
data: Array<{
|
||||
|
@ -96,10 +95,6 @@ export const getAuditLogResponse = async ({
|
|||
}
|
||||
|
||||
return {
|
||||
total:
|
||||
typeof result.body.hits.total === 'number'
|
||||
? result.body.hits.total
|
||||
: result.body.hits.total.value,
|
||||
page,
|
||||
pageSize,
|
||||
data: result.body.hits.hits.map((e) => ({
|
||||
|
|
Loading…
Reference in a new issue