[APM] Refactors property tables into single metadata table (#35150) (#35327)

* Refactors property table usage with new components and tests

* Changes default tab for transaction detail view

* Small refactors to property table

* Review feedback

* Updates translations

* Refactors metadata and adds tests

* Rearranges documentation links to prefer declarative component

* Removes unused component

* Improves metadata component tests and removes giant snapshots
This commit is contained in:
Jason Rhodes 2019-04-18 19:59:39 -04:00 committed by GitHub
parent 239f604f94
commit b86e285dab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 987 additions and 1464 deletions

View file

@ -378,6 +378,7 @@
"istanbul-instrumenter-loader": "3.0.1",
"jest": "^24.1.0",
"jest-cli": "^24.1.0",
"jest-dom": "^3.1.3",
"jest-raw-loader": "^1.0.1",
"jimp": "0.2.28",
"json5": "^1.0.1",

View file

@ -8,31 +8,20 @@ import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash';
import { idx } from '../../../../../common/idx';
import { APMError } from '../../../../../typings/es_schemas/ui/APMError';
import {
getTabsFromObject,
PropertyTab
} from '../../../shared/PropertiesTable/tabConfig';
export type ErrorTab = PropertyTab | ExceptionTab | LogTab;
interface LogTab {
key: 'log_stacktrace';
export interface ErrorTab {
key: 'log_stacktrace' | 'exception_stacktrace' | 'metadata';
label: string;
}
export const logStacktraceTab: LogTab = {
export const logStacktraceTab: ErrorTab = {
key: 'log_stacktrace',
label: i18n.translate('xpack.apm.propertiesTable.tabs.logStacktraceLabel', {
defaultMessage: 'Log stacktrace'
})
};
interface ExceptionTab {
key: 'exception_stacktrace';
label: string;
}
export const exceptionStacktraceTab: ExceptionTab = {
export const exceptionStacktraceTab: ErrorTab = {
key: 'exception_stacktrace',
label: i18n.translate(
'xpack.apm.propertiesTable.tabs.exceptionStacktraceLabel',
@ -42,11 +31,18 @@ export const exceptionStacktraceTab: ExceptionTab = {
)
};
export const metadataTab: ErrorTab = {
key: 'metadata',
label: i18n.translate('xpack.apm.propertiesTable.tabs.metadataLabel', {
defaultMessage: 'Metadata'
})
};
export function getTabs(error: APMError) {
const hasLogStacktrace = !isEmpty(idx(error, _ => _.error.log.stacktrace));
return [
...(hasLogStacktrace ? [logStacktraceTab] : []),
exceptionStacktraceTab,
...getTabsFromObject(error)
metadataTab
];
}

View file

@ -75,26 +75,10 @@ exports[`DetailView should render tabs 1`] = `
<EuiTab
disabled={false}
isSelected={false}
key="service"
key="metadata"
onClick={[Function]}
>
Service
</EuiTab>
<EuiTab
disabled={false}
isSelected={false}
key="user"
onClick={[Function]}
>
User
</EuiTab>
<EuiTab
disabled={false}
isSelected={false}
key="labels"
onClick={[Function]}
>
Labels
Metadata
</EuiTab>
</EuiTabs>
`;

View file

@ -14,9 +14,9 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import { get } from 'lodash';
import React from 'react';
import styled from 'styled-components';
import { first } from 'lodash';
import { idx } from '../../../../../common/idx';
import { ErrorGroupAPIResponse } from '../../../../../server/lib/errors/get_error_group';
import { APMError } from '../../../../../typings/es_schemas/ui/APMError';
@ -24,8 +24,7 @@ import { IUrlParams } from '../../../../store/urlParams';
import { px, unit } from '../../../../style/variables';
import { DiscoverErrorLink } from '../../../shared/Links/DiscoverLinks/DiscoverErrorLink';
import { fromQuery, history, toQuery } from '../../../shared/Links/url_helpers';
import { PropertiesTable } from '../../../shared/PropertiesTable';
import { getCurrentTab } from '../../../shared/PropertiesTable/tabConfig';
import { ErrorMetadata } from '../../../shared/MetadataTable/ErrorMetadata';
import { Stacktrace } from '../../../shared/Stacktrace';
import {
ErrorTab,
@ -48,6 +47,15 @@ interface Props {
location: Location;
}
// TODO: Move query-string-based tabs into a re-usable component?
function getCurrentTab<T extends { key: string; label: string }>(
tabs: T[] = [],
currentTabKey: string | undefined
): T {
const selectedTab = tabs.find(({ key }) => key === currentTabKey);
return selectedTab ? selectedTab : first(tabs) || {};
}
export function DetailView({ errorGroup, urlParams, location }: Props) {
const { transaction, error, occurrencesCount } = errorGroup;
@ -124,7 +132,6 @@ export function TabContent({
currentTab: ErrorTab;
}) {
const codeLanguage = error.service.name;
const agentName = error.agent.name;
const excStackframes = idx(error, _ => _.error.exception[0].stacktrace);
const logStackframes = idx(error, _ => _.error.exception[0].stacktrace);
@ -138,13 +145,6 @@ export function TabContent({
<Stacktrace stackframes={excStackframes} codeLanguage={codeLanguage} />
);
default:
const propData = get(error, currentTab.key);
return (
<PropertiesTable
propData={propData}
propKey={currentTab.key}
agentName={agentName}
/>
);
return <ErrorMetadata error={error} />;
}
}

View file

@ -33,9 +33,9 @@ import styled from 'styled-components';
import chrome from 'ui/chrome';
import { toastNotifications } from 'ui/notify';
import { IUrlParams } from '../../../../store/urlParams';
import { XPACK_DOCS } from '../../../../utils/documentation/xpack';
import { KibanaLink } from '../../../shared/Links/KibanaLink';
import { createErrorGroupWatch, Schedule } from './createErrorGroupWatch';
import { ElasticDocsLink } from '../../../shared/Links/ElasticDocsLink';
type ScheduleKey = keyof Schedule;
@ -297,14 +297,18 @@ export class WatcherFlyout extends Component<
To learn more about Watcher, please read our {documentationLink}."
values={{
documentationLink: (
<EuiLink target="_blank" href={XPACK_DOCS.xpackWatcher}>
<ElasticDocsLink
target="_blank"
section="/x-pack"
path="/watcher-getting-started.html"
>
{i18n.translate(
'xpack.apm.serviceDetails.enableErrorReportsPanel.formDescription.documentationLinkText',
{
defaultMessage: 'documentation'
}
)}
</EuiLink>
</ElasticDocsLink>
)
}}
/>
@ -499,14 +503,18 @@ export class WatcherFlyout extends Component<
defaultMessage="If you have not configured email, please see the {documentationLink}."
values={{
documentationLink: (
<EuiLink target="_blank" href={XPACK_DOCS.xpackEmails}>
<ElasticDocsLink
target="_blank"
section="/x-pack"
path="/actions-email.html#configuring-email"
>
{i18n.translate(
'xpack.apm.serviceDetails.enableErrorReportsPanel.recipientsHelpText.documentationLinkText',
{
defaultMessage: 'documentation'
}
)}
</EuiLink>
</ElasticDocsLink>
)
}}
/>

View file

@ -1,66 +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 { EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui';
import { Location } from 'history';
import { get } from 'lodash';
import React from 'react';
import { Transaction } from '../../../../../typings/es_schemas/ui/Transaction';
import { IUrlParams } from '../../../../store/urlParams';
import { fromQuery, history, toQuery } from '../../../shared/Links/url_helpers';
import { PropertiesTable } from '../../../shared/PropertiesTable';
import {
getCurrentTab,
getTabsFromObject
} from '../../../shared/PropertiesTable/tabConfig';
interface Props {
location: Location;
transaction: Transaction;
urlParams: IUrlParams;
}
export const TransactionPropertiesTableForFlyout: React.SFC<Props> = ({
location,
transaction,
urlParams
}) => {
const tabs = getTabsFromObject(transaction);
const currentTab = getCurrentTab(tabs, urlParams.flyoutDetailTab);
const agentName = transaction.agent.name;
return (
<div>
<EuiTabs>
{tabs.map(({ key, label }) => {
return (
<EuiTab
onClick={() => {
history.replace({
...location,
search: fromQuery({
...toQuery(location.search),
flyoutDetailTab: key
})
});
}}
isSelected={currentTab.key === key}
key={key}
>
{label}
</EuiTab>
);
})}
</EuiTabs>
<EuiSpacer />
<PropertiesTable
propData={get(transaction, currentTab.key)}
propKey={currentTab.key}
agentName={agentName}
/>
</div>
);
};

View file

@ -7,38 +7,28 @@
import { EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import { get } from 'lodash';
import React from 'react';
import styled from 'styled-components';
import { Transaction } from '../../../../../typings/es_schemas/ui/Transaction';
import { IUrlParams } from '../../../../store/urlParams';
import { px, units } from '../../../../style/variables';
import { HeightRetainer } from '../../../shared/HeightRetainer';
import { fromQuery, history, toQuery } from '../../../shared/Links/url_helpers';
import { PropertiesTable } from '../../../shared/PropertiesTable';
import {
getCurrentTab,
getTabsFromObject
} from '../../../shared/PropertiesTable/tabConfig';
import { TransactionMetadata } from '../../../shared/MetadataTable/TransactionMetadata';
import { WaterfallContainer } from './WaterfallContainer';
import { IWaterfall } from './WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers';
const TableContainer = styled.div`
padding: ${px(units.plus)} ${px(units.plus)} 0;
`;
interface TimelineTab {
key: 'timeline';
label: string;
}
const timelineTab: TimelineTab = {
const timelineTab = {
key: 'timeline',
label: i18n.translate('xpack.apm.propertiesTable.tabs.timelineLabel', {
defaultMessage: 'Timeline'
})
};
const metadataTab = {
key: 'metadata',
label: i18n.translate('xpack.apm.propertiesTable.tabs.metadataLabel', {
defaultMessage: 'Metadata'
})
};
interface Props {
location: Location;
transaction: Transaction;
@ -52,14 +42,12 @@ export function TransactionTabs({
urlParams,
waterfall
}: Props) {
const tabs = [timelineTab, ...getTabsFromObject(transaction)];
const currentTab = getCurrentTab(tabs, urlParams.detailTab);
const agentName = transaction.agent.name;
const tabs = [timelineTab, metadataTab];
const currentTab =
urlParams.detailTab === metadataTab.key ? metadataTab : timelineTab;
return (
<HeightRetainer
key={`${transaction.trace.id}:${transaction.transaction.id}`}
>
<React.Fragment>
<EuiTabs>
{tabs.map(({ key, label }) => {
return (
@ -92,14 +80,8 @@ export function TransactionTabs({
waterfall={waterfall}
/>
) : (
<TableContainer>
<PropertiesTable
propData={get(transaction, currentTab.key)}
propKey={currentTab.key}
agentName={agentName}
/>
</TableContainer>
<TransactionMetadata transaction={transaction} />
)}
</HeightRetainer>
</React.Fragment>
);
}

View file

@ -0,0 +1,51 @@
/*
* 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 { EuiCallOut, EuiHorizontalRule } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { idx } from '../../../../../../../../common/idx';
import { Transaction } from '../../../../../../../../typings/es_schemas/ui/Transaction';
import { ElasticDocsLink } from '../../../../../../shared/Links/ElasticDocsLink';
export function DroppedSpansWarning({
transactionDoc
}: {
transactionDoc: Transaction;
}) {
const dropped = idx(transactionDoc, _ => _.transaction.span_count.dropped);
if (!dropped) {
return null;
}
return (
<React.Fragment>
<EuiCallOut size="s">
{i18n.translate(
'xpack.apm.transactionDetails.transFlyout.callout.agentDroppedSpansMessage',
{
defaultMessage:
'The APM agent that reported this transaction dropped {dropped} spans or more based on its configuration.',
values: { dropped }
}
)}{' '}
<ElasticDocsLink
section="/apm/get-started"
path="/transaction-spans.html#dropped-spans"
target="_blank"
>
{i18n.translate(
'xpack.apm.transactionDetails.transFlyout.callout.learnMoreAboutDroppedSpansLinkText',
{
defaultMessage: 'Learn more about dropped spans.'
}
)}
</ElasticDocsLink>
</EuiCallOut>
<EuiHorizontalRule />
</React.Fragment>
);
}

View file

@ -5,78 +5,51 @@
*/
import {
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiHorizontalRule,
EuiLink,
EuiPortal,
EuiSpacer,
EuiTitle
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import React from 'react';
import { idx } from '../../../../../../../../common/idx';
import { Transaction } from '../../../../../../../../typings/es_schemas/ui/Transaction';
import { IUrlParams } from '../../../../../../../store/urlParams';
import { DROPPED_SPANS_DOCS } from '../../../../../../../utils/documentation/apm-get-started';
import { TransactionActionMenu } from '../../../../../../shared/TransactionActionMenu/TransactionActionMenu';
import { StickyTransactionProperties } from '../../../StickyTransactionProperties';
import { TransactionPropertiesTableForFlyout } from '../../../TransactionPropertiesTableForFlyout';
import { FlyoutTopLevelProperties } from '../FlyoutTopLevelProperties';
import { ResponsiveFlyout } from '../ResponsiveFlyout';
import { TransactionMetadata } from '../../../../../../shared/MetadataTable/TransactionMetadata';
import { DroppedSpansWarning } from './DroppedSpansWarning';
interface Props {
onClose: () => void;
transaction?: Transaction;
location: Location;
urlParams: IUrlParams;
errorCount: number;
traceRootDuration?: number;
}
function DroppedSpansWarning({
transactionDoc
function TransactionPropertiesTable({
transaction
}: {
transactionDoc: Transaction;
transaction: Transaction;
}) {
const dropped = idx(transactionDoc, _ => _.transaction.span_count.dropped);
if (!dropped) {
return null;
}
return (
<React.Fragment>
<EuiCallOut size="s">
{i18n.translate(
'xpack.apm.transactionDetails.transFlyout.callout.agentDroppedSpansMessage',
{
defaultMessage:
'The APM agent that reported this transaction dropped {dropped} spans or more based on its configuration.',
values: { dropped }
}
)}{' '}
<EuiLink href={DROPPED_SPANS_DOCS} target="_blank">
{i18n.translate(
'xpack.apm.transactionDetails.transFlyout.callout.learnMoreAboutDroppedSpansLinkText',
{
defaultMessage: 'Learn more about dropped spans.'
}
)}
</EuiLink>
</EuiCallOut>
<EuiHorizontalRule />
</React.Fragment>
<div>
<EuiTitle size="s">
<h4>Metadata</h4>
</EuiTitle>
<EuiSpacer />
<TransactionMetadata transaction={transaction} />
</div>
);
}
export function TransactionFlyout({
transaction: transactionDoc,
onClose,
location,
urlParams,
errorCount,
traceRootDuration
}: Props) {
@ -117,11 +90,7 @@ export function TransactionFlyout({
/>
<EuiHorizontalRule />
<DroppedSpansWarning transactionDoc={transactionDoc} />
<TransactionPropertiesTableForFlyout
transaction={transactionDoc}
location={location}
urlParams={urlParams}
/>
<TransactionPropertiesTable transaction={transactionDoc} />
</EuiFlyoutBody>
</ResponsiveFlyout>
</EuiPortal>

View file

@ -87,7 +87,7 @@ export class Waterfall extends Component<Props> {
};
public getFlyOut = () => {
const { waterfall, location, urlParams } = this.props;
const { waterfall, urlParams } = this.props;
const currentItem =
urlParams.waterfallItemId &&
@ -116,8 +116,6 @@ export class Waterfall extends Component<Props> {
<TransactionFlyout
transaction={currentItem.transaction}
onClose={this.onCloseFlyout}
location={location}
urlParams={urlParams}
traceRootDuration={waterfall.traceRootDuration}
errorCount={currentItem.errorCount}
/>

View file

@ -0,0 +1,42 @@
/*
* 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 theme from '@elastic/eui/dist/eui_theme_light.json';
import { isBoolean, isNumber, isObject } from 'lodash';
import React from 'react';
import styled from 'styled-components';
import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n';
const EmptyValue = styled.span`
color: ${theme.euiColorMediumShade};
text-align: left;
`;
export function FormattedKey({
k,
value
}: {
k: string;
value: unknown;
}): JSX.Element {
if (value == null) {
return <EmptyValue>{k}</EmptyValue>;
}
return <React.Fragment>{k}</React.Fragment>;
}
export function FormattedValue({ value }: { value: any }): JSX.Element {
if (isObject(value)) {
return <pre>{JSON.stringify(value, null, 4)}</pre>;
} else if (isBoolean(value) || isNumber(value)) {
return <React.Fragment>{String(value)}</React.Fragment>;
} else if (!value) {
return <EmptyValue>{NOT_AVAILABLE_LABEL}</EmptyValue>;
}
return <React.Fragment>{value}</React.Fragment>;
}

View file

@ -0,0 +1,107 @@
/*
* 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 { DottedKeyValueTable } from '..';
import { cleanup, render } from 'react-testing-library';
function getKeys(output: ReturnType<typeof render>) {
const keys = output.getAllByTestId('dot-key');
return Array.isArray(keys) ? keys.map(node => node.textContent) : [];
}
function getValues(output: ReturnType<typeof render>) {
const values = output.getAllByTestId('value');
return Array.isArray(values) ? values.map(node => node.textContent) : [];
}
describe('DottedKeyValueTable', () => {
afterEach(cleanup);
it('should display a nested object with alpha-ordered, dot notation keys and values', () => {
const data = {
name: {
first: 'Jo',
last: 'Smith'
},
age: 29,
active: true,
useless: false,
start: null,
end: undefined,
nested: {
b: {
c: 'ccc'
},
a: 'aaa'
}
};
const output = render(<DottedKeyValueTable data={data} />);
const rows = output.container.querySelectorAll('tr');
expect(rows.length).toEqual(9);
expect(getKeys(output)).toEqual([
'active',
'age',
'end',
'name.first',
'name.last',
'nested.a',
'nested.b.c',
'start',
'useless'
]);
expect(getValues(output)).toEqual([
'true',
'29',
'N/A',
'Jo',
'Smith',
'aaa',
'ccc',
'N/A',
'false'
]);
});
it('should respect max depth', () => {
const data = {
nested: { b: { c: 'ccc' }, a: 'aaa' }
};
const output = render(<DottedKeyValueTable data={data} maxDepth={1} />);
const rows = output.container.querySelectorAll('tr');
expect(rows.length).toEqual(2);
expect(getKeys(output)).toEqual(['nested.a', 'nested.b']);
expect(getValues(output)).toEqual([
'aaa',
JSON.stringify({ c: 'ccc' }, null, 4)
]);
});
it('should prepend a provided parent key to all of the dot-notation keys', () => {
const data = {
name: {
first: 'Jo',
last: 'Smith'
},
age: 29,
active: true
};
const output = render(<DottedKeyValueTable data={data} parentKey="top" />);
const rows = output.container.querySelectorAll('tr');
expect(rows.length).toEqual(4);
expect(getKeys(output)).toEqual([
'top.active',
'top.age',
'top.name.first',
'top.name.last'
]);
});
});

View file

@ -0,0 +1,87 @@
/*
* 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, { TableHTMLAttributes } from 'react';
import { compact, isObject } from 'lodash';
import {
EuiTable,
EuiTableProps,
EuiTableBody,
EuiTableRow,
EuiTableRowCell
} from '@elastic/eui';
import { StringMap } from '../../../../typings/common';
import { FormattedValue } from './FormattedValue';
interface PathifyOptions {
maxDepth?: number;
parentKey?: string;
depth?: number;
}
interface PathifyResult {
[key: string]: any;
}
/**
* Converts a deeply-nested object into a one-level object
* with dot-notation paths as keys.
*/
export function pathify(
item: StringMap<any>,
{ maxDepth, parentKey = '', depth = 0 }: PathifyOptions
): PathifyResult {
return Object.keys(item)
.sort()
.reduce((pathified, key) => {
const currentKey = compact([parentKey, key]).join('.');
if ((!maxDepth || depth + 1 <= maxDepth) && isObject(item[key])) {
return {
...pathified,
...pathify(item[key], {
maxDepth,
parentKey: currentKey,
depth: depth + 1
})
};
} else {
return { ...pathified, [currentKey]: item[key] };
}
}, {});
}
export function DottedKeyValueTable({
data,
parentKey,
maxDepth,
tableProps = {}
}: {
data: StringMap;
parentKey?: string;
maxDepth?: number;
tableProps?: EuiTableProps & TableHTMLAttributes<HTMLTableElement>;
}) {
const pathified = pathify(data, { maxDepth, parentKey });
const rows = Object.keys(pathified)
.sort()
.map(k => [k, pathified[k]]);
return (
<EuiTable compressed {...tableProps}>
<EuiTableBody>
{rows.map(([key, value]) => (
<EuiTableRow key={key}>
<EuiTableRowCell>
<strong data-testid="dot-key">{key}</strong>
</EuiTableRowCell>
<EuiTableRowCell data-testid="value">
<FormattedValue value={value} />
</EuiTableRowCell>
</EuiTableRow>
))}
</EuiTableBody>
</EuiTable>
);
}

View file

@ -1,29 +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, { useEffect, useRef } from 'react';
export const HeightRetainer: React.SFC = props => {
const containerElement = useRef<HTMLDivElement>(null);
const minHeight = useRef<number>(0);
useEffect(() => {
if (containerElement.current) {
const currentHeight = containerElement.current.clientHeight;
if (minHeight.current < currentHeight) {
minHeight.current = currentHeight;
}
}
});
return (
<div
{...props}
ref={containerElement}
style={{ minHeight: minHeight.current }}
/>
);
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiLink, EuiLinkAnchorProps } from '@elastic/eui';
import { metadata } from 'ui/metadata';
// TODO: metadata should be read from a useContext hook in new platform
const STACK_VERSION = metadata.branch;
// union type constisting of valid guide sections that we link to
type DocsSection = '/apm/get-started' | '/x-pack';
interface Props extends EuiLinkAnchorProps {
section: DocsSection;
path: string;
}
export function ElasticDocsLink({ section, path, ...rest }: Props) {
const href = `https://www.elastic.co/guide/en${section}/${STACK_VERSION}${path}`;
return <EuiLink href={href} {...rest} />;
}

View file

@ -0,0 +1,126 @@
/*
* 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 { ErrorMetadata } from '..';
import { render, cleanup } from 'react-testing-library';
import { APMError } from '../../../../../../typings/es_schemas/ui/APMError';
import 'jest-dom/extend-expect';
import {
expectTextsInDocument,
expectTextsNotInDocument
} from '../../../../../utils/testHelpers';
function getError() {
return ({
labels: { someKey: 'labels value' },
http: { someKey: 'http value' },
host: { someKey: 'host value' },
container: { someKey: 'container value' },
service: { someKey: 'service value' },
process: { someKey: 'process value' },
agent: { someKey: 'agent value' },
url: { someKey: 'url value' },
user: { someKey: 'user value' },
notIncluded: 'not included value',
error: {
notIncluded: 'error not included value',
custom: {
someKey: 'custom value'
}
}
} as unknown) as APMError;
}
describe('ErrorMetadata', () => {
afterEach(cleanup);
it('should render a error with all sections', () => {
const error = getError();
const output = render(<ErrorMetadata error={error} />);
// sections
expectTextsInDocument(output, [
'Labels',
'HTTP',
'Host',
'Container',
'Service',
'Process',
'Agent',
'URL',
'User',
'Custom'
]);
});
it('should render a error with all included dot notation keys', () => {
const error = getError();
const output = render(<ErrorMetadata error={error} />);
// included keys
expectTextsInDocument(output, [
'labels.someKey',
'http.someKey',
'host.someKey',
'container.someKey',
'service.someKey',
'process.someKey',
'agent.someKey',
'url.someKey',
'user.someKey',
'error.custom.someKey'
]);
// excluded keys
expectTextsNotInDocument(output, ['notIncluded', 'error.notIncluded']);
});
it('should render a error with all included values', () => {
const error = getError();
const output = render(<ErrorMetadata error={error} />);
// included values
expectTextsInDocument(output, [
'labels value',
'http value',
'host value',
'container value',
'service value',
'process value',
'agent value',
'url value',
'user value',
'custom value'
]);
// excluded values
expectTextsNotInDocument(output, [
'not included value',
'error not included value'
]);
});
it('should render a error with only the required sections', () => {
const error = {} as APMError;
const output = render(<ErrorMetadata error={error} />);
// required sections should be found
expectTextsInDocument(output, ['Labels', 'User']);
// optional sections should NOT be found
expectTextsNotInDocument(output, [
'HTTP',
'Host',
'Container',
'Service',
'Process',
'Agent',
'URL',
'Custom'
]);
});
});

View file

@ -0,0 +1,18 @@
/*
* 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 { MetadataTable } from '..';
import { ERROR_METADATA_SECTIONS } from './sections';
import { APMError } from '../../../../../typings/es_schemas/ui/APMError';
interface Props {
error: APMError;
}
export function ErrorMetadata({ error }: Props) {
return <MetadataTable item={error} sections={ERROR_METADATA_SECTIONS} />;
}

View file

@ -0,0 +1,52 @@
/*
* 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 * as SECTION_LABELS from '../sectionLabels';
export const ERROR_METADATA_SECTIONS = [
{
key: 'labels',
label: SECTION_LABELS.LABELS,
required: true
},
{
key: 'http',
label: SECTION_LABELS.HTTP
},
{
key: 'host',
label: SECTION_LABELS.HOST
},
{
key: 'container',
label: SECTION_LABELS.CONTAINER
},
{
key: 'service',
label: SECTION_LABELS.SERVICE
},
{
key: 'process',
label: SECTION_LABELS.PROCESS
},
{
key: 'agent',
label: SECTION_LABELS.AGENT
},
{
key: 'url',
label: SECTION_LABELS.URL
},
{
key: 'user',
label: SECTION_LABELS.USER,
required: true
},
{
key: 'error.custom',
label: SECTION_LABELS.CUSTOM
}
];

View file

@ -0,0 +1,129 @@
/*
* 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 { TransactionMetadata } from '..';
import { render, cleanup } from 'react-testing-library';
import { Transaction } from '../../../../../../typings/es_schemas/ui/Transaction';
import 'jest-dom/extend-expect';
import {
expectTextsInDocument,
expectTextsNotInDocument
} from '../../../../../utils/testHelpers';
function getTransaction() {
return ({
labels: { someKey: 'labels value' },
http: { someKey: 'http value' },
host: { someKey: 'host value' },
container: { someKey: 'container value' },
service: { someKey: 'service value' },
process: { someKey: 'process value' },
agent: { someKey: 'agent value' },
url: { someKey: 'url value' },
user: { someKey: 'user value' },
notIncluded: 'not included value',
transaction: {
notIncluded: 'transaction not included value',
custom: {
someKey: 'custom value'
}
}
} as unknown) as Transaction;
}
describe('TransactionMetadata', () => {
afterEach(cleanup);
it('should render a transaction with all sections', () => {
const transaction = getTransaction();
const output = render(<TransactionMetadata transaction={transaction} />);
// sections
expectTextsInDocument(output, [
'Labels',
'HTTP',
'Host',
'Container',
'Service',
'Process',
'Agent',
'URL',
'User',
'Custom'
]);
});
it('should render a transaction with all included dot notation keys', () => {
const transaction = getTransaction();
const output = render(<TransactionMetadata transaction={transaction} />);
// included keys
expectTextsInDocument(output, [
'labels.someKey',
'http.someKey',
'host.someKey',
'container.someKey',
'service.someKey',
'process.someKey',
'agent.someKey',
'url.someKey',
'user.someKey',
'transaction.custom.someKey'
]);
// excluded keys
expectTextsNotInDocument(output, [
'notIncluded',
'transaction.notIncluded'
]);
});
it('should render a transaction with all included values', () => {
const transaction = getTransaction();
const output = render(<TransactionMetadata transaction={transaction} />);
// included values
expectTextsInDocument(output, [
'labels value',
'http value',
'host value',
'container value',
'service value',
'process value',
'agent value',
'url value',
'user value',
'custom value'
]);
// excluded values
expectTextsNotInDocument(output, [
'not included value',
'transaction not included value'
]);
});
it('should render a transaction with only the required sections', () => {
const transaction = {} as Transaction;
const output = render(<TransactionMetadata transaction={transaction} />);
// required sections should be found
expectTextsInDocument(output, ['Labels', 'User']);
// optional sections should NOT be found
expectTextsNotInDocument(output, [
'HTTP',
'Host',
'Container',
'Service',
'Process',
'Agent',
'URL',
'Custom'
]);
});
});

View file

@ -0,0 +1,23 @@
/*
* 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 { MetadataTable } from '..';
import { TRANSACTION_METADATA_SECTIONS } from './sections';
import { Transaction } from '../../../../../typings/es_schemas/ui/Transaction';
interface Props {
transaction: Transaction;
}
export function TransactionMetadata({ transaction }: Props) {
return (
<MetadataTable
item={transaction}
sections={TRANSACTION_METADATA_SECTIONS}
/>
);
}

View file

@ -0,0 +1,52 @@
/*
* 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 * as SECTION_LABELS from '../sectionLabels';
export const TRANSACTION_METADATA_SECTIONS = [
{
key: 'labels',
label: SECTION_LABELS.LABELS,
required: true
},
{
key: 'http',
label: SECTION_LABELS.HTTP
},
{
key: 'host',
label: SECTION_LABELS.HOST
},
{
key: 'container',
label: SECTION_LABELS.CONTAINER
},
{
key: 'service',
label: SECTION_LABELS.SERVICE
},
{
key: 'process',
label: SECTION_LABELS.PROCESS
},
{
key: 'agent',
label: SECTION_LABELS.AGENT
},
{
key: 'url',
label: SECTION_LABELS.URL
},
{
key: 'user',
label: SECTION_LABELS.USER,
required: true
},
{
key: 'transaction.custom',
label: SECTION_LABELS.CUSTOM
}
];

View file

@ -0,0 +1,87 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiSpacer,
EuiTitle
} from '@elastic/eui';
import React from 'react';
import { get, has } from 'lodash';
import { EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Transaction } from '../../../../typings/es_schemas/ui/Transaction';
import { APMError } from '../../../../typings/es_schemas/ui/APMError';
import { StringMap } from '../../../../typings/common';
import { DottedKeyValueTable } from '../DottedKeyValueTable';
import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink';
type MetadataItem = Transaction | APMError;
interface Props {
item: MetadataItem;
sections: MetadataSection[];
}
export interface MetadataSection {
key: string;
label: string;
required?: boolean;
}
export function MetadataTable({ item, sections }: Props) {
const filteredSections = sections.filter(
({ key, required }) => required || has(item, key)
);
return (
<React.Fragment>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<ElasticDocsLink section="/apm/get-started" path="/metadata.html">
<EuiText size="s">
<EuiIcon type="help" /> How to add labels and other data
</EuiText>
</ElasticDocsLink>
</EuiFlexItem>
</EuiFlexGroup>
{filteredSections.map(section => (
<div key={section.key}>
<EuiTitle size="xs">
<h6>{section.label}</h6>
</EuiTitle>
<EuiSpacer size="s" />
<Section propData={get(item, section.key)} propKey={section.key} />
<EuiSpacer size="xl" />
</div>
))}
</React.Fragment>
);
}
function Section({
propData,
propKey
}: {
propData?: StringMap;
propKey?: string;
}) {
return (
<React.Fragment>
{propData ? (
<DottedKeyValueTable data={propData} parentKey={propKey} maxDepth={5} />
) : (
<EuiText size="s">
{i18n.translate(
'xpack.apm.propertiesTable.agentFeature.noDataAvailableLabel',
{ defaultMessage: 'No data available' }
)}
</EuiText>
)}
</React.Fragment>
);
}

View file

@ -0,0 +1,74 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const LABELS = i18n.translate(
'xpack.apm.metadataTable.section.labelsLabel',
{
defaultMessage: 'Labels'
}
);
export const HTTP = i18n.translate(
'xpack.apm.metadataTable.section.httpLabel',
{
defaultMessage: 'HTTP'
}
);
export const HOST = i18n.translate(
'xpack.apm.metadataTable.section.hostLabel',
{
defaultMessage: 'Host'
}
);
export const CONTAINER = i18n.translate(
'xpack.apm.metadataTable.section.containerLabel',
{
defaultMessage: 'Container'
}
);
export const SERVICE = i18n.translate(
'xpack.apm.metadataTable.section.serviceLabel',
{
defaultMessage: 'Service'
}
);
export const PROCESS = i18n.translate(
'xpack.apm.metadataTable.section.processLabel',
{
defaultMessage: 'Process'
}
);
export const AGENT = i18n.translate(
'xpack.apm.metadataTable.section.agentLabel',
{
defaultMessage: 'Agent'
}
);
export const URL = i18n.translate('xpack.apm.metadataTable.section.urlLabel', {
defaultMessage: 'URL'
});
export const USER = i18n.translate(
'xpack.apm.metadataTable.section.userLabel',
{
defaultMessage: 'User'
}
);
export const CUSTOM = i18n.translate(
'xpack.apm.metadataTable.section.customLabel',
{
defaultMessage: 'Custom'
}
);

View file

@ -1,126 +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 theme from '@elastic/eui/dist/eui_theme_light.json';
import { isBoolean, isNumber, isObject } from 'lodash';
import React from 'react';
import styled from 'styled-components';
import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n';
import { StringMap } from '../../../../typings/common';
import { fontFamilyCode, fontSize, px, units } from '../../../style/variables';
import { sortKeysByConfig } from './tabConfig';
const Table = styled.table`
font-family: ${fontFamilyCode};
font-size: ${fontSize};
width: 100%;
`;
const Row = styled.tr`
border-bottom: ${px(1)} solid ${theme.euiColorLightShade};
&:last-child {
border: 0;
}
`;
const Cell = styled.td`
vertical-align: top;
padding: ${px(units.half)} 0;
line-height: 1.5;
${Row}:first-child> & {
padding-top: 0;
}
${Row}:last-child> & {
padding-bottom: 0;
}
&:first-child {
width: ${px(units.unit * 12)};
font-weight: bold;
}
`;
const EmptyValue = styled.span`
color: ${theme.euiColorMediumShade};
`;
export function FormattedKey({
k,
value
}: {
k: string;
value: unknown;
}): JSX.Element {
if (value == null) {
return <EmptyValue>{k}</EmptyValue>;
}
return <React.Fragment>{k}</React.Fragment>;
}
export function FormattedValue({ value }: { value: any }): JSX.Element {
if (isObject(value)) {
return <pre>{JSON.stringify(value, null, 4)}</pre>;
} else if (isBoolean(value) || isNumber(value)) {
return <React.Fragment>{String(value)}</React.Fragment>;
} else if (!value) {
return <EmptyValue>{NOT_AVAILABLE_LABEL}</EmptyValue>;
}
return <React.Fragment>{value}</React.Fragment>;
}
export function NestedValue({
parentKey,
value,
depth
}: {
value: unknown;
depth: number;
parentKey?: string;
}): JSX.Element {
const MAX_LEVEL = 3;
if (depth < MAX_LEVEL && isObject(value)) {
return (
<NestedKeyValueTable
data={value as StringMap}
parentKey={parentKey}
depth={depth + 1}
/>
);
}
return <FormattedValue value={value} />;
}
export function NestedKeyValueTable({
data,
parentKey,
depth
}: {
data: StringMap;
parentKey?: string;
depth: number;
}): JSX.Element {
return (
<Table>
<tbody>
{sortKeysByConfig(data, parentKey).map(key => (
<Row key={key}>
<Cell>
<FormattedKey k={key} value={data[key]} />
</Cell>
<Cell>
<NestedValue parentKey={key} value={data[key]} depth={depth} />
</Cell>
</Row>
))}
</tbody>
</Table>
);
}

View file

@ -1,113 +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 { mount, shallow } from 'enzyme';
import 'jest-styled-components';
import React from 'react';
import {
FormattedKey,
FormattedValue,
NestedKeyValueTable,
NestedValue
} from '../NestedKeyValueTable';
describe('NestedKeyValueTable component', () => {
it('should render with data', () => {
const testData = {
a: 1,
b: 2,
c: [3, 4, 5],
d: { aa: 1, bb: 2 }
};
expect(
shallow(<NestedKeyValueTable data={testData} depth={0} />)
).toMatchSnapshot();
});
it('should render an empty table if there is no data', () => {
expect(
shallow(<NestedKeyValueTable data={{}} depth={0} />)
).toMatchSnapshot();
});
});
describe('NestedValue component', () => {
it('should render a formatted value when depth is 0', () => {
const wrapper = shallow(
<NestedValue value={{ a: 'hello' }} depth={0} parentKey="who_cares" />
);
expect(
wrapper.equals(
<NestedKeyValueTable
data={{ a: 'hello' }}
depth={1}
parentKey="who_cares"
/>
)
).toBe(true);
});
it('should render a formatted value when depth > 0 but value is not an object', () => {
expect(
shallow(<NestedValue value={2} depth={3} parentKey="who_cares" />)
).toMatchSnapshot();
});
it('should render a nested KV Table when depth > 0 and value is an object', () => {
expect(
shallow(
<NestedValue value={{ a: 'hello' }} depth={1} parentKey="who_cares" />
)
).toMatchSnapshot();
});
});
describe('FormattedValue component', () => {
it('should render an object', () => {
expect(mount(<FormattedValue value={{ a: 'ok' }} />)).toMatchSnapshot();
});
it('should render an array', () => {
expect(mount(<FormattedValue value={[1, 2, 3]} />)).toMatchSnapshot();
});
it('should render a boolean', () => {
expect(mount(<FormattedValue value={true} />)).toMatchSnapshot();
expect(mount(<FormattedValue value={false} />)).toMatchSnapshot();
});
it('should render a number', () => {
expect(mount(<FormattedValue value={243} />)).toMatchSnapshot();
});
it('should render a string', () => {
expect(mount(<FormattedValue value="hey ok cool" />)).toMatchSnapshot();
});
it('should render null', () => {
expect(mount(<FormattedValue value={null} />)).toMatchSnapshot();
});
it('should render undefined', () => {
expect(mount(<FormattedValue value={undefined} />)).toMatchSnapshot();
});
});
describe('FormattedKey component', () => {
it('should render when the value is null or undefined', () => {
expect(mount(<FormattedKey k="testKey" value={null} />)).toMatchSnapshot();
expect(
mount(<FormattedKey k="testKey" value={undefined} />)
).toMatchSnapshot();
});
it('should render when the value is defined', () => {
expect(mount(<FormattedKey k="testKey" value="hi" />)).toMatchSnapshot();
expect(mount(<FormattedKey k="testKey" value={123} />)).toMatchSnapshot();
expect(mount(<FormattedKey k="testKey" value={{}} />)).toMatchSnapshot();
});
});

View file

@ -1,73 +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 { shallow } from 'enzyme';
import React from 'react';
import { PropertiesTable, TabHelpMessage } from '..';
import * as agentDocs from '../../../../utils/documentation/agents';
describe('PropertiesTable', () => {
describe('PropertiesTable component', () => {
it('should render with data', () => {
expect(
shallow(
<PropertiesTable
propData={{ a: 'hello', b: 'bananas' }}
propKey="kubernetes"
agentName="java"
/>
)
).toMatchSnapshot();
});
it("should render empty when data isn't present", () => {
expect(
shallow(<PropertiesTable propKey="kubernetes" agentName="java" />)
).toMatchSnapshot();
});
it('should still render NestedKeyValueTable even when data has no keys', () => {
expect(
shallow(
<PropertiesTable
propData={{}}
propKey="kubernetes"
agentName="java"
/>
)
).toMatchSnapshot();
});
});
describe('TabHelpMessage component', () => {
const tabKey = 'user';
const agentName = 'nodejs';
it('should render when docs are returned', () => {
jest
.spyOn(agentDocs, 'getAgentDocUrlForTab')
.mockImplementation(() => 'mock-url');
expect(
shallow(<TabHelpMessage tabKey={tabKey} agentName={agentName} />)
).toMatchSnapshot();
expect(agentDocs.getAgentDocUrlForTab).toHaveBeenCalledWith(
tabKey,
agentName
);
});
it('should render null empty string when no docs are returned', () => {
jest
.spyOn(agentDocs, 'getAgentDocUrlForTab')
.mockImplementation(() => undefined);
expect(
shallow(<TabHelpMessage tabKey={tabKey} agentName={agentName} />)
).toMatchSnapshot();
});
});
});

View file

@ -1,288 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FormattedKey component should render when the value is defined 1`] = `
<FormattedKey
k="testKey"
value="hi"
>
testKey
</FormattedKey>
`;
exports[`FormattedKey component should render when the value is defined 2`] = `
<FormattedKey
k="testKey"
value={123}
>
testKey
</FormattedKey>
`;
exports[`FormattedKey component should render when the value is defined 3`] = `
<FormattedKey
k="testKey"
value={Object {}}
>
testKey
</FormattedKey>
`;
exports[`FormattedKey component should render when the value is null or undefined 1`] = `
.c0 {
color: #98a2b3;
}
<FormattedKey
k="testKey"
value={null}
>
<styled.span>
<span
className="c0"
>
testKey
</span>
</styled.span>
</FormattedKey>
`;
exports[`FormattedKey component should render when the value is null or undefined 2`] = `
.c0 {
color: #98a2b3;
}
<FormattedKey
k="testKey"
>
<styled.span>
<span
className="c0"
>
testKey
</span>
</styled.span>
</FormattedKey>
`;
exports[`FormattedValue component should render a boolean 1`] = `
<FormattedValue
value={true}
>
true
</FormattedValue>
`;
exports[`FormattedValue component should render a boolean 2`] = `
<FormattedValue
value={false}
>
false
</FormattedValue>
`;
exports[`FormattedValue component should render a number 1`] = `
<FormattedValue
value={243}
>
243
</FormattedValue>
`;
exports[`FormattedValue component should render a string 1`] = `
<FormattedValue
value="hey ok cool"
>
hey ok cool
</FormattedValue>
`;
exports[`FormattedValue component should render an array 1`] = `
<FormattedValue
value={
Array [
1,
2,
3,
]
}
>
<pre>
[
1,
2,
3
]
</pre>
</FormattedValue>
`;
exports[`FormattedValue component should render an object 1`] = `
<FormattedValue
value={
Object {
"a": "ok",
}
}
>
<pre>
{
"a": "ok"
}
</pre>
</FormattedValue>
`;
exports[`FormattedValue component should render null 1`] = `
.c0 {
color: #98a2b3;
}
<FormattedValue
value={null}
>
<styled.span>
<span
className="c0"
>
N/A
</span>
</styled.span>
</FormattedValue>
`;
exports[`FormattedValue component should render undefined 1`] = `
.c0 {
color: #98a2b3;
}
<FormattedValue>
<styled.span>
<span
className="c0"
>
N/A
</span>
</styled.span>
</FormattedValue>
`;
exports[`NestedKeyValueTable component should render an empty table if there is no data 1`] = `
<styled.table>
<tbody />
</styled.table>
`;
exports[`NestedKeyValueTable component should render with data 1`] = `
<styled.table>
<tbody>
<styled.tr
key="a"
>
<styled.td>
<FormattedKey
k="a"
value={1}
/>
</styled.td>
<styled.td>
<NestedValue
depth={0}
parentKey="a"
value={1}
/>
</styled.td>
</styled.tr>
<styled.tr
key="b"
>
<styled.td>
<FormattedKey
k="b"
value={2}
/>
</styled.td>
<styled.td>
<NestedValue
depth={0}
parentKey="b"
value={2}
/>
</styled.td>
</styled.tr>
<styled.tr
key="c"
>
<styled.td>
<FormattedKey
k="c"
value={
Array [
3,
4,
5,
]
}
/>
</styled.td>
<styled.td>
<NestedValue
depth={0}
parentKey="c"
value={
Array [
3,
4,
5,
]
}
/>
</styled.td>
</styled.tr>
<styled.tr
key="d"
>
<styled.td>
<FormattedKey
k="d"
value={
Object {
"aa": 1,
"bb": 2,
}
}
/>
</styled.td>
<styled.td>
<NestedValue
depth={0}
parentKey="d"
value={
Object {
"aa": 1,
"bb": 2,
}
}
/>
</styled.td>
</styled.tr>
</tbody>
</styled.table>
`;
exports[`NestedValue component should render a formatted value when depth > 0 but value is not an object 1`] = `
<FormattedValue
value={2}
/>
`;
exports[`NestedValue component should render a nested KV Table when depth > 0 and value is an object 1`] = `
<NestedKeyValueTable
data={
Object {
"a": "hello",
}
}
depth={2}
parentKey="who_cares"
/>
`;

View file

@ -1,67 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PropertiesTable PropertiesTable component should render empty when data isn't present 1`] = `
<styled.div>
<Styled(styled.div)>
No data available
</Styled(styled.div)>
<TabHelpMessage
agentName="java"
tabKey="kubernetes"
/>
</styled.div>
`;
exports[`PropertiesTable PropertiesTable component should render with data 1`] = `
<styled.div>
<NestedKeyValueTable
data={
Object {
"a": "hello",
"b": "bananas",
}
}
depth={1}
parentKey="kubernetes"
/>
<TabHelpMessage
agentName="java"
tabKey="kubernetes"
/>
</styled.div>
`;
exports[`PropertiesTable PropertiesTable component should still render NestedKeyValueTable even when data has no keys 1`] = `
<styled.div>
<NestedKeyValueTable
data={Object {}}
depth={1}
parentKey="kubernetes"
/>
<TabHelpMessage
agentName="java"
tabKey="kubernetes"
/>
</styled.div>
`;
exports[`PropertiesTable TabHelpMessage component should render null empty string when no docs are returned 1`] = `""`;
exports[`PropertiesTable TabHelpMessage component should render when docs are returned 1`] = `
<styled.div>
<Styled(EuiIcon)
type="iInCircle"
/>
You can configure your agent to add contextual information about your users.
<EuiLink
color="primary"
href="mock-url"
rel="noopener"
target="_blank"
type="button"
>
Learn more in the documentation.
</EuiLink>
</styled.div>
`;

View file

@ -1,81 +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.
*/
jest.mock('../tabConfigConst', () => {
return {
TAB_CONFIG: [
{
key: 'testProperty',
label: 'testPropertyLabel',
required: false,
presortedKeys: ['name', 'age']
},
{
key: 'optionalProperty',
label: 'optionalPropertyLabel',
required: false
},
{
key: 'requiredProperty',
label: 'requiredPropertyLabel',
required: true
}
]
};
});
import * as propertyConfig from '../tabConfig';
const { getTabsFromObject, sortKeysByConfig } = propertyConfig;
describe('tabConfig', () => {
describe('getTabsFromObject', () => {
it('should return selected and required keys only', () => {
const expectedTabs = [
{
key: 'testProperty',
label: 'testPropertyLabel'
},
{
key: 'requiredProperty',
label: 'requiredPropertyLabel'
}
];
expect(getTabsFromObject({ testProperty: {} } as any)).toEqual(
expectedTabs
);
});
});
describe('sortKeysByConfig', () => {
const testData = {
color: 'blue',
name: 'Jess',
age: '39',
numbers: [1, 2, 3],
_id: '44x099z'
};
it('should sort with presorted keys first', () => {
expect(sortKeysByConfig(testData, 'testProperty')).toEqual([
'name',
'age',
'_id',
'color',
'numbers'
]);
});
it('should alpha-sort keys when there is no config value found', () => {
expect(sortKeysByConfig(testData, 'nonExistentKey')).toEqual([
'_id',
'age',
'color',
'name',
'numbers'
]);
});
});
});

View file

@ -1,125 +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 { EuiIcon } from '@elastic/eui';
import { EuiLink } from '@elastic/eui';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import { i18n } from '@kbn/i18n';
import React from 'react';
import styled from 'styled-components';
import { StringMap } from '../../../../typings/common';
import { AgentName } from '../../../../typings/es_schemas/ui/fields/Agent';
import { fontSize, fontSizes, px, unit, units } from '../../../style/variables';
import { getAgentDocUrlForTab } from '../../../utils/documentation/agents';
import { NestedKeyValueTable } from './NestedKeyValueTable';
import { PropertyTabKey } from './tabConfig';
const TableContainer = styled.div`
padding-bottom: ${px(units.double)};
`;
const TableInfo = styled.div`
padding: ${px(unit)} 0 0;
text-align: center;
font-size: ${fontSize};
color: ${theme.euiColorDarkShade};
line-height: 1.5;
`;
const TableInfoHeader = styled(TableInfo)`
font-size: ${fontSizes.large};
color: ${theme.euiColorDarkestShade};
`;
const EuiIconWithSpace = styled(EuiIcon)`
margin-right: ${px(units.half)};
`;
function getTabHelpText(tabKey: PropertyTabKey) {
switch (tabKey) {
case 'user':
return i18n.translate(
'xpack.apm.propertiesTable.userTab.agentFeatureText',
{
defaultMessage:
'You can configure your agent to add contextual information about your users.'
}
);
case 'labels':
return i18n.translate(
'xpack.apm.propertiesTable.labelsTab.agentFeatureText',
{
defaultMessage:
'You can configure your agent to add filterable tags on transactions.'
}
);
case 'transaction.custom':
case 'error.custom':
return i18n.translate(
'xpack.apm.propertiesTable.customTab.agentFeatureText',
{
defaultMessage:
'You can configure your agent to add custom contextual information on transactions.'
}
);
}
}
export function TabHelpMessage({
tabKey,
agentName
}: {
tabKey?: PropertyTabKey;
agentName?: AgentName;
}) {
if (!tabKey) {
return null;
}
const docsUrl = getAgentDocUrlForTab(tabKey, agentName);
if (!docsUrl) {
return null;
}
return (
<TableInfo>
<EuiIconWithSpace type="iInCircle" />
{getTabHelpText(tabKey)}{' '}
<EuiLink target="_blank" rel="noopener" href={docsUrl}>
{i18n.translate(
'xpack.apm.propertiesTable.agentFeature.learnMoreLinkLabel',
{ defaultMessage: 'Learn more in the documentation.' }
)}
</EuiLink>
</TableInfo>
);
}
export function PropertiesTable({
propData,
propKey,
agentName
}: {
propData?: StringMap;
propKey?: PropertyTabKey;
agentName?: AgentName;
}) {
return (
<TableContainer>
{propData ? (
<NestedKeyValueTable data={propData} parentKey={propKey} depth={1} />
) : (
<TableInfoHeader>
{i18n.translate(
'xpack.apm.propertiesTable.agentFeature.noDataAvailableLabel',
{ defaultMessage: 'No data available' }
)}
</TableInfoHeader>
)}
<TabHelpMessage tabKey={propKey} agentName={agentName} />
</TableContainer>
);
}

View file

@ -1,106 +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 { i18n } from '@kbn/i18n';
export interface Tab {
key: string;
label: string;
}
export const PROPERTY_CONFIG = [
{
key: 'http',
label: i18n.translate('xpack.apm.propertiesTable.tabs.httpLabel', {
defaultMessage: 'HTTP'
}),
required: false,
presortedKeys: []
},
{
key: 'host',
label: i18n.translate('xpack.apm.propertiesTable.tabs.hostLabel', {
defaultMessage: 'Host'
}),
required: false,
presortedKeys: ['hostname', 'architecture', 'platform']
},
{
key: 'service',
label: i18n.translate('xpack.apm.propertiesTable.tabs.serviceLabel', {
defaultMessage: 'Service'
}),
required: false,
presortedKeys: ['runtime', 'framework', 'version']
},
{
key: 'process',
label: i18n.translate('xpack.apm.propertiesTable.tabs.processLabel', {
defaultMessage: 'Process'
}),
required: false,
presortedKeys: ['pid', 'title', 'args']
},
{
key: 'agent',
label: i18n.translate('xpack.apm.propertiesTable.tabs.agentLabel', {
defaultMessage: 'Agent'
}),
required: false,
presortedKeys: []
},
{
key: 'url',
label: i18n.translate('xpack.apm.propertiesTable.tabs.urlLabel', {
defaultMessage: 'URL'
}),
required: false,
presortedKeys: []
},
{
key: 'container',
label: i18n.translate('xpack.apm.propertiesTable.tabs.containerLabel', {
defaultMessage: 'Container'
}),
required: false,
presortedKeys: []
},
{
key: 'user',
label: i18n.translate('xpack.apm.propertiesTable.tabs.userLabel', {
defaultMessage: 'User'
}),
required: true,
presortedKeys: ['id', 'username', 'email']
},
{
key: 'labels',
label: i18n.translate('xpack.apm.propertiesTable.tabs.labelsLabel', {
defaultMessage: 'Labels'
}),
required: true,
presortedKeys: []
},
{
key: 'transaction.custom',
label: i18n.translate(
'xpack.apm.propertiesTable.tabs.transactionCustomLabel',
{
defaultMessage: 'Custom'
}
),
required: false,
presortedKeys: []
},
{
key: 'error.custom',
label: i18n.translate('xpack.apm.propertiesTable.tabs.errorCustomLabel', {
defaultMessage: 'Custom'
}),
required: false,
presortedKeys: []
}
];

View file

@ -1,45 +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 { get, indexBy, uniq } from 'lodash';
import { first, has } from 'lodash';
import { StringMap } from '../../../../typings/common';
import { APMError } from '../../../../typings/es_schemas/ui/APMError';
import { Transaction } from '../../../../typings/es_schemas/ui/Transaction';
import {
PropertyTab,
PropertyTabKey,
TAB_CONFIG,
TabConfig
} from './tabConfigConst';
export function getTabsFromObject(obj: Transaction | APMError): PropertyTab[] {
return TAB_CONFIG.filter(
({ key, required }) => required || has(obj, key)
).map(({ key, label }) => ({ key, label }));
}
export type KeySorter = (data: StringMap, parentKey?: string) => string[];
export const sortKeysByConfig: KeySorter = (object, currentKey) => {
const indexedPropertyConfig = indexBy(TAB_CONFIG, 'key');
const presorted = get(
indexedPropertyConfig,
`${currentKey}.presortedKeys`,
[]
);
return uniq([...presorted, ...Object.keys(object).sort()]);
};
export function getCurrentTab<T extends { key: string; label: string }>(
tabs: T[] = [],
currentTabKey: string | undefined
): T {
const selectedTab = tabs.find(({ key }) => key === currentTabKey);
return selectedTab ? selectedTab : first(tabs) || {};
}
export { TAB_CONFIG, TabConfig, PropertyTab, PropertyTabKey };

View file

@ -1,119 +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 { i18n } from '@kbn/i18n';
import { APMError } from '../../../../typings/es_schemas/ui/APMError';
import { Transaction } from '../../../../typings/es_schemas/ui/Transaction';
export type PropertyTabKey =
| keyof Transaction
| keyof APMError
| 'transaction.custom'
| 'error.custom';
export interface PropertyTab {
key: PropertyTabKey;
label: string;
}
export interface TabConfig extends PropertyTab {
required: boolean;
presortedKeys: string[];
}
export const TAB_CONFIG: TabConfig[] = [
{
key: 'http',
label: i18n.translate('xpack.apm.propertiesTable.tabs.httpLabel', {
defaultMessage: 'HTTP'
}),
required: false,
presortedKeys: []
},
{
key: 'host',
label: i18n.translate('xpack.apm.propertiesTable.tabs.hostLabel', {
defaultMessage: 'Host'
}),
required: false,
presortedKeys: ['hostname', 'architecture', 'platform']
},
{
key: 'service',
label: i18n.translate('xpack.apm.propertiesTable.tabs.serviceLabel', {
defaultMessage: 'Service'
}),
required: false,
presortedKeys: ['runtime', 'framework', 'version']
},
{
key: 'process',
label: i18n.translate('xpack.apm.propertiesTable.tabs.processLabel', {
defaultMessage: 'Process'
}),
required: false,
presortedKeys: ['pid', 'title', 'args']
},
{
key: 'agent',
label: i18n.translate('xpack.apm.propertiesTable.tabs.agentLabel', {
defaultMessage: 'Agent'
}),
required: false,
presortedKeys: []
},
{
key: 'url',
label: i18n.translate('xpack.apm.propertiesTable.tabs.urlLabel', {
defaultMessage: 'URL'
}),
required: false,
presortedKeys: []
},
{
key: 'container',
label: i18n.translate('xpack.apm.propertiesTable.tabs.containerLabel', {
defaultMessage: 'Container'
}),
required: false,
presortedKeys: []
},
{
key: 'user',
label: i18n.translate('xpack.apm.propertiesTable.tabs.userLabel', {
defaultMessage: 'User'
}),
required: true,
presortedKeys: ['id', 'username', 'email']
},
{
key: 'labels',
label: i18n.translate('xpack.apm.propertiesTable.tabs.labelsLabel', {
defaultMessage: 'Labels'
}),
required: true,
presortedKeys: []
},
{
key: 'transaction.custom',
label: i18n.translate(
'xpack.apm.propertiesTable.tabs.transactionCustomLabel',
{
defaultMessage: 'Custom'
}
),
required: false,
presortedKeys: []
},
{
key: 'error.custom',
label: i18n.translate('xpack.apm.propertiesTable.tabs.errorCustomLabel', {
defaultMessage: 'Custom'
}),
required: false,
presortedKeys: []
}
];

View file

@ -17,7 +17,7 @@ import {
units
} from '../../../style/variables';
import { Ellipsis } from '../Icons';
import { PropertiesTable } from '../PropertiesTable';
import { DottedKeyValueTable } from '../DottedKeyValueTable';
const VariablesContainer = styled.div`
background: ${theme.euiColorEmptyShade};
@ -66,7 +66,7 @@ export class Variables extends React.Component<Props> {
</VariablesToggle>
{this.state.isVisible && (
<VariablesTableContainer>
<PropertiesTable propData={this.props.vars} />
<DottedKeyValueTable data={this.props.vars} maxDepth={5} />
</VariablesTableContainer>
)}
</VariablesContainer>

View file

@ -1,60 +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 { AgentName } from '../../../typings/es_schemas/ui/fields/Agent';
import { PropertyTabKey } from '../../components/shared/PropertiesTable/tabConfig';
const AGENT_URL_ROOT = 'https://www.elastic.co/guide/en/apm/agent';
type DocUrls = {
[tabKey in PropertyTabKey]?: { [agentName in AgentName]: string | undefined }
};
const customUrls = {
'js-base': `${AGENT_URL_ROOT}/js-base/4.x/api.html#apm-set-custom-context`,
'rum-js': `${AGENT_URL_ROOT}/js-base/4.x/api.html#apm-set-custom-context`,
java: undefined,
nodejs: `${AGENT_URL_ROOT}/nodejs/2.x/agent-api.html#apm-set-custom-context`,
python: `${AGENT_URL_ROOT}/python/4.x/api.html#api-set-custom-context`,
dotnet: undefined,
ruby: `${AGENT_URL_ROOT}/ruby/2.x/context.html#_adding_custom_context`,
go: undefined
};
const AGENT_DOC_URLS: DocUrls = {
user: {
'js-base': `${AGENT_URL_ROOT}/js-base/4.x/api.html#apm-set-user-context`,
'rum-js': `${AGENT_URL_ROOT}/js-base/4.x/api.html#apm-set-user-context`,
java: `${AGENT_URL_ROOT}/java/1.x/public-api.html#api-transaction-set-user`,
nodejs: `${AGENT_URL_ROOT}/nodejs/2.x/agent-api.html#apm-set-user-context`,
python: `${AGENT_URL_ROOT}/python/4.x/api.html#api-set-user-context`,
dotnet: undefined,
ruby: `${AGENT_URL_ROOT}/ruby/2.x/context.html#_providing_info_about_the_user`,
go: undefined
},
labels: {
'js-base': `${AGENT_URL_ROOT}/js-base/4.x/api.html#apm-add-tags`,
'rum-js': `${AGENT_URL_ROOT}/js-base/4.x/api.html#apm-add-tags`,
java: `${AGENT_URL_ROOT}/java/1.x/public-api.html#api-transaction-add-tag`,
nodejs: `${AGENT_URL_ROOT}/nodejs/2.x/agent-api.html#apm-set-tag`,
python: `${AGENT_URL_ROOT}/python/4.x/api.html#api-tag`,
dotnet: `${AGENT_URL_ROOT}/dotnet/current/public-api.html#api-transaction-tags`,
ruby: `${AGENT_URL_ROOT}/ruby/2.x/context.html#_adding_tags`,
go: undefined
},
'transaction.custom': customUrls,
'error.custom': customUrls
};
export function getAgentDocUrlForTab(
tabKey: PropertyTabKey,
agentName?: AgentName
) {
const agentUrls = AGENT_DOC_URLS[tabKey];
if (agentUrls && agentName) {
return agentUrls[agentName];
}
}

View file

@ -1,10 +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 { metadata } from 'ui/metadata';
const STACK_VERSION = metadata.branch;
export const DROPPED_SPANS_DOCS = `https://www.elastic.co/guide/en/apm/get-started/${STACK_VERSION}/transaction-spans.html#dropped-spans`;

View file

@ -1,15 +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 { metadata } from 'ui/metadata';
const STACK_VERSION = metadata.branch;
const XPACK_URL_ROOT = `https://www.elastic.co/guide/en/x-pack/${STACK_VERSION}`;
export const XPACK_DOCS = {
xpackEmails: `${XPACK_URL_ROOT}/actions-email.html#configuring-email`,
xpackWatcher: `${XPACK_URL_ROOT}/watcher-getting-started.html`
};

View file

@ -72,3 +72,25 @@ export function delay(ms: number) {
// Await this when you need to "flush" promises to immediately resolve or throw in tests
export const tick = () => new Promise(resolve => setImmediate(resolve, 0));
export function expectTextsNotInDocument(output: any, texts: string[]) {
texts.forEach(text => {
try {
output.getByText(text);
} catch (err) {
if (err.message.startsWith('Unable to find an element with the text:')) {
return;
} else {
throw err;
}
}
throw new Error(`Unexpected text found: ${text}`);
});
}
export function expectTextsInDocument(output: any, texts: string[]) {
texts.forEach(text => {
expect(output.getByText(text)).toBeInTheDocument();
});
}

View file

@ -3287,16 +3287,10 @@
"xpack.apm.metrics.transactionChart.transactionDurationLabel": "事务持续时间",
"xpack.apm.metrics.transactionChart.transactionsPerMinuteLabel": "每分钟事务数",
"xpack.apm.notAvailableLabel": "不适用",
"xpack.apm.propertiesTable.agentFeature.learnMoreLinkLabel": "在文档中详细了解。",
"xpack.apm.propertiesTable.agentFeature.noDataAvailableLabel": "没有可用数据",
"xpack.apm.propertiesTable.customTab.agentFeatureText": "您可以配置代理以添加有关事务的定制上下文信息。",
"xpack.apm.propertiesTable.tabs.exceptionStacktraceLabel": "异常堆栈追溯",
"xpack.apm.propertiesTable.tabs.logStacktraceLabel": "日志堆栈追溯",
"xpack.apm.propertiesTable.tabs.processLabel": "进程",
"xpack.apm.propertiesTable.tabs.serviceLabel": "服务",
"xpack.apm.propertiesTable.tabs.timelineLabel": "时间线",
"xpack.apm.propertiesTable.tabs.userLabel": "用户",
"xpack.apm.propertiesTable.userTab.agentFeatureText": "您可以配置代理以添加有关用户的上下文信息。",
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription": "当前有 {serviceName}{transactionType})的作业正在运行。",
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription.viewJobLinkText": "查看现有作业",
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsTitle": "作业已存在",

View file

@ -8151,7 +8151,12 @@ css-what@2.1, css-what@^2.1.2:
resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2"
integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==
css@2.X, css@^2.2.1, css@^2.2.4:
css.escape@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb"
integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=
css@2.X, css@^2.2.1, css@^2.2.3, css@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/css/-/css-2.2.4.tgz#c646755c73971f2bba6a601e2cf2fd71b1298929"
integrity sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw==
@ -14770,6 +14775,20 @@ jest-docblock@^24.0.0:
dependencies:
detect-newline "^2.1.0"
jest-dom@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/jest-dom/-/jest-dom-3.1.3.tgz#9490de549c02366fe586f23bdafffd8374bd1d65"
integrity sha512-V9LdySiA74/spcAKEG3FRMRKnisKlcYr3EeCNYI4n7CWNE7uYg5WoBUHeGXirjWjRYLLZ5vx8rUaR/6x6o75oQ==
dependencies:
chalk "^2.4.1"
css "^2.2.3"
css.escape "^1.5.1"
jest-diff "^24.0.0"
jest-matcher-utils "^24.0.0"
lodash "^4.17.11"
pretty-format "^24.0.0"
redent "^2.0.0"
jest-each@^24.0.0:
version "24.0.0"
resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-24.0.0.tgz#10987a06b21c7ffbfb7706c89d24c52ed864be55"