Observability overview page (#69141)

* creating overview page and menu

* styling the home page

* adjusting breadcrumb

* renaming isnt working

* renaming isnt working

* renaming isnt working

* fixing import

* fixing scroll when resize window

* fixing eslint errors

* prepending links

* adding target option

* refactoring

* adding dark mode support

* fixing prettier format

* fixing i18n

* reverting some unnecessary changes

* addressing PR comments

* fixing functional tests

* ordering observability menu

* fixing tests

* addressing PR comments

* fixing scroll

* addressing pr comments

* addressing pr comments

* creating overview page

* mocking data

* mocking data

* refactoring

* crearting apm chart

* adding overview page

* adding metric charts

* adding charts

* changing mock data location

* adding mock registry

* adding date picker

* adding route validation

* adding io-ts

* adding io-ts

* adding io-ts support

* fixing imports and mock data

* adding app folder

* creating a section for each plugin

* adding stats

* adding domain min max

* refactoring xcoordinaters

* fixing route

* adding bucket size

* adding group property on logs

* adding home page

* dont break page if location  state is undefined

* each component fetches its own data

* Refactoring

* adding loading indicator to chart

* fixing uptime chart

* adding brush functionality to charts

* fixing refresh button and auto refresh function

* adding horizontal line to accordion section

* adding emptySection to dashboard page

* adding add data button

* adding resources section

* removing margins from horizontal rule

* changing min interval to 60s

* fixing empty section

* removing unnecessary code

* adding unit tests

* fixing imports

* adding initial story book for observability

* removeing uptime mock data

* fixing xDomain to show correct data on x-axis

* fixing empty state alignment

* adding story book and other improvements

* adding news component

* adding support to custom colors on EuiProgress and EuiStats

* removing infra mock data

* adding error message when api throwns an error

* adding alert section

* Adding alerts

* adding alert api call

* addressing PR comments

* adding storybook

* adding feedback button

* addressing PR comments

* chamging plugins return data

* fixing kibana app navigation

* fixing unit test

* fixing ts issues

* addressing PR comments

* using lodash truncate

* adding comment

* updating public documentation

* fixing alerts request

* fixing unit test

* fixing unit test

* aligin beta badge to the center

* adding moment duration to get the units as seconds

* addressing PR comments

* addressing PR comments
This commit is contained in:
Cauê Marcondes 2020-07-08 20:52:16 +01:00 committed by GitHub
parent 595e9c2d8d
commit 203fde92ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
75 changed files with 8149 additions and 389 deletions

View file

@ -8,32 +8,33 @@
```typescript
UI_SETTINGS: {
META_FIELDS: string;
DOC_HIGHLIGHT: string;
QUERY_STRING_OPTIONS: string;
QUERY_ALLOW_LEADING_WILDCARDS: string;
SEARCH_QUERY_LANGUAGE: string;
SORT_OPTIONS: string;
COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX: string;
COURIER_SET_REQUEST_PREFERENCE: string;
COURIER_CUSTOM_REQUEST_PREFERENCE: string;
COURIER_MAX_CONCURRENT_SHARD_REQUESTS: string;
COURIER_BATCH_SEARCHES: string;
SEARCH_INCLUDE_FROZEN: string;
HISTOGRAM_BAR_TARGET: string;
HISTOGRAM_MAX_BARS: string;
HISTORY_LIMIT: string;
SHORT_DOTS_ENABLE: string;
FORMAT_DEFAULT_TYPE_MAP: string;
FORMAT_NUMBER_DEFAULT_PATTERN: string;
FORMAT_PERCENT_DEFAULT_PATTERN: string;
FORMAT_BYTES_DEFAULT_PATTERN: string;
FORMAT_CURRENCY_DEFAULT_PATTERN: string;
FORMAT_NUMBER_DEFAULT_LOCALE: string;
TIMEPICKER_REFRESH_INTERVAL_DEFAULTS: string;
TIMEPICKER_QUICK_RANGES: string;
INDEXPATTERN_PLACEHOLDER: string;
FILTERS_PINNED_BY_DEFAULT: string;
FILTERS_EDITOR_SUGGEST_VALUES: string;
readonly META_FIELDS: "metaFields";
readonly DOC_HIGHLIGHT: "doc_table:highlight";
readonly QUERY_STRING_OPTIONS: "query:queryString:options";
readonly QUERY_ALLOW_LEADING_WILDCARDS: "query:allowLeadingWildcards";
readonly SEARCH_QUERY_LANGUAGE: "search:queryLanguage";
readonly SORT_OPTIONS: "sort:options";
readonly COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX: "courier:ignoreFilterIfFieldNotInIndex";
readonly COURIER_SET_REQUEST_PREFERENCE: "courier:setRequestPreference";
readonly COURIER_CUSTOM_REQUEST_PREFERENCE: "courier:customRequestPreference";
readonly COURIER_MAX_CONCURRENT_SHARD_REQUESTS: "courier:maxConcurrentShardRequests";
readonly COURIER_BATCH_SEARCHES: "courier:batchSearches";
readonly SEARCH_INCLUDE_FROZEN: "search:includeFrozen";
readonly HISTOGRAM_BAR_TARGET: "histogram:barTarget";
readonly HISTOGRAM_MAX_BARS: "histogram:maxBars";
readonly HISTORY_LIMIT: "history:limit";
readonly SHORT_DOTS_ENABLE: "shortDots:enable";
readonly FORMAT_DEFAULT_TYPE_MAP: "format:defaultTypeMap";
readonly FORMAT_NUMBER_DEFAULT_PATTERN: "format:number:defaultPattern";
readonly FORMAT_PERCENT_DEFAULT_PATTERN: "format:percent:defaultPattern";
readonly FORMAT_BYTES_DEFAULT_PATTERN: "format:bytes:defaultPattern";
readonly FORMAT_CURRENCY_DEFAULT_PATTERN: "format:currency:defaultPattern";
readonly FORMAT_NUMBER_DEFAULT_LOCALE: "format:number:defaultLocale";
readonly TIMEPICKER_REFRESH_INTERVAL_DEFAULTS: "timepicker:refreshIntervalDefaults";
readonly TIMEPICKER_QUICK_RANGES: "timepicker:quickRanges";
readonly TIMEPICKER_TIME_DEFAULTS: "timepicker:timeDefaults";
readonly INDEXPATTERN_PLACEHOLDER: "indexPattern:placeholder";
readonly FILTERS_PINNED_BY_DEFAULT: "filters:pinnedByDefault";
readonly FILTERS_EDITOR_SUGGEST_VALUES: "filterEditor:suggestValues";
}
```

View file

@ -8,32 +8,33 @@
```typescript
UI_SETTINGS: {
META_FIELDS: string;
DOC_HIGHLIGHT: string;
QUERY_STRING_OPTIONS: string;
QUERY_ALLOW_LEADING_WILDCARDS: string;
SEARCH_QUERY_LANGUAGE: string;
SORT_OPTIONS: string;
COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX: string;
COURIER_SET_REQUEST_PREFERENCE: string;
COURIER_CUSTOM_REQUEST_PREFERENCE: string;
COURIER_MAX_CONCURRENT_SHARD_REQUESTS: string;
COURIER_BATCH_SEARCHES: string;
SEARCH_INCLUDE_FROZEN: string;
HISTOGRAM_BAR_TARGET: string;
HISTOGRAM_MAX_BARS: string;
HISTORY_LIMIT: string;
SHORT_DOTS_ENABLE: string;
FORMAT_DEFAULT_TYPE_MAP: string;
FORMAT_NUMBER_DEFAULT_PATTERN: string;
FORMAT_PERCENT_DEFAULT_PATTERN: string;
FORMAT_BYTES_DEFAULT_PATTERN: string;
FORMAT_CURRENCY_DEFAULT_PATTERN: string;
FORMAT_NUMBER_DEFAULT_LOCALE: string;
TIMEPICKER_REFRESH_INTERVAL_DEFAULTS: string;
TIMEPICKER_QUICK_RANGES: string;
INDEXPATTERN_PLACEHOLDER: string;
FILTERS_PINNED_BY_DEFAULT: string;
FILTERS_EDITOR_SUGGEST_VALUES: string;
readonly META_FIELDS: "metaFields";
readonly DOC_HIGHLIGHT: "doc_table:highlight";
readonly QUERY_STRING_OPTIONS: "query:queryString:options";
readonly QUERY_ALLOW_LEADING_WILDCARDS: "query:allowLeadingWildcards";
readonly SEARCH_QUERY_LANGUAGE: "search:queryLanguage";
readonly SORT_OPTIONS: "sort:options";
readonly COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX: "courier:ignoreFilterIfFieldNotInIndex";
readonly COURIER_SET_REQUEST_PREFERENCE: "courier:setRequestPreference";
readonly COURIER_CUSTOM_REQUEST_PREFERENCE: "courier:customRequestPreference";
readonly COURIER_MAX_CONCURRENT_SHARD_REQUESTS: "courier:maxConcurrentShardRequests";
readonly COURIER_BATCH_SEARCHES: "courier:batchSearches";
readonly SEARCH_INCLUDE_FROZEN: "search:includeFrozen";
readonly HISTOGRAM_BAR_TARGET: "histogram:barTarget";
readonly HISTOGRAM_MAX_BARS: "histogram:maxBars";
readonly HISTORY_LIMIT: "history:limit";
readonly SHORT_DOTS_ENABLE: "shortDots:enable";
readonly FORMAT_DEFAULT_TYPE_MAP: "format:defaultTypeMap";
readonly FORMAT_NUMBER_DEFAULT_PATTERN: "format:number:defaultPattern";
readonly FORMAT_PERCENT_DEFAULT_PATTERN: "format:percent:defaultPattern";
readonly FORMAT_BYTES_DEFAULT_PATTERN: "format:bytes:defaultPattern";
readonly FORMAT_CURRENCY_DEFAULT_PATTERN: "format:currency:defaultPattern";
readonly FORMAT_NUMBER_DEFAULT_LOCALE: "format:number:defaultLocale";
readonly TIMEPICKER_REFRESH_INTERVAL_DEFAULTS: "timepicker:refreshIntervalDefaults";
readonly TIMEPICKER_QUICK_RANGES: "timepicker:quickRanges";
readonly TIMEPICKER_TIME_DEFAULTS: "timepicker:timeDefaults";
readonly INDEXPATTERN_PLACEHOLDER: "indexPattern:placeholder";
readonly FILTERS_PINNED_BY_DEFAULT: "filters:pinnedByDefault";
readonly FILTERS_EDITOR_SUGGEST_VALUES: "filterEditor:suggestValues";
}
```

View file

@ -26,4 +26,5 @@ export const storybookAliases = {
infra: 'x-pack/legacy/plugins/infra/scripts/storybook.js',
security_solution: 'x-pack/plugins/security_solution/scripts/storybook.js',
ui_actions_enhanced: 'x-pack/plugins/ui_actions_enhanced/scripts/storybook.js',
observability: 'x-pack/plugins/observability/scripts/storybook.js',
};

View file

@ -44,7 +44,8 @@ export const UI_SETTINGS = {
FORMAT_NUMBER_DEFAULT_LOCALE: 'format:number:defaultLocale',
TIMEPICKER_REFRESH_INTERVAL_DEFAULTS: 'timepicker:refreshIntervalDefaults',
TIMEPICKER_QUICK_RANGES: 'timepicker:quickRanges',
TIMEPICKER_TIME_DEFAULTS: 'timepicker:timeDefaults',
INDEXPATTERN_PLACEHOLDER: 'indexPattern:placeholder',
FILTERS_PINNED_BY_DEFAULT: 'filters:pinnedByDefault',
FILTERS_EDITOR_SUGGEST_VALUES: 'filterEditor:suggestValues',
};
} as const;

View file

