[APM] Removing Missing permission page (#72030)
* removing missing permission page * removing translations
This commit is contained in:
parent
77d6c4e5fe
commit
22365de6ed
|
@ -26,7 +26,6 @@ import {
|
|||
} from '../../../../../src/plugins/kibana_react/public';
|
||||
import { px, units } from '../style/variables';
|
||||
import { UpdateBreadcrumbs } from '../components/app/Main/UpdateBreadcrumbs';
|
||||
import { APMIndicesPermission } from '../components/app/APMIndicesPermission';
|
||||
import { ScrollToTopOnPathChange } from '../components/app/Main/ScrollToTopOnPathChange';
|
||||
import { routes } from '../components/app/Main/route_config';
|
||||
import { history, resetHistory } from '../utils/history';
|
||||
|
@ -52,13 +51,11 @@ const App = () => {
|
|||
<MainContainer data-test-subj="apmMainContainer" role="main">
|
||||
<UpdateBreadcrumbs routes={routes} />
|
||||
<Route component={ScrollToTopOnPathChange} />
|
||||
<APMIndicesPermission>
|
||||
<Switch>
|
||||
{routes.map((route, i) => (
|
||||
<ApmRoute key={i} {...route} />
|
||||
))}
|
||||
</Switch>
|
||||
</APMIndicesPermission>
|
||||
</MainContainer>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
|
|
@ -1,151 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, fireEvent, act } from '@testing-library/react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { APMIndicesPermission } from './';
|
||||
|
||||
import * as hooks from '../../../hooks/useFetcher';
|
||||
import {
|
||||
expectTextsInDocument,
|
||||
expectTextsNotInDocument,
|
||||
} from '../../../utils/testHelpers';
|
||||
import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext';
|
||||
|
||||
describe('APMIndicesPermission', () => {
|
||||
it('returns empty component when api status is loading', () => {
|
||||
jest.spyOn(hooks, 'useFetcher').mockReturnValue({
|
||||
status: hooks.FETCH_STATUS.LOADING,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
const component = shallow(<APMIndicesPermission />);
|
||||
expect(component.isEmptyRender()).toBeTruthy();
|
||||
});
|
||||
it('returns empty component when api status is pending', () => {
|
||||
jest.spyOn(hooks, 'useFetcher').mockReturnValue({
|
||||
status: hooks.FETCH_STATUS.PENDING,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
const component = shallow(<APMIndicesPermission />);
|
||||
expect(component.isEmptyRender()).toBeTruthy();
|
||||
});
|
||||
it('renders missing permission page', () => {
|
||||
jest.spyOn(hooks, 'useFetcher').mockReturnValue({
|
||||
status: hooks.FETCH_STATUS.SUCCESS,
|
||||
data: {
|
||||
has_all_requested: false,
|
||||
index: {
|
||||
'apm-*': { read: false },
|
||||
},
|
||||
},
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
const component = render(
|
||||
<MockApmPluginContextWrapper>
|
||||
<APMIndicesPermission />
|
||||
</MockApmPluginContextWrapper>
|
||||
);
|
||||
expectTextsInDocument(component, [
|
||||
'Missing permissions to access APM',
|
||||
'Dismiss',
|
||||
'apm-*',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows children component when no index is returned', () => {
|
||||
jest.spyOn(hooks, 'useFetcher').mockReturnValue({
|
||||
status: hooks.FETCH_STATUS.SUCCESS,
|
||||
data: {
|
||||
has_all_requested: false,
|
||||
index: {},
|
||||
},
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
const component = render(
|
||||
<MockApmPluginContextWrapper>
|
||||
<APMIndicesPermission>
|
||||
<p>My amazing component</p>
|
||||
</APMIndicesPermission>
|
||||
</MockApmPluginContextWrapper>
|
||||
);
|
||||
expectTextsNotInDocument(component, ['Missing permissions to access APM']);
|
||||
expectTextsInDocument(component, ['My amazing component']);
|
||||
});
|
||||
|
||||
it('shows children component when indices have read privileges', () => {
|
||||
jest.spyOn(hooks, 'useFetcher').mockReturnValue({
|
||||
status: hooks.FETCH_STATUS.SUCCESS,
|
||||
data: {
|
||||
has_all_requested: true,
|
||||
index: {},
|
||||
},
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
const component = render(
|
||||
<MockApmPluginContextWrapper>
|
||||
<APMIndicesPermission>
|
||||
<p>My amazing component</p>
|
||||
</APMIndicesPermission>
|
||||
</MockApmPluginContextWrapper>
|
||||
);
|
||||
expectTextsNotInDocument(component, ['Missing permissions to access APM']);
|
||||
expectTextsInDocument(component, ['My amazing component']);
|
||||
});
|
||||
|
||||
it('dismesses the warning by clicking on the escape hatch', () => {
|
||||
jest.spyOn(hooks, 'useFetcher').mockReturnValue({
|
||||
status: hooks.FETCH_STATUS.SUCCESS,
|
||||
data: {
|
||||
has_all_requested: false,
|
||||
index: {
|
||||
'apm-error-*': { read: false },
|
||||
'apm-trasanction-*': { read: false },
|
||||
'apm-metrics-*': { read: true },
|
||||
'apm-span-*': { read: true },
|
||||
},
|
||||
},
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
const component = render(
|
||||
<MockApmPluginContextWrapper>
|
||||
<APMIndicesPermission>
|
||||
<p>My amazing component</p>
|
||||
</APMIndicesPermission>
|
||||
</MockApmPluginContextWrapper>
|
||||
);
|
||||
expectTextsInDocument(component, [
|
||||
'Dismiss',
|
||||
'apm-error-*',
|
||||
'apm-trasanction-*',
|
||||
]);
|
||||
act(() => {
|
||||
fireEvent.click(component.getByText('Dismiss'));
|
||||
});
|
||||
expectTextsInDocument(component, ['My amazing component']);
|
||||
});
|
||||
|
||||
it("shows children component when api doesn't return value", () => {
|
||||
jest.spyOn(hooks, 'useFetcher').mockReturnValue({
|
||||
status: hooks.FETCH_STATUS.SUCCESS,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
const component = render(
|
||||
<MockApmPluginContextWrapper>
|
||||
<APMIndicesPermission>
|
||||
<p>My amazing component</p>
|
||||
</APMIndicesPermission>
|
||||
</MockApmPluginContextWrapper>
|
||||
);
|
||||
expectTextsNotInDocument(component, [
|
||||
'Missing permissions to access APM',
|
||||
'apm-7.5.1-error-*',
|
||||
'apm-7.5.1-metric-*',
|
||||
'apm-7.5.1-transaction-*',
|
||||
'apm-7.5.1-span-*',
|
||||
]);
|
||||
expectTextsInDocument(component, ['My amazing component']);
|
||||
});
|
||||
});
|
|
@ -1,167 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiEmptyPrompt,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiLink,
|
||||
EuiPanel,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher';
|
||||
import { fontSize, pct, px, units } from '../../../style/variables';
|
||||
import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink';
|
||||
import { SetupInstructionsLink } from '../../shared/Links/SetupInstructionsLink';
|
||||
|
||||
export const APMIndicesPermission: React.FC = ({ children }) => {
|
||||
const [
|
||||
isPermissionWarningDismissed,
|
||||
setIsPermissionWarningDismissed,
|
||||
] = useState(false);
|
||||
|
||||
const { data: indicesPrivileges, status } = useFetcher((callApmApi) => {
|
||||
return callApmApi({
|
||||
pathname: '/api/apm/security/indices_privileges',
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Return null until receive the reponse of the api.
|
||||
if (status === FETCH_STATUS.LOADING || status === FETCH_STATUS.PENDING) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show permission warning when a user has at least one index without Read privilege,
|
||||
// and they have not manually dismissed the warning
|
||||
if (
|
||||
indicesPrivileges &&
|
||||
!indicesPrivileges.has_all_requested &&
|
||||
!isEmpty(indicesPrivileges.index) &&
|
||||
!isPermissionWarningDismissed
|
||||
) {
|
||||
const indicesWithoutPermission = Object.keys(
|
||||
indicesPrivileges.index
|
||||
).filter((index) => !indicesPrivileges.index[index].read);
|
||||
return (
|
||||
<PermissionWarning
|
||||
indicesWithoutPermission={indicesWithoutPermission}
|
||||
onEscapeHatchClick={() => setIsPermissionWarningDismissed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const CentralizedContainer = styled.div`
|
||||
height: ${pct(100)};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const EscapeHatch = styled.div`
|
||||
width: ${pct(100)};
|
||||
margin-top: ${px(units.plus)};
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
indicesWithoutPermission: string[];
|
||||
onEscapeHatchClick: () => void;
|
||||
}
|
||||
|
||||
const PermissionWarning = ({
|
||||
indicesWithoutPermission,
|
||||
onEscapeHatchClick,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div style={{ height: pct(95) }}>
|
||||
<EuiFlexGroup alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="l">
|
||||
<h1>
|
||||
{i18n.translate('xpack.apm.permission.apm', {
|
||||
defaultMessage: 'APM',
|
||||
})}
|
||||
</h1>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<SetupInstructionsLink />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<CentralizedContainer>
|
||||
<div>
|
||||
<EuiPanel paddingSize="none">
|
||||
<EuiEmptyPrompt
|
||||
iconType="apmApp"
|
||||
iconColor={''}
|
||||
title={
|
||||
<h2>
|
||||
{i18n.translate('xpack.apm.permission.title', {
|
||||
defaultMessage: 'Missing permissions to access APM',
|
||||
})}
|
||||
</h2>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
{i18n.translate('xpack.apm.permission.description', {
|
||||
defaultMessage:
|
||||
"Your user doesn't have access to all APM indices. You can still use the APM app but some data may be missing. You must be granted access to the following indices:",
|
||||
})}
|
||||
</p>
|
||||
<ul style={{ listStyleType: 'none' }}>
|
||||
{indicesWithoutPermission.map((index) => (
|
||||
<li key={index} style={{ marginTop: units.half }}>
|
||||
<EuiText size="s">{index}</EuiText>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<ElasticDocsLink
|
||||
section="/apm/server"
|
||||
path="/feature-roles.html"
|
||||
>
|
||||
{(href: string) => (
|
||||
<EuiButton color="primary" fill href={href}>
|
||||
{i18n.translate('xpack.apm.permission.learnMore', {
|
||||
defaultMessage: 'Learn more about APM permissions',
|
||||
})}
|
||||
</EuiButton>
|
||||
)}
|
||||
</ElasticDocsLink>
|
||||
<EscapeHatch>
|
||||
<EuiLink
|
||||
color="subdued"
|
||||
onClick={onEscapeHatchClick}
|
||||
style={{ fontSize }}
|
||||
>
|
||||
{i18n.translate('xpack.apm.permission.dismissWarning', {
|
||||
defaultMessage: 'Dismiss',
|
||||
})}
|
||||
</EuiLink>
|
||||
</EscapeHatch>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</div>
|
||||
</CentralizedContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,148 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { Setup } from '../helpers/setup_request';
|
||||
import { getIndicesPrivileges } from './get_indices_privileges';
|
||||
|
||||
describe('getIndicesPrivileges', () => {
|
||||
const indices = {
|
||||
apm_oss: {
|
||||
errorIndices: 'apm-*',
|
||||
metricsIndices: 'apm-*',
|
||||
transactionIndices: 'apm-*',
|
||||
spanIndices: 'apm-*',
|
||||
},
|
||||
};
|
||||
it('return that the user has privileges when security plugin is disabled', async () => {
|
||||
const setup = ({
|
||||
indices,
|
||||
client: {
|
||||
hasPrivileges: () => {
|
||||
const error = {
|
||||
message:
|
||||
'no handler found for uri [/_security/user/_has_privileges]',
|
||||
statusCode: 400,
|
||||
};
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
} as unknown) as Setup;
|
||||
const privileges = await getIndicesPrivileges({
|
||||
setup,
|
||||
isSecurityPluginEnabled: false,
|
||||
});
|
||||
expect(privileges).toEqual({
|
||||
has_all_requested: true,
|
||||
index: {},
|
||||
});
|
||||
});
|
||||
it('throws when an error happens while fetching indices privileges', async () => {
|
||||
const setup = ({
|
||||
indices,
|
||||
client: {
|
||||
hasPrivileges: () => {
|
||||
throw new Error('unknow error');
|
||||
},
|
||||
},
|
||||
} as unknown) as Setup;
|
||||
await expect(
|
||||
getIndicesPrivileges({ setup, isSecurityPluginEnabled: true })
|
||||
).rejects.toThrowError('unknow error');
|
||||
});
|
||||
it("has privileges to read from 'apm-*'", async () => {
|
||||
const setup = ({
|
||||
indices,
|
||||
client: {
|
||||
hasPrivileges: () => {
|
||||
return Promise.resolve({
|
||||
has_all_requested: true,
|
||||
index: { 'apm-*': { read: true } },
|
||||
});
|
||||
},
|
||||
},
|
||||
} as unknown) as Setup;
|
||||
const privileges = await getIndicesPrivileges({
|
||||
setup,
|
||||
isSecurityPluginEnabled: true,
|
||||
});
|
||||
|
||||
expect(privileges).toEqual({
|
||||
has_all_requested: true,
|
||||
index: {
|
||||
'apm-*': {
|
||||
read: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't have privileges to read from 'apm-*'", async () => {
|
||||
const setup = ({
|
||||
indices,
|
||||
client: {
|
||||
hasPrivileges: () => {
|
||||
return Promise.resolve({
|
||||
has_all_requested: false,
|
||||
index: { 'apm-*': { read: false } },
|
||||
});
|
||||
},
|
||||
},
|
||||
} as unknown) as Setup;
|
||||
|
||||
const privileges = await getIndicesPrivileges({
|
||||
setup,
|
||||
isSecurityPluginEnabled: true,
|
||||
});
|
||||
|
||||
expect(privileges).toEqual({
|
||||
has_all_requested: false,
|
||||
index: {
|
||||
'apm-*': {
|
||||
read: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
it("doesn't have privileges on multiple indices", async () => {
|
||||
const setup = ({
|
||||
indices: {
|
||||
apm_oss: {
|
||||
errorIndices: 'apm-error-*',
|
||||
metricsIndices: 'apm-metrics-*',
|
||||
transactionIndices: 'apm-trasanction-*',
|
||||
spanIndices: 'apm-span-*',
|
||||
},
|
||||
},
|
||||
client: {
|
||||
hasPrivileges: () => {
|
||||
return Promise.resolve({
|
||||
has_all_requested: false,
|
||||
index: {
|
||||
'apm-error-*': { read: false },
|
||||
'apm-trasanction-*': { read: false },
|
||||
'apm-metrics-*': { read: true },
|
||||
'apm-span-*': { read: true },
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
} as unknown) as Setup;
|
||||
|
||||
const privileges = await getIndicesPrivileges({
|
||||
setup,
|
||||
isSecurityPluginEnabled: true,
|
||||
});
|
||||
|
||||
expect(privileges).toEqual({
|
||||
has_all_requested: false,
|
||||
index: {
|
||||
'apm-error-*': { read: false },
|
||||
'apm-trasanction-*': { read: false },
|
||||
'apm-metrics-*': { read: true },
|
||||
'apm-span-*': { read: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,36 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { Setup } from '../helpers/setup_request';
|
||||
import { IndexPrivileges } from '../helpers/es_client';
|
||||
|
||||
export async function getIndicesPrivileges({
|
||||
setup,
|
||||
isSecurityPluginEnabled,
|
||||
}: {
|
||||
setup: Setup;
|
||||
isSecurityPluginEnabled: boolean;
|
||||
}): Promise<IndexPrivileges> {
|
||||
// When security plugin is not enabled, returns that the user has all requested privileges.
|
||||
if (!isSecurityPluginEnabled) {
|
||||
return { has_all_requested: true, index: {} };
|
||||
}
|
||||
|
||||
const { client, indices } = setup;
|
||||
const response = await client.hasPrivileges({
|
||||
index: [
|
||||
{
|
||||
names: [
|
||||
indices['apm_oss.errorIndices'],
|
||||
indices['apm_oss.metricsIndices'],
|
||||
indices['apm_oss.transactionIndices'],
|
||||
indices['apm_oss.spanIndices'],
|
||||
],
|
||||
privileges: ['read'],
|
||||
},
|
||||
],
|
||||
});
|
||||
return response;
|
||||
}
|
|
@ -63,7 +63,6 @@ import {
|
|||
} from './ui_filters';
|
||||
import { createApi } from './create_api';
|
||||
import { serviceMapRoute, serviceMapServiceNodeRoute } from './service_map';
|
||||
import { indicesPrivilegesRoute } from './security';
|
||||
import {
|
||||
createCustomLinkRoute,
|
||||
updateCustomLinkRoute,
|
||||
|
@ -158,9 +157,6 @@ const createApmApi = () => {
|
|||
.add(serviceMapRoute)
|
||||
.add(serviceMapServiceNodeRoute)
|
||||
|
||||
// security
|
||||
.add(indicesPrivilegesRoute)
|
||||
|
||||
// Custom links
|
||||
.add(createCustomLinkRoute)
|
||||
.add(updateCustomLinkRoute)
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { createRoute } from './create_route';
|
||||
import { setupRequest } from '../lib/helpers/setup_request';
|
||||
import { getIndicesPrivileges } from '../lib/security/get_indices_privileges';
|
||||
|
||||
export const indicesPrivilegesRoute = createRoute(() => ({
|
||||
path: '/api/apm/security/indices_privileges',
|
||||
handler: async ({ context, request }) => {
|
||||
const setup = await setupRequest(context, request);
|
||||
return getIndicesPrivileges({
|
||||
setup,
|
||||
isSecurityPluginEnabled:
|
||||
context.plugins.security?.license.isEnabled() ?? false,
|
||||
});
|
||||
},
|
||||
}));
|
|
@ -4258,11 +4258,6 @@
|
|||
"xpack.apm.metrics.transactionChart.transactionsPerMinuteLabel": "1 分あたりのトランザクション数",
|
||||
"xpack.apm.notAvailableLabel": "N/A",
|
||||
"xpack.apm.percentOfParent": "({parentType, select, transaction { 件中 {value} 件のトランザクション} トレース {trace} })",
|
||||
"xpack.apm.permission.apm": "APM",
|
||||
"xpack.apm.permission.description": "このユーザーには、すべての APM インデックスへのアクセス権がありません。APM アプリを使用できますが、一部のデータが欠けることがあります。以下のインデックスへのアクセス権が必要です。",
|
||||
"xpack.apm.permission.dismissWarning": "閉じる",
|
||||
"xpack.apm.permission.learnMore": "APM パーミッションの詳細を表示",
|
||||
"xpack.apm.permission.title": "APM へのアクセス権がありません",
|
||||
"xpack.apm.propertiesTable.agentFeature.noDataAvailableLabel": "利用可能なデータがありません",
|
||||
"xpack.apm.propertiesTable.agentFeature.noResultFound": "\"{value}\"に対する結果が見つかりませんでした。",
|
||||
"xpack.apm.propertiesTable.tabs.exceptionStacktraceLabel": "例外のスタックトレース",
|
||||
|
|
|
@ -4262,11 +4262,6 @@
|
|||
"xpack.apm.metrics.transactionChart.transactionsPerMinuteLabel": "每分钟事务数",
|
||||
"xpack.apm.notAvailableLabel": "不适用",
|
||||
"xpack.apm.percentOfParent": "({value} 的 {parentType, select, transaction {事务} trace {trace} })",
|
||||
"xpack.apm.permission.apm": "APM",
|
||||
"xpack.apm.permission.description": "您的用户无权访问所有 APM 索引。您仍可以使用 APM 应用,但部分数据可能缺失。您必须获授以下索引的权限:",
|
||||
"xpack.apm.permission.dismissWarning": "关闭",
|
||||
"xpack.apm.permission.learnMore": "详细了解 APM 权限",
|
||||
"xpack.apm.permission.title": "缺少访问 APM 的权限",
|
||||
"xpack.apm.propertiesTable.agentFeature.noDataAvailableLabel": "没有可用数据",
|
||||
"xpack.apm.propertiesTable.agentFeature.noResultFound": "没有“{value}”的结果。",
|
||||
"xpack.apm.propertiesTable.tabs.exceptionStacktraceLabel": "异常堆栈跟踪",
|
||||
|
|
Loading…
Reference in a new issue