@ -1906,33 +1906,34 @@ export interface TimeRange {
//
// @public (undocumented)
export const UI_SETTINGS: {
META_FIELDS: string;
DOC_HIGHLIGHT: string;
QUERY_STRING_OPTIONS: string;
QUERY_ALLOW_LEADING_WILDCARDS: string;
SEARCH_QUERY_LANGUAGE: string;
SORT_OPTIONS: string;
COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX: string;
COURIER_SET_REQUEST_PREFERENCE: string;
COURIER_CUSTOM_REQUEST_PREFERENCE: string;
COURIER_MAX_CONCURRENT_SHARD_REQUESTS: string;
COURIER_BATCH_SEARCHES: string;
SEARCH_INCLUDE_FROZEN: string;
HISTOGRAM_BAR_TARGET: string;
HISTOGRAM_MAX_BARS: string;
HISTORY_LIMIT: string;
SHORT_DOTS_ENABLE: string;
FORMAT_DEFAULT_TYPE_MAP: string;
FORMAT_NUMBER_DEFAULT_PATTERN: string;
FORMAT_PERCENT_DEFAULT_PATTERN: string;
FORMAT_BYTES_DEFAULT_PATTERN: string;
FORMAT_CURRENCY_DEFAULT_PATTERN: string;
FORMAT_NUMBER_DEFAULT_LOCALE: string;
TIMEPICKER_REFRESH_INTERVAL_DEFAULTS: string;
TIMEPICKER_QUICK_RANGES: string;
INDEXPATTERN_PLACEHOLDER: string;
FILTERS_PINNED_BY_DEFAULT: string;
FILTERS_EDITOR_SUGGEST_VALUES: string;
readonly META_FIELDS: "metaFields";
readonly DOC_HIGHLIGHT: "doc_table:highlight";
readonly QUERY_STRING_OPTIONS: "query:queryString:options";
readonly QUERY_ALLOW_LEADING_WILDCARDS: "query:allowLeadingWildcards";
readonly SEARCH_QUERY_LANGUAGE: "search:queryLanguage";
readonly SORT_OPTIONS: "sort:options";
readonly COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX: "courier:ignoreFilterIfFieldNotInIndex";
readonly COURIER_SET_REQUEST_PREFERENCE: "courier:setRequestPreference";
readonly COURIER_CUSTOM_REQUEST_PREFERENCE: "courier:customRequestPreference";
readonly COURIER_MAX_CONCURRENT_SHARD_REQUESTS: "courier:maxConcurrentShardRequests";
readonly COURIER_BATCH_SEARCHES: "courier:batchSearches";
readonly SEARCH_INCLUDE_FROZEN: "search:includeFrozen";
readonly HISTOGRAM_BAR_TARGET: "histogram:barTarget";
readonly HISTOGRAM_MAX_BARS: "histogram:maxBars";
readonly HISTORY_LIMIT: "history:limit";
readonly SHORT_DOTS_ENABLE: "shortDots:enable";
readonly FORMAT_DEFAULT_TYPE_MAP: "format:defaultTypeMap";
readonly FORMAT_NUMBER_DEFAULT_PATTERN: "format:number:defaultPattern";
readonly FORMAT_PERCENT_DEFAULT_PATTERN: "format:percent:defaultPattern";
readonly FORMAT_BYTES_DEFAULT_PATTERN: "format:bytes:defaultPattern";
readonly FORMAT_CURRENCY_DEFAULT_PATTERN: "format:currency:defaultPattern";
readonly FORMAT_NUMBER_DEFAULT_LOCALE: "format:number:defaultLocale";
readonly TIMEPICKER_REFRESH_INTERVAL_DEFAULTS: "timepicker:refreshIntervalDefaults";
readonly TIMEPICKER_QUICK_RANGES: "timepicker:quickRanges";
readonly TIMEPICKER_TIME_DEFAULTS: "timepicker:timeDefaults";
readonly INDEXPATTERN_PLACEHOLDER: "indexPattern:placeholder";
readonly FILTERS_PINNED_BY_DEFAULT: "filters:pinnedByDefault";
readonly FILTERS_EDITOR_SUGGEST_VALUES: "filterEditor:suggestValues";
};

View file

@ -767,33 +767,34 @@ export type TStrategyTypes = typeof ES_SEARCH_STRATEGY | string;
//
// @public (undocumented)
export const UI_SETTINGS: {
META_FIELDS: string;
DOC_HIGHLIGHT: string;
QUERY_STRING_OPTIONS: string;
QUERY_ALLOW_LEADING_WILDCARDS: string;
SEARCH_QUERY_LANGUAGE: string;
SORT_OPTIONS: string;
COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX: string;
COURIER_SET_REQUEST_PREFERENCE: string;
COURIER_CUSTOM_REQUEST_PREFERENCE: string;
COURIER_MAX_CONCURRENT_SHARD_REQUESTS: string;
COURIER_BATCH_SEARCHES: string;
SEARCH_INCLUDE_FROZEN: string;
HISTOGRAM_BAR_TARGET: string;
HISTOGRAM_MAX_BARS: string;
HISTORY_LIMIT: string;
SHORT_DOTS_ENABLE: string;
FORMAT_DEFAULT_TYPE_MAP: string;
FORMAT_NUMBER_DEFAULT_PATTERN: string;
FORMAT_PERCENT_DEFAULT_PATTERN: string;
FORMAT_BYTES_DEFAULT_PATTERN: string;
FORMAT_CURRENCY_DEFAULT_PATTERN: string;
FORMAT_NUMBER_DEFAULT_LOCALE: string;
TIMEPICKER_REFRESH_INTERVAL_DEFAULTS: string;
TIMEPICKER_QUICK_RANGES: string;
INDEXPATTERN_PLACEHOLDER: string;
FILTERS_PINNED_BY_DEFAULT: string;
FILTERS_EDITOR_SUGGEST_VALUES: string;
readonly META_FIELDS: "metaFields";
readonly DOC_HIGHLIGHT: "doc_table:highlight";
readonly QUERY_STRING_OPTIONS: "query:queryString:options";
readonly QUERY_ALLOW_LEADING_WILDCARDS: "query:allowLeadingWildcards";
readonly SEARCH_QUERY_LANGUAGE: "search:queryLanguage";
readonly SORT_OPTIONS: "sort:options";
readonly COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX: "courier:ignoreFilterIfFieldNotInIndex";
readonly COURIER_SET_REQUEST_PREFERENCE: "courier:setRequestPreference";
readonly COURIER_CUSTOM_REQUEST_PREFERENCE: "courier:customRequestPreference";
readonly COURIER_MAX_CONCURRENT_SHARD_REQUESTS: "courier:maxConcurrentShardRequests";
readonly COURIER_BATCH_SEARCHES: "courier:batchSearches";
readonly SEARCH_INCLUDE_FROZEN: "search:includeFrozen";
readonly HISTOGRAM_BAR_TARGET: "histogram:barTarget";
readonly HISTOGRAM_MAX_BARS: "histogram:maxBars";
readonly HISTORY_LIMIT: "history:limit";
readonly SHORT_DOTS_ENABLE: "shortDots:enable";
readonly FORMAT_DEFAULT_TYPE_MAP: "format:defaultTypeMap";
readonly FORMAT_NUMBER_DEFAULT_PATTERN: "format:number:defaultPattern";
readonly FORMAT_PERCENT_DEFAULT_PATTERN: "format:percent:defaultPattern";
readonly FORMAT_BYTES_DEFAULT_PATTERN: "format:bytes:defaultPattern";
readonly FORMAT_CURRENCY_DEFAULT_PATTERN: "format:currency:defaultPattern";
readonly FORMAT_NUMBER_DEFAULT_LOCALE: "format:number:defaultLocale";
readonly TIMEPICKER_REFRESH_INTERVAL_DEFAULTS: "timepicker:refreshIntervalDefaults";
readonly TIMEPICKER_QUICK_RANGES: "timepicker:quickRanges";
readonly TIMEPICKER_TIME_DEFAULTS: "timepicker:timeDefaults";
readonly INDEXPATTERN_PLACEHOLDER: "indexPattern:placeholder";
readonly FILTERS_PINNED_BY_DEFAULT: "filters:pinnedByDefault";
readonly FILTERS_EDITOR_SUGGEST_VALUES: "filterEditor:suggestValues";
};

View file

@ -6,7 +6,6 @@
import { i18n } from '@kbn/i18n';
import { lazy } from 'react';
import { euiThemeVars as theme } from '@kbn/ui-shared-deps/theme';
import { ConfigSchema } from '.';
import { ObservabilityPluginSetup } from '../../observability/public';
import {
@ -83,7 +82,7 @@ export class ApmPlugin implements Plugin<ApmPluginSetup, ApmPluginStart> {
plugins.observability.dashboard.register({
appName: 'apm',
fetchData: async (params) => {
return fetchLandingPageData(params, { theme });
return fetchLandingPageData(params);
},
hasData,
});

View file

@ -6,7 +6,6 @@
import { fetchLandingPageData, hasData } from './observability_dashboard';
import * as createCallApmApi from './createCallApmApi';
import { euiThemeVars as theme } from '@kbn/ui-shared-deps/theme';
describe('Observability dashboard data', () => {
const callApmApiMock = jest.spyOn(createCallApmApi, 'callApmApi');
@ -38,39 +37,31 @@ describe('Observability dashboard data', () => {
],
})
);
const response = await fetchLandingPageData(
{
startTime: '1',
endTime: '2',
bucketSize: '3',
},
{ theme }
);
const response = await fetchLandingPageData({
startTime: '1',
endTime: '2',
bucketSize: '3',
});
expect(response).toEqual({
title: 'APM',
appLink: '/app/apm',
stats: {
services: {
type: 'number',
label: 'Services',
value: 10,
},
transactions: {
type: 'number',
label: 'Transactions',
value: 2,
color: '#6092c0',
},
},
series: {
transactions: {
label: 'Transactions',
coordinates: [
{ x: 1, y: 1 },
{ x: 2, y: 2 },
{ x: 3, y: 3 },
],
color: '#6092c0',
},
},
});
@ -82,35 +73,27 @@ describe('Observability dashboard data', () => {
transactionCoordinates: [],
})
);
const response = await fetchLandingPageData(
{
startTime: '1',
endTime: '2',
bucketSize: '3',
},
{ theme }
);
const response = await fetchLandingPageData({
startTime: '1',
endTime: '2',
bucketSize: '3',
});
expect(response).toEqual({
title: 'APM',
appLink: '/app/apm',
stats: {
services: {
type: 'number',
label: 'Services',
value: 0,
},
transactions: {
type: 'number',
label: 'Transactions',
value: 0,
color: '#6092c0',
},
},
series: {
transactions: {
label: 'Transactions',
coordinates: [],
color: '#6092c0',
},
},
});
@ -122,35 +105,27 @@ describe('Observability dashboard data', () => {
transactionCoordinates: [{ x: 1 }, { x: 2 }, { x: 3 }],
})
);
const response = await fetchLandingPageData(
{
startTime: '1',
endTime: '2',
bucketSize: '3',
},
{ theme }
);
const response = await fetchLandingPageData({
startTime: '1',
endTime: '2',
bucketSize: '3',
});
expect(response).toEqual({
title: 'APM',
appLink: '/app/apm',
stats: {
services: {
type: 'number',
label: 'Services',
value: 0,
},
transactions: {
type: 'number',
label: 'Transactions',
value: 0,
color: '#6092c0',
},
},
series: {
transactions: {
label: 'Transactions',
coordinates: [{ x: 1 }, { x: 2 }, { x: 3 }],
color: '#6092c0',
},
},
});

View file

@ -6,21 +6,17 @@
import { i18n } from '@kbn/i18n';
import { mean } from 'lodash';
import { Theme } from '@kbn/ui-shared-deps/theme';
import {
ApmFetchDataResponse,
FetchDataParams,
} from '../../../../observability/public';
import { callApmApi } from './createCallApmApi';
interface Options {
theme: Theme;
}
export const fetchLandingPageData = async (
{ startTime, endTime, bucketSize }: FetchDataParams,
{ theme }: Options
): Promise<ApmFetchDataResponse> => {
export const fetchLandingPageData = async ({
startTime,
endTime,
bucketSize,
}: FetchDataParams): Promise<ApmFetchDataResponse> => {
const data = await callApmApi({
pathname: '/api/apm/observability_dashboard',
params: { query: { start: startTime, end: endTime, bucketSize } },
@ -36,34 +32,20 @@ export const fetchLandingPageData = async (
stats: {
services: {
type: 'number',
label: i18n.translate(
'xpack.apm.observabilityDashboard.stats.services',
{ defaultMessage: 'Services' }
),
value: serviceCount,
},
transactions: {
type: 'number',
label: i18n.translate(
'xpack.apm.observabilityDashboard.stats.transactions',
{ defaultMessage: 'Transactions' }
),
value:
mean(
transactionCoordinates
.map(({ y }) => y)
.filter((y) => y && isFinite(y))
) || 0,
color: theme.euiColorVis1,
},
},
series: {
transactions: {
label: i18n.translate(
'xpack.apm.observabilityDashboard.chart.transactions',
{ defaultMessage: 'Transactions' }
),
color: theme.euiColorVis1,
coordinates: transactionCoordinates,
},
},

View file

@ -91,7 +91,6 @@ Object {
"y": 3.5,
},
],
"label": "Inbound traffic",
},
"outboundTraffic": Object {
"coordinates": Array [
@ -180,32 +179,26 @@ Object {
"y": 4,
},
],
"label": "Outbound traffic",
},
},
"stats": Object {
"cpu": Object {
"label": "CPU usage",
"type": "percent",
"value": 0.0015,
},
"hosts": Object {
"label": "Hosts",
"type": "number",
"value": 2,
},
"inboundTraffic": Object {
"label": "Inbound traffic",
"type": "bytesPerSecond",
"value": 3.5,
},
"memory": Object {
"label": "Memory usage",
"type": "percent",
"value": 0.0015,
},
"outboundTraffic": Object {
"label": "Outbound traffic",
"type": "bytesPerSecond",
"value": 3,
},

View file

@ -103,14 +103,6 @@ export const createMetricsFetchData = (
body: JSON.stringify(snapshotRequest),
});
const inboundLabel = i18n.translate('xpack.infra.observabilityHomepage.metrics.rxLabel', {
defaultMessage: 'Inbound traffic',
});
const outboundLabel = i18n.translate('xpack.infra.observabilityHomepage.metrics.txLabel', {
defaultMessage: 'Outbound traffic',
});
return {
title: i18n.translate('xpack.infra.observabilityHomepage.metrics.title', {
defaultMessage: 'Metrics',
@ -119,43 +111,30 @@ export const createMetricsFetchData = (
stats: {
hosts: {
type: 'number',
label: i18n.translate('xpack.infra.observabilityHomepage.metrics.hostsLabel', {
defaultMessage: 'Hosts',
}),
value: results.nodes.length,
},
cpu: {
type: 'percent',
label: i18n.translate('xpack.infra.observabilityHomepage.metrics.cpuLabel', {
defaultMessage: 'CPU usage',
}),
value: combineNodesBy('cpu', results.nodes, average),
},
memory: {
type: 'percent',
label: i18n.translate('xpack.infra.observabilityHomepage.metrics.memoryLabel', {
defaultMessage: 'Memory usage',
}),
value: combineNodesBy('memory', results.nodes, average),
},
inboundTraffic: {
type: 'bytesPerSecond',
label: inboundLabel,
value: combineNodesBy('rx', results.nodes, average),
},
outboundTraffic: {
type: 'bytesPerSecond',
label: outboundLabel,
value: combineNodesBy('tx', results.nodes, average),
},
},
series: {
inboundTraffic: {
label: inboundLabel,
coordinates: combineNodeTimeseriesBy('rx', results.nodes, average),
},
outboundTraffic: {
label: outboundLabel,
coordinates: combineNodeTimeseriesBy('tx', results.nodes, average),
},
},

View file

@ -3,23 +3,64 @@
* 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 { createHashHistory } from 'history';
import React, { useEffect } from 'react';
import ReactDOM from 'react-dom';
import { EuiThemeProvider } from '../../../../legacy/common/eui_styled_components';
import { Route, Router, Switch } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { RedirectAppLinks } from '../../../../../src/plugins/kibana_react/public';
import { AppMountParameters, CoreStart } from '../../../../../src/core/public';
import { Home } from '../pages/home';
import { EuiThemeProvider } from '../../../../legacy/common/eui_styled_components';
import { PluginContext } from '../context/plugin_context';
import { useUrlParams } from '../hooks/use_url_params';
import { routes } from '../routes';
import { usePluginContext } from '../hooks/use_plugin_context';
const App = () => {
return (
<>
<Switch>
{Object.keys(routes).map((key) => {
const path = key as keyof typeof routes;
const route = routes[path];
const Wrapper = () => {
const { core } = usePluginContext();
useEffect(() => {
core.chrome.setBreadcrumbs([
{
text: i18n.translate('xpack.observability.observability.breadcrumb.', {
defaultMessage: 'Observability',
}),
},
...route.breadcrumb,
]);
}, [core]);
const { query, path: pathParams } = useUrlParams(route.params);
return route.handler({ query, path: pathParams });
};
return <Route key={path} path={path} exact={true} component={Wrapper} />;
})}
</Switch>
</>
);
};
export const renderApp = (core: CoreStart, { element }: AppMountParameters) => {
const i18nCore = core.i18n;
const isDarkMode = core.uiSettings.get('theme:darkMode');
const history = createHashHistory();
ReactDOM.render(
<PluginContext.Provider value={{ core }}>
<EuiThemeProvider darkMode={isDarkMode}>
<i18nCore.Context>
<Home />
</i18nCore.Context>
</EuiThemeProvider>
<Router history={history}>
<EuiThemeProvider darkMode={isDarkMode}>
<i18nCore.Context>
<RedirectAppLinks application={core.application}>
<App />
</RedirectAppLinks>
</i18nCore.Context>
</EuiThemeProvider>
</Router>
</PluginContext.Provider>,
element
);

View file

@ -0,0 +1,29 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { ChartContainer } from './';
describe('chart container', () => {
it('shows loading indicator', () => {
const component = render(
<ChartContainer height={100} isInitialLoad={true}>
<div>My amazing component</div>
</ChartContainer>
);
expect(component.getByTestId('loading')).toBeInTheDocument();
expect(component.queryByText('My amazing component')).not.toBeInTheDocument();
});
it("doesn't show loading indicator", () => {
const component = render(
<ChartContainer height={100} isInitialLoad={false}>
<div>My amazing component</div>
</ChartContainer>
);
expect(component.queryByTestId('loading')).not.toBeInTheDocument();
expect(component.getByText('My amazing component')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,43 @@
/*
* 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 { Chart } from '@elastic/charts';
import { EuiLoadingChart } from '@elastic/eui';
import { EuiLoadingChartSize } from '@elastic/eui/src/components/loading/loading_chart';
import React from 'react';
interface Props {
isInitialLoad: boolean;
height?: number;
width?: number;
iconSize?: EuiLoadingChartSize;
children: React.ReactNode;
}
const CHART_HEIGHT = 170;
export const ChartContainer = ({
isInitialLoad,
children,
iconSize = 'xl',
height = CHART_HEIGHT,
}: Props) => {
if (isInitialLoad) {
return (
<div
data-test-subj="loading"
style={{
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<EuiLoadingChart size={iconSize} />
</div>
);
}
return <Chart size={{ height }}>{children}</Chart>;
};

View file

@ -0,0 +1,41 @@
/*
* 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 { ISection } from '../../../typings/section';
import { render } from '../../../utils/test_helper';
import { EmptySection } from './';
describe('EmptySection', () => {
it('renders without action button', () => {
const section: ISection = {
id: 'apm',
title: 'APM',
icon: 'logoAPM',
description: 'foo bar',
};
const { getByText, queryAllByText } = render(<EmptySection section={section} />);
expect(getByText('APM')).toBeInTheDocument();
expect(getByText('foo bar')).toBeInTheDocument();
expect(queryAllByText('Install agent')).toEqual([]);
});
it('renders with action button', () => {
const section: ISection = {
id: 'apm',
title: 'APM',
icon: 'logoAPM',
description: 'foo bar',
linkTitle: 'install agent',
href: 'https://www.elastic.co',
};
const { getByText, getByTestId } = render(<EmptySection section={section} />);
expect(getByText('APM')).toBeInTheDocument();
expect(getByText('foo bar')).toBeInTheDocument();
const linkButton = getByTestId('empty-apm') as HTMLAnchorElement;
expect(linkButton.href).toEqual('https://www.elastic.co/');
});
});

View file

@ -0,0 +1,41 @@
/*
* 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, EuiText } from '@elastic/eui';
import React from 'react';
import { ISection } from '../../../typings/section';
interface Props {
section: ISection;
}
export const EmptySection = ({ section }: Props) => {
return (
<EuiEmptyPrompt
style={{ maxWidth: 'none' }}
iconType={section.icon}
iconColor="default"
title={<h2>{section.title}</h2>}
titleSize="xs"
body={<EuiText color="default">{section.description}</EuiText>}
actions={
<>
{section.linkTitle && (
<EuiButton
size="s"
color="primary"
fill
href={section.href}
target={section.target}
data-test-subj={`empty-${section.id}`}
>
{section.linkTitle}
</EuiButton>
)}
</>
}
/>
);
};

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 { render } from '../../../utils/test_helper';
import { Header } from './';
describe('Header', () => {
it('renders without add data button', () => {
const { getByText, queryAllByText, getByTestId } = render(<Header color="#fff" />);
expect(getByTestId('observability-logo')).toBeInTheDocument();
expect(getByText('Observability')).toBeInTheDocument();
expect(queryAllByText('Add data')).toEqual([]);
});
it('renders with add data button', () => {
const { getByText, getByTestId } = render(<Header color="#fff" showAddData />);
expect(getByTestId('observability-logo')).toBeInTheDocument();
expect(getByText('Observability')).toBeInTheDocument();
expect(getByText('Add data')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,97 @@
/*
* 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 {
EuiBetaBadge,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import styled from 'styled-components';
import { usePluginContext } from '../../../hooks/use_plugin_context';
const Container = styled.div<{ color: string }>`
background: ${(props) => props.color};
border-bottom: ${(props) => props.theme.eui.euiBorderThin};
`;
const Wrapper = styled.div<{ restrictWidth?: number }>`
width: 100%;
max-width: ${(props) => `${props.restrictWidth}px`};
margin: 0 auto;
overflow: hidden;
padding: ${(props) => (props.restrictWidth ? 0 : '0 24px')};
`;
interface Props {
color: string;
showAddData?: boolean;
restrictWidth?: number;
showGiveFeedback?: boolean;
}
export const Header = ({
color,
restrictWidth,
showAddData = false,
showGiveFeedback = false,
}: Props) => {
const { core } = usePluginContext();
return (
<Container color={color}>
<Wrapper restrictWidth={restrictWidth}>
<EuiSpacer size="l" />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiIcon type="logoObservability" size="xxl" data-test-subj="observability-logo" />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="m">
<h1>
{i18n.translate('xpack.observability.home.title', {
defaultMessage: 'Observability',
})}{' '}
<EuiBetaBadge
className="eui-alignMiddle"
label={i18n.translate('xpack.observability.beta', { defaultMessage: 'Beta' })}
tooltipContent="This feature is in beta. Please help us improve it by reporting any bugs or give us feedback."
/>
</h1>
</EuiTitle>
</EuiFlexItem>
{showGiveFeedback && (
<EuiFlexItem style={{ alignItems: 'flex-end' }} grow={false}>
<EuiButtonEmpty
href={'https://discuss.elastic.co/c/observability/'}
iconType="popout"
>
{i18n.translate('xpack.observability.home.feedback', {
defaultMessage: 'Give us feedback',
})}
</EuiButtonEmpty>
</EuiFlexItem>
)}
{showAddData && (
<EuiFlexItem style={{ alignItems: 'flex-end' }} grow={false}>
<EuiButtonEmpty
href={core.http.basePath.prepend('/app/home#/tutorial_directory/logging')}
iconType="plusInCircle"
>
{i18n.translate('xpack.observability.home.addData', { defaultMessage: 'Add data' })}
</EuiButtonEmpty>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="l" />
</Wrapper>
</Container>
);
};

View file

@ -0,0 +1,54 @@
/*
* 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 { EuiPage, EuiPageBody, EuiPageProps } from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
import { Header } from '../header/index';
const getPaddingSize = (props: EuiPageProps) => (props.restrictWidth ? 0 : '24px');
const Page = styled(EuiPage)<EuiPageProps>`
background: transparent;
padding-right: ${getPaddingSize};
padding-left: ${getPaddingSize};
`;
const Container = styled.div<{ color?: string }>`
overflow-y: hidden;
min-height: calc(100vh - ${(props) => props.theme.eui.euiHeaderChildSize});
background: ${(props) => props.color};
`;
interface Props {
headerColor: string;
bodyColor: string;
children?: React.ReactNode;
restrictWidth?: number;
showAddData?: boolean;
showGiveFeedback?: boolean;
}
export const WithHeaderLayout = ({
headerColor,
bodyColor,
children,
restrictWidth,
showAddData,
showGiveFeedback,
}: Props) => (
<Container color={bodyColor}>
<Header
color={headerColor}
restrictWidth={restrictWidth}
showAddData={showAddData}
showGiveFeedback={showGiveFeedback}
/>
<Page restrictWidth={restrictWidth}>
<EuiPageBody>{children}</EuiPageBody>
</Page>
</Container>
);

View file

@ -0,0 +1,3 @@
.obsNewsFeed__itemImg{
@include euiBottomShadowSmall;
}

View file

@ -0,0 +1,16 @@
/*
* 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 } from '../../../utils/test_helper';
import { News } from './';
describe('News', () => {
it('renders resources with all elements', () => {
const { getByText, getAllByText } = render(<News />);
expect(getByText("What's new")).toBeInTheDocument();
expect(getAllByText('Read full story')).not.toEqual([]);
});
});

View file

@ -0,0 +1,97 @@
/*
* 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,
EuiHorizontalRule,
EuiLink,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useContext } from 'react';
import { ThemeContext } from 'styled-components';
import './index.scss';
import { truncate } from 'lodash';
import { news as newsMockData } from './mock/news.mock.data';
interface NewsItem {
title: string;
description: string;
link_url: string;
image_url: string;
}
export const News = () => {
const newsItems: NewsItem[] = newsMockData;
return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h4>
{i18n.translate('xpack.observability.news.title', {
defaultMessage: "What's new",
})}
</h4>
</EuiTitle>
</EuiFlexItem>
{newsItems.map((item, index) => (
<EuiFlexItem key={index} grow={false}>
<NewsItem item={item} />
</EuiFlexItem>
))}
</EuiFlexGroup>
);
};
const limitString = (string: string, limit: number) => truncate(string, { length: limit });
const NewsItem = ({ item }: { item: NewsItem }) => {
const theme = useContext(ThemeContext);
return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiTitle size="xxxs">
<h4>{item.title}</h4>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="s" alignItems="baseline">
<EuiFlexItem>
<EuiText grow={false} size="xs" color="subdued">
{limitString(item.description, 128)}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink href={item.link_url} target="_blank">
<EuiText size="xs">
{i18n.translate('xpack.observability.news.readFullStory', {
defaultMessage: 'Read full story',
})}
</EuiText>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<img
style={{ border: theme.eui.euiBorderThin }}
width={48}
height={48}
alt={item.title}
src={item.image_url}
className="obsNewsFeed__itemImg"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiHorizontalRule margin="s" />
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,34 @@
/*
* 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.
*/
export const news = [
{
title: 'Have SIEM questions?',
description:
'Join our growing community of Elastic SIEM users to discuss the configuration and use of Elastic SIEM for threat detection and response.',
link_url: 'https://discuss.elastic.co/c/security/siem/?blade=securitysolutionfeed',
image_url:
'https://aws1.discourse-cdn.com/elastic/original/3X/f/8/f8c3d0b9971cfcd0be349d973aa5799f71d280cc.png?blade=securitysolutionfeed',
},
{
title: 'Elastic SIEM on-demand training course — free for a limited time',
description:
'With this self-paced, on-demand course, you will learn how to leverage Elastic SIEM to drive your security operations and threat hunting. This course is designed for security analysts and practitioners who have used other SIEMs or are familiar with SIEM concepts.',
link_url:
'https://training.elastic.co/elearning/security-analytics/elastic-siem-fundamentals-promo?blade=securitysolutionfeed',
image_url:
'https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt50f58e0358ebea9d/5c30508693d9791a70cd73ad/illustration-specialization-course-page-security.svg?blade=securitysolutionfeed',
},
{
title: 'New to Elastic SIEM? Take our on-demand training course',
description:
'With this self-paced, on-demand course, you will learn how to leverage Elastic SIEM to drive your security operations and threat hunting. This course is designed for security analysts and practitioners who have used other SIEMs or are familiar with SIEM concepts.',
link_url:
'https://www.elastic.co/training/specializations/security-analytics/elastic-siem-fundamentals?blade=securitysolutionfeed',
image_url:
'https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt50f58e0358ebea9d/5c30508693d9791a70cd73ad/illustration-specialization-course-page-security.svg?blade=securitysolutionfeed',
},
];

View file

@ -0,0 +1,17 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { Resources } from './';
describe('Resources', () => {
it('renders resources with all elements', () => {
const { getByText } = render(<Resources />);
expect(getByText('Documentation')).toBeInTheDocument();
expect(getByText('Discuss forum')).toBeInTheDocument();
expect(getByText('Observability fundamentals')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,49 @@
/*
* 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, EuiListGroup, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
const resources = [
{
iconType: 'documents',
label: i18n.translate('xpack.observability.resources.documentation', {
defaultMessage: 'Documentation',
}),
href: 'https://www.elastic.co/guide/en/observability/current/observability-ui.html',
},
{
iconType: 'editorComment',
label: i18n.translate('xpack.observability.resources.forum', {
defaultMessage: 'Discuss forum',
}),
href: 'https://discuss.elastic.co/c/observability/',
},
{
iconType: 'training',
label: i18n.translate('xpack.observability.resources.training', {
defaultMessage: 'Observability fundamentals',
}),
href: 'https://www.elastic.co/training/observability-fundamentals',
},
];
export const Resources = () => {
return (
<EuiFlexGroup direction="column" alignItems="flexStart" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h4>
{i18n.translate('xpack.observability.resources.title', {
defaultMessage: 'Resources',
})}
</h4>
</EuiTitle>
</EuiFlexItem>
<EuiListGroup flush listItems={resources} data-test-subj="list-group" />
</EuiFlexGroup>
);
};

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 {
EuiBadge,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiIconTip,
EuiLink,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import React, { useState } from 'react';
import { EuiSelect } from '@elastic/eui';
import { uniqBy } from 'lodash';
import { Alert } from '../../../../../../alerts/common';
import { usePluginContext } from '../../../../hooks/use_plugin_context';
import { SectionContainer } from '..';
const ALL_TYPES = 'ALL_TYPES';
const allTypes = {
value: ALL_TYPES,
text: i18n.translate('xpack.observability.overview.alert.allTypes', {
defaultMessage: 'All types',
}),
};
interface Props {
alerts: Alert[];
}
export const AlertsSection = ({ alerts }: Props) => {
const { core } = usePluginContext();
const [filter, setFilter] = useState(ALL_TYPES);
const filterOptions = uniqBy(alerts, (alert) => alert.consumer).map(({ consumer }) => ({
value: consumer,
text: consumer,
}));
return (
<SectionContainer
title="Alerts"
appLink={'/app/management/insightsAndAlerting/triggersActions/alerts'}
hasError={false}
appLinkName={i18n.translate('xpack.observability.overview.alert.appLink', {
defaultMessage: 'Manage alerts',
})}
>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiSelect
compressed
id="filterAlerts"
options={[allTypes, ...filterOptions]}
value={filter}
onChange={(e) => setFilter(e.target.value)}
prepend={i18n.translate('xpack.observability.overview.alert.view', {
defaultMessage: 'View',
})}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiHorizontalRule margin="xs" />
<EuiFlexItem>
{alerts
.filter((alert) => filter === ALL_TYPES || alert.consumer === filter)
.map((alert, index) => {
const isLastElement = index === alerts.length - 1;
return (
<EuiFlexGroup direction="column" gutterSize="s" key={alert.id}>
<EuiFlexItem>
<EuiLink
href={core.http.basePath.prepend(
`/app/management/insightsAndAlerting/triggersActions/alert/${alert.id}`
)}
>
{alert.name}
</EuiLink>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiBadge color="hollow">{alert.alertTypeId}</EuiBadge>
</EuiFlexItem>
{alert.tags.map((tag, idx) => {
return (
<EuiFlexItem key={idx} grow={false}>
<EuiBadge color="default">{tag}</EuiBadge>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiText color="subdued" size="xs">
Updated {moment.duration(moment().diff(alert.updatedAt)).humanize()} ago
</EuiText>
</EuiFlexItem>
{alert.muteAll && (
<EuiFlexItem grow={false}>
<EuiIconTip
type="minusInCircle"
content={i18n.translate('xpack.observability.overview.alerts.muted', {
defaultMessage: 'Muted',
})}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
{!isLastElement && <EuiHorizontalRule margin="xs" />}
</EuiFlexGroup>
);
})}
</EuiFlexItem>
</EuiFlexGroup>
</SectionContainer>
);
};

View file

@ -0,0 +1,53 @@
/*
* 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 * as fetcherHook from '../../../../hooks/use_fetcher';
import { render } from '../../../../utils/test_helper';
import { APMSection } from './';
import { response } from './mock_data/apm.mock';
describe('APMSection', () => {
it('renders with transaction series and stats', () => {
jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({
data: response,
status: fetcherHook.FETCH_STATUS.SUCCESS,
refetch: jest.fn(),
});
const { getByText, queryAllByTestId } = render(
<APMSection
startTime="2020-06-29T11:38:23.747Z"
endTime="2020-06-29T12:08:23.748Z"
bucketSize="60s"
/>
);
expect(getByText('APM')).toBeInTheDocument();
expect(getByText('View in app')).toBeInTheDocument();
expect(getByText('Services 11')).toBeInTheDocument();
expect(getByText('Transactions per minute 312.00k')).toBeInTheDocument();
expect(queryAllByTestId('loading')).toEqual([]);
});
it('shows loading state', () => {
jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({
data: undefined,
status: fetcherHook.FETCH_STATUS.LOADING,
refetch: jest.fn(),
});
const { getByText, queryAllByText, getByTestId } = render(
<APMSection
startTime="2020-06-29T11:38:23.747Z"
endTime="2020-06-29T12:08:23.748Z"
bucketSize="60s"
/>
);
expect(getByText('APM')).toBeInTheDocument();
expect(getByTestId('loading')).toBeInTheDocument();
expect(queryAllByText('View in app')).toEqual([]);
expect(queryAllByText('Services 11')).toEqual([]);
expect(queryAllByText('Transactions per minute 312.00k')).toEqual([]);
});
});

View file

@ -0,0 +1,112 @@
/*
* 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 { Axis, BarSeries, niceTimeFormatter, Position, ScaleType, Settings } from '@elastic/charts';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import React, { useContext } from 'react';
import { useHistory } from 'react-router-dom';
import { ThemeContext } from 'styled-components';
import { SectionContainer } from '../';
import { getDataHandler } from '../../../../data_handler';
import { useChartTheme } from '../../../../hooks/use_chart_theme';
import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { ChartContainer } from '../../chart_container';
import { StyledStat } from '../../styled_stat';
import { onBrushEnd } from '../helper';
interface Props {
startTime?: string;
endTime?: string;
bucketSize?: string;
}
function formatTpm(value?: number) {
return numeral(value).format('0.00a');
}
export const APMSection = ({ startTime, endTime, bucketSize }: Props) => {
const theme = useContext(ThemeContext);
const history = useHistory();
const { data, status } = useFetcher(() => {
if (startTime && endTime && bucketSize) {
return getDataHandler('apm')?.fetchData({ startTime, endTime, bucketSize });
}
}, [startTime, endTime, bucketSize]);
const { title = 'APM', appLink, stats, series } = data || {};
const min = moment.utc(startTime).valueOf();
const max = moment.utc(endTime).valueOf();
const formatter = niceTimeFormatter([min, max]);
const isLoading = status === FETCH_STATUS.LOADING;
const transactionsColor = theme.eui.euiColorVis1;
return (
<SectionContainer
title={title || 'APM'}
appLink={appLink}
hasError={status === FETCH_STATUS.FAILURE}
>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<StyledStat
title={numeral(stats?.services.value).format('0a')}
description={i18n.translate('xpack.observability.overview.apm.services', {
defaultMessage: 'Services',
})}
isLoading={isLoading}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<StyledStat
title={formatTpm(stats?.transactions.value)}
description={i18n.translate('xpack.observability.overview.apm.transactionsPerMinute', {
defaultMessage: 'Transactions per minute',
})}
isLoading={isLoading}
color={transactionsColor}
/>
</EuiFlexItem>
</EuiFlexGroup>
<ChartContainer isInitialLoad={isLoading && !data}>
<Settings
onBrushEnd={({ x }) => onBrushEnd({ x, history })}
theme={useChartTheme()}
showLegend={false}
xDomain={{ min, max }}
/>
{series?.transactions.coordinates && (
<>
<BarSeries
id="transactions"
name="Transactions"
data={series?.transactions.coordinates}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor={'x'}
yAccessors={['y']}
color={transactionsColor}
/>
<Axis
id="y-axis"
position={Position.Left}
showGridLines
tickFormat={(value) => `${formatTpm(value)} tpm`}
/>
<Axis id="x-axis" position={Position.Bottom} tickFormat={formatter} />
</>
)}
</ChartContainer>
</SectionContainer>
);
};

View file

@ -0,0 +1,168 @@
/*
* 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 { ApmFetchDataResponse } from '../../../../../typings';
export const response: ApmFetchDataResponse = {
title: 'APM',
appLink: '/app/apm',
stats: {
services: { value: 11, type: 'number' },
transactions: { value: 312000, type: 'number' },
},
series: {
transactions: {
coordinates: [
{ x: 1591365600000, y: 32 },
{ x: 1591366200000, y: 43 },
{ x: 1591366800000, y: 22 },
{ x: 1591367400000, y: 29 },
{ x: 1591368000000, y: 39 },
{ x: 1591368600000, y: 36 },
{ x: 1591369200000, y: 50 },
{ x: 1591369800000, y: 31 },
{ x: 1591370400000, y: 39 },
{ x: 1591371000000, y: 26 },
{ x: 1591371600000, y: 45 },
{ x: 1591372200000, y: 27 },
{ x: 1591372800000, y: 37 },
{ x: 1591373400000, y: 55 },
{ x: 1591374000000, y: 31 },
{ x: 1591374600000, y: 26 },
{ x: 1591375200000, y: 57 },
{ x: 1591375800000, y: 25 },
{ x: 1591376400000, y: 28 },
{ x: 1591377000000, y: 40 },
{ x: 1591377600000, y: 33 },
{ x: 1591378200000, y: 33 },
{ x: 1591378800000, y: 31 },
{ x: 1591379400000, y: 32 },
{ x: 1591380000000, y: 34 },
{ x: 1591380600000, y: 31 },
{ x: 1591381200000, y: 16 },
{ x: 1591381800000, y: 34 },
{ x: 1591382400000, y: 33 },
{ x: 1591383000000, y: 35 },
{ x: 1591383600000, y: 47 },
{ x: 1591384200000, y: 44 },
{ x: 1591384800000, y: 21 },
{ x: 1591385400000, y: 25 },
{ x: 1591386000000, y: 34 },
{ x: 1591386600000, y: 37 },
{ x: 1591387200000, y: 38 },
{ x: 1591387800000, y: 28 },
{ x: 1591388400000, y: 32 },
{ x: 1591389000000, y: 37 },
{ x: 1591389600000, y: 25 },
{ x: 1591390200000, y: 33 },
{ x: 1591390800000, y: 34 },
{ x: 1591391400000, y: 30 },
{ x: 1591392000000, y: 45 },
{ x: 1591392600000, y: 42 },
{ x: 1591393200000, y: 23 },
{ x: 1591393800000, y: 33 },
{ x: 1591394400000, y: 38 },
{ x: 1591395000000, y: 30 },
{ x: 1591395600000, y: 25 },
{ x: 1591396200000, y: 33 },
{ x: 1591396800000, y: 37 },
{ x: 1591397400000, y: 43 },
{ x: 1591398000000, y: 30 },
{ x: 1591398600000, y: 36 },
{ x: 1591399200000, y: 28 },
{ x: 1591399800000, y: 39 },
{ x: 1591400400000, y: 27 },
{ x: 1591401000000, y: 41 },
{ x: 1591401600000, y: 25 },
{ x: 1591402200000, y: 31 },
{ x: 1591402800000, y: 28 },
{ x: 1591403400000, y: 29 },
{ x: 1591404000000, y: 49 },
{ x: 1591404600000, y: 24 },
{ x: 1591405200000, y: 41 },
{ x: 1591405800000, y: 30 },
{ x: 1591406400000, y: 36 },
{ x: 1591407000000, y: 39 },
{ x: 1591407600000, y: 23 },
{ x: 1591408200000, y: 40 },
{ x: 1591408800000, y: 34 },
{ x: 1591409400000, y: 28 },
{ x: 1591410000000, y: 33 },
{ x: 1591410600000, y: 31 },
{ x: 1591411200000, y: 39 },
{ x: 1591411800000, y: 33 },
{ x: 1591412400000, y: 35 },
{ x: 1591413000000, y: 31 },
{ x: 1591413600000, y: 35 },
{ x: 1591414200000, y: 37 },
{ x: 1591414800000, y: 26 },
{ x: 1591415400000, y: 27 },
{ x: 1591416000000, y: 26 },
{ x: 1591416600000, y: 34 },
{ x: 1591417200000, y: 33 },
{ x: 1591417800000, y: 38 },
{ x: 1591418400000, y: 34 },
{ x: 1591419000000, y: 37 },
{ x: 1591419600000, y: 24 },
{ x: 1591420200000, y: 25 },
{ x: 1591420800000, y: 20 },
{ x: 1591421400000, y: 35 },
{ x: 1591422000000, y: 41 },
{ x: 1591422600000, y: 40 },
{ x: 1591423200000, y: 33 },
{ x: 1591423800000, y: 24 },
{ x: 1591424400000, y: 44 },
{ x: 1591425000000, y: 24 },
{ x: 1591425600000, y: 32 },
{ x: 1591426200000, y: 37 },
{ x: 1591426800000, y: 34 },
{ x: 1591427400000, y: 28 },
{ x: 1591428000000, y: 26 },
{ x: 1591428600000, y: 37 },
{ x: 1591429200000, y: 36 },
{ x: 1591429800000, y: 37 },
{ x: 1591430400000, y: 23 },
{ x: 1591431000000, y: 47 },
{ x: 1591431600000, y: 41 },
{ x: 1591432200000, y: 24 },
{ x: 1591432800000, y: 34 },
{ x: 1591433400000, y: 27 },
{ x: 1591434000000, y: 34 },
{ x: 1591434600000, y: 44 },
{ x: 1591435200000, y: 20 },
{ x: 1591435800000, y: 34 },
{ x: 1591436400000, y: 29 },
{ x: 1591437000000, y: 28 },
{ x: 1591437600000, y: 36 },
{ x: 1591438200000, y: 34 },
{ x: 1591438800000, y: 26 },
{ x: 1591439400000, y: 29 },
{ x: 1591440000000, y: 45 },
{ x: 1591440600000, y: 34 },
{ x: 1591441200000, y: 25 },
{ x: 1591441800000, y: 34 },
{ x: 1591442400000, y: 28 },
{ x: 1591443000000, y: 34 },
{ x: 1591443600000, y: 31 },
{ x: 1591444200000, y: 24 },
{ x: 1591444800000, y: 34 },
{ x: 1591445400000, y: 21 },
{ x: 1591446000000, y: 40 },
{ x: 1591446600000, y: 37 },
{ x: 1591447200000, y: 31 },
{ x: 1591447800000, y: 21 },
{ x: 1591448400000, y: 24 },
{ x: 1591449000000, y: 30 },
{ x: 1591449600000, y: 22 },
{ x: 1591450200000, y: 27 },
{ x: 1591450800000, y: 30 },
{ x: 1591451400000, y: 22 },
{ x: 1591452000000, y: 9 },
],
},
},
};

View file

@ -0,0 +1,22 @@
/*
* 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, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
export const ErrorPanel = () => {
return (
<EuiFlexGroup justifyContent="center" alignItems="center">
<EuiFlexItem grow={false}>
<EuiText color="subdued">
{i18n.translate('xpack.observability.section.errorPanel', {
defaultMessage: 'An error happened when trying to fetch data. Please try again',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,37 @@
/*
* 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 { onBrushEnd } from './helper';
import { History } from 'history';
describe('Chart helper', () => {
describe('onBrushEnd', () => {
const history = ({
push: jest.fn(),
location: {
search: '',
},
} as unknown) as History;
it("doesn't push a new history when x is not defined", () => {
onBrushEnd({ x: undefined, history });
expect(history.push).not.toBeCalled();
});
it('pushes a new history with time range converted to ISO', () => {
onBrushEnd({ x: [1593409448167, 1593415727797], history });
expect(history.push).toBeCalledWith({
search: 'rangeFrom=2020-06-29T05:44:08.167Z&rangeTo=2020-06-29T07:28:47.797Z',
});
});
it('pushes a new history keeping current search', () => {
history.location.search = '?foo=bar';
onBrushEnd({ x: [1593409448167, 1593415727797], history });
expect(history.push).toBeCalledWith({
search: 'foo=bar&rangeFrom=2020-06-29T05:44:08.167Z&rangeTo=2020-06-29T07:28:47.797Z',
});
});
});
});

View file

@ -0,0 +1,29 @@
/*
* 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 { XYBrushArea } from '@elastic/charts';
import { History } from 'history';
import { fromQuery, toQuery } from '../../../utils/url';
export const onBrushEnd = ({ x, history }: { x: XYBrushArea['x']; history: History }) => {
if (x) {
const start = x[0];
const end = x[1];
const currentSearch = toQuery(history.location.search);
const nextSearch = {
rangeFrom: new Date(start).toISOString(),
rangeTo: new Date(end).toISOString(),
};
history.push({
...history.location,
search: fromQuery({
...currentSearch,
...nextSearch,
}),
});
}
};

View file

@ -0,0 +1,43 @@
/*
* 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 } from '../../../utils/test_helper';
import { SectionContainer } from './';
describe('SectionContainer', () => {
it('renders section without app link', () => {
const component = render(
<SectionContainer title="Foo" hasError={false}>
<div>I am a very nice component</div>
</SectionContainer>
);
expect(component.getByText('I am a very nice component')).toBeInTheDocument();
expect(component.getByText('Foo')).toBeInTheDocument();
expect(component.queryAllByText('View in app')).toEqual([]);
});
it('renders section with app link', () => {
const component = render(
<SectionContainer title="Foo" appLink="/foo/bar" hasError={false}>
<div>I am a very nice component</div>
</SectionContainer>
);
expect(component.getByText('I am a very nice component')).toBeInTheDocument();
expect(component.getByText('Foo')).toBeInTheDocument();
expect(component.getByText('View in app')).toBeInTheDocument();
});
it('renders section with error', () => {
const component = render(
<SectionContainer title="Foo" hasError={true}>
<div>I am a very nice component</div>
</SectionContainer>
);
expect(component.queryByText('I am a very nice component')).not.toBeInTheDocument();
expect(component.getByText('Foo')).toBeInTheDocument();
expect(
component.getByText('An error happened when trying to fetch data. Please try again')
).toBeInTheDocument();
});
});

View file

@ -0,0 +1,62 @@
/*
* 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 { EuiAccordion, EuiLink, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ErrorPanel } from './error_panel';
import { usePluginContext } from '../../../hooks/use_plugin_context';
interface Props {
title: string;
hasError: boolean;
children: React.ReactNode;
minHeight?: number;
appLink?: string;
appLinkName?: string;
}
export const SectionContainer = ({ title, appLink, children, hasError, appLinkName }: Props) => {
const { core } = usePluginContext();
return (
<EuiAccordion
initialIsOpen
id={title}
buttonContentClassName="accordion-button"
buttonContent={
<EuiTitle size="s">
<h5>{title}</h5>
</EuiTitle>
}
extraAction={
appLink && (
<EuiLink href={core.http.basePath.prepend(appLink)}>
<EuiText size="s">
{appLinkName
? appLinkName
: i18n.translate('xpack.observability.chart.viewInAppLabel', {
defaultMessage: 'View in app',
})}
</EuiText>
</EuiLink>
)
}
>
<>
<EuiSpacer size="s" />
<EuiPanel hasShadow>
{hasError ? (
<ErrorPanel />
) : (
<>
<EuiSpacer size="s" />
{children}
</>
)}
</EuiPanel>
</>
</EuiAccordion>
);
};

View file

@ -0,0 +1,151 @@
/*
* 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 { Axis, BarSeries, niceTimeFormatter, Position, ScaleType, Settings } from '@elastic/charts';
import { EuiFlexGroup, EuiFlexItem, euiPaletteColorBlind } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { isEmpty } from 'lodash';
import moment from 'moment';
import React, { Fragment } from 'react';
import { useHistory } from 'react-router-dom';
import { EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiSpacer } from '@elastic/eui';
import { SectionContainer } from '../';
import { getDataHandler } from '../../../../data_handler';
import { useChartTheme } from '../../../../hooks/use_chart_theme';
import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { LogsFetchDataResponse } from '../../../../typings';
import { formatStatValue } from '../../../../utils/format_stat_value';
import { ChartContainer } from '../../chart_container';
import { StyledStat } from '../../styled_stat';
import { onBrushEnd } from '../helper';
interface Props {
startTime?: string;
endTime?: string;
bucketSize?: string;
}
function getColorPerItem(series?: LogsFetchDataResponse['series']) {
if (!series) {
return {};
}
const availableColors = euiPaletteColorBlind({
rotations: Math.ceil(Object.keys(series).length / 10),
});
const colorsPerItem = Object.keys(series).reduce((acc: Record<string, string>, key, index) => {
acc[key] = availableColors[index];
return acc;
}, {});
return colorsPerItem;
}
export const LogsSection = ({ startTime, endTime, bucketSize }: Props) => {
const history = useHistory();
const { data, status } = useFetcher(() => {
if (startTime && endTime && bucketSize) {
return getDataHandler('infra_logs')?.fetchData({ startTime, endTime, bucketSize });
}
}, [startTime, endTime, bucketSize]);
const min = moment.utc(startTime).valueOf();
const max = moment.utc(endTime).valueOf();
const formatter = niceTimeFormatter([min, max]);
const { title, appLink, stats, series } = data || {};
const colorsPerItem = getColorPerItem(series);
const isLoading = status === FETCH_STATUS.LOADING;
return (
<SectionContainer
title={title || 'Logs'}
appLink={appLink}
hasError={status === FETCH_STATUS.FAILURE}
>
<EuiTitle size="xs">
<h4>
{i18n.translate('xpack.observability.overview.logs.subtitle', {
defaultMessage: 'Logs rate per minute',
})}
</h4>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup>
{!stats || isEmpty(stats) ? (
<EuiFlexItem grow={false}>
<StyledStat isLoading={isLoading} />
</EuiFlexItem>
) : (
Object.keys(stats).map((key) => {
const stat = stats[key];
return (
<EuiFlexItem grow={false} key={key}>
<StyledStat
title={formatStatValue(stat)}
description={stat.label}
isLoading={isLoading}
color={colorsPerItem[key]}
/>
</EuiFlexItem>
);
})
)}
</EuiFlexGroup>
<ChartContainer isInitialLoad={isLoading && !data}>
<Settings
onBrushEnd={({ x }) => onBrushEnd({ x, history })}
theme={useChartTheme()}
showLegend
legendPosition={Position.Right}
xDomain={{ min, max }}
showLegendExtra
/>
{series &&
Object.keys(series).map((key) => {
const serie = series[key];
const chartData = serie.coordinates.map((coordinate) => ({
...coordinate,
g: serie.label,
}));
return (
<Fragment key={key}>
<BarSeries
id={key}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor={'x'}
yAccessors={['y']}
stackAccessors={['x']}
splitSeriesAccessors={['g']}
data={chartData}
color={colorsPerItem[key]}
/>
<Axis
id="x-axis"
position={Position.Bottom}
showOverlappingTicks={false}
showOverlappingLabels={false}
tickFormat={formatter}
/>
<Axis
id="y-axis"
showGridLines
position={Position.Left}
tickFormat={(d: number) => numeral(d).format('0a')}
/>
</Fragment>
);
})}
</ChartContainer>
</SectionContainer>
);
};

View file

@ -0,0 +1,182 @@
/*
* 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 { AreaSeries, ScaleType, Settings } from '@elastic/charts';
import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiSpacer } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
import React, { useContext } from 'react';
import styled, { ThemeContext } from 'styled-components';
import { SectionContainer } from '../';
import { getDataHandler } from '../../../../data_handler';
import { useChartTheme } from '../../../../hooks/use_chart_theme';
import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { Series } from '../../../../typings';
import { ChartContainer } from '../../chart_container';
import { StyledStat } from '../../styled_stat';
interface Props {
startTime?: string;
endTime?: string;
bucketSize?: string;
}
/**
* EuiProgress doesn't support custom color, when it does this component can be removed.
*/
const StyledProgress = styled.div<{ color?: string }>`
progress {
&.euiProgress--native {
&::-webkit-progress-value {
background-color: ${(props) => props.color};
}
&::-moz-progress-bar {
background-color: ${(props) => props.color};
}
}
&.euiProgress--indeterminate {
&:before {
background-color: ${(props) => props.color};
}
}
}
`;
export const MetricsSection = ({ startTime, endTime, bucketSize }: Props) => {
const theme = useContext(ThemeContext);
const { data, status } = useFetcher(() => {
if (startTime && endTime && bucketSize) {
return getDataHandler('infra_metrics')?.fetchData({ startTime, endTime, bucketSize });
}
}, [startTime, endTime, bucketSize]);
const isLoading = status === FETCH_STATUS.LOADING;
const { title = 'Metrics', appLink, stats, series } = data || {};
const cpuColor = theme.eui.euiColorVis7;
const memoryColor = theme.eui.euiColorVis0;
const inboundTrafficColor = theme.eui.euiColorVis3;
const outboundTrafficColor = theme.eui.euiColorVis2;
return (
<SectionContainer
minHeight={135}
title={title}
appLink={appLink}
hasError={status === FETCH_STATUS.FAILURE}
>
<EuiFlexGroup>
<EuiFlexItem>
<StyledStat
title={numeral(stats?.hosts.value).format('0a')}
description={i18n.translate('xpack.observability.overview.metrics.hosts', {
defaultMessage: 'Hosts',
})}
isLoading={isLoading}
/>
</EuiFlexItem>
<EuiFlexItem>
<StyledStat
title={numeral(stats?.cpu.value).format('0.0%')}
description={i18n.translate('xpack.observability.overview.metrics.cpuUsage', {
defaultMessage: 'CPU Usage',
})}
isLoading={isLoading}
color={cpuColor}
>
<EuiSpacer size="s" />
<StyledProgress color={cpuColor}>
<EuiProgress
value={stats?.cpu.value}
max={1}
style={{ width: '100px' }}
color="accent"
/>
</StyledProgress>
</StyledStat>
</EuiFlexItem>
<EuiFlexItem>
<StyledStat
title={numeral(stats?.memory.value).format('0.0%')}
description={i18n.translate('xpack.observability.overview.metrics.memoryUsage', {
defaultMessage: 'Memory usage',
})}
isLoading={isLoading}
color={memoryColor}
>
<EuiSpacer size="s" />
<StyledProgress color={memoryColor}>
<EuiProgress value={stats?.memory.value} max={1} style={{ width: '100px' }} />
</StyledProgress>
</StyledStat>
</EuiFlexItem>
<EuiFlexItem>
<StyledStat
title={`${numeral(stats?.inboundTraffic.value).format('0.0b')}/s`}
description={i18n.translate('xpack.observability.overview.metrics.inboundTraffic', {
defaultMessage: 'Inbound traffic',
})}
isLoading={isLoading}
color={inboundTrafficColor}
>
<AreaChart
serie={series?.inboundTraffic}
isLoading={isLoading}
color={inboundTrafficColor}
/>
</StyledStat>
</EuiFlexItem>
<EuiFlexItem>
<StyledStat
title={`${numeral(stats?.outboundTraffic.value).format('0.0b')}/s`}
description={i18n.translate('xpack.observability.overview.metrics.outboundTraffic', {
defaultMessage: 'Outbound traffic',
})}
isLoading={isLoading}
color={outboundTrafficColor}
>
<AreaChart
serie={series?.outboundTraffic}
isLoading={isLoading}
color={outboundTrafficColor}
/>
</StyledStat>
</EuiFlexItem>
</EuiFlexGroup>
</SectionContainer>
);
};
const AreaChart = ({
serie,
isLoading,
color,
}: {
serie?: Series;
isLoading: boolean;
color: string;
}) => {
const chartTheme = useChartTheme();
return (
<ChartContainer height={30} isInitialLoad={isLoading && !serie} iconSize="m">
<Settings theme={chartTheme} showLegend={false} tooltip="none" />
{serie && (
<AreaSeries
id="area"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor={'x'}
yAccessors={['y']}
data={serie.coordinates}
color={color}
/>
)}
</ChartContainer>
);
};

View file

@ -0,0 +1,179 @@
/*
* 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 {
Axis,
BarSeries,
niceTimeFormatter,
Position,
ScaleType,
Settings,
TickFormatter,
} from '@elastic/charts';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import React, { useContext } from 'react';
import { useHistory } from 'react-router-dom';
import { ThemeContext } from 'styled-components';
import { SectionContainer } from '../';
import { getDataHandler } from '../../../../data_handler';
import { useChartTheme } from '../../../../hooks/use_chart_theme';
import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { Series } from '../../../../typings';
import { ChartContainer } from '../../chart_container';
import { StyledStat } from '../../styled_stat';
import { onBrushEnd } from '../helper';
interface Props {
startTime?: string;
endTime?: string;
bucketSize?: string;
}
export const UptimeSection = ({ startTime, endTime, bucketSize }: Props) => {
const theme = useContext(ThemeContext);
const history = useHistory();
const { data, status } = useFetcher(() => {
if (startTime && endTime && bucketSize) {
return getDataHandler('uptime')?.fetchData({ startTime, endTime, bucketSize });
}
}, [startTime, endTime, bucketSize]);
const min = moment.utc(startTime).valueOf();
const max = moment.utc(endTime).valueOf();
const formatter = niceTimeFormatter([min, max]);
const isLoading = status === FETCH_STATUS.LOADING;
const { title = 'Uptime', appLink, stats, series } = data || {};
const downColor = theme.eui.euiColorVis2;
const upColor = theme.eui.euiColorLightShade;
return (
<SectionContainer
minHeight={273}
title={title}
appLink={appLink}
hasError={status === FETCH_STATUS.FAILURE}
>
<EuiFlexGroup>
{/* Stats section */}
<EuiFlexItem grow={false}>
<StyledStat
title={numeral(stats?.monitors.value).format('0a')}
description={i18n.translate('xpack.observability.overview.uptime.monitors', {
defaultMessage: 'Monitors',
})}
isLoading={isLoading}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<StyledStat
title={numeral(stats?.up.value).format('0a')}
description={i18n.translate('xpack.observability.overview.uptime.up', {
defaultMessage: 'Up',
})}
isLoading={isLoading}
color={upColor}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<StyledStat
title={numeral(stats?.down.value).format('0a')}
description={i18n.translate('xpack.observability.overview.uptime.down', {
defaultMessage: 'Down',
})}
isLoading={isLoading}
color={downColor}
/>
</EuiFlexItem>
</EuiFlexGroup>
{/* Chart section */}
<ChartContainer isInitialLoad={isLoading && !data}>
<Settings
onBrushEnd={({ x }) => onBrushEnd({ x, history })}
theme={useChartTheme()}
showLegend={false}
legendPosition={Position.Right}
xDomain={{ min, max }}
/>
<UptimeBarSeries
id="down"
label={i18n.translate('xpack.observability.overview.uptime.chart.down', {
defaultMessage: 'Down',
})}
series={series?.down}
ticktFormatter={formatter}
color={downColor}
/>
<UptimeBarSeries
id="up"
label={i18n.translate('xpack.observability.overview.uptime.chart.up', {
defaultMessage: 'Up',
})}
series={series?.up}
ticktFormatter={formatter}
color={upColor}
/>
</ChartContainer>
</SectionContainer>
);
};
const UptimeBarSeries = ({
id,
label,
series,
color,
ticktFormatter,
}: {
id: string;
label: string;
series?: Series;
color: string;
ticktFormatter: TickFormatter;
}) => {
if (!series) {
return null;
}
const chartData = series.coordinates.map((coordinate) => ({
...coordinate,
g: label,
}));
return (
<>
<BarSeries
id={id}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor={'x'}
yAccessors={['y']}
color={color}
stackAccessors={['x']}
splitSeriesAccessors={['g']}
data={chartData}
/>
<Axis
id="x-axis"
position={Position.Bottom}
showOverlappingTicks={false}
showOverlappingLabels={false}
tickFormat={ticktFormatter}
/>
<Axis
id="y-axis"
showGridLines
position={Position.Left}
tickFormat={(x: any) => numeral(x).format('0a')}
/>
</>
);
};

View file

@ -0,0 +1,27 @@
/*
* 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 styled from 'styled-components';
import { EuiStat } from '@elastic/eui';
import React from 'react';
import { EuiStatProps } from '@elastic/eui/src/components/stat/stat';
const Stat = styled(EuiStat)`
.euiStat__title {
color: ${(props) => props.color};
}
`;
interface Props extends Partial<EuiStatProps> {
children?: React.ReactNode;
color?: string;
}
const EMPTY_VALUE = '--';
export const StyledStat = (props: Props) => {
const { description = EMPTY_VALUE, title = EMPTY_VALUE, ...rest } = props;
return <Stat description={description} title={title} titleSize="s" {...rest} />;
};

View file

@ -0,0 +1,89 @@
/*
* 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 { EuiSuperDatePicker } from '@elastic/eui';
import React from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { UI_SETTINGS, useKibanaUISettings } from '../../../hooks/use_kibana_ui_settings';
import { fromQuery, toQuery } from '../../../utils/url';
export interface TimePickerTime {
from: string;
to: string;
}
export interface TimePickerQuickRange extends TimePickerTime {
display: string;
}
export interface TimePickerRefreshInterval {
pause: boolean;
value: number;
}
interface Props {
rangeFrom: string;
rangeTo: string;
refreshPaused: boolean;
refreshInterval: number;
}
export const DatePicker = ({ rangeFrom, rangeTo, refreshPaused, refreshInterval }: Props) => {
const location = useLocation();
const history = useHistory();
const timePickerQuickRanges = useKibanaUISettings<TimePickerQuickRange[]>(
UI_SETTINGS.TIMEPICKER_QUICK_RANGES
);
const commonlyUsedRanges = timePickerQuickRanges.map(({ from, to, display }) => ({
start: from,
end: to,
label: display,
}));
function updateUrl(nextQuery: {
rangeFrom?: string;
rangeTo?: string;
refreshPaused?: boolean;
refreshInterval?: number;
}) {
history.push({
...location,
search: fromQuery({
...toQuery(location.search),
...nextQuery,
}),
});
}
function onRefreshChange({
isPaused,
refreshInterval: interval,
}: {
isPaused: boolean;
refreshInterval: number;
}) {
updateUrl({ refreshPaused: isPaused, refreshInterval: interval });
}
function onTimeChange({ start, end }: { start: string; end: string }) {
updateUrl({ rangeFrom: start, rangeTo: end });
}
return (
<EuiSuperDatePicker
start={rangeFrom}
end={rangeTo}
onTimeChange={onTimeChange}
isPaused={refreshPaused}
refreshInterval={refreshInterval}
onRefreshChange={onRefreshChange}
commonlyUsedRanges={commonlyUsedRanges}
onRefresh={onTimeChange}
/>
);
};

View file

@ -17,9 +17,20 @@ export function registerDataHandler<T extends ObservabilityApp>({
dataHandlers[appName] = { fetchData, hasData };
}
export function unregisterDataHandler<T extends ObservabilityApp>({ appName }: { appName: T }) {
delete dataHandlers[appName];
}
export function getDataHandler<T extends ObservabilityApp>(appName: T) {
const dataHandler = dataHandlers[appName];
if (dataHandler) {
return dataHandler as DataHandler<T>;
}
}
export async function fetchHasData() {
const apps: ObservabilityApp[] = ['apm', 'uptime', 'infra_logs', 'infra_metrics'];
const promises = apps.map((app) => getDataHandler(app)?.hasData());
const [apm, uptime, logs, metrics] = await Promise.all(promises);
return { apm, uptime, infra_logs: logs, infra_metrics: metrics };
}

View file

@ -0,0 +1,13 @@
/*
* 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 { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme';
import { useContext } from 'react';
import { ThemeContext } from 'styled-components';
export function useChartTheme() {
const theme = useContext(ThemeContext);
return theme.darkMode ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme;
}

View file

@ -0,0 +1,84 @@
/*
* 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 { useEffect, useState, useMemo } from 'react';
export enum FETCH_STATUS {
LOADING = 'loading',
SUCCESS = 'success',
FAILURE = 'failure',
PENDING = 'pending',
}
export interface FetcherResult<Data> {
data?: Data;
status: FETCH_STATUS;
error?: Error;
}
// fetcher functions can return undefined OR a promise. Previously we had a more simple type
// but it led to issues when using object destructuring with default values
type InferResponseType<TReturn> = Exclude<TReturn, undefined> extends Promise<infer TResponseType>
? TResponseType
: unknown;
export function useFetcher<TReturn>(
fn: () => TReturn,
fnDeps: any[],
options: {
preservePreviousData?: boolean;
} = {}
): FetcherResult<InferResponseType<TReturn>> & { refetch: () => void } {
const { preservePreviousData = true } = options;
const [result, setResult] = useState<FetcherResult<InferResponseType<TReturn>>>({
data: undefined,
status: FETCH_STATUS.PENDING,
});
const [counter, setCounter] = useState(0);
useEffect(() => {
async function doFetch() {
const promise = fn();
if (!promise) {
return;
}
setResult((prevResult) => ({
data: preservePreviousData ? prevResult.data : undefined,
status: FETCH_STATUS.LOADING,
error: undefined,
}));
try {
const data = await promise;
setResult({
data,
status: FETCH_STATUS.SUCCESS,
error: undefined,
} as FetcherResult<InferResponseType<TReturn>>);
} catch (e) {
setResult((prevResult) => ({
data: preservePreviousData ? prevResult.data : undefined,
status: FETCH_STATUS.FAILURE,
error: e,
}));
}
}
doFetch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [counter, ...fnDeps]);
return useMemo(() => {
return {
...result,
refetch: () => {
// this will invalidate the deps to `useEffect` and will result in a new request
setCounter((count) => count + 1);
},
};
}, [result]);
}

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 { usePluginContext } from './use_plugin_context';
import { UI_SETTINGS } from '../../../../../src/plugins/data/public';
export { UI_SETTINGS };
type SettingKeys = keyof typeof UI_SETTINGS;
type SettingValues = typeof UI_SETTINGS[SettingKeys];
export function useKibanaUISettings<T>(key: SettingValues): T {
const { core } = usePluginContext();
return core.uiSettings.get<T>(key);
}

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 t from 'io-ts';
import { useLocation, useParams } from 'react-router-dom';
import { isLeft } from 'fp-ts/lib/Either';
import { PathReporter } from 'io-ts/lib/PathReporter';
import { Params } from '../routes';
function getQueryParams(location: ReturnType<typeof useLocation>) {
const urlSearchParms = new URLSearchParams(location.search);
const queryParams: Record<string, string> = {};
urlSearchParms.forEach((value, key) => {
queryParams[key] = value;
});
return queryParams;
}
/**
* Extracts query and path params from the url and validate it against the type defined in the route file.
* It removes any aditional item which is not declared in the type.
* @param params
*/
export function useUrlParams(params: Params) {
const location = useLocation();
const pathParams = useParams();
const queryParams = getQueryParams(location);
const rts = {
queryRt: params.query ? t.exact(params.query) : t.strict({}),
pathRt: params.path ? t.exact(params.path) : t.strict({}),
};
const queryResult = rts.queryRt.decode(queryParams);
const pathResult = rts.pathRt.decode(pathParams);
if (isLeft(queryResult)) {
// eslint-disable-next-line no-console
console.error(PathReporter.report(queryResult)[0]);
}
if (isLeft(pathResult)) {
// eslint-disable-next-line no-console
console.error(PathReporter.report(pathResult)[0]);
}
return {
query: isLeft(queryResult) ? {} : queryResult.right,
path: isLeft(pathResult) ? {} : pathResult.right,
};
}

View file

@ -15,7 +15,7 @@ export const plugin: PluginInitializer<ObservabilityPluginSetup, ObservabilityPl
return new Plugin(context);
};
export * from './components/action_menu';
export * from './components/shared/action_menu/';
export {
useTrackPageview,

View file

@ -3,162 +3,24 @@
* 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 { useHistory } from 'react-router-dom';
import { fetchHasData } from '../../data_handler';
import { useFetcher } from '../../hooks/use_fetcher';
import {
EuiButton,
EuiCard,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiImage,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect } from 'react';
import styled from 'styled-components';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { appsSection } from './section';
export const HomePage = () => {
const history = useHistory();
const { data = {} } = useFetcher(() => fetchHasData(), []);
const Container = styled.div`
min-height: calc(100vh - 48px);
background: ${(props) => props.theme.eui.euiColorEmptyShade};
`;
const values = Object.values(data);
const hasSomeData = values.length ? values.some((hasData) => hasData) : null;
const Title = styled.div`
background-color: ${(props) => props.theme.eui.euiPageBackgroundColor};
border-bottom: ${(props) => props.theme.eui.euiBorderThin};
`;
if (hasSomeData === true) {
history.push({ pathname: '/overview' });
}
if (hasSomeData === false) {
history.push({ pathname: '/landing' });
}
const Page = styled.div`
width: 100%;
max-width: 1200px;
margin: 0 auto;
overflow: hidden;
}
`;
const EuiCardWithoutPadding = styled(EuiCard)`
padding: 0;
`;
export const Home = () => {
const { core } = usePluginContext();
useEffect(() => {
core.chrome.setBreadcrumbs([
{
text: i18n.translate('xpack.observability.home.breadcrumb.observability', {
defaultMessage: 'Observability',
}),
},
{
text: i18n.translate('xpack.observability.home.breadcrumb.gettingStarted', {
defaultMessage: 'Getting started',
}),
},
]);
}, [core]);
return (
<Container>
<Title>
<Page>
<EuiSpacer size="xxl" />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiIcon type="logoObservability" size="xxl" />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="m">
<h1>
{i18n.translate('xpack.observability.home.title', {
defaultMessage: 'Observability',
})}
</h1>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xxl" />
</Page>
</Title>
<Page>
<EuiSpacer size="xxl" />
<EuiFlexGroup direction="column">
{/* title and description */}
<EuiFlexItem style={{ maxWidth: '50%' }}>
<EuiTitle size="s">
<h2>
{i18n.translate('xpack.observability.home.sectionTitle', {
defaultMessage: 'Unified visibility across your entire ecosystem',
})}
</h2>
</EuiTitle>
<EuiSpacer size="m" />
<EuiText size="s" color="subdued">
{i18n.translate('xpack.observability.home.sectionsubtitle', {
defaultMessage:
'Monitor, analyze, and react to events happening anywhere in your environment by bringing logs, metrics, and traces together at scale in a single stack.',
})}
</EuiText>
</EuiFlexItem>
{/* Apps sections */}
<EuiFlexItem>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFlexGrid columns={2}>
{appsSection.map((app) => (
<EuiFlexItem key={app.id}>
<EuiCardWithoutPadding
display="plain"
layout="horizontal"
icon={<EuiIcon size="l" type={app.icon} />}
title={
<EuiTitle size="xs" className="title">
<h3>{app.title}</h3>
</EuiTitle>
}
description={app.description}
/>
</EuiFlexItem>
))}
</EuiFlexGrid>
</EuiFlexItem>
<EuiFlexItem>
<EuiImage
size="xl"
alt="observability overview image"
url={core.http.basePath.prepend(
'/plugins/observability/assets/observability_overview.png'
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{/* Get started button */}
<EuiFlexItem>
<EuiFlexGroup justifyContent="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType="sortRight"
iconSide="right"
href={core.http.basePath.prepend('/app/home#/tutorial_directory/logging')}
>
{i18n.translate('xpack.observability.home.getStatedButton', {
defaultMessage: 'Get started',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</Page>
</Container>
);
return <></>;
};

View file

@ -4,19 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
interface ISection {
id: string;
title: string;
icon: string;
description: string;
href?: string;
target?: '_blank';
}
import { ISection } from '../../typings/section';
export const appsSection: ISection[] = [
{
id: 'logs',
id: 'infra_logs',
title: i18n.translate('xpack.observability.section.apps.logs.title', {
defaultMessage: 'Logs',
}),
@ -25,6 +17,7 @@ export const appsSection: ISection[] = [
defaultMessage:
'Centralize logs from any source. Search, tail, automate anomaly detection, and visualize trends so you can take action quicker.',
}),
href: 'https://www.elastic.co',
},
{
id: 'apm',
@ -36,9 +29,10 @@ export const appsSection: ISection[] = [
defaultMessage:
'Trace transactions through a distributed architecture and map your services interactions to easily spot performance bottlenecks.',
}),
href: 'https://www.elastic.co',
},
{
id: 'metrics',
id: 'infra_metrics',
title: i18n.translate('xpack.observability.section.apps.metrics.title', {
defaultMessage: 'Metrics',
}),
@ -47,6 +41,7 @@ export const appsSection: ISection[] = [
defaultMessage:
'Analyze metrics from your infrastructure, apps, and services. Discover trends, forecast behavior, get alerts on anomalies, and more.',
}),
href: 'https://www.elastic.co',
},
{
id: 'uptime',
@ -58,5 +53,6 @@ export const appsSection: ISection[] = [
defaultMessage:
'Proactively monitor the availability of your sites and services. Receive alerts and resolve issues faster to optimize your users experience.',
}),
href: 'https://www.elastic.co',
},
];

View file

@ -0,0 +1,116 @@
/*
* 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,
EuiCard,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiImage,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useContext } from 'react';
import styled, { ThemeContext } from 'styled-components';
import { WithHeaderLayout } from '../../components/app/layout/with_header';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { appsSection } from '../home/section';
const EuiCardWithoutPadding = styled(EuiCard)`
padding: 0;
`;
export const LandingPage = () => {
const { core } = usePluginContext();
const theme = useContext(ThemeContext);
return (
<WithHeaderLayout
restrictWidth={1200}
headerColor={theme.eui.euiPageBackgroundColor}
bodyColor={theme.eui.euiColorEmptyShade}
>
<EuiFlexGroup direction="column">
{/* title and description */}
<EuiFlexItem style={{ maxWidth: '50%' }}>
<EuiTitle size="s">
<h2>
{i18n.translate('xpack.observability.home.sectionTitle', {
defaultMessage: 'Unified visibility across your entire ecosystem',
})}
</h2>
</EuiTitle>
<EuiSpacer size="m" />
<EuiText size="s" color="subdued">
{i18n.translate('xpack.observability.home.sectionsubtitle', {
defaultMessage:
'Monitor, analyze, and react to events happening anywhere in your environment by bringing logs, metrics, and traces together at scale in a single stack.',
})}
</EuiText>
</EuiFlexItem>
{/* Apps sections */}
<EuiFlexItem>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFlexGrid columns={2}>
{appsSection.map((app) => (
<EuiFlexItem key={app.id}>
<EuiCardWithoutPadding
display="plain"
layout="horizontal"
icon={<EuiIcon size="l" type={app.icon} />}
title={
<EuiTitle size="xs" className="title">
<h3>{app.title}</h3>
</EuiTitle>
}
description={app.description}
/>
</EuiFlexItem>
))}
</EuiFlexGrid>
</EuiFlexItem>
<EuiFlexItem>
<EuiImage
size="xl"
alt="observability overview image"
url={core.http.basePath.prepend(
'/plugins/observability/assets/observability_overview.png'
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiSpacer size="xxl" />
{/* Get started button */}
<EuiFlexItem>
<EuiFlexGroup justifyContent="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType="sortRight"
iconSide="right"
href={core.http.basePath.prepend('/app/home#/tutorial_directory/logging')}
>
{i18n.translate('xpack.observability.home.getStatedButton', {
defaultMessage: 'Get started',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</WithHeaderLayout>
);
};

View file

@ -0,0 +1,90 @@
/*
* 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 { AppMountContext } from 'kibana/public';
import { ISection } from '../../typings/section';
export const getEmptySections = ({ core }: { core: AppMountContext['core'] }): ISection[] => {
return [
{
id: 'infra_logs',
title: i18n.translate('xpack.observability.emptySection.apps.logs.title', {
defaultMessage: 'Logs',
}),
icon: 'logoLogging',
description: i18n.translate('xpack.observability.emptySection.apps.logs.description', {
defaultMessage:
'Centralize logs from any source. Search, tail, automate anomaly detection, and visualize trends so you can take action quicker.',
}),
linkTitle: i18n.translate('xpack.observability.emptySection.apps.logs.link', {
defaultMessage: 'Install Filebeat',
}),
href: 'https://www.elastic.co',
},
{
id: 'apm',
title: i18n.translate('xpack.observability.emptySection.apps.apm.title', {
defaultMessage: 'APM',
}),
icon: 'logoAPM',
description: i18n.translate('xpack.observability.emptySection.apps.apm.description', {
defaultMessage:
'Trace transactions through a distributed architecture and map your services interactions to easily spot performance bottlenecks.',
}),
linkTitle: i18n.translate('xpack.observability.emptySection.apps.apm.link', {
defaultMessage: 'Install agent',
}),
href: 'https://www.elastic.co',
},
{
id: 'infra_metrics',
title: i18n.translate('xpack.observability.emptySection.apps.metrics.title', {
defaultMessage: 'Metrics',
}),
icon: 'logoMetrics',
description: i18n.translate('xpack.observability.emptySection.apps.metrics.description', {
defaultMessage:
'Analyze metrics from your infrastructure, apps, and services. Discover trends, forecast behavior, get alerts on anomalies, and more.',
}),
linkTitle: i18n.translate('xpack.observability.emptySection.apps.metrics.link', {
defaultMessage: 'Install metrics module',
}),
href: 'https://www.elastic.co',
},
{
id: 'uptime',
title: i18n.translate('xpack.observability.emptySection.apps.uptime.title', {
defaultMessage: 'Uptime',
}),
icon: 'logoUptime',
description: i18n.translate('xpack.observability.emptySection.apps.uptime.description', {
defaultMessage:
'Proactively monitor the availability of your sites and services. Receive alerts and resolve issues faster to optimize your users experience.',
}),
linkTitle: i18n.translate('xpack.observability.emptySection.apps.uptime.link', {
defaultMessage: 'Install Heartbeat',
}),
href: 'https://www.elastic.co',
},
{
id: 'alert',
title: i18n.translate('xpack.observability.emptySection.apps.alert.title', {
defaultMessage: 'No alerts found.',
}),
icon: 'watchesApp',
description: i18n.translate('xpack.observability.emptySection.apps.alert.description', {
defaultMessage:
'503 errors stacking up. Applications not responding. CPU and RAM utilization jumping. See these warnings as they happen - not as part of the post-mortem.',
}),
linkTitle: i18n.translate('xpack.observability.emptySection.apps.alert.link', {
defaultMessage: 'Create alert',
}),
href: core.http.basePath.prepend(
'/app/management/insightsAndAlerting/triggersActions/alerts'
),
},
];
};

View file

@ -0,0 +1,198 @@
/*
* 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 { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer } from '@elastic/eui';
import moment from 'moment';
import React, { useContext } from 'react';
import { ThemeContext } from 'styled-components';
import { EmptySection } from '../../components/app/empty_section';
import { WithHeaderLayout } from '../../components/app/layout/with_header';
import { Resources } from '../../components/app/resources';
import { AlertsSection } from '../../components/app/section/alerts';
import { APMSection } from '../../components/app/section/apm';
import { LogsSection } from '../../components/app/section/logs';
import { MetricsSection } from '../../components/app/section/metrics';
import { UptimeSection } from '../../components/app/section/uptime';
import { DatePicker, TimePickerTime } from '../../components/shared/data_picker';
import { fetchHasData } from '../../data_handler';
import { FETCH_STATUS, useFetcher } from '../../hooks/use_fetcher';
import { UI_SETTINGS, useKibanaUISettings } from '../../hooks/use_kibana_ui_settings';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { RouteParams } from '../../routes';
import { getObservabilityAlerts } from '../../services/get_observability_alerts';
import { getParsedDate } from '../../utils/date';
import { getBucketSize } from '../../utils/get_bucket_size';
import { getEmptySections } from './empty_section';
import { LoadingObservability } from './loading_observability';
interface Props {
routeParams: RouteParams<'/overview'>;
}
function calculatetBucketSize({ startTime, endTime }: { startTime?: string; endTime?: string }) {
if (startTime && endTime) {
return getBucketSize({
start: moment.utc(startTime).valueOf(),
end: moment.utc(endTime).valueOf(),
minInterval: '60s',
});
}
}
export const OverviewPage = ({ routeParams }: Props) => {
const { core } = usePluginContext();
const { data: alerts = [], status: alertStatus } = useFetcher(() => {
return getObservabilityAlerts({ core });
}, []);
const theme = useContext(ThemeContext);
const timePickerTime = useKibanaUISettings<TimePickerTime>(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS);
const result = useFetcher(() => fetchHasData(), []);
const hasData = result.data;
if (!hasData) {
return <LoadingObservability />;
}
const {
rangeFrom = timePickerTime.from,
rangeTo = timePickerTime.to,
refreshInterval = 10000,
refreshPaused = true,
} = routeParams.query;
const startTime = getParsedDate(rangeFrom);
const endTime = getParsedDate(rangeTo, { roundUp: true });
const bucketSize = calculatetBucketSize({ startTime, endTime });
const appEmptySections = getEmptySections({ core }).filter(({ id }) => {
if (id === 'alert') {
return alertStatus !== FETCH_STATUS.FAILURE && !alerts.length;
}
return !hasData[id];
});
// Hides the data section when all 'hasData' is false or undefined
const showDataSections = Object.values(hasData).some((hasPluginData) => hasPluginData);
return (
<WithHeaderLayout
headerColor={theme.eui.euiColorEmptyShade}
bodyColor={theme.eui.euiPageBackgroundColor}
showAddData
showGiveFeedback
>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<DatePicker
rangeFrom={rangeFrom}
rangeTo={rangeTo}
refreshInterval={refreshInterval}
refreshPaused={refreshPaused}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule
style={{
width: 'auto', // full width
margin: '24px -24px', // counteract page paddings
}}
/>
<EuiFlexGroup>
<EuiFlexItem grow={6}>
{/* Data sections */}
{showDataSections && (
<EuiFlexGroup direction="column">
{hasData.infra_logs && (
<EuiFlexItem grow={false}>
<LogsSection
startTime={startTime}
endTime={endTime}
bucketSize={bucketSize?.intervalString}
/>
</EuiFlexItem>
)}
{hasData.infra_metrics && (
<EuiFlexItem grow={false}>
<MetricsSection
startTime={startTime}
endTime={endTime}
bucketSize={bucketSize?.intervalString}
/>
</EuiFlexItem>
)}
{hasData.apm && (
<EuiFlexItem grow={false}>
<APMSection
startTime={startTime}
endTime={endTime}
bucketSize={bucketSize?.intervalString}
/>
</EuiFlexItem>
)}
{hasData.uptime && (
<EuiFlexItem grow={false}>
<UptimeSection
startTime={startTime}
endTime={endTime}
bucketSize={bucketSize?.intervalString}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
)}
{/* Empty sections */}
{!!appEmptySections.length && (
<EuiFlexItem>
<EuiSpacer size="s" />
<EuiFlexGrid
columns={
// when more than 2 empty sections are available show them on 2 columns, otherwise 1
appEmptySections.length > 2 ? 2 : 1
}
gutterSize="s"
>
{appEmptySections.map((app) => {
return (
<EuiFlexItem
key={app.id}
style={{
border: `1px dashed ${theme.eui.euiBorderColor}`,
borderRadius: '4px',
}}
>
<EmptySection section={app} />
</EuiFlexItem>
);
})}
</EuiFlexGrid>
</EuiFlexItem>
)}
</EuiFlexItem>
{/* Alert section */}
{!!alerts.length && (
<EuiFlexItem grow={3}>
<AlertsSection alerts={alerts} />
</EuiFlexItem>
)}
{/* Resources section */}
<EuiFlexItem grow={1}>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<Resources />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</WithHeaderLayout>
);
};

View file

@ -0,0 +1,53 @@
/*
* 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, { useContext } from 'react';
import styled, { ThemeContext } from 'styled-components';
import { EuiFlexItem } from '@elastic/eui';
import { EuiPanel } from '@elastic/eui';
import { EuiFlexGroup } from '@elastic/eui';
import { EuiLoadingSpinner } from '@elastic/eui';
import { EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { WithHeaderLayout } from '../../components/app/layout/with_header';
const CentralizedFlexGroup = styled(EuiFlexGroup)`
justify-content: center;
align-items: center;
// place the element in the center of the page
min-height: calc(100vh - ${(props) => props.theme.eui.euiHeaderChildSize});
`;
export const LoadingObservability = () => {
const theme = useContext(ThemeContext);
return (
<WithHeaderLayout
headerColor={theme.eui.euiColorEmptyShade}
bodyColor={theme.eui.euiPageBackgroundColor}
showAddData
showGiveFeedback
>
<CentralizedFlexGroup>
<EuiFlexItem grow={false}>
<EuiPanel>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="xl" />
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ justifyContent: 'center' }}>
<EuiText>
{i18n.translate('xpack.observability.overview.loadingObservability', {
defaultMessage: 'Loading Observability',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
</CentralizedFlexGroup>
</WithHeaderLayout>
);
};

View file

@ -0,0 +1,57 @@
/*
* 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.
*/
export const alertsFetchData = async () => {
return Promise.resolve({
data: [
{
id: '1',
consumer: 'apm',
name: 'Error rate | opbeans-java',
alertTypeId: 'apm.error_rate',
tags: ['apm', 'service.name:opbeans-java'],
updatedAt: '2020-07-03T14:27:51.488Z',
muteAll: true,
},
{
id: '2',
consumer: 'apm',
name: 'Transaction duration | opbeans-java',
alertTypeId: 'apm.transaction_duration',
tags: ['apm', 'service.name:opbeans-java'],
updatedAt: '2020-07-02T14:27:51.488Z',
muteAll: true,
},
{
id: '3',
consumer: 'logs',
name: 'Logs obs test',
alertTypeId: 'logs.alert.document.count',
tags: ['logs', 'observability'],
updatedAt: '2020-06-30T14:27:51.488Z',
muteAll: true,
},
{
id: '4',
consumer: 'metrics',
name: 'Metrics obs test',
alertTypeId: 'metrics.alert.inventory.threshold',
tags: ['metrics', 'observability'],
updatedAt: '2020-03-20T14:27:51.488Z',
muteAll: true,
},
{
id: '5',
consumer: 'uptime',
name: 'Uptime obs test',
alertTypeId: 'xpack.uptime.alerts.monitorStatus',
tags: ['uptime', 'observability'],
updatedAt: '2020-03-25T17:27:51.488Z',
muteAll: true,
},
],
});
};

View file

@ -0,0 +1,627 @@
/*
* 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 { ApmFetchDataResponse, FetchData } from '../../../typings';
export const fetchApmData: FetchData<ApmFetchDataResponse> = () => {
return Promise.resolve(response);
};
const response: ApmFetchDataResponse = {
title: 'APM',
appLink: '/app/apm',
stats: {
services: {
type: 'number',
value: 7,
},
transactions: {
type: 'number',
value: 125808,
},
},
series: {
transactions: {
coordinates: [
{
x: 1593295200000,
y: 891,
},
{
x: 1593297000000,
y: 902,
},
{
x: 1593298800000,
y: 924,
},
{
x: 1593300600000,
y: 944,
},
{
x: 1593302400000,
y: 935,
},
{
x: 1593304200000,
y: 915,
},
{
x: 1593306000000,
y: 917,
},
{
x: 1593307800000,
y: 941,
},
{
x: 1593309600000,
y: 906,
},
{
x: 1593311400000,
y: 939,
},
{
x: 1593313200000,
y: 961,
},
{
x: 1593315000000,
y: 911,
},
{
x: 1593316800000,
y: 958,
},
{
x: 1593318600000,
y: 861,
},
{
x: 1593320400000,
y: 906,
},
{
x: 1593322200000,
y: 899,
},
{
x: 1593324000000,
y: 785,
},
{
x: 1593325800000,
y: 952,
},
{
x: 1593327600000,
y: 910,
},
{
x: 1593329400000,
y: 869,
},
{
x: 1593331200000,
y: 895,
},
{
x: 1593333000000,
y: 924,
},
{
x: 1593334800000,
y: 930,
},
{
x: 1593336600000,
y: 947,
},
{
x: 1593338400000,
y: 905,
},
{
x: 1593340200000,
y: 963,
},
{
x: 1593342000000,
y: 877,
},
{
x: 1593343800000,
y: 839,
},
{
x: 1593345600000,
y: 884,
},
{
x: 1593347400000,
y: 934,
},
{
x: 1593349200000,
y: 908,
},
{
x: 1593351000000,
y: 982,
},
{
x: 1593352800000,
y: 897,
},
{
x: 1593354600000,
y: 903,
},
{
x: 1593356400000,
y: 877,
},
{
x: 1593358200000,
y: 893,
},
{
x: 1593360000000,
y: 919,
},
{
x: 1593361800000,
y: 844,
},
{
x: 1593363600000,
y: 940,
},
{
x: 1593365400000,
y: 951,
},
{
x: 1593367200000,
y: 869,
},
{
x: 1593369000000,
y: 901,
},
{
x: 1593370800000,
y: 940,
},
{
x: 1593372600000,
y: 942,
},
{
x: 1593374400000,
y: 881,
},
{
x: 1593376200000,
y: 935,
},
{
x: 1593378000000,
y: 892,
},
{
x: 1593379800000,
y: 861,
},
{
x: 1593381600000,
y: 868,
},
{
x: 1593383400000,
y: 990,
},
{
x: 1593385200000,
y: 931,
},
{
x: 1593387000000,
y: 898,
},
{
x: 1593388800000,
y: 906,
},
{
x: 1593390600000,
y: 928,
},
{
x: 1593392400000,
y: 975,
},
{
x: 1593394200000,
y: 842,
},
{
x: 1593396000000,
y: 940,
},
{
x: 1593397800000,
y: 922,
},
{
x: 1593399600000,
y: 962,
},
{
x: 1593401400000,
y: 940,
},
{
x: 1593403200000,
y: 974,
},
{
x: 1593405000000,
y: 887,
},
{
x: 1593406800000,
y: 920,
},
{
x: 1593408600000,
y: 854,
},
{
x: 1593410400000,
y: 898,
},
{
x: 1593412200000,
y: 952,
},
{
x: 1593414000000,
y: 987,
},
{
x: 1593415800000,
y: 932,
},
{
x: 1593417600000,
y: 1009,
},
{
x: 1593419400000,
y: 989,
},
{
x: 1593421200000,
y: 939,
},
{
x: 1593423000000,
y: 929,
},
{
x: 1593424800000,
y: 929,
},
{
x: 1593426600000,
y: 864,
},
{
x: 1593428400000,
y: 895,
},
{
x: 1593430200000,
y: 876,
},
{
x: 1593432000000,
y: 68,
},
{
x: 1593433800000,
y: 0,
},
{
x: 1593435600000,
y: 0,
},
{
x: 1593437400000,
y: 0,
},
{
x: 1593439200000,
y: 0,
},
{
x: 1593441000000,
y: 0,
},
{
x: 1593442800000,
y: 700,
},
{
x: 1593444600000,
y: 930,
},
{
x: 1593446400000,
y: 953,
},
{
x: 1593448200000,
y: 995,
},
{
x: 1593450000000,
y: 883,
},
{
x: 1593451800000,
y: 902,
},
{
x: 1593453600000,
y: 988,
},
{
x: 1593455400000,
y: 947,
},
{
x: 1593457200000,
y: 889,
},
{
x: 1593459000000,
y: 982,
},
{
x: 1593460800000,
y: 919,
},
{
x: 1593462600000,
y: 854,
},
{
x: 1593464400000,
y: 894,
},
{
x: 1593466200000,
y: 901,
},
{
x: 1593468000000,
y: 970,
},
{
x: 1593469800000,
y: 840,
},
{
x: 1593471600000,
y: 857,
},
{
x: 1593473400000,
y: 943,
},
{
x: 1593475200000,
y: 825,
},
{
x: 1593477000000,
y: 955,
},
{
x: 1593478800000,
y: 959,
},
{
x: 1593480600000,
y: 921,
},
{
x: 1593482400000,
y: 924,
},
{
x: 1593484200000,
y: 840,
},
{
x: 1593486000000,
y: 943,
},
{
x: 1593487800000,
y: 919,
},
{
x: 1593489600000,
y: 882,
},
{
x: 1593491400000,
y: 900,
},
{
x: 1593493200000,
y: 930,
},
{
x: 1593495000000,
y: 854,
},
{
x: 1593496800000,
y: 905,
},
{
x: 1593498600000,
y: 922,
},
{
x: 1593500400000,
y: 863,
},
{
x: 1593502200000,
y: 966,
},
{
x: 1593504000000,
y: 910,
},
{
x: 1593505800000,
y: 851,
},
{
x: 1593507600000,
y: 867,
},
{
x: 1593509400000,
y: 904,
},
{
x: 1593511200000,
y: 913,
},
{
x: 1593513000000,
y: 889,
},
{
x: 1593514800000,
y: 907,
},
{
x: 1593516600000,
y: 965,
},
{
x: 1593518400000,
y: 868,
},
{
x: 1593520200000,
y: 919,
},
{
x: 1593522000000,
y: 945,
},
{
x: 1593523800000,
y: 883,
},
{
x: 1593525600000,
y: 902,
},
{
x: 1593527400000,
y: 900,
},
{
x: 1593529200000,
y: 829,
},
{
x: 1593531000000,
y: 919,
},
{
x: 1593532800000,
y: 942,
},
{
x: 1593534600000,
y: 924,
},
{
x: 1593536400000,
y: 958,
},
{
x: 1593538200000,
y: 867,
},
{
x: 1593540000000,
y: 844,
},
{
x: 1593541800000,
y: 976,
},
{
x: 1593543600000,
y: 937,
},
{
x: 1593545400000,
y: 891,
},
{
x: 1593547200000,
y: 936,
},
{
x: 1593549000000,
y: 895,
},
{
x: 1593550800000,
y: 850,
},
{
x: 1593552600000,
y: 899,
},
],
},
},
};
export const emptyResponse: ApmFetchDataResponse = {
title: 'APM',
appLink: '/app/apm',
stats: {
services: {
type: 'number',
value: 0,
},
transactions: {
type: 'number',
value: 0,
},
},
series: {
transactions: {
coordinates: [],
},
},
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,133 @@
/*
* 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 { MetricsFetchDataResponse, FetchData } from '../../../typings';
export const fetchMetricsData: FetchData<MetricsFetchDataResponse> = () => {
return Promise.resolve(response);
};
const response: MetricsFetchDataResponse = {
title: 'Metrics',
appLink: '/app/apm',
stats: {
hosts: { value: 11, type: 'number' },
cpu: { value: 0.8, type: 'percent' },
memory: { value: 0.362, type: 'percent' },
inboundTraffic: { value: 1024, type: 'bytesPerSecond' },
outboundTraffic: { value: 1024, type: 'bytesPerSecond' },
},
series: {
outboundTraffic: {
coordinates: [
{
x: 1589805437549,
y: 331514,
},
{
x: 1590047357549,
y: 319208,
},
{
x: 1590289277549,
y: 309648,
},
{
x: 1590531197549,
y: 280568,
},
{
x: 1590773117549,
y: 337180,
},
{
x: 1591015037549,
y: 122468,
},
{
x: 1591256957549,
y: 184164,
},
{
x: 1591498877549,
y: 316323,
},
{
x: 1591740797549,
y: 307351,
},
{
x: 1591982717549,
y: 290262,
},
],
},
inboundTraffic: {
coordinates: [
{
x: 1589805437549,
y: 331514,
},
{
x: 1590047357549,
y: 319208,
},
{
x: 1590289277549,
y: 309648,
},
{
x: 1590531197549,
y: 280568,
},
{
x: 1590773117549,
y: 337180,
},
{
x: 1591015037549,
y: 122468,
},
{
x: 1591256957549,
y: 184164,
},
{
x: 1591498877549,
y: 316323,
},
{
x: 1591740797549,
y: 307351,
},
{
x: 1591982717549,
y: 290262,
},
],
},
},
};
export const emptyResponse: MetricsFetchDataResponse = {
title: 'Metrics',
appLink: '/app/apm',
stats: {
hosts: { value: 0, type: 'number' },
cpu: { value: 0, type: 'percent' },
memory: { value: 0, type: 'percent' },
inboundTraffic: { value: 0, type: 'bytesPerSecond' },
outboundTraffic: { value: 0, type: 'bytesPerSecond' },
},
series: {
outboundTraffic: {
coordinates: [],
},
inboundTraffic: {
coordinates: [],
},
},
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,534 @@
/*
* 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 { storiesOf } from '@storybook/react';
import { AppMountContext } from 'kibana/public';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { UI_SETTINGS } from '../../../../../../src/plugins/data/public';
import { PluginContext } from '../../context/plugin_context';
import { registerDataHandler, unregisterDataHandler } from '../../data_handler';
import { emptyResponse as emptyAPMResponse, fetchApmData } from './mock/apm.mock';
import { fetchLogsData, emptyResponse as emptyLogsResponse } from './mock/logs.mock';
import { fetchMetricsData, emptyResponse as emptyMetricsResponse } from './mock/metrics.mock';
import { fetchUptimeData, emptyResponse as emptyUptimeResponse } from './mock/uptime.mock';
import { EuiThemeProvider } from '../../typings';
import { OverviewPage } from './';
import { alertsFetchData } from './mock/alerts.mock';
const core = {
http: {
basePath: {
prepend: (link) => `http://localhost:5601${link}`,
},
},
uiSettings: {
get: (key: string) => {
const euiSettings = {
[UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS]: {
from: 'now-15m',
to: 'now',
},
[UI_SETTINGS.TIMEPICKER_REFRESH_INTERVAL_DEFAULTS]: {
pause: true,
value: 1000,
},
[UI_SETTINGS.TIMEPICKER_QUICK_RANGES]: [
{
from: 'now/d',
to: 'now/d',
display: 'Today',
},
{
from: 'now/w',
to: 'now/w',
display: 'This week',
},
{
from: 'now-15m',
to: 'now',
display: 'Last 15 minutes',
},
{
from: 'now-30m',
to: 'now',
display: 'Last 30 minutes',
},
{
from: 'now-1h',
to: 'now',
display: 'Last 1 hour',
},
{
from: 'now-24h',
to: 'now',
display: 'Last 24 hours',
},
{
from: 'now-7d',
to: 'now',
display: 'Last 7 days',
},
{
from: 'now-30d',
to: 'now',
display: 'Last 30 days',
},
{
from: 'now-90d',
to: 'now',
display: 'Last 90 days',
},
{
from: 'now-1y',
to: 'now',
display: 'Last 1 year',
},
],
};
// @ts-expect-error
return euiSettings[key];
},
},
} as AppMountContext['core'];
const coreWithAlerts = ({
...core,
http: {
...core.http,
get: alertsFetchData,
},
} as unknown) as AppMountContext['core'];
function unregisterAll() {
unregisterDataHandler({ appName: 'apm' });
unregisterDataHandler({ appName: 'infra_logs' });
unregisterDataHandler({ appName: 'infra_metrics' });
unregisterDataHandler({ appName: 'uptime' });
}
storiesOf('app/Overview', module)
.addDecorator((storyFn) => (
<MemoryRouter>
<PluginContext.Provider value={{ core }}>
<EuiThemeProvider>{storyFn()}</EuiThemeProvider>)
</PluginContext.Provider>
</MemoryRouter>
))
.add('Empty state', () => {
unregisterAll();
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
hasData: async () => false,
});
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
hasData: async () => false,
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
hasData: async () => false,
});
registerDataHandler({
appName: 'uptime',
fetchData: fetchUptimeData,
hasData: async () => false,
});
return <OverviewPage routeParams={{ query: {} }} />;
});
storiesOf('app/Overview', module)
.addDecorator((storyFn) => (
<MemoryRouter>
<PluginContext.Provider value={{ core }}>
<EuiThemeProvider>{storyFn()}</EuiThemeProvider>)
</PluginContext.Provider>
</MemoryRouter>
))
.add('single panel', () => {
unregisterAll();
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
hasData: async () => true,
});
return (
<OverviewPage
routeParams={{
query: { rangeFrom: '2020-06-27T22:00:00.000Z', rangeTo: '2020-06-30T21:59:59.999Z' },
}}
/>
);
});
storiesOf('app/Overview', module)
.addDecorator((storyFn) => (
<MemoryRouter>
<PluginContext.Provider value={{ core }}>
<EuiThemeProvider>{storyFn()}</EuiThemeProvider>)
</PluginContext.Provider>
</MemoryRouter>
))
.add('logs and metrics', () => {
unregisterAll();
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
hasData: async () => true,
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
hasData: async () => true,
});
return (
<OverviewPage
routeParams={{
query: { rangeFrom: '2020-06-27T22:00:00.000Z', rangeTo: '2020-06-30T21:59:59.999Z' },
}}
/>
);
});
storiesOf('app/Overview', module)
.addDecorator((storyFn) => (
<MemoryRouter>
<PluginContext.Provider value={{ core: coreWithAlerts }}>
<EuiThemeProvider>{storyFn()}</EuiThemeProvider>)
</PluginContext.Provider>
</MemoryRouter>
))
.add('logs, metrics and alerts', () => {
unregisterAll();
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
hasData: async () => true,
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
hasData: async () => true,
});
return (
<OverviewPage
routeParams={{
query: { rangeFrom: '2020-06-27T22:00:00.000Z', rangeTo: '2020-06-30T21:59:59.999Z' },
}}
/>
);
});
storiesOf('app/Overview', module)
.addDecorator((storyFn) => (
<MemoryRouter>
<PluginContext.Provider value={{ core: coreWithAlerts }}>
<EuiThemeProvider>{storyFn()}</EuiThemeProvider>)
</PluginContext.Provider>
</MemoryRouter>
))
.add('logs, metrics, APM and alerts', () => {
unregisterAll();
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
hasData: async () => true,
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
hasData: async () => true,
});
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
hasData: async () => true,
});
return (
<OverviewPage
routeParams={{
query: { rangeFrom: '2020-06-27T22:00:00.000Z', rangeTo: '2020-06-30T21:59:59.999Z' },
}}
/>
);
});
storiesOf('app/Overview', module)
.addDecorator((storyFn) => (
<MemoryRouter>
<PluginContext.Provider value={{ core }}>
<EuiThemeProvider>{storyFn()}</EuiThemeProvider>)
</PluginContext.Provider>
</MemoryRouter>
))
.add('logs, metrics, APM and Uptime', () => {
unregisterAll();
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
hasData: async () => true,
});
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
hasData: async () => true,
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
hasData: async () => true,
});
registerDataHandler({
appName: 'uptime',
fetchData: fetchUptimeData,
hasData: async () => true,
});
return (
<OverviewPage
routeParams={{
query: { rangeFrom: '2020-06-27T22:00:00.000Z', rangeTo: '2020-06-30T21:59:59.999Z' },
}}
/>
);
});
storiesOf('app/Overview', module)
.addDecorator((storyFn) => (
<MemoryRouter>
<PluginContext.Provider value={{ core: coreWithAlerts }}>
<EuiThemeProvider>{storyFn()}</EuiThemeProvider>)
</PluginContext.Provider>
</MemoryRouter>
))
.add('logs, metrics, APM, Uptime and Alerts', () => {
unregisterAll();
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
hasData: async () => true,
});
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
hasData: async () => true,
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
hasData: async () => true,
});
registerDataHandler({
appName: 'uptime',
fetchData: fetchUptimeData,
hasData: async () => true,
});
return (
<OverviewPage
routeParams={{
query: { rangeFrom: '2020-06-27T22:00:00.000Z', rangeTo: '2020-06-30T21:59:59.999Z' },
}}
/>
);
});
storiesOf('app/Overview', module)
.addDecorator((storyFn) => (
<MemoryRouter>
<PluginContext.Provider value={{ core }}>
<EuiThemeProvider>{storyFn()}</EuiThemeProvider>)
</PluginContext.Provider>
</MemoryRouter>
))
.add('no data', () => {
unregisterAll();
registerDataHandler({
appName: 'apm',
fetchData: async () => emptyAPMResponse,
hasData: async () => true,
});
registerDataHandler({
appName: 'infra_logs',
fetchData: async () => emptyLogsResponse,
hasData: async () => true,
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: async () => emptyMetricsResponse,
hasData: async () => true,
});
registerDataHandler({
appName: 'uptime',
fetchData: async () => emptyUptimeResponse,
hasData: async () => true,
});
return (
<OverviewPage
routeParams={{
query: { rangeFrom: '2020-06-27T22:00:00.000Z', rangeTo: '2020-06-30T21:59:59.999Z' },
}}
/>
);
});
const coreAlertsThrowsError = ({
...core,
http: {
...core.http,
get: async () => {
throw new Error('Error fetching Alerts data');
},
},
} as unknown) as AppMountContext['core'];
storiesOf('app/Overview', module)
.addDecorator((storyFn) => (
<MemoryRouter>
<PluginContext.Provider value={{ core: coreAlertsThrowsError }}>
<EuiThemeProvider>{storyFn()}</EuiThemeProvider>)
</PluginContext.Provider>
</MemoryRouter>
))
.add('fetch data with error', () => {
unregisterAll();
registerDataHandler({
appName: 'apm',
fetchData: async () => {
throw new Error('Error fetching APM data');
},
hasData: async () => true,
});
registerDataHandler({
appName: 'infra_logs',
fetchData: async () => {
throw new Error('Error fetching Logs data');
},
hasData: async () => true,
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: async () => {
throw new Error('Error fetching Metric data');
},
hasData: async () => true,
});
registerDataHandler({
appName: 'uptime',
fetchData: async () => {
throw new Error('Error fetching Uptime data');
},
hasData: async () => true,
});
return (
<OverviewPage
routeParams={{
query: { rangeFrom: '2020-06-27T22:00:00.000Z', rangeTo: '2020-06-30T21:59:59.999Z' },
}}
/>
);
});
storiesOf('app/Overview', module)
.addDecorator((storyFn) => (
<MemoryRouter>
<PluginContext.Provider value={{ core: coreWithAlerts }}>
<EuiThemeProvider>{storyFn()}</EuiThemeProvider>)
</PluginContext.Provider>
</MemoryRouter>
))
.add('hasData with error and alerts', () => {
unregisterAll();
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
// @ts-ignore thows an error instead
hasData: async () => {
new Error('Error has data');
},
});
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
// @ts-ignore thows an error instead
hasData: async () => {
new Error('Error has data');
},
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
// @ts-ignore thows an error instead
hasData: async () => {
new Error('Error has data');
},
});
registerDataHandler({
appName: 'uptime',
fetchData: fetchUptimeData,
// @ts-ignore thows an error instead
hasData: async () => {
new Error('Error has data');
},
});
return (
<OverviewPage
routeParams={{
query: { rangeFrom: '2020-06-27T22:00:00.000Z', rangeTo: '2020-06-30T21:59:59.999Z' },
}}
/>
);
});
storiesOf('app/Overview', module)
.addDecorator((storyFn) => (
<MemoryRouter>
<PluginContext.Provider value={{ core }}>
<EuiThemeProvider>{storyFn()}</EuiThemeProvider>)
</PluginContext.Provider>
</MemoryRouter>
))
.add('hasData with error', () => {
unregisterAll();
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
// @ts-ignore thows an error instead
hasData: async () => {
new Error('Error has data');
},
});
registerDataHandler({
appName: 'infra_logs',
fetchData: fetchLogsData,
// @ts-ignore thows an error instead
hasData: async () => {
new Error('Error has data');
},
});
registerDataHandler({
appName: 'infra_metrics',
fetchData: fetchMetricsData,
// @ts-ignore thows an error instead
hasData: async () => {
new Error('Error has data');
},
});
registerDataHandler({
appName: 'uptime',
fetchData: fetchUptimeData,
// @ts-ignore thows an error instead
hasData: async () => {
new Error('Error has data');
},
});
return (
<OverviewPage
routeParams={{
query: { rangeFrom: '2020-06-27T22:00:00.000Z', rangeTo: '2020-06-30T21:59:59.999Z' },
}}
/>
);
});

View file

@ -0,0 +1,71 @@
/*
* 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 * as t from 'io-ts';
import { i18n } from '@kbn/i18n';
import { HomePage } from '../pages/home';
import { LandingPage } from '../pages/landing';
import { OverviewPage } from '../pages/overview';
import { jsonRt } from './json_rt';
export type RouteParams<T extends keyof typeof routes> = DecodeParams<typeof routes[T]['params']>;
type DecodeParams<TParams extends Params | undefined> = {
[key in keyof TParams]: TParams[key] extends t.Any ? t.TypeOf<TParams[key]> : never;
};
export interface Params {
query?: t.HasProps;
path?: t.HasProps;
}
export const routes = {
'/': {
handler: () => {
return <HomePage />;
},
params: {},
breadcrumb: [
{
text: i18n.translate('xpack.observability.home.breadcrumb', {
defaultMessage: 'Overview',
}),
},
],
},
'/landing': {
handler: () => {
return <LandingPage />;
},
params: {},
breadcrumb: [
{
text: i18n.translate('xpack.observability.landing.breadcrumb', {
defaultMessage: 'Getting started',
}),
},
],
},
'/overview': {
handler: ({ query }: any) => {
return <OverviewPage routeParams={{ query }} />;
},
params: {
query: t.partial({
rangeFrom: t.string,
rangeTo: t.string,
refreshPaused: jsonRt.pipe(t.boolean),
refreshInterval: jsonRt.pipe(t.number),
}),
},
breadcrumb: [
{
text: i18n.translate('xpack.observability.overview.breadcrumb', {
defaultMessage: 'Overview',
}),
},
],
},
};

View file

@ -0,0 +1,21 @@
/*
* 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 t from 'io-ts';
import { either } from 'fp-ts/lib/Either';
export const jsonRt = new t.Type<any, string, unknown>(
'JSON',
t.any.is,
(input, context) =>
either.chain(t.string.validate(input, context), (str) => {
try {
return t.success(JSON.parse(str));
} catch (e) {
return t.failure(input, context);
}
}),
(a) => JSON.stringify(a)
);

View file

@ -0,0 +1,91 @@
/*
* 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 { AppMountContext } from 'kibana/public';
import { getObservabilityAlerts } from './get_observability_alerts';
describe('getObservabilityAlerts', () => {
it('Returns empty array when api throws exception', async () => {
const core = ({
http: {
get: async () => {
throw new Error('Boom');
},
},
} as unknown) as AppMountContext['core'];
const alerts = await getObservabilityAlerts({ core });
expect(alerts).toEqual([]);
});
it('Returns empty array when api return undefined', async () => {
const core = ({
http: {
get: async () => {
return {
data: undefined,
};
},
},
} as unknown) as AppMountContext['core'];
const alerts = await getObservabilityAlerts({ core });
expect(alerts).toEqual([]);
});
it('Shows alerts from Observability', async () => {
const core = ({
http: {
get: async () => {
return {
data: [
{
id: 1,
consumer: 'siem',
},
{
id: 2,
consumer: 'apm',
},
{
id: 3,
consumer: 'uptime',
},
{
id: 4,
consumer: 'logs',
},
{
id: 5,
consumer: 'metrics',
},
],
};
},
},
} as unknown) as AppMountContext['core'];
const alerts = await getObservabilityAlerts({ core });
expect(alerts).toEqual([
{
id: 2,
consumer: 'apm',
},
{
id: 3,
consumer: 'uptime',
},
{
id: 4,
consumer: 'logs',
},
{
id: 5,
consumer: 'metrics',
},
]);
});
});

View file

@ -0,0 +1,27 @@
/*
* 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 { AppMountContext } from 'kibana/public';
import { Alert } from '../../../alerts/common';
export async function getObservabilityAlerts({ core }: { core: AppMountContext['core'] }) {
try {
const { data = [] }: { data: Alert[] } = await core.http.get('/api/alerts/_find', {
query: {
page: 1,
per_page: 20,
},
});
return data.filter(({ consumer }) => {
return (
consumer === 'apm' || consumer === 'uptime' || consumer === 'logs' || consumer === 'metrics'
);
});
} catch (e) {
return [];
}
}

View file

@ -6,11 +6,9 @@
import { ObservabilityApp } from '../../../typings/common';
interface Stat {
export interface Stat {
type: 'number' | 'percent' | 'bytesPerSecond';
label: string;
value: number;
color?: string;
}
export interface Coordinates {
@ -18,10 +16,8 @@ export interface Coordinates {
y?: number;
}
interface Series {
label: string;
export interface Series {
coordinates: Coordinates[];
color?: string;
}
export interface FetchDataParams {
@ -50,8 +46,8 @@ export interface FetchDataResponse {
}
export interface LogsFetchDataResponse extends FetchDataResponse {
stats: Record<string, Stat>;
series: Record<string, Series>;
stats: Record<string, Stat & { label: string }>;
series: Record<string, Series & { label: string }>;
}
export interface MetricsFetchDataResponse extends FetchDataResponse {

View file

@ -0,0 +1,17 @@
/*
* 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 { ObservabilityApp } from '../../../typings/common';
export interface ISection {
id: ObservabilityApp | 'alert';
title: string;
icon: string;
description: string;
href?: string;
linkTitle?: string;
target?: '_blank';
}

View file

@ -0,0 +1,15 @@
/*
* 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 datemath from '@elastic/datemath';
export function getParsedDate(range?: string, opts = {}) {
if (range) {
const parsed = datemath.parse(range, opts);
if (parsed) {
return parsed.toISOString();
}
}
}

View file

@ -0,0 +1,49 @@
/*
* 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 { formatStatValue } from './format_stat_value';
import { Stat } from '../typings';
describe('formatStatValue', () => {
it('formats value as number', () => {
const stat = {
type: 'number',
label: 'numeral stat',
value: 1000,
} as Stat;
expect(formatStatValue(stat)).toEqual('1k');
});
it('formats value as bytes', () => {
expect(
formatStatValue({
type: 'bytesPerSecond',
label: 'bytes stat',
value: 1,
} as Stat)
).toEqual('1.0B/s');
expect(
formatStatValue({
type: 'bytesPerSecond',
label: 'bytes stat',
value: 1048576,
} as Stat)
).toEqual('1.0MB/s');
expect(
formatStatValue({
type: 'bytesPerSecond',
label: 'bytes stat',
value: 1073741824,
} as Stat)
).toEqual('1.0GB/s');
});
it('formats value as percent', () => {
const stat = {
type: 'percent',
label: 'percent stat',
value: 0.841,
} as Stat;
expect(formatStatValue(stat)).toEqual('84.1%');
});
});

View file

@ -0,0 +1,20 @@
/*
* 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 numeral from '@elastic/numeral';
import { Stat } from '../typings';
export function formatStatValue(stat: Stat) {
const { value, type } = stat;
switch (type) {
case 'bytesPerSecond':
return `${numeral(value).format('0.0b')}/s`;
case 'number':
return numeral(value).format('0a');
case 'percent':
return numeral(value).format('0.0%');
}
}

View file

@ -0,0 +1,77 @@
/*
* 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 moment from 'moment';
const d = moment.duration;
const roundingRules = [
[d(500, 'ms'), d(100, 'ms')],
[d(5, 'second'), d(1, 'second')],
[d(7.5, 'second'), d(5, 'second')],
[d(15, 'second'), d(10, 'second')],
[d(45, 'second'), d(30, 'second')],
[d(3, 'minute'), d(1, 'minute')],
[d(9, 'minute'), d(5, 'minute')],
[d(20, 'minute'), d(10, 'minute')],
[d(45, 'minute'), d(30, 'minute')],
[d(2, 'hour'), d(1, 'hour')],
[d(6, 'hour'), d(3, 'hour')],
[d(24, 'hour'), d(12, 'hour')],
[d(1, 'week'), d(1, 'd')],
[d(3, 'week'), d(1, 'week')],
[d(1, 'year'), d(1, 'month')],
[Infinity, d(1, 'year')],
];
const revRoundingRules = roundingRules.slice(0).reverse();
function find(rules, check, last) {
function pick(buckets, duration) {
const target = duration / buckets;
let lastResp = null;
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
const resp = check(rule[0], rule[1], target);
if (resp == null) {
if (!last) continue;
if (lastResp) return lastResp;
break;
}
if (!last) return resp;
lastResp = resp;
}
// fallback to just a number of milliseconds, ensure ms is >= 1
const ms = Math.max(Math.floor(target), 1);
return moment.duration(ms, 'ms');
}
return (buckets, duration) => {
const interval = pick(buckets, duration);
if (interval) return moment.duration(interval._data);
};
}
export const calculateAuto = {
near: find(
revRoundingRules,
function near(bound, interval, target) {
if (bound > target) return interval;
},
true
),
lessThan: find(revRoundingRules, function lessThan(_bound, interval, target) {
if (interval < target) return interval;
}),
atLeast: find(revRoundingRules, function atLeast(_bound, interval, target) {
if (interval <= target) return interval;
}),
};

View file

@ -0,0 +1,70 @@
/*
* 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 { getBucketSize } from './index';
import moment from 'moment';
describe('getBuckets', () => {
describe("minInterval 'auto'", () => {
it('last 15 minutes', () => {
const start = moment().subtract(15, 'minutes').valueOf();
const end = moment.now();
expect(getBucketSize({ start, end, minInterval: 'auto' })).toEqual({
bucketSize: 10,
intervalString: '10s',
});
});
it('last 1 hour', () => {
const start = moment().subtract(1, 'hour').valueOf();
const end = moment.now();
expect(getBucketSize({ start, end, minInterval: 'auto' })).toEqual({
bucketSize: 30,
intervalString: '30s',
});
});
it('last 1 week', () => {
const start = moment().subtract(1, 'week').valueOf();
const end = moment.now();
expect(getBucketSize({ start, end, minInterval: 'auto' })).toEqual({
bucketSize: 3600,
intervalString: '3600s',
});
});
it('last 30 days', () => {
const start = moment().subtract(30, 'days').valueOf();
const end = moment.now();
expect(getBucketSize({ start, end, minInterval: 'auto' })).toEqual({
bucketSize: 43200,
intervalString: '43200s',
});
});
it('last 1 year', () => {
const start = moment().subtract(1, 'year').valueOf();
const end = moment.now();
expect(getBucketSize({ start, end, minInterval: 'auto' })).toEqual({
bucketSize: 86400,
intervalString: '86400s',
});
});
});
describe("minInterval '30s'", () => {
it('last 15 minutes', () => {
const start = moment().subtract(15, 'minutes').valueOf();
const end = moment.now();
expect(getBucketSize({ start, end, minInterval: '30s' })).toEqual({
bucketSize: 30,
intervalString: '30s',
});
});
it('last 1 year', () => {
const start = moment().subtract(1, 'year').valueOf();
const end = moment.now();
expect(getBucketSize({ start, end, minInterval: '30s' })).toEqual({
bucketSize: 86400,
intervalString: '86400s',
});
});
});
});

View file

@ -0,0 +1,35 @@
/*
* 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 moment from 'moment';
// @ts-ignore
import { calculateAuto } from './calculate_auto';
import { unitToSeconds } from './unit_to_seconds';
export function getBucketSize({
start,
end,
minInterval,
}: {
start: number;
end: number;
minInterval: string;
}) {
const duration = moment.duration(end - start, 'ms');
const bucketSize = Math.max(calculateAuto.near(100, duration).asSeconds(), 1);
const intervalString = `${bucketSize}s`;
const matches = minInterval && minInterval.match(/^([\d]+)([shmdwMy]|ms)$/);
const minBucketSize = matches ? Number(matches[1]) * unitToSeconds(matches[2]) : 0;
if (bucketSize < minBucketSize) {
return {
bucketSize: minBucketSize,
intervalString: minInterval,
};
}
return { bucketSize, intervalString };
}

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 moment, { unitOfTime as UnitOfTIme } from 'moment';
function getDurationAsSeconds(value: number, unitOfTime: UnitOfTIme.Base) {
return moment.duration(value, unitOfTime).asSeconds();
}
const units = {
ms: getDurationAsSeconds(1, 'millisecond'),
s: getDurationAsSeconds(1, 'second'),
m: getDurationAsSeconds(1, 'minute'),
h: getDurationAsSeconds(1, 'hour'),
d: getDurationAsSeconds(1, 'day'),
w: getDurationAsSeconds(1, 'week'),
M: getDurationAsSeconds(1, 'month'),
y: getDurationAsSeconds(1, 'year'),
};
export function unitToSeconds(unit: string) {
return units[unit as keyof typeof units];
}

View file

@ -0,0 +1,26 @@
/*
* 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 as testLibRender } from '@testing-library/react';
import { AppMountContext } from 'kibana/public';
import { PluginContext } from '../context/plugin_context';
import { EuiThemeProvider } from '../typings';
export const core = ({
http: {
basePath: {
prepend: jest.fn(),
},
},
} as unknown) as AppMountContext['core'];
export const render = (component: React.ReactNode) => {
return testLibRender(
<PluginContext.Provider value={{ core }}>
<EuiThemeProvider>{component}</EuiThemeProvider>
</PluginContext.Provider>
);
};

View file

@ -0,0 +1,19 @@
/*
* 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 { parse, stringify } from 'query-string';
import { url } from '../../../../../src/plugins/kibana_utils/public';
export function toQuery(search?: string) {
return search ? parse(search.slice(1), { sort: false }) : {};
}
export function fromQuery(query: Record<string, any>) {
const encodedQuery = url.encodeQuery(query, (value) =>
encodeURIComponent(value).replace(/%3A/g, ':')
);
return stringify(encodedQuery, { sort: false, encode: false });
}

View file

@ -0,0 +1,16 @@
/*
* 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 { join } from 'path';
// eslint-disable-next-line
require('@kbn/storybook').runStorybookCli({
name: 'observability',
storyGlobs: [
join(__dirname, '..', 'public', 'components', '**', '*.stories.tsx'),
join(__dirname, '..', 'public', 'pages', '**', '*.stories.tsx'),
],
});

View file

@ -29,29 +29,24 @@ export async function fetchUptimeOverviewData({
stats: {
monitors: {
type: 'number',
label: 'Monitors',
value: snapshot.total,
},
up: {
type: 'number',
label: 'Up',
value: snapshot.up,
},
down: {
type: 'number',
label: 'Down',
value: snapshot.down,
},
},
series: {
up: {
label: 'Up',
coordinates: pings.histogram.map((p) => {
return { x: p.x!, y: p.upCount || 0 };
}),
},
down: {
label: 'Down',
coordinates: pings.histogram.map((p) => {
return { x: p.x!, y: p.downCount || 0 };
}),